mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
5323 lines
208 KiB
Rust
5323 lines
208 KiB
Rust
use std::collections::{HashMap, HashSet, VecDeque};
|
||
|
||
mod actions;
|
||
mod agent_sidebar;
|
||
mod composition;
|
||
mod config;
|
||
mod context_menu;
|
||
mod copy_mode;
|
||
mod global_menu;
|
||
mod input;
|
||
mod mouse;
|
||
mod notifications;
|
||
mod overlay_input;
|
||
mod preferences;
|
||
mod render;
|
||
mod scroll;
|
||
mod settings;
|
||
mod state;
|
||
mod worktrees;
|
||
|
||
pub(super) use state::*;
|
||
|
||
use crossterm::event::KeyCode;
|
||
#[cfg(test)]
|
||
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::{Modifier, Style};
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
use crate::app::state::Palette;
|
||
use crate::config::{
|
||
Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig,
|
||
TabBarPositionConfig,
|
||
};
|
||
use crate::protocol::{
|
||
ClientMessage, ClientMousePosition, ClientPaneInputEvent, ClientShellSnapshot, ClientShellTab,
|
||
ClientShellWorkspace, ClientSurfaceSize, FrameData, PaneSurfaceFrame, SemanticNotification,
|
||
SemanticNotificationKind, SemanticNotificationSound,
|
||
};
|
||
#[cfg(test)]
|
||
use crate::raw_input::RawInputEvent;
|
||
|
||
fn delete_overlay_word(rename: &mut ClientRenameOverlay) {
|
||
if rename.replace_on_type {
|
||
rename.input.clear();
|
||
rename.replace_on_type = false;
|
||
return;
|
||
}
|
||
while rename.input.chars().last().is_some_and(char::is_whitespace) {
|
||
rename.input.pop();
|
||
}
|
||
let Some(word) = rename
|
||
.input
|
||
.chars()
|
||
.last()
|
||
.map(|character| character.is_alphanumeric() || character == '_')
|
||
else {
|
||
return;
|
||
};
|
||
while rename.input.chars().last().is_some_and(|character| {
|
||
!character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word
|
||
}) {
|
||
rename.input.pop();
|
||
}
|
||
}
|
||
|
||
fn push_target_event(
|
||
target: ClientInputTarget,
|
||
event: ClientPaneInputEvent,
|
||
outcome: &mut ClientShellInput,
|
||
) {
|
||
match target {
|
||
ClientInputTarget::Pane(pane_id) => {
|
||
if let Some(ClientMessage::ClientShellPaneInput {
|
||
pane_id: pending_pane,
|
||
events,
|
||
}) = outcome.requests.last_mut()
|
||
{
|
||
if *pending_pane == pane_id {
|
||
events.push(event);
|
||
return;
|
||
}
|
||
}
|
||
outcome.requests.push(ClientMessage::ClientShellPaneInput {
|
||
pane_id,
|
||
events: vec![event],
|
||
});
|
||
}
|
||
ClientInputTarget::Popup(terminal_id) => {
|
||
if let Some(ClientMessage::ClientShellPopupInput {
|
||
terminal_id: pending_terminal,
|
||
events,
|
||
}) = outcome.requests.last_mut()
|
||
{
|
||
if *pending_terminal == terminal_id {
|
||
events.push(event);
|
||
return;
|
||
}
|
||
}
|
||
outcome.requests.push(ClientMessage::ClientShellPopupInput {
|
||
terminal_id,
|
||
events: vec![event],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
fn contains(rect: Rect, point: (u16, u16)) -> bool {
|
||
rect.width > 0
|
||
&& rect.height > 0
|
||
&& point.0 >= rect.x
|
||
&& point.0 < rect.right()
|
||
&& point.1 >= rect.y
|
||
&& point.1 < rect.bottom()
|
||
}
|
||
|
||
fn pane_surface_topology_signature(surface: &PaneSurfaceFrame) -> u64 {
|
||
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||
|
||
fn write(hash: &mut u64, bytes: &[u8]) {
|
||
for byte in bytes {
|
||
*hash ^= u64::from(*byte);
|
||
*hash = hash.wrapping_mul(PRIME);
|
||
}
|
||
*hash ^= 0xff;
|
||
*hash = hash.wrapping_mul(PRIME);
|
||
}
|
||
|
||
let mut pane_ids = surface
|
||
.panes
|
||
.iter()
|
||
.map(|pane| pane.pane_id.as_bytes())
|
||
.collect::<Vec<_>>();
|
||
pane_ids.sort_unstable();
|
||
let mut hash = OFFSET;
|
||
for pane_id in pane_ids {
|
||
write(&mut hash, pane_id);
|
||
}
|
||
let mut splits = surface.splits.iter().collect::<Vec<_>>();
|
||
splits.sort_by(|left, right| left.path.cmp(&right.path));
|
||
for split in splits {
|
||
write(
|
||
&mut hash,
|
||
&[match split.direction {
|
||
crate::protocol::PaneSurfaceSplitDirection::Horizontal => 0,
|
||
crate::protocol::PaneSurfaceSplitDirection::Vertical => 1,
|
||
}],
|
||
);
|
||
write(
|
||
&mut hash,
|
||
&split
|
||
.path
|
||
.iter()
|
||
.map(|right| u8::from(*right))
|
||
.collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
hash
|
||
}
|
||
|
||
fn status_icon(
|
||
status: crate::api::schema::AgentStatus,
|
||
style: crate::config::StatusIndicatorStyle,
|
||
) -> &'static str {
|
||
use crate::api::schema::AgentStatus;
|
||
use crate::config::StatusIndicatorStyle;
|
||
match (style, status) {
|
||
(
|
||
StatusIndicatorStyle::Dots,
|
||
AgentStatus::Working | AgentStatus::Blocked | AgentStatus::Done,
|
||
) => "●",
|
||
(StatusIndicatorStyle::Dots, AgentStatus::Idle) => "○",
|
||
(StatusIndicatorStyle::Dots, AgentStatus::Unknown) => "·",
|
||
(StatusIndicatorStyle::Symbols, AgentStatus::Blocked) => "×",
|
||
(StatusIndicatorStyle::Symbols, AgentStatus::Working) => "◐",
|
||
(StatusIndicatorStyle::Symbols, AgentStatus::Done) => "✓",
|
||
(StatusIndicatorStyle::Symbols, AgentStatus::Idle) => "○",
|
||
(StatusIndicatorStyle::Symbols, AgentStatus::Unknown) => "·",
|
||
}
|
||
}
|
||
|
||
fn status_dot(status: crate::api::schema::AgentStatus) -> &'static str {
|
||
status_icon(status, crate::config::StatusIndicatorStyle::Dots)
|
||
}
|
||
|
||
fn status_priority(status: crate::api::schema::AgentStatus) -> u8 {
|
||
use crate::api::schema::AgentStatus;
|
||
match status {
|
||
AgentStatus::Blocked => 4,
|
||
AgentStatus::Done => 3,
|
||
AgentStatus::Working => 2,
|
||
AgentStatus::Idle => 1,
|
||
AgentStatus::Unknown => 0,
|
||
}
|
||
}
|
||
|
||
fn status_text(status: crate::api::schema::AgentStatus) -> &'static str {
|
||
use crate::api::schema::AgentStatus;
|
||
match status {
|
||
AgentStatus::Working => "working",
|
||
AgentStatus::Blocked => "blocked",
|
||
AgentStatus::Done => "done",
|
||
AgentStatus::Idle => "idle",
|
||
AgentStatus::Unknown => "unknown",
|
||
}
|
||
}
|
||
|
||
fn status_color(
|
||
status: crate::api::schema::AgentStatus,
|
||
palette: &Palette,
|
||
) -> ratatui::style::Color {
|
||
use crate::api::schema::AgentStatus;
|
||
match status {
|
||
AgentStatus::Working => palette.yellow,
|
||
AgentStatus::Blocked => palette.red,
|
||
AgentStatus::Done => palette.teal,
|
||
AgentStatus::Idle => palette.green,
|
||
AgentStatus::Unknown => palette.overlay0,
|
||
}
|
||
}
|
||
|
||
fn panel_contrast_fg(palette: &Palette) -> ratatui::style::Color {
|
||
match palette.panel_bg {
|
||
ratatui::style::Color::Reset => palette.surface_dim,
|
||
color => color,
|
||
}
|
||
}
|
||
|
||
fn blit_pane_surface(target: &mut FrameData, source: &FrameData, area: Rect) {
|
||
let copy_width = source.width.min(area.width);
|
||
let copy_height = source.height.min(area.height);
|
||
let hyperlink_base = target.hyperlinks.len() as u32;
|
||
target.hyperlinks.extend(source.hyperlinks.iter().cloned());
|
||
|
||
for row in 0..copy_height {
|
||
for col in 0..copy_width {
|
||
let source_index = row as usize * source.width as usize + col as usize;
|
||
let target_x = area.x + col;
|
||
let target_y = area.y + row;
|
||
let target_index = target_y as usize * target.width as usize + target_x as usize;
|
||
let (Some(source_cell), Some(target_cell)) = (
|
||
source.cells.get(source_index),
|
||
target.cells.get_mut(target_index),
|
||
) else {
|
||
continue;
|
||
};
|
||
*target_cell = source_cell.clone();
|
||
target_cell.hyperlink = source_cell.hyperlink.and_then(|index| {
|
||
((index as usize) < source.hyperlinks.len()).then_some(hyperlink_base + index)
|
||
});
|
||
}
|
||
}
|
||
|
||
target.cursor = source.cursor.as_ref().and_then(|cursor| {
|
||
(cursor.x < copy_width && cursor.y < copy_height).then(|| crate::protocol::CursorState {
|
||
x: area.x + cursor.x,
|
||
y: area.y + cursor.y,
|
||
visible: cursor.visible,
|
||
shape: cursor.shape,
|
||
})
|
||
});
|
||
target.graphics.clear();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::api::schema::AgentStatus;
|
||
use crate::protocol::{
|
||
ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane,
|
||
PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect,
|
||
};
|
||
|
||
fn snapshot() -> ClientShellSnapshot {
|
||
ClientShellSnapshot {
|
||
boot_id: "boot-1".into(),
|
||
revision: 1,
|
||
config_diagnostic: None,
|
||
focused_workspace_id: Some("ws_1".into()),
|
||
focused_tab_id: Some("tab_1".into()),
|
||
focused_pane_id: Some("pane_1".into()),
|
||
tab_bar_right: Vec::new(),
|
||
tab_bar_right_separator: " ".into(),
|
||
agent_view_label: None,
|
||
agent_order: Vec::new(),
|
||
workspaces: vec![ClientShellWorkspace {
|
||
workspace_id: "ws_1".into(),
|
||
number: 1,
|
||
label: "client-shell".into(),
|
||
custom_label: false,
|
||
branch: Some("main".into()),
|
||
git_ahead_behind: None,
|
||
tokens: Vec::new(),
|
||
worktree: None,
|
||
focused: true,
|
||
agent_status: AgentStatus::Idle,
|
||
}],
|
||
tabs: vec![ClientShellTab {
|
||
tab_id: "tab_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
number: 1,
|
||
label: "1".into(),
|
||
custom_label: false,
|
||
zoomed: false,
|
||
focused: true,
|
||
agent_status: AgentStatus::Idle,
|
||
}],
|
||
panes: vec![ClientShellPane {
|
||
pane_id: "pane_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
label: None,
|
||
cwd: Some("/repo".into()),
|
||
foreground_cwd: Some("/repo".into()),
|
||
focused: true,
|
||
right_click_passthrough: false,
|
||
}],
|
||
agents: Vec::new(),
|
||
commands: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn worktree_list_result(open_workspace_id: Option<&str>) -> crate::api::schema::ResponseResult {
|
||
crate::api::schema::ResponseResult::WorktreeList {
|
||
source: crate::api::schema::WorktreeSourceInfo {
|
||
repo_key: "repo-key".into(),
|
||
repo_name: "repo".into(),
|
||
repo_root: "/repo".into(),
|
||
source_checkout_path: "/repo".into(),
|
||
source_workspace_id: Some("ws_1".into()),
|
||
},
|
||
worktrees: vec![crate::api::schema::WorktreeInfo {
|
||
path: "/repo-feature".into(),
|
||
branch: Some("feature".into()),
|
||
is_bare: false,
|
||
is_detached: false,
|
||
is_prunable: false,
|
||
is_linked_worktree: true,
|
||
open_workspace_id: open_workspace_id.map(str::to_owned),
|
||
label: "repo".into(),
|
||
}],
|
||
}
|
||
}
|
||
|
||
fn surface() -> PaneSurfaceFrame {
|
||
let surface_buffer = Buffer::with_lines(["LIVE", "PANE"]);
|
||
PaneSurfaceFrame {
|
||
projection_revision: 1,
|
||
frame: FrameData::from_ratatui_buffer_with_hyperlinks(
|
||
&surface_buffer,
|
||
Some(crate::protocol::CursorState {
|
||
x: 1,
|
||
y: 1,
|
||
visible: true,
|
||
shape: 2,
|
||
}),
|
||
&[],
|
||
),
|
||
panes: vec![PaneSurfacePane {
|
||
pane_id: "pane_1".into(),
|
||
content_revision: 0,
|
||
rect: SurfaceRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 4,
|
||
height: 2,
|
||
},
|
||
inner_rect: SurfaceRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 4,
|
||
height: 2,
|
||
},
|
||
scrollbar_rect: None,
|
||
scroll: None,
|
||
focused: true,
|
||
mouse_reporting: false,
|
||
sgr_pixel_mouse: false,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
}],
|
||
splits: Vec::new(),
|
||
popup: None,
|
||
}
|
||
}
|
||
|
||
fn pane_scroll_result(
|
||
offset_from_bottom: u64,
|
||
max_offset_from_bottom: u64,
|
||
viewport_rows: u64,
|
||
) -> crate::api::schema::ResponseResult {
|
||
crate::api::schema::ResponseResult::PaneInfo {
|
||
pane: crate::api::schema::PaneInfo {
|
||
pane_id: "pane_1".into(),
|
||
terminal_id: "terminal_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
focused: true,
|
||
cwd: None,
|
||
foreground_cwd: None,
|
||
label: None,
|
||
agent: None,
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
display_agent: None,
|
||
agent_status: crate::api::schema::AgentStatus::Unknown,
|
||
state_labels: HashMap::new(),
|
||
tokens: HashMap::new(),
|
||
agent_session: None,
|
||
scroll: Some(crate::api::schema::PaneScrollInfo {
|
||
offset_from_bottom,
|
||
max_offset_from_bottom,
|
||
viewport_rows,
|
||
}),
|
||
revision: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn copy_search_result(
|
||
matches: Vec<crate::api::schema::PaneTextRange>,
|
||
current: Option<u32>,
|
||
) -> crate::api::schema::ResponseResult {
|
||
let total = matches.len() as u64;
|
||
crate::api::schema::ResponseResult::PaneCopySearch {
|
||
pane_id: "pane_1".into(),
|
||
content_revision: 0,
|
||
matches,
|
||
total,
|
||
current,
|
||
current_global: current.map(u64::from),
|
||
}
|
||
}
|
||
|
||
fn surface_with_popup() -> PaneSurfaceFrame {
|
||
let mut surface = surface();
|
||
let popup_buffer = Buffer::with_lines(["popup-live", "", ""]);
|
||
surface.popup = Some(Box::new(crate::protocol::ClientShellPopupSurface {
|
||
terminal_id: "terminal-popup".into(),
|
||
title: "popup title".into(),
|
||
width: Some(crate::protocol::ClientShellPopupSize::Cells(12)),
|
||
height: Some(crate::protocol::ClientShellPopupSize::Cells(5)),
|
||
frame: FrameData::from_ratatui_buffer_with_hyperlinks(
|
||
&popup_buffer,
|
||
Some(crate::protocol::CursorState {
|
||
x: 2,
|
||
y: 1,
|
||
visible: true,
|
||
shape: 1,
|
||
}),
|
||
&[],
|
||
),
|
||
mouse_reporting: true,
|
||
sgr_pixel_mouse: false,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
}));
|
||
surface
|
||
}
|
||
|
||
#[test]
|
||
fn desktop_composition_keeps_shell_outside_origin_relative_surface() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
|
||
let frame = state.compose(106, 20).expect("composed frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("spaces"));
|
||
assert!(text.contains("client-shell"));
|
||
assert!(text.contains("main"));
|
||
assert!(text.contains("LIVE"));
|
||
assert!(!text.contains("1 1"));
|
||
assert_eq!(
|
||
frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)),
|
||
Some((27, 2))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() {
|
||
let config = ClientShellConfig::from_config(&Config::default())
|
||
.with_startup_config_diagnostic(Some("local config warning".into()));
|
||
let mut state = ClientShellState::new(config);
|
||
let mut shared_snapshot = snapshot();
|
||
shared_snapshot.config_diagnostic = Some("local config warning".into());
|
||
state.set_snapshot(Box::new(shared_snapshot));
|
||
assert_eq!(
|
||
state.config_diagnostic.as_deref(),
|
||
Some("client + endpoint: local config warning")
|
||
);
|
||
|
||
let mut endpoint_snapshot = snapshot();
|
||
endpoint_snapshot.config_diagnostic = Some("endpoint config warning".into());
|
||
state.set_snapshot(Box::new(endpoint_snapshot));
|
||
state.set_pane_surface(surface());
|
||
|
||
let frame = state.compose(106, 20).expect("diagnostic frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("client: local config warning"));
|
||
assert!(text.contains("endpoint: endpoint config warning"));
|
||
|
||
state.handle_input_bytes(b"x");
|
||
assert!(state.config_diagnostic.is_some());
|
||
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
assert_eq!(
|
||
state.config_diagnostic.as_deref(),
|
||
Some("local config warning")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() {
|
||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||
config.toast_delay_seconds = 0;
|
||
let mut state = ClientShellState::new(config);
|
||
let mut endpoint_snapshot = snapshot();
|
||
endpoint_snapshot.config_diagnostic = Some("one-line warning".into());
|
||
state.set_snapshot(Box::new(endpoint_snapshot));
|
||
state.set_pane_surface(surface());
|
||
state.visible_notification = Some(ClientVisibleNotification {
|
||
event: SemanticNotification {
|
||
kind: SemanticNotificationKind::Custom,
|
||
title: "notification".into(),
|
||
body: None,
|
||
sound: None,
|
||
agent: None,
|
||
workspace_id: None,
|
||
tab_id: None,
|
||
pane_id: None,
|
||
position: Some(crate::config::ToastHerdrPosition::TopRight),
|
||
},
|
||
deadline: std::time::Instant::now(),
|
||
});
|
||
|
||
state.compose(106, 20).expect("one-line frame");
|
||
let pane_area = state.layout(106, 20).pane_surface;
|
||
assert_eq!(state.hits.notification_toast.y, pane_area.y);
|
||
|
||
let mut endpoint_snapshot = snapshot();
|
||
endpoint_snapshot.config_diagnostic = Some("first warning\nsecond warning".into());
|
||
state.set_snapshot(Box::new(endpoint_snapshot));
|
||
state.compose(106, 20).expect("two-line frame");
|
||
assert_eq!(state.hits.notification_toast.y, pane_area.y + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
let mut endpoint_snapshot = snapshot();
|
||
endpoint_snapshot.config_diagnostic = Some("endpoint warning".into());
|
||
state.set_snapshot(Box::new(endpoint_snapshot));
|
||
state.pending_requests.insert(
|
||
"reload-1".into(),
|
||
PendingEndpointRequest {
|
||
boot_id: "boot-1".into(),
|
||
confirmation_workspace_id: None,
|
||
kind: PendingEndpointKind::ReloadConfig,
|
||
},
|
||
);
|
||
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
"reload-1",
|
||
Ok(crate::api::schema::ResponseResult::ConfigReload {
|
||
status: crate::config::ConfigReloadStatus::Partial,
|
||
diagnostics: vec!["keybinding warning".into()],
|
||
}),
|
||
);
|
||
assert_eq!(state.config_diagnostic.as_deref(), Some("endpoint warning"));
|
||
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
assert!(state.config_diagnostic.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn live_client_config_keeps_sound_diagnostics() {
|
||
let mut shell_config = ClientShellConfig::from_config(&Config::default());
|
||
let mut config = Config::default();
|
||
config.ui.sound.path = Some(std::path::PathBuf::from("invalid.wav"));
|
||
|
||
let diagnostics = shell_config.apply_live_config(&config, &[], &[]);
|
||
assert!(diagnostics
|
||
.iter()
|
||
.any(|diagnostic| diagnostic.contains("expected an mp3 file")));
|
||
}
|
||
|
||
#[test]
|
||
fn client_composes_popup_terminal_content_inside_client_owned_chrome() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface_with_popup());
|
||
|
||
let frame = state.compose(106, 20).expect("popup frame");
|
||
let popup = state.hits.popup.as_ref().expect("popup hit geometry");
|
||
assert_eq!(popup.rect.width, 12);
|
||
assert_eq!(popup.rect.height, 5);
|
||
assert_eq!(popup.inner_rect.width, 9);
|
||
assert_eq!(popup.inner_rect.height, 3);
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("popup tit"));
|
||
assert!(text.contains("popup-liv"));
|
||
assert_eq!(
|
||
frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)),
|
||
Some((popup.inner_rect.x + 2, popup.inner_rect.y + 1))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn popup_owns_keys_text_paste_and_mouse_before_shell_controls() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface_with_popup());
|
||
state.compose(106, 20).expect("popup frame");
|
||
|
||
for bytes in [b"x".as_slice(), b"\x02".as_slice(), b"\x1b".as_slice()] {
|
||
let input = state.handle_input_bytes(bytes);
|
||
assert!(matches!(
|
||
&input.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { terminal_id, .. }]
|
||
if terminal_id == "terminal-popup"
|
||
));
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
}
|
||
|
||
let text = state.handle_raw_events(vec![RawInputEvent::Text(
|
||
crate::input::TextCommit::new("ime"),
|
||
)]);
|
||
assert!(matches!(
|
||
&text.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { events, .. }]
|
||
if matches!(&events[..], [ClientPaneInputEvent::TextCommit(value)] if value == "ime")
|
||
));
|
||
let paste = state.handle_raw_events(vec![RawInputEvent::Paste("paste".into())]);
|
||
assert!(matches!(
|
||
&paste.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { events, .. }]
|
||
if matches!(&events[..], [ClientPaneInputEvent::Paste(value)] if value == "paste")
|
||
));
|
||
|
||
let popup = state.hits.popup.as_ref().expect("popup hit").clone();
|
||
let mouse =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: popup.inner_rect.x + 3,
|
||
row: popup.inner_rect.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&mouse.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { events, .. }]
|
||
if matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
position: ClientMousePosition::Cell { column: 3, row: 1 },
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_some());
|
||
state.set_pane_surface(surface());
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn popup_transition_dismisses_client_overlays_and_restores_pane_input_after_close() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help),
|
||
&mut ClientShellInput::default(),
|
||
);
|
||
assert!(state.overlay.is_some());
|
||
|
||
state.set_pane_surface(surface_with_popup());
|
||
assert!(state.overlay.is_none());
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
let popup_input = state.handle_input_bytes(b"p");
|
||
assert!(matches!(
|
||
&popup_input.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { .. }]
|
||
));
|
||
|
||
state.set_pane_surface(surface());
|
||
let pane_input = state.handle_input_bytes(b"p");
|
||
assert!(matches!(
|
||
&pane_input.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn popup_target_survives_surface_invalidation_during_resize() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface_with_popup());
|
||
state.invalidate_pane_surface();
|
||
|
||
assert!(matches!(
|
||
&state.handle_input_bytes(b"x").requests[..],
|
||
[ClientMessage::ClientShellPopupInput { terminal_id, .. }]
|
||
if terminal_id == "terminal-popup"
|
||
));
|
||
let mouse =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(mouse.requests.is_empty());
|
||
assert!(mouse.actions.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn popup_close_reprocesses_held_key_repeats_into_the_focused_pane() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface_with_popup());
|
||
|
||
let press = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()),
|
||
)]);
|
||
assert!(matches!(
|
||
&press.requests[..],
|
||
[ClientMessage::ClientShellPopupInput { .. }]
|
||
));
|
||
|
||
state.set_pane_surface(surface());
|
||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())
|
||
.with_kind(crossterm::event::KeyEventKind::Repeat),
|
||
)]);
|
||
assert!(matches!(
|
||
&repeat.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn pending_popup_suppresses_held_pane_repeats_but_preserves_release() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty());
|
||
assert!(matches!(
|
||
&state
|
||
.handle_raw_events(vec![RawInputEvent::Key(key.clone())])
|
||
.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { .. }]
|
||
));
|
||
|
||
state.popup_pending = true;
|
||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
key.clone()
|
||
.with_kind(crossterm::event::KeyEventKind::Repeat),
|
||
)]);
|
||
assert!(repeat.requests.is_empty());
|
||
let release = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
key.with_kind(crossterm::event::KeyEventKind::Release),
|
||
)]);
|
||
assert!(matches!(
|
||
&release.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { events, .. }]
|
||
if matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Key {
|
||
kind: crate::protocol::ClientKeyKind::Release,
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn pane_mouse_release_survives_popup_open_transition() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("pane frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
|
||
let down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&down.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { .. }]
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_some());
|
||
|
||
state.set_pane_surface(surface_with_popup());
|
||
let up =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&up.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Up(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn popup_command_blocks_underlying_input_until_surface_or_error() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
let binding = crate::config::CustomCommandKeybind {
|
||
bindings: crate::config::ActionKeybinds::prefix("t"),
|
||
label: "prefix+t".into(),
|
||
command: "secret-popup-command".into(),
|
||
action: crate::config::CustomCommandAction::Popup,
|
||
description: None,
|
||
width: None,
|
||
height: None,
|
||
};
|
||
let mut projection = snapshot();
|
||
projection
|
||
.commands
|
||
.push(crate::protocol::ClientShellCommand {
|
||
command_id: "cmd_popup".into(),
|
||
binding_label: binding.label.clone(),
|
||
action: crate::protocol::ClientShellCommandAction::Popup,
|
||
});
|
||
state.set_snapshot(Box::new(projection));
|
||
state.set_pane_surface(surface());
|
||
|
||
let mut invoke = ClientShellInput::default();
|
||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke);
|
||
assert!(state.popup_pending);
|
||
assert!(state
|
||
.handle_input_bytes(b"not-for-pane")
|
||
.requests
|
||
.is_empty());
|
||
assert!(state
|
||
.handle_raw_events(vec![RawInputEvent::Paste("secret".into())])
|
||
.requests
|
||
.is_empty());
|
||
|
||
let request_id = match &invoke.actions[..] {
|
||
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
|
||
other => panic!("expected popup command request, got {other:?}"),
|
||
};
|
||
let (repaint, _) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Err(ClientShellEndpointError {
|
||
code: Some("command_failed".into()),
|
||
message: "popup failed".into(),
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(!state.popup_pending);
|
||
assert!(matches!(
|
||
&state.handle_input_bytes(b"p").requests[..],
|
||
[ClientMessage::ClientShellPaneInput { .. }]
|
||
));
|
||
|
||
let binding = crate::config::CustomCommandKeybind {
|
||
bindings: crate::config::ActionKeybinds::prefix("t"),
|
||
label: "prefix+t".into(),
|
||
command: "secret-popup-command".into(),
|
||
action: crate::config::CustomCommandAction::Popup,
|
||
description: None,
|
||
width: None,
|
||
height: None,
|
||
};
|
||
let mut invoke = ClientShellInput::default();
|
||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke);
|
||
let request_id = match &invoke.actions[..] {
|
||
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
|
||
other => panic!("expected popup command request, got {other:?}"),
|
||
};
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(crate::api::schema::ResponseResult::Ok {}),
|
||
);
|
||
assert!(state.popup_pending);
|
||
assert!(state
|
||
.handle_input_bytes(b"still-blocked")
|
||
.requests
|
||
.is_empty());
|
||
let deadline = state.popup_pending_deadline.expect("pending timeout");
|
||
state.tick_popup_pending(deadline);
|
||
assert!(!state.popup_pending);
|
||
}
|
||
|
||
#[test]
|
||
fn shell_refuses_mismatched_projection_and_clears_stale_hits_in_either_order() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("initial frame");
|
||
assert!(!state.hits.panes.is_empty());
|
||
|
||
let mut replacement = snapshot();
|
||
replacement.revision = 2;
|
||
state.set_snapshot(Box::new(replacement));
|
||
assert!(state.hits.panes.is_empty());
|
||
assert!(state.compose(106, 20).is_none());
|
||
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("restored frame");
|
||
let mut replacement_surface = surface();
|
||
replacement_surface.projection_revision = 2;
|
||
state.set_pane_surface(replacement_surface);
|
||
assert!(state.hits.panes.is_empty());
|
||
assert!(state.compose(106, 20).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_hits_use_stable_workspace_tab_and_pane_ids() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
|
||
let workspace_down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 2,
|
||
row: 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(workspace_down.actions.is_empty());
|
||
let workspace =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 2,
|
||
row: 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(workspace.requests.is_empty());
|
||
let [ClientShellAction::Endpoint { request, .. }] = &workspace.actions[..] else {
|
||
panic!("workspace click should use the endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceFocus(target)
|
||
if target.workspace_id == "ws_1"
|
||
));
|
||
|
||
let pane =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 27,
|
||
row: 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(pane.requests.is_empty());
|
||
let [ClientShellAction::Endpoint { request, .. }] = &pane.actions[..] else {
|
||
panic!("pane click should use the endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn resize_invalidation_drops_stale_hits_but_preserves_gesture_release() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x + 1,
|
||
row: pane.inner_rect.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(state.pane_mouse_gesture.is_some());
|
||
|
||
state.invalidate_pane_surface();
|
||
assert!(state.pane_surface.is_none());
|
||
assert!(state.hits.panes.is_empty());
|
||
let stale_click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Right),
|
||
column: 27,
|
||
row: 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(stale_click.requests.is_empty());
|
||
assert!(stale_click.actions.is_empty());
|
||
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: pane.inner_rect.x + 1,
|
||
row: pane.inner_rect.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&release.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Up(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pane_split_drag_uses_projected_handle_and_stable_tab_path() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.splits.push(PaneSurfaceSplit {
|
||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||
pos: 40,
|
||
area: SurfaceRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 19,
|
||
},
|
||
hit_rect: SurfaceRect {
|
||
x: 40,
|
||
y: 0,
|
||
width: 1,
|
||
height: 19,
|
||
},
|
||
path: vec![false, true],
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("split pane surface");
|
||
let split = state.hits.pane_splits[0].clone();
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: split.hit_rect.x,
|
||
row: split.hit_rect.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
state.chrome_drag,
|
||
Some(ClientChromeDrag::PaneSplit { .. })
|
||
));
|
||
let mut replacement = snapshot();
|
||
replacement.revision = 2;
|
||
replacement
|
||
.tab_bar_right
|
||
.push(crate::protocol::ClientShellTabStatusSegment {
|
||
text: "updated".into(),
|
||
accent: false,
|
||
});
|
||
let mut replacement_surface = surface();
|
||
replacement_surface.projection_revision = 2;
|
||
replacement_surface.splits.push(PaneSurfaceSplit {
|
||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||
pos: 40,
|
||
area: SurfaceRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 19,
|
||
},
|
||
hit_rect: SurfaceRect {
|
||
x: 40,
|
||
y: 0,
|
||
width: 1,
|
||
height: 19,
|
||
},
|
||
path: vec![false, true],
|
||
});
|
||
state.set_snapshot(Box::new(replacement));
|
||
state.set_pane_surface(replacement_surface);
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: split.area.x + 48,
|
||
row: split.hit_rect.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &drag.actions[..] else {
|
||
panic!("pane split drag should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::LayoutSetSplitRatio(params)
|
||
if params.tab_id.as_deref() == Some("tab_1")
|
||
&& params.path == vec![false, true]
|
||
&& (params.ratio - 0.6).abs() < f32::EPSILON
|
||
));
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: split.area.x + 48,
|
||
row: split.hit_rect.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(release.actions.is_empty());
|
||
assert!(state.chrome_drag.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scrollbar_rect = Some(SurfaceRect {
|
||
x: 3,
|
||
y: 0,
|
||
width: 1,
|
||
height: 2,
|
||
});
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
let track = pane.scrollbar_rect.expect("scrollbar track");
|
||
let metrics = pane.scroll.expect("scroll metrics");
|
||
|
||
let track_click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: track.x,
|
||
row: track.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let expected = crate::ui::scrollbar_offset_from_row(metrics, track, track.y);
|
||
assert!(track_click.requests.is_empty());
|
||
assert!(matches!(
|
||
&track_click.actions[..],
|
||
[
|
||
ClientShellAction::Endpoint { request: focus, .. },
|
||
ClientShellAction::Endpoint { request: scroll, .. }
|
||
] if matches!(
|
||
&focus.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
) && matches!(
|
||
&scroll.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.pane_id == "pane_1"
|
||
&& params.offset_from_bottom == expected as u64
|
||
)
|
||
));
|
||
let track_scroll_id = match &track_click.actions[1] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&track_scroll_id,
|
||
Ok(pane_scroll_result(expected as u64, 20, 2)),
|
||
);
|
||
|
||
let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb");
|
||
let thumb_down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: track.x,
|
||
row: thumb.top,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&thumb_down.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
)
|
||
));
|
||
assert!(matches!(
|
||
state.chrome_drag,
|
||
Some(ClientChromeDrag::PaneScrollbar { .. })
|
||
));
|
||
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: track.x,
|
||
row: track.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let expected = crate::ui::scrollbar_offset_from_drag_row(metrics, track, track.y, 0);
|
||
assert!(matches!(
|
||
&drag.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.pane_id == "pane_1"
|
||
&& params.offset_from_bottom == expected as u64
|
||
)
|
||
));
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(release.actions.is_empty());
|
||
assert!(state.chrome_drag.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn edit_scrollback_binding_targets_the_focused_endpoint_pane() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut input = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::EditScrollback),
|
||
&mut input,
|
||
);
|
||
|
||
assert!(input.requests.is_empty());
|
||
assert!(matches!(
|
||
&input.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneEditScrollback(target)
|
||
if target.pane_id == "pane_1"
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() {
|
||
let mut config = Config::default();
|
||
config.ui.mouse_capture = false;
|
||
let mut projected = snapshot();
|
||
let mut second_tab = projected.tabs[0].clone();
|
||
second_tab.tab_id = "tab_2".into();
|
||
second_tab.number = 2;
|
||
second_tab.label = "2".into();
|
||
second_tab.focused = false;
|
||
projected.tabs.push(second_tab);
|
||
let mut pane_surface = surface();
|
||
pane_surface.splits.push(PaneSurfaceSplit {
|
||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||
pos: 40,
|
||
area: SurfaceRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 19,
|
||
},
|
||
hit_rect: SurfaceRect {
|
||
x: 40,
|
||
y: 0,
|
||
width: 1,
|
||
height: 19,
|
||
},
|
||
path: Vec::new(),
|
||
});
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("mouse-disabled shell");
|
||
assert!(state.hits.pane_splits.is_empty());
|
||
let first_tab = state.hits.tabs[0].0;
|
||
let wheel =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: first_tab.x,
|
||
row: first_tab.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&wheel.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2"
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
|
||
let down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&down.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
)
|
||
));
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| !selection.is_visible()));
|
||
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: pane.inner_rect.x + 2,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(drag.repaint);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_visible));
|
||
let selected = state.compose(106, 20).expect("selected frame");
|
||
let selected_cell =
|
||
&selected.cells[usize::from(pane.inner_rect.y) * 106 + usize::from(pane.inner_rect.x)];
|
||
assert_ne!(
|
||
selected_cell.bg,
|
||
crate::protocol::color_to_u32(ratatui::style::Color::Reset)
|
||
);
|
||
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: pane.inner_rect.x + 2,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(state.selection.is_none());
|
||
let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else {
|
||
panic!("selection release should request endpoint extraction");
|
||
};
|
||
let request_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneSelectionRead(params)
|
||
if params.pane_id == "pane_1"
|
||
&& params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 }
|
||
&& params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 }
|
||
));
|
||
|
||
let (repaint, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||
pane_id: "pane_1".into(),
|
||
text: "LIV".into(),
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(matches!(
|
||
&actions[..],
|
||
[ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIV"
|
||
));
|
||
assert_eq!(
|
||
state
|
||
.copy_feedback
|
||
.as_ref()
|
||
.map(|feedback| feedback.message.as_str()),
|
||
Some("copied to clipboard")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn client_double_click_selects_and_copies_endpoint_row_word() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
let click = || {
|
||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x + 1,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})
|
||
};
|
||
let release = || {
|
||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: pane.inner_rect.x + 1,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})
|
||
};
|
||
|
||
state.handle_raw_events(vec![click()]);
|
||
state.handle_raw_events(vec![release()]);
|
||
let second = state.handle_raw_events(vec![click()]);
|
||
let ClientShellAction::Endpoint { request, .. } = second
|
||
.actions
|
||
.iter()
|
||
.find(|action| {
|
||
matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
|
||
)
|
||
})
|
||
.expect("word-row read")
|
||
else {
|
||
unreachable!()
|
||
};
|
||
let word_request_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneSelectionRead(params)
|
||
if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 }
|
||
&& params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 }
|
||
));
|
||
|
||
let (repaint, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&word_request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||
pane_id: "pane_1".into(),
|
||
text: "LIVE".into(),
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_finalized));
|
||
let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else {
|
||
panic!("auto-copy should read the selected word");
|
||
};
|
||
let copy_request_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneSelectionRead(params)
|
||
if params.anchor.col == 0 && params.cursor.col == 3
|
||
));
|
||
let (_, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
©_request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||
pane_id: "pane_1".into(),
|
||
text: "LIVE".into(),
|
||
}),
|
||
);
|
||
assert!(matches!(
|
||
&actions[..],
|
||
[ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.config.copy_on_select = false;
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
for event in [
|
||
crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
},
|
||
crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: pane.inner_rect.x + 2,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
},
|
||
crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: pane.inner_rect.x + 2,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
},
|
||
] {
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(event)]);
|
||
}
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_finalized));
|
||
|
||
let copy = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||
)]);
|
||
assert!(state.selection.is_none());
|
||
assert!(matches!(
|
||
©.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
|
||
));
|
||
assert!(copy.requests.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_edge_drag_requests_scroll_and_timer_continues_it() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x,
|
||
row: pane.inner_rect.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: pane.inner_rect.x,
|
||
row: pane.inner_rect.y.saturating_sub(1),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&drag.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 3
|
||
)
|
||
));
|
||
let drag_request_id = match &drag.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
let now = std::time::Instant::now();
|
||
state.selection_autoscroll_deadline = Some(now);
|
||
let tick = state.tick_selection_autoscroll(now);
|
||
assert!(tick.actions.is_empty());
|
||
let (_, next_scroll) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&drag_request_id,
|
||
Ok(pane_scroll_result(3, 20, 3)),
|
||
);
|
||
assert!(matches!(
|
||
&next_scroll[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 4
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.row),
|
||
Some(21)
|
||
);
|
||
assert!(enter.actions.is_empty());
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('b'),
|
||
KeyModifiers::CONTROL,
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Prefix);
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
|
||
let page = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()),
|
||
)]);
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.row),
|
||
Some(20)
|
||
);
|
||
assert!(matches!(
|
||
&page.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 1
|
||
)
|
||
));
|
||
let page_request_id = match &page.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
|
||
let top = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()),
|
||
)]);
|
||
assert!(top.actions.is_empty());
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.row),
|
||
Some(0)
|
||
);
|
||
let (_, top_actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&page_request_id,
|
||
Ok(pane_scroll_result(1, 20, 2)),
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &top_actions[..] else {
|
||
panic!("latest queued scroll should follow the completed request");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.pane_id == "pane_1" && params.offset_from_bottom == 20
|
||
));
|
||
let top_request_id = request.id.clone();
|
||
state.handle_endpoint_result("boot-1", &top_request_id, Ok(pane_scroll_result(20, 20, 2)));
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('v'),
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('l'),
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_visible));
|
||
|
||
let copy = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()),
|
||
)]);
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
assert!(state.copy_mode.is_none());
|
||
assert!(state.selection.is_none());
|
||
assert_eq!(copy.actions.len(), 2);
|
||
assert!(copy.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
|
||
)));
|
||
assert!(copy.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 0
|
||
)
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn keyboard_copy_mode_content_motion_is_endpoint_backed_and_stale_safe() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 0,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let origin = state.copy_mode.as_ref().expect("copy mode").cursor;
|
||
|
||
let motion = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &motion.actions[..] else {
|
||
panic!("word motion should use endpoint semantics");
|
||
};
|
||
let request_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneCopyMotion(params)
|
||
if params.cursor == origin
|
||
&& params.motion == crate::api::schema::PaneCopyMotion::NextWordStart
|
||
));
|
||
let (repaint, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneCopyMotion {
|
||
pane_id: "pane_1".into(),
|
||
cursor: crate::api::schema::PaneTextPoint {
|
||
row: origin.row,
|
||
col: 3,
|
||
},
|
||
content_revision: 0,
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(actions.is_empty());
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.col),
|
||
Some(3)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let origin = state.copy_mode.as_ref().expect("copy mode").cursor;
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('?'),
|
||
KeyModifiers::SHIFT,
|
||
))]);
|
||
assert!(state.copy_mode.as_ref().is_some_and(|mode| {
|
||
mode.search_prompt.as_ref().is_some_and(|prompt| {
|
||
prompt.direction == crate::api::schema::PaneCopySearchDirection::Backward
|
||
})
|
||
}));
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert!(state
|
||
.copy_mode
|
||
.as_ref()
|
||
.is_some_and(|mode| mode.search_prompt.is_none()));
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('/'),
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||
"junk",
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('u'),
|
||
KeyModifiers::CONTROL,
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||
"nee",
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Paste("dleX".into())]);
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Backspace,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(
|
||
state
|
||
.copy_mode
|
||
.as_ref()
|
||
.and_then(|mode| mode.search_prompt.as_ref())
|
||
.map(|prompt| prompt.query.as_str()),
|
||
Some("needle")
|
||
);
|
||
|
||
let search = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &search.actions[..] else {
|
||
panic!("search should use endpoint terminal semantics");
|
||
};
|
||
let request_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneCopySearch(params)
|
||
if params.pane_id == "pane_1"
|
||
&& params.query == "needle"
|
||
&& params.direction == crate::api::schema::PaneCopySearchDirection::Forward
|
||
&& params.cursor == origin
|
||
&& params.previous.is_none()
|
||
));
|
||
let matches = vec![
|
||
crate::api::schema::PaneTextRange {
|
||
start: crate::api::schema::PaneTextPoint { row: 5, col: 2 },
|
||
end: crate::api::schema::PaneTextPoint { row: 5, col: 7 },
|
||
},
|
||
crate::api::schema::PaneTextRange {
|
||
start: crate::api::schema::PaneTextPoint { row: 15, col: 1 },
|
||
end: crate::api::schema::PaneTextPoint { row: 15, col: 6 },
|
||
},
|
||
];
|
||
let (repaint, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(copy_search_result(matches.clone(), Some(0))),
|
||
);
|
||
assert!(repaint);
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.row),
|
||
Some(5)
|
||
);
|
||
assert!(actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 15
|
||
)
|
||
)));
|
||
let initial_scroll_id = actions
|
||
.iter()
|
||
.find_map(|action| match action {
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) =>
|
||
{
|
||
Some(request.id.clone())
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("initial search scroll");
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&initial_scroll_id,
|
||
Ok(pane_scroll_result(15, 20, 2)),
|
||
);
|
||
let mut scrolled_surface = state.pane_surface.clone().expect("pane surface");
|
||
scrolled_surface.panes[0]
|
||
.scroll
|
||
.as_mut()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom = 15;
|
||
state.set_pane_surface(scrolled_surface);
|
||
let frame = state.compose(106, 20).expect("search frame");
|
||
let hit = state.hits.panes[0].clone();
|
||
let viewport_top = 5u16;
|
||
let restored = frame.to_ratatui_buffer().expect("search frame buffer");
|
||
let highlighted = restored
|
||
.cell((hit.inner_rect.x + 2, hit.inner_rect.y + (5 - viewport_top)))
|
||
.expect("highlighted search cell");
|
||
assert_eq!(highlighted.bg, state.config.palette.accent);
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('v'),
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else {
|
||
panic!("repeat should use endpoint search");
|
||
};
|
||
let repeat_id = request.id.clone();
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneCopySearch(params)
|
||
if params.direction == crate::api::schema::PaneCopySearchDirection::Forward
|
||
&& params.previous == Some(matches[0])
|
||
));
|
||
let (_, repeat_actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&repeat_id,
|
||
Ok(copy_search_result(matches.clone(), Some(1))),
|
||
);
|
||
if let Some(scroll_id) = repeat_actions.iter().find_map(|action| match action {
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) =>
|
||
{
|
||
Some(request.id.clone())
|
||
}
|
||
_ => None,
|
||
}) {
|
||
state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(6, 20, 2)));
|
||
}
|
||
assert_eq!(
|
||
state.copy_mode.as_ref().map(|mode| mode.cursor.row),
|
||
Some(15)
|
||
);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_visible));
|
||
|
||
let reverse = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &reverse.actions[..] else {
|
||
panic!("reverse search should use endpoint search");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneCopySearch(params)
|
||
if params.direction == crate::api::schema::PaneCopySearchDirection::Backward
|
||
&& params.previous == Some(matches[1])
|
||
));
|
||
let (_, reverse_actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request.id,
|
||
Ok(copy_search_result(matches.clone(), Some(0))),
|
||
);
|
||
if let Some(scroll_id) = reverse_actions.iter().find_map(|action| match action {
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) =>
|
||
{
|
||
Some(request.id.clone())
|
||
}
|
||
_ => None,
|
||
}) {
|
||
state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(15, 20, 2)));
|
||
}
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert!(state
|
||
.copy_mode
|
||
.as_ref()
|
||
.is_some_and(|mode| mode.search_query.is_empty() && mode.selection.is_none()));
|
||
let exit = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()),
|
||
)]);
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
assert!(exit.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneScroll(params)
|
||
if params.offset_from_bottom == 0
|
||
)
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_search_match_copies_after_in_flight_repeat() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let matches = vec![
|
||
crate::api::schema::PaneTextRange {
|
||
start: crate::api::schema::PaneTextPoint { row: 5, col: 2 },
|
||
end: crate::api::schema::PaneTextPoint { row: 5, col: 7 },
|
||
},
|
||
crate::api::schema::PaneTextRange {
|
||
start: crate::api::schema::PaneTextPoint { row: 15, col: 1 },
|
||
end: crate::api::schema::PaneTextPoint { row: 15, col: 6 },
|
||
},
|
||
];
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Char('/'),
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||
"needle",
|
||
))]);
|
||
let initial = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &initial.actions[..] else {
|
||
panic!("initial search request");
|
||
};
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request.id,
|
||
Ok(copy_search_result(matches.clone(), Some(0))),
|
||
);
|
||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()),
|
||
)]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else {
|
||
panic!("repeat search request");
|
||
};
|
||
let repeat_id = request.id.clone();
|
||
|
||
let early_copy = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()),
|
||
)]);
|
||
assert!(early_copy.actions.is_empty());
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
|
||
let (_, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&repeat_id,
|
||
Ok(copy_search_result(matches, Some(1))),
|
||
);
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
let selection_request_id = actions
|
||
.iter()
|
||
.find_map(|action| match action {
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
request.method,
|
||
crate::api::schema::Method::PaneSelectionRead(_)
|
||
) =>
|
||
{
|
||
Some(request.id.clone())
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("deferred selection read");
|
||
let (_, clipboard) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&selection_request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||
pane_id: "pane_1".into(),
|
||
text: "needle".into(),
|
||
}),
|
||
);
|
||
assert!(matches!(
|
||
&clipboard[..],
|
||
[ClientShellAction::ClipboardWrite(bytes)] if bytes == b"needle"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn pane_mouse_input_keeps_stable_target_and_endpoint_encoding() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
|
||
let click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane.inner_rect.x + 2,
|
||
row: pane.inner_rect.y + 1,
|
||
modifiers: KeyModifiers::ALT,
|
||
})]);
|
||
let [ClientMessage::ClientShellPaneInput { pane_id, events }] = &click.requests[..] else {
|
||
panic!("pane application click should use targeted canonical input");
|
||
};
|
||
assert_eq!(pane_id, "pane_1");
|
||
assert!(matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Down(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
position: ClientMousePosition::Cell { column: 2, row: 1 },
|
||
modifiers,
|
||
..
|
||
}] if *modifiers == KeyModifiers::ALT.bits()
|
||
));
|
||
let moved =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::ALT,
|
||
})]);
|
||
assert!(moved.requests.is_empty());
|
||
assert!(state.pane_mouse_gesture.is_some());
|
||
state.hits.panes.clear();
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::ALT,
|
||
})]);
|
||
assert!(matches!(
|
||
&release.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Up(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pane_pixel_mouse_preserves_pane_relative_pixel_coordinates() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
pane_surface.panes[0].sgr_pixel_mouse = true;
|
||
pane_surface.panes[0].pixel_width = 39;
|
||
pane_surface.panes[0].pixel_height = 38;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
let geometry =
|
||
crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry");
|
||
let x = u32::from(pane.inner_rect.x) * 10 + 21;
|
||
let y = u32::from(pane.inner_rect.y) * 20 + 21;
|
||
let report = format!("\x1b[<0;{x};{y}M");
|
||
|
||
let outcome = state.handle_pixel_mouse(report.as_bytes(), geometry);
|
||
assert!(matches!(
|
||
&outcome.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Down(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
position: ClientMousePosition::Pixels { x: 20, y: 20, .. },
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn pixel_host_reports_use_cells_without_target_pixel_mode_and_release_outside() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
let geometry =
|
||
crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry");
|
||
let x = u32::from(pane.inner_rect.x) * 10 + 21;
|
||
let y = u32::from(pane.inner_rect.y) * 20 + 21;
|
||
|
||
let down = state.handle_pixel_mouse(format!("\x1b[<0;{x};{y}M").as_bytes(), geometry);
|
||
assert!(matches!(
|
||
&down.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { events, .. }]
|
||
if matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
position: ClientMousePosition::Cell { column: 2, row: 1 },
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
|
||
state.hits.panes.clear();
|
||
let release = state.handle_pixel_mouse(b"\x1b[<0;1;1m", geometry);
|
||
assert!(matches!(
|
||
&release.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Up(
|
||
crate::protocol::ClientMouseButton::Left
|
||
),
|
||
position: ClientMousePosition::Cell { .. },
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pane_owned_right_click_forwards_the_complete_gesture() {
|
||
let mut snapshot = snapshot();
|
||
snapshot.panes[0].right_click_passthrough = true;
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].mouse_reporting = true;
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].clone();
|
||
|
||
let down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Right),
|
||
column: pane.inner_rect.x + 1,
|
||
row: pane.inner_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&down.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1"
|
||
));
|
||
assert!(state.overlay.is_none());
|
||
assert!(state.pane_mouse_gesture.is_some());
|
||
|
||
let up =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Right),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&up.requests[..],
|
||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||
if pane_id == "pane_1"
|
||
&& matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Up(
|
||
crate::protocol::ClientMouseButton::Right
|
||
),
|
||
..
|
||
}]
|
||
)
|
||
));
|
||
assert!(state.pane_mouse_gesture.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() {
|
||
let mut config = Config::default();
|
||
config.ui.prompt_new_workspace_name = false;
|
||
config.ui.prompt_new_tab_name = true;
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
|
||
let new_workspace = state.hits.new_workspace;
|
||
let create_workspace =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: new_workspace.x + 1,
|
||
row: new_workspace.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else {
|
||
panic!("new workspace click should use the endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
request.method,
|
||
crate::api::schema::Method::WorkspaceCreate(_)
|
||
));
|
||
|
||
let new_tab = state.hits.new_tab;
|
||
let open_new_tab =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: new_tab.x + 1,
|
||
row: new_tab.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(open_new_tab.actions.is_empty());
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||
target: ClientRenameTarget::NewTab { .. },
|
||
..
|
||
}))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn tab_overflow_controls_scroll_the_client_owned_tab_bar() {
|
||
let mut snapshot = snapshot();
|
||
snapshot.tabs.extend((2..=8).map(|number| ClientShellTab {
|
||
tab_id: format!("tab_{number}"),
|
||
workspace_id: "ws_1".into(),
|
||
number,
|
||
label: number.to_string(),
|
||
custom_label: false,
|
||
zoomed: false,
|
||
focused: false,
|
||
agent_status: AgentStatus::Idle,
|
||
}));
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
state.set_pane_surface(surface());
|
||
state.compose(80, 20).expect("overflow tab bar");
|
||
|
||
assert!(state.hits.tab_scroll_right.width > 0);
|
||
let scroll_right = state.hits.tab_scroll_right;
|
||
let outcome =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: scroll_right.x + 1,
|
||
row: scroll_right.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(outcome.repaint);
|
||
assert_eq!(state.tab_scroll, 1);
|
||
|
||
let mut update = state.snapshot.as_deref().expect("snapshot").clone();
|
||
update.focused_tab_id = Some("tab_8".into());
|
||
for tab in &mut update.tabs {
|
||
tab.focused = tab.tab_id == "tab_8";
|
||
}
|
||
state.set_snapshot(Box::new(update));
|
||
state.compose(80, 20).expect("focused overflow tab");
|
||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
|
||
|
||
state.compose(300, 20).expect("tabs without overflow");
|
||
assert_eq!(state.tab_scroll, 0);
|
||
assert_eq!(state.hits.tabs.len(), 8);
|
||
state.compose(80, 20).expect("focused tab after narrowing");
|
||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
|
||
}
|
||
|
||
#[test]
|
||
fn client_owned_sidebar_dividers_resize_live() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 30).expect("expanded sidebar");
|
||
let workspace_body = state.hits.workspace_body;
|
||
let needless_scroll =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: workspace_body.x,
|
||
row: workspace_body.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert_eq!(state.hits.workspace_max_scroll, 0);
|
||
assert_eq!(state.workspace_scroll, 0);
|
||
assert!(!needless_scroll.repaint);
|
||
let width_divider = state.hits.sidebar_divider;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: width_divider.x,
|
||
row: width_divider.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let resize =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: 31,
|
||
row: width_divider.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert_eq!(state.sidebar_width, 32);
|
||
assert!(state.sidebar_width_manual);
|
||
assert!(resize.repaint);
|
||
assert!(resize.resize);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 31,
|
||
row: width_divider.y + 2,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 30).expect("resized sidebar");
|
||
let section_divider = state.hits.sidebar_section_divider;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: section_divider.x + 2,
|
||
row: section_divider.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let split =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: section_divider.x + 2,
|
||
row: 20,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(state.sidebar_section_split > 0.6);
|
||
assert!(split.repaint);
|
||
assert!(!split.resize);
|
||
}
|
||
|
||
#[test]
|
||
fn manual_client_chrome_preferences_round_trip_per_endpoint() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-client-shell-prefs-{}.json",
|
||
std::process::id()
|
||
));
|
||
let _ = std::fs::remove_file(&path);
|
||
let config =
|
||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||
let mut state = ClientShellState::new(config);
|
||
state.sidebar_width = 31;
|
||
state.sidebar_width_manual = true;
|
||
state.sidebar_section_split = 0.7;
|
||
state.sidebar_section_split_manual = true;
|
||
state.sidebar_collapsed = true;
|
||
state.sidebar_collapsed_manual = true;
|
||
state.persist_chrome_preferences(&mut ClientShellInput::default());
|
||
|
||
let reloaded_config =
|
||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||
let reloaded = ClientShellState::new(reloaded_config);
|
||
assert_eq!(reloaded.sidebar_width, 31);
|
||
assert!(reloaded.sidebar_width_manual);
|
||
assert_eq!(reloaded.sidebar_section_split, 0.7);
|
||
assert!(reloaded.sidebar_section_split_manual);
|
||
assert!(reloaded.sidebar_collapsed);
|
||
assert!(reloaded.sidebar_collapsed_manual);
|
||
std::fs::remove_file(path).expect("remove client chrome preferences");
|
||
}
|
||
|
||
#[test]
|
||
fn tab_click_waits_for_release_and_drag_reorders_by_stable_id() {
|
||
let mut projected = snapshot();
|
||
for index in 2..=3 {
|
||
let mut tab = projected.tabs[0].clone();
|
||
tab.tab_id = format!("tab_{index}");
|
||
tab.number = index;
|
||
tab.label = index.to_string();
|
||
tab.focused = false;
|
||
projected.tabs.push(tab);
|
||
}
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("three tabs");
|
||
let first = state.hits.tabs[0].0;
|
||
let third = state.hits.tabs[2].0;
|
||
|
||
let down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: first.x + 1,
|
||
row: first.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(down.actions.is_empty());
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: third.right().saturating_sub(1),
|
||
row: third.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(drag.repaint);
|
||
assert!(matches!(
|
||
state.chrome_drag,
|
||
Some(ClientChromeDrag::Tab {
|
||
ref tab_id,
|
||
insert_index: Some(3),
|
||
..
|
||
}) if tab_id == "tab_1"
|
||
));
|
||
let frame = state.compose(106, 20).expect("tab drop indicator");
|
||
assert!(frame
|
||
.cells
|
||
.iter()
|
||
.take(frame.width as usize)
|
||
.any(|cell| cell.symbol == "│"));
|
||
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: third.right().saturating_sub(1),
|
||
row: third.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else {
|
||
panic!("tab drag should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::TabMove(params)
|
||
if params.tab_id == "tab_1" && params.insert_index == 3
|
||
));
|
||
|
||
state.compose(106, 20).expect("tabs after drag");
|
||
let second = state.hits.tabs[1].0;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: second.x + 1,
|
||
row: second.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: second.x + 1,
|
||
row: second.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&click.actions[0],
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(&request.method, crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn tab_drag_clears_its_drop_target_after_leaving_the_tab_row() {
|
||
let mut projected = snapshot();
|
||
for index in 2..=3 {
|
||
let mut tab = projected.tabs[0].clone();
|
||
tab.tab_id = format!("tab_{index}");
|
||
tab.number = index;
|
||
tab.label = index.to_string();
|
||
tab.focused = false;
|
||
projected.tabs.push(tab);
|
||
}
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("three tabs");
|
||
let first = state.hits.tabs[0].0;
|
||
let third = state.hits.tabs[2].0;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: first.x + 1,
|
||
row: first.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: third.x,
|
||
row: third.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: third.x,
|
||
row: third.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
state.chrome_drag,
|
||
Some(ClientChromeDrag::Tab {
|
||
insert_index: None,
|
||
..
|
||
})
|
||
));
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: third.x,
|
||
row: third.y + 1,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(release.actions.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn tab_wheel_switches_tabs_without_changing_overflow_scroll() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("tab bar");
|
||
let tab = state.hits.tabs[0].0;
|
||
|
||
let outcome =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: tab.x,
|
||
row: tab.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&outcome.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_1"
|
||
)
|
||
));
|
||
assert_eq!(state.tab_scroll, 0);
|
||
state.compose(106, 20).expect("tab bar after wheel");
|
||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_1"));
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_workspace_jitter_remains_a_click() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.sidebar_collapsed = true;
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("collapsed sidebar");
|
||
let workspace = state.hits.workspaces[0].rect;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: workspace.x,
|
||
row: workspace.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: workspace.x + 1,
|
||
row: workspace.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(state.chrome_drag.is_none());
|
||
assert!(state.workspace_press.is_some());
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: workspace.x + 1,
|
||
row: workspace.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&release.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceFocus(target)
|
||
if target.workspace_id == "ws_1"
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn sidebar_scrollbars_use_proportional_shared_geometry_and_drag() {
|
||
let mut projected = snapshot();
|
||
for index in 2..=10 {
|
||
let mut workspace = projected.workspaces[0].clone();
|
||
workspace.workspace_id = format!("ws_{index}");
|
||
workspace.number = index;
|
||
workspace.label = format!("workspace-{index}");
|
||
workspace.focused = false;
|
||
projected.workspaces.push(workspace);
|
||
}
|
||
for index in 1..=10 {
|
||
projected.agents.push(crate::protocol::ClientShellAgent {
|
||
pane_id: format!("agent-pane-{index}"),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some(format!("agent-{index}")),
|
||
display_agent: None,
|
||
agent: Some("codex".into()),
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Idle,
|
||
state_change_seq: index,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: false,
|
||
});
|
||
}
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("overflowing sidebars");
|
||
|
||
for agent in [false, true] {
|
||
let (track, metrics) = if agent {
|
||
(
|
||
state.hits.agent_scrollbar,
|
||
state.hits.agent_scroll_metrics.expect("agent metrics"),
|
||
)
|
||
} else {
|
||
(
|
||
state.hits.workspace_scrollbar,
|
||
state
|
||
.hits
|
||
.workspace_scroll_metrics
|
||
.expect("workspace metrics"),
|
||
)
|
||
};
|
||
assert!(track.width > 0);
|
||
let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb");
|
||
assert!(thumb.len > 1);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: track.x,
|
||
row: thumb.top,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let dragged =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: track.x,
|
||
row: track.bottom().saturating_sub(1),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(dragged.repaint);
|
||
if agent {
|
||
assert_eq!(state.agent_scroll, metrics.max_offset_from_bottom);
|
||
} else {
|
||
assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom);
|
||
}
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: track.x,
|
||
row: track.bottom().saturating_sub(1),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() {
|
||
let mut projected = snapshot();
|
||
projected.tab_bar_right = vec![
|
||
crate::protocol::ClientShellTabStatusSegment {
|
||
text: "ZOOM".into(),
|
||
accent: true,
|
||
},
|
||
crate::protocol::ClientShellTabStatusSegment {
|
||
text: "host".into(),
|
||
accent: false,
|
||
},
|
||
];
|
||
projected.tab_bar_right_separator = " · ".into();
|
||
for number in 2..=8 {
|
||
projected.tabs.push(ClientShellTab {
|
||
tab_id: format!("tab_{number}"),
|
||
workspace_id: "ws_1".into(),
|
||
number,
|
||
label: number.to_string(),
|
||
custom_label: false,
|
||
zoomed: false,
|
||
focused: false,
|
||
agent_status: AgentStatus::Idle,
|
||
});
|
||
}
|
||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||
config.mobile_width_threshold = 0;
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
let frame = state.compose(106, 20).expect("status and overflow tabs");
|
||
let top = frame.cells[..frame.width as usize]
|
||
.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>();
|
||
assert!(top.contains("ZOOM · host"));
|
||
assert!(top.contains('…'));
|
||
|
||
state.tab_scroll = usize::MAX;
|
||
state.reveal_focused_tab = false;
|
||
state.compose(106, 20).expect("clamped tab scroll");
|
||
assert!(state.tab_scroll < 7);
|
||
let manual_scroll = state.tab_scroll;
|
||
let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone();
|
||
replacement.revision = 2;
|
||
replacement.tab_bar_right[1].text = "tick".into();
|
||
let mut replacement_surface = surface();
|
||
replacement_surface.projection_revision = 2;
|
||
state.set_snapshot(Box::new(replacement));
|
||
state.set_pane_surface(replacement_surface);
|
||
assert!(!state.reveal_focused_tab);
|
||
state.compose(106, 20).expect("same-width status update");
|
||
assert_eq!(state.tab_scroll, manual_scroll);
|
||
|
||
state.compose(45, 20).expect("narrow tabs win over status");
|
||
let narrow = state.compose(45, 20).expect("narrow tab frame");
|
||
let top = narrow.cells[..narrow.width as usize]
|
||
.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>();
|
||
assert!(!top.contains("ZOOM · host"));
|
||
}
|
||
|
||
#[test]
|
||
fn context_menus_capture_stable_targets_and_route_actions() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
|
||
let workspace = state.hits.workspaces[0].rect;
|
||
let open_workspace_menu =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Right),
|
||
column: workspace.x + 2,
|
||
row: workspace.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(open_workspace_menu.actions.is_empty());
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay {
|
||
target: ClientContextMenuTarget::Workspace { ref workspace_id, .. },
|
||
..
|
||
})) if workspace_id == "ws_1"
|
||
));
|
||
let workspace_items = match state.overlay.as_ref() {
|
||
Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(),
|
||
_ => panic!("workspace context menu"),
|
||
};
|
||
assert!(workspace_items
|
||
.iter()
|
||
.any(|item| item.action == ClientContextMenuAction::NewWorktree));
|
||
state.compose(106, 20).expect("workspace context menu");
|
||
let rename = state.hits.context_menu_rows[0].0;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: rename.x + 1,
|
||
row: rename.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||
target: ClientRenameTarget::Workspace { ref workspace_id },
|
||
..
|
||
})) if workspace_id == "ws_1"
|
||
));
|
||
|
||
state.overlay = None;
|
||
state.compose(106, 20).expect("composed frame");
|
||
let pane = state.hits.panes[0].rect;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Right),
|
||
column: pane.x + 1,
|
||
row: pane.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.compose(106, 20).expect("pane context menu");
|
||
let split_index = match state.overlay.as_ref() {
|
||
Some(ClientShellOverlay::ContextMenu(menu)) => menu
|
||
.items()
|
||
.iter()
|
||
.position(|item| item.action == ClientContextMenuAction::SplitRight)
|
||
.expect("split right item"),
|
||
_ => panic!("pane context menu"),
|
||
};
|
||
let split = state.hits.context_menu_rows[split_index].0;
|
||
let outcome =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: split.x + 1,
|
||
row: split.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
|
||
panic!("pane split context action should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneSplit(params)
|
||
if params.target_pane_id.as_deref() == Some("pane_1")
|
||
&& params.direction == crate::api::schema::SplitDirection::Right
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn context_menu_keyboard_and_outside_click_are_client_owned() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let tab = state.hits.tabs[0].0;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Right),
|
||
column: tab.x + 1,
|
||
row: tab.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.compose(106, 20).expect("tab context menu");
|
||
let moved = state.handle_input_bytes(b"\x1b[B");
|
||
assert!(moved.repaint);
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay {
|
||
highlighted: 1,
|
||
..
|
||
}))
|
||
));
|
||
let text = state.handle_raw_events(vec![RawInputEvent::Text(
|
||
crate::input::TextCommit::new("not pane input"),
|
||
)]);
|
||
assert!(text.requests.is_empty());
|
||
let paste = state.handle_raw_events(vec![RawInputEvent::Paste("not pane input".into())]);
|
||
assert!(paste.requests.is_empty());
|
||
let outside =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 105,
|
||
row: 19,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(outside.repaint);
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn grouped_worktrees_render_parent_branch_and_indented_child() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let mut state = ClientShellState::new(config);
|
||
let mut snapshot = snapshot();
|
||
snapshot.workspaces[0].worktree = Some(ClientShellWorktree {
|
||
key: "repo".into(),
|
||
label: "repo".into(),
|
||
is_linked_worktree: false,
|
||
});
|
||
snapshot.workspaces.push(ClientShellWorkspace {
|
||
workspace_id: "ws_2".into(),
|
||
number: 2,
|
||
label: "repo-feature".into(),
|
||
custom_label: false,
|
||
branch: Some("worktree/feature".into()),
|
||
git_ahead_behind: None,
|
||
tokens: Vec::new(),
|
||
worktree: Some(ClientShellWorktree {
|
||
key: "repo".into(),
|
||
label: "repo".into(),
|
||
is_linked_worktree: true,
|
||
}),
|
||
focused: false,
|
||
agent_status: AgentStatus::Idle,
|
||
});
|
||
state.set_snapshot(Box::new(snapshot));
|
||
state.set_pane_surface(surface());
|
||
let frame = state.compose(106, 20).expect("composed frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("main"));
|
||
assert!(text.contains("└─"));
|
||
assert!(text.contains("feature"));
|
||
|
||
let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone();
|
||
replacement.revision = 2;
|
||
replacement.workspaces[1].agent_status = AgentStatus::Blocked;
|
||
let mut replacement_surface = surface();
|
||
replacement_surface.projection_revision = 2;
|
||
state.collapsed_groups.insert("repo".into());
|
||
state.set_snapshot(Box::new(replacement));
|
||
state.set_pane_surface(replacement_surface);
|
||
let collapsed = state.compose(106, 20).expect("collapsed worktree group");
|
||
let parent = state.hits.workspaces[0].rect;
|
||
let status_cell = usize::from(parent.y) * usize::from(collapsed.width)
|
||
+ usize::from(parent.x.saturating_add(1));
|
||
assert_eq!(
|
||
collapsed.cells[status_cell].fg,
|
||
crate::protocol::color_to_u32(state.config.palette.red)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() {
|
||
let mut projected = snapshot();
|
||
for index in 2..=3 {
|
||
let mut workspace = projected.workspaces[0].clone();
|
||
workspace.workspace_id = format!("ws_{index}");
|
||
workspace.number = index;
|
||
workspace.label = format!("workspace-{index}");
|
||
workspace.focused = false;
|
||
projected.workspaces.push(workspace);
|
||
}
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 24).expect("three workspaces");
|
||
let first = state.hits.workspaces[0].rect;
|
||
let third = state.hits.workspaces[2].rect;
|
||
|
||
let down =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: first.x + 2,
|
||
row: first.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(down.actions.is_empty());
|
||
let drag =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: third.x + 2,
|
||
row: third.bottom(),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(drag.repaint);
|
||
assert!(matches!(
|
||
state.chrome_drag,
|
||
Some(ClientChromeDrag::Workspace {
|
||
ref source_workspace_id,
|
||
target: Some((None, _)),
|
||
}) if source_workspace_id == "ws_1"
|
||
));
|
||
let frame = state.compose(106, 24).expect("workspace drop indicator");
|
||
assert!(frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.any(|row| row.iter().take(20).any(|cell| cell.symbol == "─")));
|
||
|
||
let release =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: third.x + 2,
|
||
row: third.bottom(),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&release.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceMove(params)
|
||
if params.workspace_id == "ws_1" && params.insert_index == 3
|
||
)
|
||
));
|
||
|
||
state.compose(106, 24).expect("workspaces after drag");
|
||
let second = state.hits.workspaces[1].rect;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: second.x + 2,
|
||
row: second.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: second.x + 2,
|
||
row: second.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&click.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceFocus(target)
|
||
if target.workspace_id == "ws_2"
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() {
|
||
let mut projected = snapshot();
|
||
projected.workspaces[0].worktree = Some(ClientShellWorktree {
|
||
key: "repo".into(),
|
||
label: "repo".into(),
|
||
is_linked_worktree: false,
|
||
});
|
||
let mut child = projected.workspaces[0].clone();
|
||
child.workspace_id = "ws_child".into();
|
||
child.number = 2;
|
||
child.label = "feature".into();
|
||
child.focused = false;
|
||
child.worktree = Some(ClientShellWorktree {
|
||
key: "repo".into(),
|
||
label: "repo".into(),
|
||
is_linked_worktree: true,
|
||
});
|
||
let mut other = projected.workspaces[0].clone();
|
||
other.workspace_id = "ws_other".into();
|
||
other.number = 3;
|
||
other.label = "other".into();
|
||
other.focused = false;
|
||
other.worktree = None;
|
||
projected.workspaces.extend([child, other]);
|
||
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 24).expect("worktree workspaces");
|
||
assert!(state.hits.workspaces[1].indented);
|
||
let parent = state.hits.workspaces[0].rect;
|
||
let child = state.hits.workspaces[1].rect;
|
||
let other = state.hits.workspaces[2].rect;
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: parent.x + 2,
|
||
row: parent.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: other.x + 2,
|
||
row: other.bottom(),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let moved =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: other.x + 2,
|
||
row: other.bottom(),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(matches!(
|
||
&moved.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceMoveBlock(params)
|
||
if params.workspace_ids == ["ws_1", "ws_child"]
|
||
&& params.before_workspace_id.is_none()
|
||
)
|
||
));
|
||
|
||
state.compose(106, 24).expect("worktree child");
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: child.x + 2,
|
||
row: child.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let dragging_child =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: other.x + 2,
|
||
row: other.bottom(),
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(dragging_child.actions.is_empty());
|
||
assert!(state.chrome_drag.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn shell_targets_unconsumed_input_and_keeps_prefix_local() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
|
||
let text = state.handle_input_bytes(b"hello");
|
||
assert_eq!(text.requests.len(), 1);
|
||
let ClientMessage::ClientShellPaneInput { pane_id, events } = &text.requests[0] else {
|
||
panic!("expected targeted pane input");
|
||
};
|
||
assert_eq!(pane_id, "pane_1");
|
||
assert_eq!(events.len(), 5);
|
||
assert!(matches!(
|
||
&events[0],
|
||
ClientPaneInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Char('h'),
|
||
generated_text: Some(text),
|
||
..
|
||
} if text == "h"
|
||
));
|
||
|
||
let interrupt = state.handle_input_bytes(b"\x1b[99;5u");
|
||
assert_eq!(interrupt.requests.len(), 1);
|
||
let ClientMessage::ClientShellPaneInput { events, .. } = &interrupt.requests[0] else {
|
||
panic!("expected semantic interrupt");
|
||
};
|
||
assert!(matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Char('c'),
|
||
modifiers,
|
||
kind: crate::protocol::ClientKeyKind::Press,
|
||
..
|
||
}] if *modifiers == KeyModifiers::CONTROL.bits()
|
||
));
|
||
|
||
let alt = state.handle_input_bytes(b"\x1b[120;3u");
|
||
let ClientMessage::ClientShellPaneInput { events, .. } = &alt.requests[0] else {
|
||
panic!("expected semantic alt key");
|
||
};
|
||
assert!(matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Char('x'),
|
||
modifiers,
|
||
..
|
||
}] if *modifiers == KeyModifiers::ALT.bits()
|
||
));
|
||
assert!(!state.handle_input_bytes(&[0x02]).detach);
|
||
let detach = state.handle_input_bytes(b"q");
|
||
assert!(detach.detach);
|
||
assert!(detach.requests.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn configured_prefix_is_client_owned_and_renders_its_bar() {
|
||
let config = toml::from_str::<Config>(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
detach = "prefix+x"
|
||
"#,
|
||
)
|
||
.expect("configured keybinds");
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
|
||
let old_default = state.handle_input_bytes(&[0x02]);
|
||
assert_eq!(
|
||
old_default.requests.len(),
|
||
1,
|
||
"ctrl-b should reach the pane"
|
||
);
|
||
|
||
let prefix = state.handle_input_bytes(&[0x01]);
|
||
assert!(prefix.requests.is_empty());
|
||
assert!(prefix.repaint);
|
||
let frame = state.compose(106, 20).expect("prefix frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("PREFIX"), "frame: {text:?}");
|
||
assert!(text.contains("ctrl+a"), "frame: {text:?}");
|
||
|
||
let detach = state.handle_input_bytes(b"x");
|
||
assert!(detach.detach);
|
||
assert!(detach.requests.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn prefix_endpoint_action_uses_public_api_with_stable_ids() {
|
||
let mut config = Config::default();
|
||
config.ui.prompt_new_tab_name = false;
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
|
||
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
|
||
let create = state.handle_input_bytes(b"c");
|
||
let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else {
|
||
panic!("expected one endpoint action: {:?}", create.actions);
|
||
};
|
||
assert_eq!(boot_id, "boot-1");
|
||
match &request.method {
|
||
crate::api::schema::Method::TabCreate(params) => {
|
||
assert_eq!(params.workspace_id.as_deref(), Some("ws_1"));
|
||
assert!(params.focus);
|
||
}
|
||
other => panic!("expected tab.create, got {other:?}"),
|
||
}
|
||
assert!(state.pending_requests.contains_key(&request.id));
|
||
}
|
||
|
||
#[test]
|
||
fn pane_key_release_keeps_the_press_target() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
|
||
let press = state.handle_input_bytes(b"\x1b[99;5u");
|
||
let release = state.handle_input_bytes(b"\x1b[99;5:3u");
|
||
let ClientMessage::ClientShellPaneInput {
|
||
pane_id: press_target,
|
||
..
|
||
} = &press.requests[0]
|
||
else {
|
||
panic!("expected targeted press");
|
||
};
|
||
let ClientMessage::ClientShellPaneInput {
|
||
pane_id: release_target,
|
||
events,
|
||
} = &release.requests[0]
|
||
else {
|
||
panic!("expected targeted release");
|
||
};
|
||
assert_eq!(release_target, press_target);
|
||
assert!(matches!(
|
||
&events[..],
|
||
[ClientPaneInputEvent::Key {
|
||
kind: crate::protocol::ClientKeyKind::Release,
|
||
..
|
||
}]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn help_overlay_uses_live_keymap_and_owns_filter_state() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help),
|
||
&mut open,
|
||
);
|
||
let initial = state.compose(106, 30).expect("help overlay");
|
||
let text = initial
|
||
.cells
|
||
.chunks(initial.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("keybinds"));
|
||
assert!(text.contains("prefix mode"));
|
||
|
||
assert!(state.handle_input_bytes(b"/").actions.is_empty());
|
||
assert!(state.handle_input_bytes(b"workspace").actions.is_empty());
|
||
let filtered = state.compose(106, 30).expect("filtered help");
|
||
let text = filtered
|
||
.cells
|
||
.chunks(filtered.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("workspace navigation"));
|
||
assert!(!text.contains("prefix mode"));
|
||
assert!(filtered
|
||
.cursor
|
||
.as_ref()
|
||
.is_some_and(|cursor| cursor.visible));
|
||
|
||
assert!(state.handle_input_bytes(b"\x1b").repaint);
|
||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_))));
|
||
assert!(state.handle_input_bytes(b"\x1b").repaint);
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn global_menu_opens_from_sidebar_and_routes_client_actions() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 30).expect("shell frame");
|
||
let launcher = state.hits.global_launcher;
|
||
assert_ne!(launcher, Rect::default());
|
||
|
||
let open =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: launcher.x,
|
||
row: launcher.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(open.repaint);
|
||
let menu = state.compose(106, 30).expect("global menu");
|
||
let text = menu
|
||
.cells
|
||
.chunks(menu.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("settings"));
|
||
assert!(text.contains("keybinds"));
|
||
assert!(text.contains("reload config"));
|
||
assert!(text.contains("detach"));
|
||
|
||
let keybinds = state.hits.global_menu_rows[1].0;
|
||
let help =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: keybinds.x,
|
||
row: keybinds.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(help.actions.is_empty());
|
||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_))));
|
||
|
||
state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay {
|
||
highlighted: 3,
|
||
}));
|
||
let detach = state.handle_input_bytes(b"\r");
|
||
assert!(detach.detach);
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overlay() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay {
|
||
highlighted: 0,
|
||
}));
|
||
let open = state.handle_input_bytes(b"\r");
|
||
assert!(open.actions.is_empty());
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
|
||
section: ClientSettingsSection::Theme,
|
||
..
|
||
}))
|
||
));
|
||
let original_theme = state.config.theme_name.clone();
|
||
let original_palette = state.config.palette.clone();
|
||
state.handle_input_bytes(b"j");
|
||
assert_ne!(state.config.theme_name, original_theme);
|
||
assert_ne!(state.config.palette.accent, original_palette.accent);
|
||
state.handle_input_bytes(b"\x1b");
|
||
assert!(state.overlay.is_none());
|
||
assert_eq!(state.config.theme_name, original_theme);
|
||
assert_eq!(state.config.palette.accent, original_palette.accent);
|
||
|
||
state.open_settings_overlay();
|
||
state.handle_input_bytes(b"j");
|
||
state.handle_input_bytes(b"\t");
|
||
state
|
||
.compose(106, 30)
|
||
.expect("settings outside-click geometry");
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(state.overlay.is_none());
|
||
assert_eq!(state.config.theme_name, original_theme);
|
||
assert_eq!(state.config.palette.accent, original_palette.accent);
|
||
|
||
state.open_settings_overlay();
|
||
state.compose(106, 30).expect("settings overlay");
|
||
for _ in 0..3 {
|
||
let next = state.handle_input_bytes(b"\t");
|
||
assert!(next.actions.is_empty());
|
||
}
|
||
let integrations = state.handle_input_bytes(b"\t");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &integrations.actions[..] else {
|
||
panic!("integration section should request endpoint status");
|
||
};
|
||
assert!(matches!(
|
||
request.method,
|
||
crate::api::schema::Method::IntegrationList(_)
|
||
));
|
||
let request_id = request.id.clone();
|
||
assert!(
|
||
state
|
||
.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(crate::api::schema::ResponseResult::IntegrationList {
|
||
integrations: vec![
|
||
crate::api::schema::IntegrationInfo {
|
||
target: crate::api::schema::IntegrationTarget::Codex,
|
||
label: "codex".into(),
|
||
command: "codex".into(),
|
||
available: true,
|
||
state: crate::api::schema::IntegrationState::Outdated,
|
||
},
|
||
crate::api::schema::IntegrationInfo {
|
||
target: crate::api::schema::IntegrationTarget::Claude,
|
||
label: "claude".into(),
|
||
command: "claude".into(),
|
||
available: false,
|
||
state: crate::api::schema::IntegrationState::NotInstalled,
|
||
},
|
||
],
|
||
}),
|
||
)
|
||
.0
|
||
);
|
||
let frame = state.compose(106, 30).expect("loaded integrations");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("update available"));
|
||
assert!(text.contains("not found"));
|
||
assert!(!text.contains("pane labels"));
|
||
|
||
let popup = state.hits.settings_popup;
|
||
let blank_click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: popup.right().saturating_sub(2),
|
||
row: popup.y + 3,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(!blank_click.repaint);
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Settings(_))
|
||
));
|
||
|
||
let install = state.handle_input_bytes(b"\r");
|
||
assert_eq!(install.actions.len(), 1);
|
||
assert!(matches!(
|
||
&install.actions[0],
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
request.method,
|
||
crate::api::schema::Method::IntegrationInstall(
|
||
crate::api::schema::IntegrationInstallParams {
|
||
target: crate::api::schema::IntegrationTarget::Codex
|
||
}
|
||
)
|
||
)
|
||
));
|
||
let escape = state.handle_input_bytes(b"\x1b");
|
||
assert!(!escape.repaint);
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Settings(_))
|
||
));
|
||
let install_request_id = match &install.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!("integration install action"),
|
||
};
|
||
let (repaint, refresh_actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&install_request_id,
|
||
Ok(crate::api::schema::ResponseResult::IntegrationInstall {
|
||
target: crate::api::schema::IntegrationTarget::Codex,
|
||
details: crate::api::schema::IntegrationInstallResult {
|
||
messages: vec!["installed codex".into()],
|
||
},
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(matches!(
|
||
refresh_actions.as_slice(),
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(request.method, crate::api::schema::Method::IntegrationList(_))
|
||
));
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
|
||
loading_integrations: true,
|
||
installing_integrations: false,
|
||
ref integration_messages,
|
||
..
|
||
})) if integration_messages == &["installed codex"]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn custom_binding_invokes_only_the_endpoint_manifest_id() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
let binding = crate::config::CustomCommandKeybind {
|
||
bindings: crate::config::ActionKeybinds::prefix("z"),
|
||
label: "prefix+z".into(),
|
||
command: "secret-command --token hidden".into(),
|
||
action: crate::config::CustomCommandAction::Shell,
|
||
description: None,
|
||
width: None,
|
||
height: None,
|
||
};
|
||
let mut projection = snapshot();
|
||
projection
|
||
.commands
|
||
.push(crate::protocol::ClientShellCommand {
|
||
command_id: "cmd_0123456789abcdef0123456789abcdef".into(),
|
||
binding_label: binding.label.clone(),
|
||
action: crate::protocol::ClientShellCommandAction::Shell,
|
||
});
|
||
state.set_snapshot(Box::new(projection));
|
||
|
||
let mut outcome = ClientShellInput::default();
|
||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
|
||
|
||
let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
|
||
panic!("expected endpoint command invocation");
|
||
};
|
||
let crate::api::schema::Method::CommandInvoke(params) = &request.method else {
|
||
panic!("expected command.invoke");
|
||
};
|
||
assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef");
|
||
assert_eq!(params.workspace_id.as_deref(), Some("ws_1"));
|
||
assert_eq!(params.tab_id.as_deref(), Some("tab_1"));
|
||
assert_eq!(params.pane_id.as_deref(), Some("pane_1"));
|
||
assert!(!serde_json::to_string(request)
|
||
.unwrap()
|
||
.contains("secret-command"));
|
||
}
|
||
|
||
#[test]
|
||
fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let binding = crate::config::CustomCommandKeybind {
|
||
bindings: crate::config::ActionKeybinds::prefix("z"),
|
||
label: "prefix+z".into(),
|
||
command: "secret-command".into(),
|
||
action: crate::config::CustomCommandAction::Shell,
|
||
description: None,
|
||
width: None,
|
||
height: None,
|
||
};
|
||
|
||
let mut outcome = ClientShellInput::default();
|
||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
|
||
|
||
assert!(outcome.actions.is_empty());
|
||
assert!(outcome.repaint);
|
||
assert!(state
|
||
.endpoint_error
|
||
.as_deref()
|
||
.is_some_and(|error| error.contains("not available")));
|
||
}
|
||
|
||
#[test]
|
||
fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state
|
||
.config
|
||
.keybinds
|
||
.keybinds
|
||
.custom_commands
|
||
.push(crate::config::CustomCommandKeybind {
|
||
bindings: crate::config::ActionKeybinds::prefix("z"),
|
||
label: "prefix+z".into(),
|
||
command: "plugin.action".into(),
|
||
action: crate::config::CustomCommandAction::PluginAction,
|
||
description: Some("run plugin action".into()),
|
||
width: None,
|
||
height: None,
|
||
});
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help),
|
||
&mut open,
|
||
);
|
||
let initial = state.compose(106, 30).expect("help overlay");
|
||
let text = initial
|
||
.cells
|
||
.chunks(initial.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("global"));
|
||
assert!(state.hits.help_max_scroll > 0);
|
||
assert_ne!(state.hits.help_scrollbar, Rect::default());
|
||
|
||
state.handle_input_bytes(b"/");
|
||
state.handle_input_bytes(b"plugin");
|
||
let custom = state.compose(106, 30).expect("custom help search");
|
||
let text = custom
|
||
.cells
|
||
.chunks(custom.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("custom"));
|
||
assert!(text.contains("run plugin action"));
|
||
state.handle_input_bytes(b"\x1b");
|
||
|
||
state.handle_input_bytes(b"/");
|
||
state.handle_input_bytes(b"does-not-exist");
|
||
let empty = state.compose(106, 30).expect("empty help search");
|
||
let text = empty
|
||
.cells
|
||
.chunks(empty.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("no matching keybinds"));
|
||
|
||
state.handle_input_bytes(b"\x1b");
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Help(ClientHelpOverlay {
|
||
search_focused: false,
|
||
ref query,
|
||
scroll: 0,
|
||
})) if query.is_empty()
|
||
));
|
||
state.compose(106, 30).expect("restored help");
|
||
state.handle_input_bytes(b"\x1b[F");
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. }))
|
||
if scroll == state.hits.help_max_scroll
|
||
));
|
||
state.handle_input_bytes(b"\x1b[H");
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Help(ClientHelpOverlay {
|
||
scroll: 0,
|
||
..
|
||
}))
|
||
));
|
||
state.handle_input_bytes(b"?");
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() {
|
||
let mut initial = snapshot();
|
||
let mut second = initial.panes[0].clone();
|
||
second.pane_id = "pane_2".into();
|
||
second.focused = false;
|
||
initial.panes.push(second);
|
||
initial.agents = vec![
|
||
ClientShellAgent {
|
||
pane_id: "pane_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("first".into()),
|
||
display_agent: None,
|
||
agent: None,
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Idle,
|
||
state_change_seq: 1,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: true,
|
||
},
|
||
ClientShellAgent {
|
||
pane_id: "pane_2".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("second".into()),
|
||
display_agent: None,
|
||
agent: None,
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Idle,
|
||
state_change_seq: 2,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: false,
|
||
},
|
||
];
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(initial.clone()));
|
||
|
||
let mut cycle = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CyclePaneNext),
|
||
&mut cycle,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &cycle.actions[..] else {
|
||
panic!("pane cycle should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"
|
||
));
|
||
|
||
let mut replacement = initial;
|
||
replacement.revision = 2;
|
||
replacement.focused_pane_id = Some("pane_2".into());
|
||
replacement.panes[0].focused = false;
|
||
replacement.panes[1].focused = true;
|
||
state.set_snapshot(Box::new(replacement));
|
||
let mut last = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::LastPane),
|
||
&mut last,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &last.actions[..] else {
|
||
panic!("last pane should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
));
|
||
|
||
let mut agent = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(1)),
|
||
&mut agent,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &agent.actions[..] else {
|
||
panic!("agent focus should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() {
|
||
let mut projected = snapshot();
|
||
let mut second_pane = projected.panes[0].clone();
|
||
second_pane.pane_id = "pane_2".into();
|
||
second_pane.focused = false;
|
||
projected.panes.push(second_pane);
|
||
projected.agents = vec![
|
||
ClientShellAgent {
|
||
pane_id: "pane_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("pi one".into()),
|
||
display_agent: None,
|
||
agent: Some("pi".into()),
|
||
title: None,
|
||
terminal_title: Some("first title".into()),
|
||
terminal_title_stripped: Some("first".into()),
|
||
agent_status: AgentStatus::Done,
|
||
state_change_seq: 10,
|
||
state_labels: Vec::new(),
|
||
tokens: vec![("summary".into(), "review complete".into())],
|
||
focused: true,
|
||
},
|
||
ClientShellAgent {
|
||
pane_id: "pane_2".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("pi two".into()),
|
||
display_agent: None,
|
||
agent: Some("pi".into()),
|
||
title: None,
|
||
terminal_title: Some("second title".into()),
|
||
terminal_title_stripped: Some("second".into()),
|
||
agent_status: AgentStatus::Blocked,
|
||
state_change_seq: 20,
|
||
state_labels: vec![("blocked".into(), "needs input".into())],
|
||
tokens: vec![("summary".into(), "waiting for Can".into())],
|
||
focused: false,
|
||
},
|
||
];
|
||
let mut config = Config::default();
|
||
config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
|
||
config.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols;
|
||
config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
|
||
config.ui.sidebar.agents.rows_by_agent.insert(
|
||
"pi".into(),
|
||
vec![
|
||
vec![
|
||
crate::config::AgentSidebarToken::StateIcon,
|
||
crate::config::AgentSidebarToken::StateText,
|
||
],
|
||
vec![
|
||
crate::config::AgentSidebarToken::Agent,
|
||
crate::config::AgentSidebarToken::Custom("summary".into()),
|
||
],
|
||
],
|
||
);
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
|
||
let frame = state.compose(106, 30).expect("agent sidebar frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("× needs input"), "frame: {text}");
|
||
assert!(text.contains("pi two"), "frame: {text}");
|
||
assert!(text.contains("waiting for"), "frame: {text}");
|
||
assert_eq!(
|
||
state
|
||
.hits
|
||
.agents
|
||
.first()
|
||
.map(|(_, pane_id)| pane_id.as_str()),
|
||
Some("pane_2")
|
||
);
|
||
|
||
let first = state.hits.agents[0].0;
|
||
let click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: first.x,
|
||
row: first.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else {
|
||
panic!("agent row should focus through endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"
|
||
));
|
||
|
||
state.compose(106, 10).expect("short agent sidebar frame");
|
||
assert_eq!(
|
||
state
|
||
.hits
|
||
.agents
|
||
.first()
|
||
.map(|(_, pane_id)| pane_id.as_str()),
|
||
Some("pane_2")
|
||
);
|
||
let body = state.hits.agent_body;
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: body.x,
|
||
row: body.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
state
|
||
.compose(106, 10)
|
||
.expect("scrolled agent sidebar frame");
|
||
assert_eq!(
|
||
state
|
||
.hits
|
||
.agents
|
||
.first()
|
||
.map(|(_, pane_id)| pane_id.as_str()),
|
||
Some("pane_1")
|
||
);
|
||
|
||
state.sidebar_collapsed = true;
|
||
let compact = state.compose(106, 30).expect("compact agent sidebar frame");
|
||
let blocked = state
|
||
.hits
|
||
.agents
|
||
.iter()
|
||
.find(|(_, pane_id)| pane_id == "pane_2")
|
||
.expect("blocked compact agent")
|
||
.0;
|
||
let row_start = blocked.y as usize * compact.width as usize + blocked.x as usize;
|
||
assert_ne!(compact.cells[row_start].fg, compact.cells[row_start + 2].fg);
|
||
assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg);
|
||
}
|
||
|
||
#[test]
|
||
fn active_agent_view_controls_sidebar_order_and_focus_indices() {
|
||
let mut projected = snapshot();
|
||
let mut second_pane = projected.panes[0].clone();
|
||
second_pane.pane_id = "pane_2".into();
|
||
second_pane.focused = false;
|
||
projected.panes.push(second_pane);
|
||
projected.agents = vec![
|
||
ClientShellAgent {
|
||
pane_id: "pane_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("first".into()),
|
||
display_agent: None,
|
||
agent: Some("pi".into()),
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Idle,
|
||
state_change_seq: 1,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: true,
|
||
},
|
||
ClientShellAgent {
|
||
pane_id: "pane_2".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("second".into()),
|
||
display_agent: None,
|
||
agent: Some("pi".into()),
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Blocked,
|
||
state_change_seq: 2,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: false,
|
||
},
|
||
];
|
||
projected.agent_view_label = Some("review".into());
|
||
projected.agent_order = vec!["pane_2".into()];
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 30).expect("filtered agent sidebar");
|
||
assert_eq!(
|
||
state
|
||
.hits
|
||
.agents
|
||
.iter()
|
||
.map(|(_, pane_id)| pane_id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec!["pane_2"]
|
||
);
|
||
assert_eq!(state.hits.agent_sort_toggle, Rect::default());
|
||
|
||
let mut focus = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(0)),
|
||
&mut focus,
|
||
);
|
||
assert!(matches!(
|
||
&focus.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_sort_toggle_is_client_local_and_persists_per_endpoint() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-shell-agent-sort-{}-{}.json",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.expect("clock after epoch")
|
||
.as_nanos()
|
||
));
|
||
let mut projected = snapshot();
|
||
projected.agents.push(ClientShellAgent {
|
||
pane_id: "pane_1".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
name: Some("pi".into()),
|
||
display_agent: None,
|
||
agent: Some("pi".into()),
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Working,
|
||
state_change_seq: 1,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: true,
|
||
});
|
||
let config =
|
||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||
let mut state = ClientShellState::new(config);
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 30).expect("agent sidebar frame");
|
||
let toggle = state.hits.agent_sort_toggle;
|
||
|
||
let click =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: toggle.x,
|
||
row: toggle.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
|
||
assert_eq!(
|
||
state.config.agent_panel_sort,
|
||
crate::config::AgentPanelSortConfig::Priority
|
||
);
|
||
assert!(click.actions.is_empty());
|
||
let reloaded_config =
|
||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||
let reloaded = ClientShellState::new(reloaded_config);
|
||
assert_eq!(
|
||
reloaded.config.agent_panel_sort,
|
||
crate::config::AgentPanelSortConfig::Priority
|
||
);
|
||
assert!(reloaded.agent_panel_sort_manual);
|
||
std::fs::remove_file(path).expect("remove agent sort preferences");
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_actions_preserve_selected_target_and_client_confirmation() {
|
||
let mut snapshot = snapshot();
|
||
let mut second = snapshot.workspaces[0].clone();
|
||
second.workspace_id = "ws_2".into();
|
||
second.number = 2;
|
||
second.label = "second".into();
|
||
second.focused = false;
|
||
snapshot.workspaces.push(second);
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
state.navigate_workspace_id = Some("ws_2".into());
|
||
|
||
let mut rename = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenameWorkspace),
|
||
&mut rename,
|
||
);
|
||
assert!(matches!(
|
||
state.overlay.as_ref(),
|
||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||
target: ClientRenameTarget::Workspace { workspace_id },
|
||
..
|
||
})) if workspace_id == "ws_2"
|
||
));
|
||
assert!(state.handle_input_bytes(&[0x15]).actions.is_empty());
|
||
assert!(state.handle_input_bytes(b"renamed").actions.is_empty());
|
||
let save = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else {
|
||
panic!("workspace rename should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceRename(params)
|
||
if params.workspace_id == "ws_2" && params.label == "renamed"
|
||
));
|
||
|
||
state.navigate_workspace_id = Some("ws_2".into());
|
||
let mut close = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace),
|
||
&mut close,
|
||
);
|
||
assert!(close.actions.is_empty());
|
||
assert!(matches!(
|
||
state.overlay.as_ref(),
|
||
Some(ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay {
|
||
workspace_id,
|
||
..
|
||
})) if workspace_id == "ws_2"
|
||
));
|
||
let confirm = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else {
|
||
panic!("workspace confirmation should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceClose(params)
|
||
if params.workspace_id == "ws_2" && params.close_group
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn named_workspace_overlay_uses_projected_pane_cwd() {
|
||
let mut config = Config::default();
|
||
config.ui.prompt_new_workspace_name = true;
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace),
|
||
&mut open,
|
||
);
|
||
assert!(matches!(
|
||
state.overlay.as_ref(),
|
||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||
input: value,
|
||
target: ClientRenameTarget::NewWorkspace { cwd, .. },
|
||
..
|
||
})) if value == "repo" && cwd.as_deref() == Some("/repo")
|
||
));
|
||
let create = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
|
||
panic!("named workspace should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceCreate(params)
|
||
if params.cwd.as_deref() == Some("/repo") && params.label.is_none()
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab),
|
||
&mut open,
|
||
);
|
||
assert!(open.actions.is_empty());
|
||
let frame = state.compose(106, 20).expect("new tab overlay");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("new tab"));
|
||
assert!(text.contains("save"));
|
||
let restored = frame.to_ratatui_buffer().expect("overlay frame");
|
||
assert!(!restored
|
||
.cell((26, 7))
|
||
.expect("overlay title cell")
|
||
.modifier
|
||
.contains(Modifier::DIM));
|
||
assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible));
|
||
|
||
assert!(state.handle_input_bytes(b"logs").actions.is_empty());
|
||
let create = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
|
||
panic!("new tab save should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::TabCreate(params)
|
||
if params.workspace_id.as_deref() == Some("ws_1")
|
||
&& params.label.as_deref() == Some("logs")
|
||
));
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rename_pane_empty_value_is_preserved_as_a_clear_request() {
|
||
let mut snapshot = snapshot();
|
||
snapshot.panes[0].label = Some("build".into());
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenamePane),
|
||
&mut open,
|
||
);
|
||
assert!(state.handle_input_bytes(&[0x15]).actions.is_empty());
|
||
let save = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else {
|
||
panic!("pane rename should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneRename(params)
|
||
if params.pane_id == "pane_1" && params.label.as_deref() == Some("")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut close = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane),
|
||
&mut close,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else {
|
||
panic!("pane close should use endpoint API");
|
||
};
|
||
let request_id = request.id.clone();
|
||
assert!(
|
||
state
|
||
.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Err(ClientShellEndpointError {
|
||
code: Some("confirmation_required".into()),
|
||
message: "confirmation required".into(),
|
||
}),
|
||
)
|
||
.0
|
||
);
|
||
let frame = state.compose(106, 20).expect("confirmation overlay");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("Close workspace?"));
|
||
assert!(text.contains("1 pane"));
|
||
|
||
let confirm = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else {
|
||
panic!("confirmation should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceClose(params)
|
||
if params.workspace_id == "ws_1" && params.close_group
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() {
|
||
let mut snapshot = snapshot();
|
||
let mut second = snapshot.workspaces[0].clone();
|
||
second.workspace_id = "ws_2".into();
|
||
second.number = 2;
|
||
second.label = "second".into();
|
||
second.focused = false;
|
||
snapshot.workspaces.push(second);
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
state.set_pane_surface(surface());
|
||
|
||
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
|
||
let enter_navigate = state.handle_input_bytes(b"w");
|
||
assert!(enter_navigate.repaint);
|
||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
|
||
|
||
let invalid = state.handle_input_bytes(b"9");
|
||
assert!(invalid.actions.is_empty());
|
||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
|
||
|
||
let move_selection = state.handle_input_bytes(b"\x1b[B");
|
||
assert!(move_selection.actions.is_empty());
|
||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2"));
|
||
let frame = state.compose(106, 20).expect("navigate frame");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("second"));
|
||
assert!(text.contains("NAVIGATE"));
|
||
|
||
let focus = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &focus.actions[..] else {
|
||
panic!("selected workspace should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorkspaceFocus(target)
|
||
if target.workspace_id == "ws_2"
|
||
));
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
}
|
||
|
||
#[test]
|
||
fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
|
||
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
|
||
assert!(state.handle_input_bytes(b"r").actions.is_empty());
|
||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||
|
||
let modified = state.handle_input_bytes(b"\x1b[1;2D");
|
||
assert!(matches!(
|
||
&modified.actions[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneResize(params)
|
||
if params.direction == crate::api::schema::PaneDirection::Left
|
||
)
|
||
));
|
||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||
|
||
let resize = state.handle_input_bytes(b"h");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else {
|
||
panic!("resize should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneResize(params)
|
||
if params.pane_id.as_deref() == Some("pane_1")
|
||
&& params.direction == crate::api::schema::PaneDirection::Left
|
||
));
|
||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||
|
||
assert!(state.handle_input_bytes(b"\r").actions.is_empty());
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
}
|
||
|
||
#[test]
|
||
fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut open = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNavigator),
|
||
&mut open,
|
||
);
|
||
let navigator = state.compose(106, 30).expect("navigator overlay");
|
||
let navigator_text = navigator
|
||
.cells
|
||
.chunks(navigator.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(navigator_text.contains("client-shell"));
|
||
assert!(navigator_text.contains("pane 1"));
|
||
|
||
let search = state.hits.navigator_search;
|
||
let focus_search =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: search.x,
|
||
row: search.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(focus_search.repaint);
|
||
assert!(matches!(
|
||
state.overlay,
|
||
Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
|
||
search_focused: true,
|
||
..
|
||
}))
|
||
));
|
||
assert!(state.handle_input_bytes(b"client").actions.is_empty());
|
||
let filtered = state.compose(106, 30).expect("filtered navigator");
|
||
assert!(filtered
|
||
.cursor
|
||
.as_ref()
|
||
.is_some_and(|cursor| cursor.visible));
|
||
|
||
state.handle_input_bytes(b"\x1b");
|
||
state.handle_input_bytes(b"a");
|
||
state.compose(106, 30).expect("navigator rows");
|
||
let pane_index = {
|
||
let snapshot = state.snapshot.as_deref().expect("snapshot");
|
||
let ClientShellOverlay::Navigator(navigator) =
|
||
state.overlay.as_ref().expect("navigator")
|
||
else {
|
||
panic!("expected navigator");
|
||
};
|
||
render::client_navigator_rows(snapshot, navigator)
|
||
.iter()
|
||
.position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_)))
|
||
.expect("pane row")
|
||
};
|
||
let pane_rect = state
|
||
.hits
|
||
.navigator_rows
|
||
.iter()
|
||
.find(|(_, index)| *index == pane_index)
|
||
.map(|(rect, _)| *rect)
|
||
.expect("visible pane row");
|
||
let select =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: pane_rect.x + 6,
|
||
row: pane_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert!(select.repaint);
|
||
let accept =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: pane_rect.x + 6,
|
||
row: pane_rect.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &accept.actions[..] else {
|
||
panic!("navigator pane click should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1"
|
||
));
|
||
assert!(state.overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn worktree_create_prepares_from_public_list_and_submits_derived_path() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut prepare = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree),
|
||
&mut prepare,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else {
|
||
panic!("new worktree should prepare through worktree.list");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorktreeList(params)
|
||
if params.workspace_id.as_deref() == Some("ws_1")
|
||
));
|
||
let request_id = request.id.clone();
|
||
assert!(
|
||
state
|
||
.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None)))
|
||
.0
|
||
);
|
||
let frame = state.compose(106, 30).expect("new worktree modal");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("new worktree"));
|
||
assert!(text.contains("create and open"));
|
||
assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible));
|
||
|
||
assert!(state
|
||
.handle_input_bytes(b"feature/client-shell")
|
||
.actions
|
||
.is_empty());
|
||
let submit = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else {
|
||
panic!("worktree create should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorktreeCreate(params)
|
||
if params.workspace_id.as_deref() == Some("ws_1")
|
||
&& params.branch.as_deref() == Some("feature/client-shell")
|
||
&& params.path.as_deref().is_some_and(|path| path.ends_with("repo/feature-client-shell"))
|
||
&& params.focus
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn worktree_open_filters_and_clicks_a_stable_public_entry() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut prepare = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenWorktree),
|
||
&mut prepare,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else {
|
||
panic!("open worktree should prepare through worktree.list");
|
||
};
|
||
let request_id = request.id.clone();
|
||
state.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None)));
|
||
let frame = state.compose(106, 30).expect("open worktree modal");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("feature"));
|
||
let row = state.hits.worktree_rows[0].0;
|
||
let open =
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: row.x + 2,
|
||
row: row.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &open.actions[..] else {
|
||
panic!("worktree row should open through endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorktreeOpen(params)
|
||
if params.workspace_id.as_deref() == Some("ws_1")
|
||
&& params.path.as_deref() == Some("/repo-feature")
|
||
&& params.focus
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn worktree_remove_escalates_dirty_failure_to_force_confirmation() {
|
||
let mut snapshot = snapshot();
|
||
snapshot.workspaces[0].worktree = Some(ClientShellWorktree {
|
||
key: "repo-key".into(),
|
||
label: "repo".into(),
|
||
is_linked_worktree: true,
|
||
});
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot));
|
||
state.set_pane_surface(surface());
|
||
let mut prepare = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::RemoveWorktree),
|
||
&mut prepare,
|
||
);
|
||
let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else {
|
||
panic!("remove worktree should prepare through worktree.list");
|
||
};
|
||
let request_id = request.id.clone();
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(worktree_list_result(Some("ws_1"))),
|
||
);
|
||
let remove = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &remove.actions[..] else {
|
||
panic!("worktree remove should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorktreeRemove(params)
|
||
if params.workspace_id == "ws_1" && !params.force
|
||
));
|
||
let request_id = request.id.clone();
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Err(ClientShellEndpointError {
|
||
code: Some("dirty_worktree_requires_force".into()),
|
||
message: "dirty worktree".into(),
|
||
}),
|
||
);
|
||
let frame = state.compose(106, 30).expect("force remove modal");
|
||
let text = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(text.contains("delete anyway"));
|
||
assert!(text.contains("permanently deleted"));
|
||
let force = state.handle_input_bytes(b"\r");
|
||
let [ClientShellAction::Endpoint { request, .. }] = &force.actions[..] else {
|
||
panic!("forced worktree remove should use endpoint API");
|
||
};
|
||
assert!(matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::WorktreeRemove(params)
|
||
if params.workspace_id == "ws_1" && params.force
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
|
||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||
config.toast_delivery = crate::config::ToastDelivery::Herdr;
|
||
config.toast_delay_seconds = 0;
|
||
let mut state = ClientShellState::new(config);
|
||
let mut projected = snapshot();
|
||
projected.agents.push(ClientShellAgent {
|
||
pane_id: "pane_2".into(),
|
||
workspace_id: "ws_2".into(),
|
||
tab_id: "tab_2".into(),
|
||
name: None,
|
||
display_agent: Some("codex".into()),
|
||
agent: Some("codex".into()),
|
||
title: None,
|
||
terminal_title: None,
|
||
terminal_title_stripped: None,
|
||
agent_status: AgentStatus::Blocked,
|
||
state_change_seq: 1,
|
||
state_labels: Vec::new(),
|
||
tokens: Vec::new(),
|
||
focused: false,
|
||
});
|
||
state.set_snapshot(Box::new(projected));
|
||
state.set_pane_surface(surface());
|
||
let now = std::time::Instant::now();
|
||
let (effects, repaint) = state.receive_notification(
|
||
SemanticNotification {
|
||
kind: SemanticNotificationKind::NeedsAttention,
|
||
title: "codex needs attention".into(),
|
||
body: Some("other · 2".into()),
|
||
sound: Some(SemanticNotificationSound::Request),
|
||
agent: Some("codex".into()),
|
||
workspace_id: Some("ws_2".into()),
|
||
tab_id: Some("tab_2".into()),
|
||
pane_id: Some("pane_2".into()),
|
||
position: None,
|
||
},
|
||
now,
|
||
);
|
||
assert!(repaint);
|
||
assert!(matches!(
|
||
effects.as_slice(),
|
||
[ClientShellNotificationEffect::Sound {
|
||
sound: crate::sound::Sound::Request,
|
||
..
|
||
}]
|
||
));
|
||
let frame = state.compose(100, 28).expect("notification frame");
|
||
let rendered = frame
|
||
.cells
|
||
.chunks(frame.width as usize)
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("codex needs attention"));
|
||
let hit = state.hits.notification_toast;
|
||
let click = RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: hit.x,
|
||
row: hit.y,
|
||
modifiers: KeyModifiers::empty(),
|
||
});
|
||
let outcome = state.handle_raw_events(vec![click]);
|
||
assert!(outcome.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(params)
|
||
if params.pane_id == "pane_2"
|
||
)
|
||
)));
|
||
assert!(state.visible_notification.is_none());
|
||
|
||
state.receive_notification(
|
||
SemanticNotification {
|
||
kind: SemanticNotificationKind::NeedsAttention,
|
||
title: "codex needs attention".into(),
|
||
body: None,
|
||
sound: None,
|
||
agent: Some("codex".into()),
|
||
workspace_id: Some("ws_2".into()),
|
||
tab_id: Some("tab_2".into()),
|
||
pane_id: Some("pane_2".into()),
|
||
position: None,
|
||
},
|
||
now,
|
||
);
|
||
let mut keybind = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNotificationTarget),
|
||
&mut keybind,
|
||
);
|
||
assert!(keybind.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneFocus(params)
|
||
if params.pane_id == "pane_2"
|
||
)
|
||
)));
|
||
assert!(state.visible_notification.is_none());
|
||
|
||
state.receive_notification(
|
||
SemanticNotification {
|
||
kind: SemanticNotificationKind::NeedsAttention,
|
||
title: "first".into(),
|
||
body: None,
|
||
sound: None,
|
||
agent: Some("codex".into()),
|
||
workspace_id: Some("ws_2".into()),
|
||
tab_id: Some("tab_2".into()),
|
||
pane_id: Some("pane_2".into()),
|
||
position: None,
|
||
},
|
||
now,
|
||
);
|
||
assert!(state.visible_notification.is_some());
|
||
state.config.toast_delay_seconds = 1;
|
||
let (_, repaint) = state.receive_notification(
|
||
SemanticNotification {
|
||
kind: SemanticNotificationKind::NeedsAttention,
|
||
title: "replacement".into(),
|
||
body: None,
|
||
sound: None,
|
||
agent: Some("codex".into()),
|
||
workspace_id: Some("ws_2".into()),
|
||
tab_id: Some("tab_2".into()),
|
||
pane_id: Some("pane_2".into()),
|
||
position: None,
|
||
},
|
||
now,
|
||
);
|
||
assert!(repaint);
|
||
assert!(state.visible_notification.is_none());
|
||
assert_eq!(state.pending_notifications.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 10,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
|
||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 0,
|
||
row: 0,
|
||
modifiers: KeyModifiers::empty(),
|
||
})]);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert!(state.copy_mode.is_some());
|
||
|
||
state.handle_input_bytes(b"v");
|
||
assert!(state
|
||
.copy_mode
|
||
.as_ref()
|
||
.is_some_and(|copy_mode| copy_mode.selection.is_some()));
|
||
|
||
let mut unfocused = snapshot();
|
||
unfocused.focused_pane_id = Some("pane_2".into());
|
||
unfocused.panes[0].focused = false;
|
||
unfocused.panes.push(ClientShellPane {
|
||
pane_id: "pane_2".into(),
|
||
workspace_id: "ws_1".into(),
|
||
tab_id: "tab_1".into(),
|
||
label: None,
|
||
cwd: Some("/repo".into()),
|
||
foreground_cwd: Some("/repo".into()),
|
||
focused: true,
|
||
right_click_passthrough: false,
|
||
});
|
||
state.set_snapshot(Box::new(unfocused.clone()));
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
assert!(state
|
||
.copy_mode
|
||
.as_ref()
|
||
.is_some_and(|copy_mode| copy_mode.selection.is_some()));
|
||
|
||
let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix;
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
prefix_key,
|
||
prefix_modifiers,
|
||
))]);
|
||
state.set_snapshot(Box::new(unfocused.clone()));
|
||
assert_eq!(state.mode, ClientShellMode::Prefix);
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
|
||
let mut other_selection =
|
||
crate::selection::Selection::absolute_range("pane_2".to_owned(), (0, 0), (0, 1));
|
||
assert!(other_selection.finish());
|
||
state.selection = Some(other_selection);
|
||
state.set_snapshot(Box::new(unfocused));
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| selection.pane_id == "pane_2"));
|
||
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert!(state.copy_mode.is_some());
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| selection.pane_id == "pane_1"));
|
||
state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||
"ignored",
|
||
))]);
|
||
state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| selection.pane_id == "pane_1"));
|
||
|
||
state.mode = ClientShellMode::Navigate;
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| selection.pane_id == "pane_1"));
|
||
state.mode = ClientShellMode::Resize;
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
KeyCode::Esc,
|
||
KeyModifiers::empty(),
|
||
))]);
|
||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(|selection| selection.pane_id == "pane_1"));
|
||
}
|
||
|
||
#[test]
|
||
fn retained_selection_copy_suppresses_key_repeats() {
|
||
let mut config = Config::default();
|
||
config.ui.copy_on_select = false;
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
let mut selection =
|
||
crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1));
|
||
assert!(selection.finish());
|
||
state.selection = Some(selection);
|
||
|
||
let key = crate::input::TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||
let press = state.handle_raw_events(vec![RawInputEvent::Key(key.clone())]);
|
||
assert!(press.actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
|
||
)));
|
||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(
|
||
key.with_kind(crossterm::event::KeyEventKind::Repeat),
|
||
)]);
|
||
assert!(repeat.actions.is_empty());
|
||
assert!(repeat.requests.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_copy_motions_are_chained_from_the_previous_result() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 0,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let origin = state.copy_mode.as_ref().expect("copy mode").cursor;
|
||
|
||
let first = state.handle_input_bytes(b"w");
|
||
let second = state.handle_input_bytes(b"w");
|
||
assert_eq!(first.actions.len(), 1);
|
||
assert!(second.actions.is_empty());
|
||
let first_id = match &first.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
let intermediate = crate::api::schema::PaneTextPoint {
|
||
row: origin.row,
|
||
col: 2,
|
||
};
|
||
let (_, follow_up) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&first_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneCopyMotion {
|
||
pane_id: "pane_1".into(),
|
||
cursor: intermediate,
|
||
content_revision: 0,
|
||
}),
|
||
);
|
||
assert!(matches!(
|
||
&follow_up[..],
|
||
[ClientShellAction::Endpoint { request, .. }]
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneCopyMotion(params)
|
||
if params.cursor == intermediate
|
||
)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn queued_copy_keys_preserve_prefix_order() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 10,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let origin = state.copy_mode.as_ref().expect("copy mode").cursor;
|
||
let motion = state.handle_input_bytes(b"w");
|
||
state.handle_input_bytes(b"l");
|
||
let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix;
|
||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||
prefix_key,
|
||
prefix_modifiers,
|
||
))]);
|
||
let motion_id = match &motion.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
state.handle_endpoint_result(
|
||
"boot-1",
|
||
&motion_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneCopyMotion {
|
||
pane_id: "pane_1".into(),
|
||
cursor: origin,
|
||
content_revision: 0,
|
||
}),
|
||
);
|
||
assert_eq!(state.mode, ClientShellMode::Prefix);
|
||
assert_eq!(
|
||
state
|
||
.copy_mode
|
||
.as_ref()
|
||
.map(|copy_mode| copy_mode.cursor.col),
|
||
Some(origin.col.saturating_add(1))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reentering_copy_mode_on_the_same_pane_is_a_no_op() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 10,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut first = ClientShellInput::default();
|
||
assert!(state.enter_copy_mode(&mut first));
|
||
state
|
||
.copy_mode
|
||
.as_mut()
|
||
.expect("copy mode")
|
||
.offset_from_bottom = 10;
|
||
let mut reenter = ClientShellInput::default();
|
||
assert!(state.enter_copy_mode(&mut reenter));
|
||
assert!(reenter.actions.is_empty());
|
||
assert_eq!(
|
||
state
|
||
.copy_mode
|
||
.as_ref()
|
||
.map(|copy_mode| copy_mode.entry_offset_from_bottom),
|
||
Some(0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_waits_for_endpoint_motion_before_copying_selection() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 0,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface);
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
state.handle_input_bytes(b"v");
|
||
let origin = state.copy_mode.as_ref().expect("copy mode").cursor;
|
||
let motion = state.handle_input_bytes(b"w");
|
||
let queued_copy = state.handle_input_bytes(b"y");
|
||
assert!(queued_copy.actions.is_empty());
|
||
let motion_id = match &motion.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
let target = crate::api::schema::PaneTextPoint {
|
||
row: origin.row,
|
||
col: 2,
|
||
};
|
||
let (_, actions) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&motion_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneCopyMotion {
|
||
pane_id: "pane_1".into(),
|
||
cursor: target,
|
||
content_revision: 0,
|
||
}),
|
||
);
|
||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||
assert!(actions.iter().any(|action| matches!(
|
||
action,
|
||
ClientShellAction::Endpoint { request, .. }
|
||
if matches!(
|
||
&request.method,
|
||
crate::api::schema::Method::PaneSelectionRead(params)
|
||
if params.anchor == origin && params.cursor == target
|
||
)
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn styled_client_composition_preserves_pane_hyperlinks() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
let linked = Buffer::with_lines(["LIVE", "PANE"]);
|
||
pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks(
|
||
&linked,
|
||
None,
|
||
&[((0, 0), "L".into(), "https://example.test".into())],
|
||
);
|
||
state.set_pane_surface(pane_surface);
|
||
let mut selection =
|
||
crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1));
|
||
assert!(selection.finish());
|
||
state.selection = Some(selection);
|
||
let frame = state.compose(106, 20).expect("composed frame");
|
||
let hit = &state.hits.panes[0];
|
||
let index = usize::from(hit.inner_rect.y) * usize::from(frame.width)
|
||
+ usize::from(hit.inner_rect.x);
|
||
let link = frame.cells[index].hyperlink.expect("linked cell") as usize;
|
||
assert_eq!(frame.hyperlinks[link], "https://example.test");
|
||
}
|
||
|
||
#[test]
|
||
fn new_content_revision_invalidates_copy_search_coordinates() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 0,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface.clone());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let mut enter = ClientShellInput::default();
|
||
state.record_binding(
|
||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||
&mut enter,
|
||
);
|
||
let copy_mode = state.copy_mode.as_mut().expect("copy mode");
|
||
copy_mode.search_query = "needle".into();
|
||
copy_mode
|
||
.search_matches
|
||
.push(crate::api::schema::PaneTextRange {
|
||
start: crate::api::schema::PaneTextPoint { row: 0, col: 0 },
|
||
end: crate::api::schema::PaneTextPoint { row: 0, col: 1 },
|
||
});
|
||
copy_mode.search_total = 1;
|
||
copy_mode.search_current = Some(0);
|
||
copy_mode.search_current_global = Some(0);
|
||
|
||
pane_surface.panes[0].content_revision = 2;
|
||
state.set_pane_surface(pane_surface);
|
||
let copy_mode = state.copy_mode.as_ref().expect("copy mode retained");
|
||
assert!(copy_mode.search_matches.is_empty());
|
||
assert_eq!(copy_mode.search_total, 0);
|
||
assert_eq!(copy_mode.search_current, None);
|
||
}
|
||
|
||
#[test]
|
||
fn pending_scroll_target_does_not_relabel_an_older_surface() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
let mut pane_surface = surface();
|
||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||
offset_from_bottom: 0,
|
||
max_offset_from_bottom: 20,
|
||
viewport_rows: 2,
|
||
});
|
||
state.set_pane_surface(pane_surface.clone());
|
||
let mut outcome = ClientShellInput::default();
|
||
state.push_pane_scroll_offset("pane_1".into(), 10, &mut outcome);
|
||
state.set_pane_surface(pane_surface);
|
||
assert_eq!(
|
||
state
|
||
.pane_surface
|
||
.as_ref()
|
||
.and_then(|surface| surface.panes[0].scroll)
|
||
.map(|scroll| scroll.offset_from_bottom),
|
||
Some(0)
|
||
);
|
||
assert_eq!(state.pane_scroll_targets.get("pane_1"), Some(&10));
|
||
}
|
||
|
||
#[test]
|
||
fn word_selection_result_survives_focus_snapshot_lag() {
|
||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||
state.set_snapshot(Box::new(snapshot()));
|
||
state.set_pane_surface(surface());
|
||
state.compose(106, 20).expect("composed frame");
|
||
let hit = state.hits.panes[0].clone();
|
||
let mut request = ClientShellInput::default();
|
||
state.request_word_selection(&hit, 0, 1, &mut request);
|
||
let request_id = match &request.actions[0] {
|
||
ClientShellAction::Endpoint { request, .. } => request.id.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
let mut lagging = snapshot();
|
||
lagging.focused_pane_id = None;
|
||
lagging.panes[0].focused = false;
|
||
state.set_snapshot(Box::new(lagging));
|
||
let (repaint, _) = state.handle_endpoint_result(
|
||
"boot-1",
|
||
&request_id,
|
||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||
pane_id: "pane_1".into(),
|
||
text: "hello world".into(),
|
||
}),
|
||
);
|
||
assert!(repaint);
|
||
assert!(state
|
||
.selection
|
||
.as_ref()
|
||
.is_some_and(crate::selection::Selection::is_visible));
|
||
}
|
||
|
||
#[test]
|
||
fn mobile_layout_reserves_only_client_header() {
|
||
let config = ClientShellConfig::from_config(&Config::default());
|
||
let state = ClientShellState::new(config);
|
||
let layout = state.layout(44, 20);
|
||
assert_eq!(layout.mobile_header, Rect::new(0, 0, 44, 2));
|
||
assert_eq!(layout.pane_surface, Rect::new(0, 2, 44, 18));
|
||
assert_eq!(
|
||
state.surface_size(44, 20),
|
||
ClientSurfaceSize { cols: 44, rows: 18 }
|
||
);
|
||
}
|
||
}
|