Files
herdr/src/client/shell/tests/endpoints.rs
T

2555 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
#[path = "workspace_navigation.rs"]
mod workspace_navigation;
use crate::client::endpoint::{
ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
};
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
fn remote_profile() -> SavedSshEndpoint {
SavedSshEndpoint {
id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
label: "Build".into(),
target: "dev@build.example".into(),
session: "agents".into(),
enabled: true,
}
}
fn agent(
name: &str,
status: crate::api::schema::AgentStatus,
state_change_seq: u64,
) -> ClientShellAgent {
ClientShellAgent {
pane_id: "pane_1".into(),
workspace_id: "ws_1".into(),
tab_id: "tab_1".into(),
name: Some(name.into()),
display_agent: None,
agent: Some("pi".into()),
title: None,
terminal_title: None,
terminal_title_stripped: None,
agent_status: status,
state_change_seq,
state_labels: Vec::new(),
tokens: Vec::new(),
focused: true,
}
}
fn current_workspace_view() -> crate::api::schema::AgentViewSetParams {
use crate::api::schema::{
AgentViewBuiltinField, AgentViewContext, AgentViewField, AgentViewFilter, AgentViewValue,
};
crate::api::schema::AgentViewSetParams {
source: "example.views".into(),
label: Some("current space".into()),
filter: Some(AgentViewFilter::Eq {
field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
value: AgentViewValue::Context {
context: AgentViewContext::CurrentWorkspaceId,
},
}),
sort: Vec::new(),
}
}
fn state_with_remote() -> (ClientShellState, ClientEndpointId) {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
state.set_snapshot(Box::new(snapshot()));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.workspaces[0].label = "remote-workspace".into();
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
(state, endpoint_id)
}
fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) {
let (mut state, remote) = state_with_remote();
for endpoint_id in [ClientEndpointId::Local, remote.clone()] {
let mut projection = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.unwrap()
.snapshot
.clone()
.unwrap();
projection.agents = (0..8)
.map(|index| ClientShellAgent {
pane_id: format!("pane_{}", index + 1),
focused: index == 0,
..agent(&format!("agent {index}"), AgentStatus::Idle, 1)
})
.collect();
projection.panes = projection
.agents
.iter()
.map(|agent| ClientShellPane {
pane_id: agent.pane_id.clone(),
focused: agent.focused,
..projection.panes[0].clone()
})
.collect();
state.set_endpoint_snapshot(&endpoint_id, projection);
}
state.compose(100, 28).unwrap();
state.agent_scroll = 6;
state.compose(100, 28).unwrap();
assert_eq!(state.agent_scroll, 6);
(state, remote)
}
#[test]
fn agent_navigation_reveals_offscreen_targets() {
use crate::input::KeybindAction;
for action in [
KeybindAction::NextAgent,
KeybindAction::PreviousAgent,
KeybindAction::FocusAgent(0),
] {
let (mut state, remote) = state_with_scrollable_agents();
let (endpoint_id, pane_id) = match action {
KeybindAction::NextAgent => (ClientEndpointId::Local, "pane_2"),
KeybindAction::PreviousAgent => (remote, "pane_8"),
_ => (ClientEndpointId::Local, "pane_1"),
};
state.agent_scroll = if action == KeybindAction::PreviousAgent {
0
} else {
state.hits.agent_max_scroll
};
state.compose(100, 28).unwrap();
assert!(!state
.hits
.endpoint_agents
.iter()
.any(|(_, endpoint, pane)| { endpoint == &endpoint_id && pane == pane_id }));
let mut outcome = ClientShellInput::default();
assert!(state.handle_endpoint_navigation(action, &mut outcome));
assert!(outcome.repaint, "agent navigation must request a frame");
if endpoint_id != state.active_endpoint_id {
assert!(state.activate_endpoint_projection(&endpoint_id));
}
state.compose(100, 28).unwrap();
assert!(
state
.hits
.endpoint_agents
.iter()
.any(|(_, endpoint, pane)| { endpoint == &endpoint_id && pane == pane_id }),
"{action:?} must reveal the selected agent"
);
}
}
#[test]
fn agent_navigation_reveals_target_using_destination_sort() {
use crate::api::schema::{
AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, AgentViewSortOrder,
};
let (mut state, remote) = state_with_scrollable_agents();
for (endpoint_id, base) in [(ClientEndpointId::Local, 0), (remote.clone(), 8)] {
let mut projection = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.unwrap()
.snapshot
.clone()
.unwrap();
for (index, agent) in projection.agents.iter_mut().enumerate() {
agent.state_change_seq = base + index as u64;
}
if endpoint_id == remote {
projection.agent_view_label = Some("recent".into());
}
state.set_endpoint_snapshot(&endpoint_id, projection);
}
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, None);
let mut view = current_workspace_view();
view.label = Some("recent".into());
view.filter = None;
view.sort = vec![AgentViewSort {
field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::StateChangeSeq),
order: AgentViewSortOrder::Desc,
}];
state.set_test_endpoint_agent_view(&remote, Some(view));
state.compose(100, 28).unwrap();
let mut outcome = ClientShellInput::default();
assert!(state
.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(15), &mut outcome,));
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id,
target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
}] if endpoint_id == &remote && pane_id == "pane_8"
));
// A superseded handoff restores its source before activating the new target.
assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
state.compose(100, 28).unwrap();
assert!(state.activate_endpoint_projection(&remote));
state.compose(100, 28).unwrap();
assert!(state
.hits
.endpoint_agents
.iter()
.any(|(_, endpoint, pane)| { endpoint == &remote && pane == "pane_8" }));
}
#[test]
fn agent_navigation_reveal_is_cancelled_by_another_selection() {
for select_pane in [false, true] {
let (mut state, remote) = state_with_scrollable_agents();
let scroll = state.agent_scroll;
let mut outcome = ClientShellInput::default();
assert!(state
.handle_endpoint_navigation(crate::input::KeybindAction::PreviousAgent, &mut outcome,));
assert_eq!(state.agent_scroll, scroll);
if select_pane {
assert!(state.focus_or_activate(
remote.clone(),
ClientEndpointFocusTarget::Pane("pane_1".into()),
&mut outcome,
));
} else {
assert!(state.activate_endpoint(remote.clone(), &mut outcome));
}
assert!(state.activate_endpoint_projection(&remote));
state.compose(100, 28).unwrap();
assert_eq!(state.agent_scroll, scroll);
}
}
#[test]
fn agent_navigation_keeps_scroll_when_target_is_visible() {
let (mut state, _) = state_with_scrollable_agents();
let (_, endpoint_id, pane_id) = state.hits.endpoint_agents[1].clone();
let targets = super::super::aggregate_navigation::online_agent_targets(
&state.endpoints,
&state.active_endpoint_id,
state.config.agent_panel_sort,
);
let index = targets
.iter()
.position(|target| target.endpoint_id == endpoint_id && target.pane_id == pane_id)
.unwrap();
let scroll = state.agent_scroll;
assert!(state.handle_endpoint_navigation(
crate::input::KeybindAction::FocusAgent(index),
&mut ClientShellInput::default(),
));
state.compose(100, 28).unwrap();
assert_eq!(state.agent_scroll, scroll);
}
#[test]
fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() {
let (mut state, remote) = state_with_scrollable_agents();
for endpoint_id in [remote.clone(), ClientEndpointId::Local, remote] {
let visible = state.hits.endpoint_agents.clone();
let (rect, _, pane_id) = visible
.iter()
.find(|(_, endpoint, _)| endpoint == &endpoint_id)
.expect("destination agent remains visible");
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x + 2,
row: rect.y,
modifiers: KeyModifiers::NONE,
})]);
assert!(matches!(
click.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: target,
target: Some(ClientEndpointFocusTarget::Pane(target_pane)),
}] if target == &endpoint_id && target_pane == pane_id
));
state.workspace_scroll = 3;
state.tab_scroll = 2;
assert!(state.activate_endpoint_projection(&endpoint_id));
assert_eq!(state.agent_scroll, 6);
assert_eq!(state.workspace_scroll, 0);
assert_eq!(state.tab_scroll, 0);
assert!(state.pane_surface.is_none());
let mut next_surface = surface();
next_surface.boot_id = state.endpoint_boot_id(&endpoint_id).unwrap().into();
state.set_pane_surface(next_surface);
state.compose(100, 28).unwrap();
assert_eq!(state.agent_scroll, 6);
assert_eq!(state.hits.endpoint_agents, visible);
}
}
#[test]
fn local_agent_click_can_cancel_a_pending_remote_switch() {
for reconnecting in [false, true] {
let (mut state, remote) = state_with_scrollable_agents();
assert!(state.activate_endpoint(remote, &mut ClientShellInput::default()));
if reconnecting {
state.mark_endpoint_disconnected(&ClientEndpointId::Local);
}
state.compose(100, 28).unwrap();
let (rect, _, pane_id) = state
.hits
.endpoint_agents
.iter()
.find(|(_, endpoint, _)| endpoint.is_local())
.unwrap()
.clone();
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x + 2,
row: rect.y,
modifiers: KeyModifiers::NONE,
})]);
assert!(
matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
endpoint_id: ClientEndpointId::Local,
target: Some(ClientEndpointFocusTarget::Pane(target)),
}] if target == &pane_id)
);
}
}
#[test]
fn aggregate_agent_scroll_still_clamps_when_rows_shrink_on_activation() {
let (mut state, remote) = state_with_scrollable_agents();
for endpoint_id in [ClientEndpointId::Local, remote.clone()] {
let mut projection = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.unwrap()
.snapshot
.clone()
.unwrap();
projection.revision += 1;
projection.agents.truncate(1);
state.set_endpoint_snapshot(&endpoint_id, projection);
}
assert!(state.activate_endpoint_projection(&remote));
state.compose(100, 28).unwrap();
assert_eq!(state.agent_scroll, 0);
assert_eq!(state.hits.agent_max_scroll, 0);
assert_eq!(state.hits.endpoint_agents.len(), 2);
}
#[test]
fn same_machine_reboot_still_resets_agent_scroll() {
let (mut state, _) = state_with_scrollable_agents();
let mut projection = state.snapshot.clone().unwrap();
projection.boot_id = "restarted-local".into();
state.cache_endpoint_snapshot(&ClientEndpointId::Local, projection);
assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
assert_eq!(state.agent_scroll, 0);
}
#[test]
fn switching_machines_from_copy_mode_restores_terminal_input() {
let (mut state, remote) = state_with_remote();
let mut local_surface = surface();
local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 2,
});
state.set_pane_surface(local_surface);
state.compose(100, 28).unwrap();
assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
assert_eq!(state.mode, ClientShellMode::Copy);
assert!(state.activate_endpoint_projection(&remote));
let mut remote_surface = surface();
remote_surface.boot_id = "remote-boot".into();
state.set_pane_surface(remote_surface);
state.compose(100, 28).unwrap();
assert!(state.copy_mode.is_none());
assert_eq!(state.mode, ClientShellMode::Terminal);
let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
))]);
assert!(matches!(
input.requests.as_slice(),
[ClientMessage::ClientShellPaneInput { pane_id, events }]
if pane_id == "pane_1" && events.len() == 1
));
}
#[test]
fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() {
let (mut state, remote) = state_with_remote();
let mut profile = remote_profile();
profile.label = "Renamed".into();
state.set_endpoint_catalog(&[profile.clone()]);
assert_eq!(state.endpoint_label(&remote), "Renamed");
assert!(state.endpoint_is_online(&remote));
assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot"));
profile.enabled = false;
state.set_endpoint_catalog(&[profile.clone()]);
assert_eq!(
state.endpoint_status(&remote),
Some(ClientEndpointStatus::Disabled)
);
assert!(!state.endpoint_has_snapshot(&remote));
profile.enabled = true;
state.set_endpoint_catalog(&[profile]);
assert_eq!(
state.endpoint_status(&remote),
Some(ClientEndpointStatus::Connecting)
);
assert!(!state.endpoint_has_snapshot(&remote));
}
#[test]
fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() {
let (mut state, remote) = state_with_remote();
assert!(state.activate_endpoint_projection(&remote));
state.set_pane_surface(surface());
state.mode = ClientShellMode::Prefix;
state.overlay = Some(ClientShellOverlay::Onboarding);
state.select_unavailable_local();
state.retire_endpoint(&remote);
state.set_endpoint_catalog(&[]);
assert!(state.endpoint_is_active(&ClientEndpointId::Local));
assert!(state.snapshot.is_none());
assert!(state.pane_surface.is_none());
assert!(state.pending_pane_surface.is_none());
assert!(state.overlay.is_none());
assert_eq!(state.mode, ClientShellMode::Terminal);
assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local));
let frame = state.compose(100, 30).unwrap();
let buffer = frame.to_ratatui_buffer().unwrap();
let text = buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(!text.contains("remote-workspace"));
}
#[test]
fn machine_navigation_does_not_require_a_local_snapshot_or_surface() {
for (cols, rows) in [(100, 28), (36, 18)] {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
let profile = remote_profile();
let remote = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
state.set_endpoint_snapshot(&remote, Box::new(snapshot()));
assert!(state.snapshot.is_none());
assert!(state.pane_surface.is_none());
let frame = state
.compose(cols, rows)
.expect("connection chrome without Local");
let local = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id.is_local())
.unwrap()
.rect;
let buffer = frame.to_ratatui_buffer().unwrap();
let local_row = (local.x..local.right())
.map(|x| buffer[(x, local.y)].symbol())
.collect::<String>();
assert!(!local_row.contains("reconnecting"));
assert!(
!local_row.contains('◐'),
"Local never gets a connection badge"
);
let hit = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == remote)
.unwrap()
.rect;
let mut outcome = ClientShellInput::default();
state.handle_mouse(
crossterm::event::MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: hit.x + 5,
row: hit.y,
modifiers: KeyModifiers::NONE,
},
&mut outcome,
);
assert!(
matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote)
);
assert!(
state.snapshot.is_none(),
"selection is committed only by coherent activation"
);
}
}
#[test]
fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() {
let (mut state, _) = state_with_remote();
let frame = state.compose(100, 28).expect("combined endpoint 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("Local"));
assert!(text.contains("Build"));
assert!(text.contains("remote-workspace"));
let local = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id.is_local())
.expect("local machine row")
.rect;
let remote = state
.hits
.machines
.iter()
.find(|hit| !hit.endpoint_id.is_local())
.expect("remote machine row")
.rect;
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
assert_eq!(
buffer[(remote.right() - 1, remote.y)].fg,
state.config.palette.green
);
state.sidebar_collapsed = true;
let frame = state.compose(100, 28).expect("collapsed endpoint frame");
let local = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id.is_local())
.expect("collapsed local machine row")
.rect;
let remote = state
.hits
.machines
.iter()
.find(|hit| !hit.endpoint_id.is_local())
.expect("collapsed remote machine row")
.rect;
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
}
#[test]
fn saved_machine_preserves_endpoint_scoped_worktree_collapses() {
fn add_worktree_group(snapshot: &mut ClientShellSnapshot, parent_id: &str, child_id: &str) {
snapshot.workspaces[0].workspace_id = parent_id.into();
snapshot.workspaces[0].worktree = Some(ClientShellWorktree {
key: "repo".into(),
label: "repo".into(),
is_linked_worktree: false,
});
let mut child = snapshot.workspaces[0].clone();
child.workspace_id = child_id.into();
child.active_tab_id = format!("tab_{child_id}");
child.number = 2;
child.label = "feature".into();
child.focused = false;
child.agent_status = AgentStatus::Blocked;
child.worktree = Some(ClientShellWorktree {
key: "repo".into(),
label: "repo".into(),
is_linked_worktree: true,
});
snapshot.workspaces.push(child);
}
let (mut state, remote_id) = state_with_remote();
let mut local = snapshot();
add_worktree_group(&mut local, "ws_1", "ws_2");
state.set_snapshot(Box::new(local));
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.focused_workspace_id = Some("remote_ws_1".into());
add_worktree_group(&mut remote, "remote_ws_1", "remote_ws_2");
state.set_endpoint_snapshot(&remote_id, Box::new(remote));
state.open_workspace_context_menu("ws_1".into(), 0, 0);
let toggle_index = match state.overlay.as_ref() {
Some(ClientShellOverlay::ContextMenu(menu)) => menu
.items()
.iter()
.position(|item| item.action == ClientContextMenuAction::ToggleGroup)
.expect("collapse menu item"),
_ => panic!("workspace context menu"),
};
state.activate_context_menu_item(toggle_index, &mut ClientShellInput::default());
let frame = state
.compose(100, 28)
.expect("collapsed local worktree group");
assert!(!state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_2" }));
assert!(state
.hits
.workspaces
.iter()
.any(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2"));
let local_parent = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_1")
.expect("local parent workspace");
let (local_toggle, key) = local_parent
.group_toggle
.as_ref()
.expect("local worktree group marker");
assert_eq!(key, "repo");
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
assert_eq!(buffer[(local_toggle.x, local_toggle.y)].symbol(), "▸");
assert!((local_parent.rect.x..local_parent.rect.right())
.any(|x| buffer[(x, local_parent.rect.y)].fg == state.config.palette.red));
assert!(state.activate_endpoint_projection(&remote_id));
let mut remote_surface = surface();
remote_surface.boot_id = "remote-boot".into();
state.set_pane_surface(remote_surface);
state
.compose(100, 28)
.expect("active remote worktree group");
let mut switch = ClientShellInput::default();
state.record_binding(
crate::input::KeybindMatch::Action(crate::input::KeybindAction::SwitchWorkspace(1)),
&mut switch,
);
assert!(matches!(
&switch.actions[..],
[ClientShellAction::Endpoint { request, .. }]
if matches!(
&request.method,
crate::api::schema::Method::WorkspaceFocus(target)
if target.workspace_id == "remote_ws_2"
)
));
let remote_parent = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_1")
.expect("visible remote worktree parent")
.rect;
let remote_child = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2")
.expect("visible remote worktree child")
.rect;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: remote_parent.x + 3,
row: remote_parent.y,
modifiers: KeyModifiers::empty(),
})]);
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: remote_child.x + 3,
row: remote_child.bottom(),
modifiers: KeyModifiers::empty(),
})]);
assert!(matches!(
state.chrome_drag,
Some(ClientChromeDrag::Workspace {
target: Some(_),
..
})
));
state.chrome_drag = None;
state.workspace_press = None;
let remote_toggle = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_1")
.and_then(|hit| hit.group_toggle.as_ref())
.expect("remote worktree group marker")
.0;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: remote_toggle.x,
row: remote_toggle.y,
modifiers: KeyModifiers::empty(),
})]);
state.compose(100, 28).expect("both groups collapsed");
assert!(!state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2" }));
let local_toggle = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_1")
.and_then(|hit| hit.group_toggle.as_ref())
.expect("collapsed local group marker")
.0;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: local_toggle.x,
row: local_toggle.y,
modifiers: KeyModifiers::empty(),
})]);
state.compose(100, 28).expect("only remote group collapsed");
assert!(state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_2" }));
assert!(!state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2" }));
}
#[test]
fn expanded_machine_sidebar_reveals_newly_focused_workspace() {
let (mut state, remote_id) = state_with_remote();
let mut initial = snapshot();
let template = initial.workspaces[0].clone();
initial.workspaces = (1..=12)
.map(|number| ClientShellWorkspace {
workspace_id: format!("ws_{number}"),
number,
label: format!("space-{number}"),
focused: number == 1,
..template.clone()
})
.collect();
// Reuse workspace IDs across machines so revealing must be endpoint-scoped.
let mut remote = initial.clone();
remote.boot_id = "remote-boot".into();
remote.workspaces.push(ClientShellWorkspace {
workspace_id: "ws_13".into(),
number: 13,
focused: false,
..template.clone()
});
state.set_endpoint_snapshot(&remote_id, Box::new(remote));
state.set_snapshot(Box::new(initial));
state.compose(106, 20).expect("full machines sidebar");
assert!(state.hits.workspace_max_scroll > 0);
let mut update = state.snapshot.as_deref().expect("snapshot").clone();
update.revision = 2;
update.workspaces.push(ClientShellWorkspace {
workspace_id: "ws_13".into(),
number: 13,
label: "new-space".into(),
..template
});
update.focused_workspace_id = Some("ws_13".into());
for workspace in &mut update.workspaces {
workspace.focused = workspace.workspace_id == "ws_13";
}
state.set_snapshot(Box::new(update));
let mut updated_surface = surface();
updated_surface.projection_revision = 2;
state.set_pane_surface(updated_surface);
state.compose(106, 2).expect("zero-height workspace body");
assert!(state.reveal_focused_workspace);
state.compose(106, 20).expect("new workspace revealed");
assert!(state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_13" }));
state.workspace_scroll = 0;
state.compose(106, 20).expect("manual scroll");
assert_eq!(state.workspace_scroll, 0);
assert!(!state
.hits
.workspaces
.iter()
.any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_13" }));
let unchanged = state.snapshot.as_deref().expect("snapshot").clone();
state.set_snapshot(Box::new(unchanged));
state
.compose(106, 20)
.expect("unchanged focus preserves scroll");
assert_eq!(state.workspace_scroll, 0);
}
#[test]
fn expanded_machine_sidebar_applies_space_row_gap_within_each_machine() {
let (mut state, remote_id) = state_with_remote();
state.config.spaces.row_gap = 1;
let add_second_workspace = |snapshot: &mut ClientShellSnapshot| {
let mut workspace = snapshot.workspaces[0].clone();
workspace.workspace_id = "ws_2".into();
workspace.active_tab_id = "tab_2".into();
workspace.number = 2;
workspace.label = "second-workspace".into();
workspace.focused = false;
snapshot.workspaces.push(workspace);
};
let mut local = snapshot();
add_second_workspace(&mut local);
state.set_snapshot(Box::new(local));
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.workspaces[0].worktree = Some(ClientShellWorktree {
key: "repo".into(),
label: "repo".into(),
is_linked_worktree: false,
});
add_second_workspace(&mut remote);
remote.workspaces[1].worktree = Some(ClientShellWorktree {
key: "repo".into(),
label: "repo".into(),
is_linked_worktree: true,
});
let mut third = remote.workspaces[1].clone();
third.workspace_id = "ws_3".into();
third.number = 3;
third.label = "third-workspace".into();
third.worktree = None;
remote.workspaces.push(third);
state.set_endpoint_snapshot(&remote_id, Box::new(remote));
state.compose(100, 40).expect("combined endpoint frame");
let local_workspaces = state
.hits
.workspaces
.iter()
.filter(|hit| hit.endpoint_id.is_local())
.collect::<Vec<_>>();
assert_eq!(local_workspaces.len(), 2);
assert_eq!(
local_workspaces[1].rect.y,
local_workspaces[0].rect.bottom() + 1
);
let local_machine = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id.is_local())
.expect("local machine");
let remote_machine = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == remote_id)
.expect("remote machine");
assert_eq!(local_workspaces[0].rect.y, local_machine.rect.bottom());
assert_eq!(remote_machine.rect.y, local_workspaces[1].rect.bottom());
let remote_workspaces = state
.hits
.workspaces
.iter()
.filter(|hit| hit.endpoint_id == remote_id)
.collect::<Vec<_>>();
assert_eq!(remote_workspaces.len(), 3);
assert_eq!(remote_workspaces[0].rect.y, remote_machine.rect.bottom());
assert_eq!(
remote_workspaces[1].rect.y,
remote_workspaces[0].rect.bottom()
);
assert_eq!(
remote_workspaces[2].rect.y,
remote_workspaces[1].rect.bottom() + 1
);
state.workspace_scroll = usize::MAX;
state.compose(100, 18).expect("scrolled endpoint frame");
let metrics = state
.hits
.workspace_scroll_metrics
.expect("workspace scroll metrics");
assert!(metrics.max_offset_from_bottom > 0);
assert_eq!(metrics.offset_from_bottom, 0);
assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom);
let visible_remote = state
.hits
.workspaces
.iter()
.filter(|hit| hit.endpoint_id == remote_id)
.collect::<Vec<_>>();
assert_eq!(visible_remote.len(), 3);
let gap_y = visible_remote[1].rect.bottom();
assert_eq!(visible_remote[2].rect.y, gap_y + 1);
assert!(visible_remote[2].rect.bottom() <= state.hits.workspace_body.bottom());
assert!(state
.hits
.workspaces
.iter()
.all(|hit| gap_y < hit.rect.top() || gap_y >= hit.rect.bottom()));
}
#[test]
fn active_workspace_is_the_only_highlight_when_machine_is_expanded() {
let (mut state, endpoint_id) = state_with_remote();
assert!(state.activate_endpoint_projection(&endpoint_id));
let mut remote_surface = surface();
remote_surface.boot_id = "remote-boot".into();
state.set_pane_surface(remote_surface);
let frame = state.compose(100, 28).expect("combined endpoint frame");
let machine = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote machine hit")
.rect;
let workspace = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote workspace hit")
.rect;
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
assert_ne!(
buffer[(machine.x, machine.y)].bg,
state.config.palette.active_row_bg
);
assert_eq!(
buffer[(workspace.x + 2, workspace.y)].bg,
state.config.palette.active_row_bg
);
state.collapsed_endpoints.insert(endpoint_id.clone());
let frame = state.compose(100, 28).expect("collapsed endpoint frame");
let machine = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote machine hit")
.rect;
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
assert_eq!(
buffer[(machine.x, machine.y)].bg,
state.config.palette.active_row_bg
);
}
#[test]
fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() {
use crate::api::schema::AgentStatus;
use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
let mut config = Config::default();
config.ui.status_indicators = StatusIndicatorStyle::Symbols;
config.ui.sidebar.agents.rows = vec![vec![
AgentSidebarToken::StateIcon,
AgentSidebarToken::Machine,
AgentSidebarToken::Agent,
]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let frame = state.compose(100, 28).expect("combined endpoint 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("○ Local · local agent"), "frame: {text}");
assert!(text.contains("× Build · remote agent"), "frame: {text}");
assert!(text.contains("grouped"), "frame: {text}");
let toggle = state.hits.agent_sort_toggle;
assert!(!toggle.is_empty());
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(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 buffer = frame
.to_ratatui_buffer()
.expect("aggregate frame should reconstruct");
assert!(buffer
.content()
.iter()
.any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red));
}
#[test]
fn current_workspace_agent_view_excludes_same_workspace_id_on_other_machine() {
use crate::api::schema::AgentStatus;
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agent_view_label = Some("current space".into());
local.agent_order = vec!["pane_1".into()];
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agent_view_label = Some("current space".into());
remote.agent_order = vec!["pane_1".into()];
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let view = current_workspace_view();
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view.clone()));
state.set_test_endpoint_agent_view(&endpoint_id, Some(view));
let frame = state.compose(100, 28).expect("combined endpoint 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("Local · local agent"), "frame: {text}");
assert!(!text.contains("Build · remote agent"), "frame: {text}");
assert!(state.activate_endpoint_projection(&endpoint_id));
let mut remote_surface = surface();
remote_surface.boot_id = "remote-boot".into();
state.set_pane_surface(remote_surface);
let frame = state.compose(100, 28).expect("remote endpoint 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("Local · local agent"), "frame: {text}");
assert!(text.contains("Build · remote agent"), "frame: {text}");
}
#[test]
fn current_workspace_or_blocked_keeps_foreign_attention_only() {
use crate::api::schema::{
AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue,
};
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agent_view_label = Some("focus".into());
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agents = vec![
agent("remote idle", AgentStatus::Idle, 1),
ClientShellAgent {
pane_id: "pane_2".into(),
name: Some("remote blocked".into()),
agent_status: AgentStatus::Blocked,
focused: false,
..agent("remote blocked", AgentStatus::Blocked, 2)
},
];
remote.panes.push(ClientShellPane {
pane_id: "pane_2".into(),
focused: false,
..remote.panes[0].clone()
});
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let mut view = current_workspace_view();
view.label = Some("focus".into());
view.filter = Some(AgentViewFilter::Any {
filters: vec![
view.filter.take().expect("current workspace filter"),
AgentViewFilter::Eq {
field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
value: AgentViewValue::String("blocked".into()),
},
],
});
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view));
let frame = state.compose(100, 28).expect("combined endpoint 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("Local · local agent"), "frame: {text}");
assert!(!text.contains("Build · remote idle"), "frame: {text}");
assert!(text.contains("Build · remote blocked"), "frame: {text}");
}
#[test]
fn selected_default_view_ignores_inactive_endpoint_projection() {
use crate::api::schema::{
AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue,
};
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agent_view_label = Some("blocked".into());
remote.agent_order.clear();
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, None);
state.set_test_endpoint_agent_view(
&endpoint_id,
Some(crate::api::schema::AgentViewSetParams {
source: "remote.views".into(),
label: Some("blocked".into()),
filter: Some(AgentViewFilter::Eq {
field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
value: AgentViewValue::String("blocked".into()),
}),
sort: Vec::new(),
}),
);
let frame = state.compose(100, 28).expect("combined endpoint 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("Local · local agent"), "frame: {text}");
assert!(text.contains("Build · remote agent"), "frame: {text}");
assert!(text.contains("grouped"), "frame: {text}");
}
#[test]
fn newer_snapshot_does_not_reuse_stale_agent_view_projection() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
let mut local = snapshot();
local.agent_view_label = Some("current space".into());
state.set_snapshot(Box::new(local.clone()));
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(current_workspace_view()));
let endpoint = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id.is_local())
.expect("local endpoint");
assert!(matches!(
ClientShellState::endpoint_agent_view(endpoint),
Some(Ok(Some(_)))
));
state.set_test_endpoint_agent_view_projection(
&ClientEndpointId::Local,
"foreign-boot",
99,
None,
);
let endpoint = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id.is_local())
.expect("local endpoint");
assert!(matches!(
ClientShellState::endpoint_agent_view(endpoint),
Some(Ok(Some(_)))
));
local.revision += 1;
state.set_snapshot(Box::new(local));
let endpoint = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id.is_local())
.expect("local endpoint");
assert!(ClientShellState::endpoint_agent_view(endpoint).is_none());
}
#[test]
fn legacy_custom_views_keep_v1_per_endpoint_projection() {
use crate::api::schema::AgentStatus;
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agent_view_label = Some("current space".into());
local.agent_order = vec!["pane_1".into()];
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agent_view_label = Some("current space".into());
remote.agent_order = vec!["pane_1".into()];
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let frame = state.compose(100, 28).expect("legacy combined 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("Local · local agent"), "frame: {text}");
assert!(text.contains("Build · remote agent"), "frame: {text}");
}
#[test]
fn selected_custom_sort_orders_rendering_and_indexed_navigation() {
use crate::api::schema::{
AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField,
AgentViewSortOrder,
};
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agent_view_label = Some("recent".into());
local.agents = vec![agent("local blocked", AgentStatus::Blocked, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agents = vec![agent("remote idle", AgentStatus::Idle, 9)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let mut view = current_workspace_view();
view.label = Some("recent".into());
view.filter = None;
view.sort = vec![AgentViewSort {
field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::StateChangeSeq),
order: AgentViewSortOrder::Desc,
}];
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view));
let frame = state.compose(100, 28).expect("custom sorted 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.find("Build · remote idle").expect("remote row")
< text.find("Local · local blocked").expect("local row"),
"frame: {text}"
);
let mut outcome = ClientShellInput::default();
assert!(
state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,)
);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: selected,
target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
}] if selected == &endpoint_id && pane_id == "pane_1"
));
}
#[test]
fn selected_position_sort_uses_public_tab_and_pane_numbers() {
use crate::api::schema::{
AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField,
AgentViewSortOrder,
};
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
let mut selected = snapshot();
selected.agent_view_label = Some("positions".into());
let mut tab_nine = selected.tabs[0].clone();
tab_nine.tab_id = "ws_1:t9".into();
tab_nine.number = 9;
let mut tab_two = tab_nine.clone();
tab_two.tab_id = "ws_1:t2".into();
tab_two.number = 2;
selected.tabs = vec![tab_nine, tab_two];
let mut pane_tab_nine = selected.panes[0].clone();
pane_tab_nine.tab_id = "ws_1:t9".into();
pane_tab_nine.pane_id = "ws_1:p1".into();
let mut pane_nine = pane_tab_nine.clone();
pane_nine.tab_id = "ws_1:t2".into();
pane_nine.pane_id = "ws_1:p9".into();
let mut pane_two = pane_nine.clone();
pane_two.pane_id = "ws_1:p2".into();
selected.panes = vec![pane_tab_nine, pane_nine, pane_two];
let mut late_tab = agent("tab nine", AgentStatus::Idle, 1);
late_tab.tab_id = "ws_1:t9".into();
late_tab.pane_id = "ws_1:p1".into();
let mut late_pane = agent("pane nine", AgentStatus::Idle, 1);
late_pane.tab_id = "ws_1:t2".into();
late_pane.pane_id = "ws_1:p9".into();
let mut early_pane = agent("pane two", AgentStatus::Idle, 1);
early_pane.tab_id = "ws_1:t2".into();
early_pane.pane_id = "ws_1:p2".into();
selected.agents = vec![late_tab, late_pane, early_pane];
state.set_snapshot(Box::new(selected));
let mut view = current_workspace_view();
view.label = Some("positions".into());
view.filter = None;
view.sort = vec![
AgentViewSort {
field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::TabOrder),
order: AgentViewSortOrder::Asc,
},
AgentViewSort {
field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::PaneOrder),
order: AgentViewSortOrder::Asc,
},
];
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view));
let names = aggregate_navigation::aggregate_agent_rows(
&state.endpoints,
&state.active_endpoint_id,
crate::config::AgentPanelSortConfig::Priority,
)
.into_iter()
.map(|row| row.agent.name.as_deref().expect("agent name"))
.collect::<Vec<_>>();
assert_eq!(names, ["pane two", "pane nine", "tab nine"]);
}
#[test]
fn aggregate_priority_uses_client_observed_recency_across_machines() {
use crate::api::schema::AgentStatus;
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
let mut local = snapshot();
local.agents = vec![agent("local agent", AgentStatus::Idle, 2)];
state.set_snapshot(Box::new(local));
let frame_text = |state: &mut ClientShellState| {
let frame = state.compose(100, 28).expect("combined endpoint frame");
frame
.cells
.chunks(frame.width as usize)
.map(|row| {
row.iter()
.map(|cell| cell.symbol.as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let text = frame_text(&mut state);
assert!(
text.find("Local · local agent").expect("local agent")
< text.find("Build · remote agent").expect("remote agent")
);
remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
let text = frame_text(&mut state);
assert!(
text.find("Build · remote agent").expect("remote agent")
< text.find("Local · local agent").expect("local agent")
);
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let text = frame_text(&mut state);
assert!(
text.find("Build · remote agent").expect("remote agent")
< text.find("Local · local agent").expect("local agent")
);
let mut outcome = ClientShellInput::default();
assert!(
state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,)
);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
}] if activated == &endpoint_id && pane_id == "pane_1"
));
}
#[test]
fn unselected_endpoint_completion_projects_done_client_side() {
use crate::api::schema::AgentStatus;
let (mut state, endpoint_id) = state_with_remote();
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agents = vec![agent("background agent", AgentStatus::Working, 2)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
remote.revision = 2;
remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let status = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.and_then(|endpoint| endpoint.snapshot.as_deref())
.and_then(|snapshot| snapshot.agents.first())
.map(|agent| agent.agent_status);
assert_eq!(status, Some(AgentStatus::Done));
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
}
#[test]
fn clicking_remote_machine_name_requests_activation_without_mutating_projection() {
let (mut state, endpoint_id) = state_with_remote();
state.compose(100, 28).expect("combined endpoint frame");
let hit = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote endpoint hit")
.rect;
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: hit.x + 3,
row: hit.y,
modifiers: KeyModifiers::empty(),
})]);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: None,
}] if activated == &endpoint_id
));
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
assert_eq!(
state
.snapshot
.as_deref()
.map(|snapshot| snapshot.boot_id.as_str()),
Some("boot-1")
);
}
#[test]
fn clicking_local_can_cancel_a_remote_switch_while_local_is_still_displayed() {
for workspace in [false, true] {
let (mut state, remote) = state_with_remote();
state.compose(100, 28).unwrap();
let mut pending = ClientShellInput::default();
assert!(state.activate_endpoint(remote, &mut pending));
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
let rect = if workspace {
state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id.is_local())
.unwrap()
.rect
} else {
state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id.is_local())
.unwrap()
.rect
};
let outcome = state.handle_raw_events(vec![
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x + 5,
row: rect.y,
modifiers: KeyModifiers::empty(),
}),
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: rect.x + 5,
row: rect.y,
modifiers: KeyModifiers::empty(),
}),
]);
assert!(
matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
endpoint_id: ClientEndpointId::Local, target,
}] if target.is_some() == workspace)
);
}
}
#[test]
fn reconnecting_local_selection_still_reaches_the_runtime() {
let (mut state, _) = state_with_remote();
state.mark_endpoint_disconnected(&ClientEndpointId::Local);
let mut outcome = ClientShellInput::default();
state.focus_or_activate(
ClientEndpointId::Local,
ClientEndpointFocusTarget::Workspace("local-workspace".into()),
&mut outcome,
);
assert!(
matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
endpoint_id: ClientEndpointId::Local,
target: Some(ClientEndpointFocusTarget::Workspace(id)),
}] if id == "local-workspace")
);
}
#[test]
fn machine_arrow_toggles_inactive_machine_without_switching() {
for sidebar_collapsed in [false, true] {
for status in [
ClientEndpointStatus::Online,
ClientEndpointStatus::Reconnecting,
] {
let (mut state, remote_id) = state_with_remote();
let mut other_profile = remote_profile();
other_profile.id = ProfileId::parse("1123456789abcdef0123456789abcdef").unwrap();
let other_id = ClientEndpointId::Ssh(other_profile.id.clone());
state.set_endpoint_catalog(&[remote_profile(), other_profile]);
state.set_endpoint_status(&other_id, ClientEndpointStatus::Online);
state.set_endpoint_snapshot(&other_id, Box::new(snapshot()));
state.set_endpoint_status(&remote_id, status);
state.sidebar_collapsed = sidebar_collapsed;
for collapsed in [true, false] {
let frame = state.compose(100, 28).expect("three machine frame");
let machine = state
.hits
.machines
.iter()
.find(|hit| hit.endpoint_id == remote_id)
.expect("remote machine")
.rect;
let column = machine.x + u16::from(!sidebar_collapsed);
let buffer = frame.to_ratatui_buffer().expect("frame buffer");
assert_eq!(
buffer[(column, machine.y)].symbol(),
if collapsed { "▾" } else { "▸" }
);
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: machine.y,
modifiers: KeyModifiers::empty(),
})]);
assert!(
outcome.actions.is_empty(),
"collapse must not switch machines"
);
assert!(outcome.requests.is_empty());
assert!(outcome.repaint);
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
assert_eq!(state.snapshot.as_ref().unwrap().boot_id, "boot-1");
assert_eq!(
state
.snapshot
.as_ref()
.unwrap()
.focused_workspace_id
.as_deref(),
Some("ws_1")
);
assert_eq!(state.collapsed_endpoints.contains(&remote_id), collapsed);
assert!(!state.collapsed_endpoints.contains(&ClientEndpointId::Local));
assert!(!state.collapsed_endpoints.contains(&other_id));
assert!(state.endpoint_error.is_none());
state.compose(100, 28).expect("toggled machine frame");
assert_eq!(
state
.hits
.workspaces
.iter()
.any(|hit| hit.endpoint_id == remote_id),
!collapsed
);
for endpoint_id in [&ClientEndpointId::Local, &other_id] {
assert!(state
.hits
.workspaces
.iter()
.any(|hit| &hit.endpoint_id == endpoint_id));
}
}
}
}
}
#[test]
fn context_menu_lookup_ignores_inactive_endpoint_workspaces() {
let (mut state, endpoint_id) = state_with_remote();
state.compose(100, 28).expect("combined endpoint frame");
let remote = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote workspace")
.rect;
let local = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id.is_local())
.expect("local workspace")
.rect;
assert_eq!(
state.active_endpoint_workspace_at((remote.x, remote.y)),
None
);
assert_eq!(
state.active_endpoint_workspace_at((local.x, local.y)),
Some("ws_1".into())
);
}
#[test]
fn future_surface_waits_for_its_exact_snapshot_revision() {
let (mut state, _) = state_with_remote();
let mut future = surface();
future.projection_revision = 2;
future.surface_revision = 2;
state.set_pane_surface(future);
assert_eq!(
state
.pane_surface
.as_ref()
.map(|surface| surface.projection_revision),
Some(1)
);
assert_eq!(
state
.pending_pane_surface
.as_ref()
.map(|surface| surface.projection_revision),
Some(2)
);
let mut next = snapshot();
next.revision = 2;
state.set_snapshot(Box::new(next));
assert_eq!(
state
.pane_surface
.as_ref()
.map(|surface| surface.projection_revision),
Some(2)
);
assert!(state.pending_pane_surface.is_none());
}
#[test]
fn inactive_endpoint_snapshot_cache_never_regresses_revision() {
let (mut state, endpoint_id) = state_with_remote();
let mut newest = snapshot();
newest.boot_id = "remote-boot".into();
newest.revision = 3;
newest.workspaces[0].label = "newest".into();
state.set_endpoint_snapshot(&endpoint_id, Box::new(newest));
let mut delayed = snapshot();
delayed.boot_id = "remote-boot".into();
delayed.revision = 2;
delayed.workspaces[0].label = "delayed".into();
state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed));
let label = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.and_then(|endpoint| endpoint.snapshot.as_deref())
.and_then(|snapshot| snapshot.workspaces.first())
.map(|workspace| workspace.label.as_str());
assert_eq!(label, Some("newest"));
}
#[test]
fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() {
let (mut state, endpoint_id) = state_with_remote();
let mut previous = snapshot();
previous.boot_id = "shared-server-boot".into();
previous.revision = 9;
previous.workspaces[0].label = "old connection".into();
state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous));
let mut reconnected = snapshot();
reconnected.boot_id = "shared-server-boot".into();
reconnected.revision = 1;
reconnected.workspaces[0].label = "new connection".into();
state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected));
let endpoint = state
.endpoints
.iter()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint");
assert_eq!(endpoint.snapshot_generation, Some(5));
assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1);
assert_eq!(
endpoint.snapshot.as_ref().unwrap().workspaces[0].label,
"new connection"
);
}
#[test]
fn reconnect_same_endpoint_accepts_new_generation_surface_revision() {
for previous_revision in [9, 1] {
let (mut state, endpoint_id) = state_with_remote();
let mut previous = snapshot();
previous.boot_id = "shared-server-boot".into();
previous.revision = previous_revision;
state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous));
assert!(state.activate_endpoint_projection(&endpoint_id));
let mut previous_surface = surface();
previous_surface.boot_id = "shared-server-boot".into();
previous_surface.projection_revision = previous_revision;
previous_surface.surface_revision = 9;
state.set_pane_surface(previous_surface.clone());
previous_surface.projection_revision += 1;
state.set_pane_surface(previous_surface);
assert!(state.pending_pane_surface.is_some());
state.agent_scroll = 7;
state.mark_endpoint_disconnected(&endpoint_id);
let mut reconnected = snapshot();
reconnected.boot_id = "shared-server-boot".into();
reconnected.revision = 1;
state.cache_endpoint_snapshot_inactive_for_generation(
&endpoint_id,
5,
Box::new(reconnected),
);
assert_eq!(state.snapshot.as_ref().unwrap().revision, previous_revision);
assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 9);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
assert!(state.activate_endpoint_projection(&endpoint_id));
assert!(state.compose(106, 20).is_none());
let mut reconnected_surface = surface();
reconnected_surface.boot_id = "shared-server-boot".into();
reconnected_surface.projection_revision = 1;
reconnected_surface.surface_revision = 1;
state.set_pane_surface(reconnected_surface);
assert_eq!(state.snapshot.as_ref().unwrap().revision, 1);
assert_eq!(state.pane_surface.as_ref().unwrap().projection_revision, 1);
assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 1);
assert!(state.pending_pane_surface.is_none());
assert_eq!(state.agent_scroll, 7);
assert!(state.compose(106, 20).is_some());
}
}
#[test]
fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() {
let (mut state, endpoint_id) = state_with_remote();
assert!(state.activate_endpoint_projection(&endpoint_id));
assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
state.mark_endpoint_disconnected(&endpoint_id);
let mut replacement = snapshot();
replacement.boot_id = "replacement-boot".into();
state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement));
assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
assert!(state.activate_endpoint_projection(&endpoint_id));
assert_eq!(
state.snapshot.as_deref().unwrap().boot_id,
"replacement-boot"
);
}
#[test]
fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() {
use crate::api::schema::AgentStatus;
use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
let (mut state, endpoint_id) = state_with_remote();
state.config.status_indicators = StatusIndicatorStyle::Symbols;
state.config.agents.rows = vec![vec![
AgentSidebarToken::StateIcon,
AgentSidebarToken::Machine,
AgentSidebarToken::Agent,
]];
let endpoint = state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint");
endpoint.snapshot.as_mut().expect("remote snapshot").agents =
vec![agent("remote agent", AgentStatus::Blocked, 1)];
assert!(state.activate_endpoint_projection(&endpoint_id));
let mut remote_surface = surface();
remote_surface.boot_id = "remote-boot".into();
state.set_pane_surface(remote_surface);
state.pending_integration_installs = 2;
state.mark_endpoint_disconnected(&endpoint_id);
let frame = state.compose(100, 28).expect("frozen endpoint 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_eq!(state.pending_integration_installs, 0);
assert_eq!(
state.endpoint_status(&endpoint_id),
Some(ClientEndpointStatus::Reconnecting)
);
assert!(text.contains("◐ reconnecting"), "frame: {text}");
assert!(text.contains("Build · remote agent"), "frame: {text}");
assert!(
text.contains("LIVE"),
"frozen surface should remain: {text}"
);
assert!(state.hits.panes.is_empty());
assert!(frame.cursor.is_none());
let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
let stale_icon = buffer
.content()
.iter()
.find(|cell| cell.symbol() == "×")
.expect("stale blocked icon");
assert_eq!(stale_icon.fg, state.config.palette.overlay0);
}
#[cfg(unix)]
#[test]
fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() {
let (mut state, endpoint_id) = state_with_remote();
let local_scope = state.graphics_scope().to_owned();
let mut remote = snapshot();
remote.boot_id = "boot-1".into();
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
assert!(state.activate_endpoint_projection(&endpoint_id));
let remote_scope = state.graphics_scope();
assert_ne!(local_scope, remote_scope);
assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:"));
}
#[cfg(unix)]
#[test]
fn local_direct_graphics_accept_server_ids_across_endpoint_switches_and_restarts() {
use crate::kitty_graphics::surface::{direct_upload_control, host_image_id};
use crate::protocol::{SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsSource};
let (mut state, remote_id) = state_with_remote();
let asset = SurfaceGraphicsAssetKey {
source: SurfaceGraphicsSource::PaneLayer {
pane_id: "pane_1".into(),
layer_id: "primary".into(),
},
image_width: 2,
image_height: 2,
format: SurfaceGraphicsFormat::Rgba,
data_len: 16,
data_fingerprint: 17,
};
let (server_image_id, _) = direct_upload_control("boot-1", &asset);
assert_eq!(
host_image_id(state.graphics_scope(), &asset),
server_image_id
);
assert!(state.trust_direct_graphics_asset(&asset, server_image_id));
assert!(!state.trust_direct_graphics_asset(&asset, server_image_id + 1));
let mut remote = snapshot();
remote.boot_id = "boot-1".into();
state.set_endpoint_snapshot(&remote_id, Box::new(remote));
assert!(state.activate_endpoint_projection(&remote_id));
assert!(!state.trust_direct_graphics_asset(&asset, server_image_id));
assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
assert!(state.trust_direct_graphics_asset(&asset, server_image_id));
let mut restarted = snapshot();
restarted.boot_id = "replacement-boot".into();
state.set_snapshot(Box::new(restarted));
let (restarted_image_id, _) = direct_upload_control("replacement-boot", &asset);
assert!(!state.trust_direct_graphics_asset(&asset, server_image_id));
assert_eq!(
host_image_id(state.graphics_scope(), &asset),
restarted_image_id
);
assert!(state.trust_direct_graphics_asset(&asset, restarted_image_id));
}
#[test]
fn navigator_uses_machine_parents_only_for_federated_clients() {
let (mut state, _) = state_with_remote();
state.open_navigator_overlay();
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
let rows =
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
let machines = rows
.iter()
.filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. }))
.collect::<Vec<_>>();
assert_eq!(
machines
.iter()
.map(|row| row.label.as_str())
.collect::<Vec<_>>(),
vec!["Local", "Build"]
);
assert!(rows.iter().all(|row| {
matches!(row.target, ClientNavigatorTarget::Machine { .. })
|| (!row.label.contains("Local ·") && !row.label.contains("Build ·"))
}));
assert!(rows.iter().all(|row| match row.target {
ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
ClientNavigatorTarget::Pane { .. } => row.depth == 2 && row.status.is_some(),
}));
assert_eq!(rows.iter().filter(|row| row.current).count(), 1);
let frame = state.compose(106, 30).expect("federated navigator");
for (rect, target) in &state.hits.navigator_rows {
let expected = match target {
ClientNavigatorTarget::Machine { .. } => " ",
ClientNavigatorTarget::Workspace { .. } => " ",
ClientNavigatorTarget::Pane { .. } => " └─ ",
};
let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..]
.iter()
.take(expected.chars().count())
.map(|cell| cell.symbol.as_str())
.collect::<String>();
assert_eq!(prefix, expected, "{target:?}");
}
let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
local.set_snapshot(Box::new(snapshot()));
local.set_pane_surface(surface());
let frame = local.compose(100, 28).expect("local-only sidebar");
assert!(local.hits.machines.is_empty());
assert!(!frame
.cells
.chunks(frame.width as usize)
.map(|row| {
row.iter()
.map(|cell| cell.symbol.as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
.contains(" machines"));
local.open_navigator_overlay();
let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
let rows =
render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator);
assert!(rows
.iter()
.all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
assert!(rows.iter().all(|row| match row.target {
ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
ClientNavigatorTarget::Pane { .. } => row.depth == 1,
ClientNavigatorTarget::Machine { .. } => false,
}));
}
#[test]
fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
let (mut state, endpoint_id) = state_with_remote();
let endpoint = state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("saved remote endpoint");
endpoint.snapshot = None;
endpoint.status = ClientEndpointStatus::Connecting;
state.open_navigator_overlay();
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
let rows =
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
assert!(rows.iter().any(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
) && row.label == "Build"
&& row.stale
}));
assert!(!rows.iter().any(|row| match &row.target {
ClientNavigatorTarget::Machine { .. } => false,
ClientNavigatorTarget::Workspace {
endpoint_id: target,
..
}
| ClientNavigatorTarget::Pane {
endpoint_id: target,
..
} => target == &endpoint_id,
}));
}
#[test]
fn navigator_machine_selection_opens_its_remembered_view() {
let (mut state, endpoint_id) = state_with_remote();
state.open_navigator_overlay();
let selected = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.into_iter()
.find(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
)
})
.map(|row| row.target)
.expect("remote machine row")
};
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
navigator.selected = Some(selected);
}
let mut outcome = ClientShellInput::default();
state.accept_navigator_selection(&mut outcome);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: None,
}] if activated == &endpoint_id
));
assert!(state.overlay.is_none());
}
#[test]
fn navigator_foreign_pane_selection_activates_its_endpoint() {
let (mut state, endpoint_id) = state_with_remote();
state.open_navigator_overlay();
let selected = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.iter()
.find(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Pane {
endpoint_id: target_endpoint,
pane_id,
} if target_endpoint == &endpoint_id && pane_id == "pane_1"
)
})
.map(|row| row.target.clone())
.expect("remote pane row")
};
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
navigator.selected = Some(selected);
}
let mut local = snapshot();
let mut inserted = local.workspaces[0].clone();
inserted.workspace_id = "ws_2".into();
inserted.focused = false;
local.workspaces.push(inserted);
state.set_snapshot(Box::new(local));
let mut outcome = ClientShellInput::default();
state.accept_navigator_selection(&mut outcome);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
}] if activated == &endpoint_id && pane_id == "pane_1"
));
assert!(state.overlay.is_none());
}
#[test]
fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() {
use crate::api::schema::AgentStatus;
let (mut state, endpoint_id) = state_with_remote();
state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint")
.snapshot
.as_mut()
.expect("remote snapshot")
.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
state.mode = ClientShellMode::Navigate;
state.compose(44, 30).expect("mobile switcher");
let remote_agent = state
.hits
.mobile_targets
.iter()
.find_map(|(rect, target)| {
matches!(
target,
ClientMobileTarget::Agent {
endpoint_id: target_endpoint,
pane_id,
} if target_endpoint == &endpoint_id && pane_id == "pane_1"
)
.then_some(*rect)
})
.expect("remote agent target");
let click = |rect: Rect| {
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x,
row: rect.y,
modifiers: KeyModifiers::empty(),
})
};
let outcome = state.handle_raw_events(vec![click(remote_agent)]);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
}] if activated == &endpoint_id && pane_id == "pane_1"
));
state.mode = ClientShellMode::Navigate;
state.compose(44, 30).expect("mobile switcher");
let remote_workspace = state
.hits
.mobile_targets
.iter()
.find_map(|(rect, target)| {
matches!(
target,
ClientMobileTarget::Workspace {
endpoint_id: target_endpoint,
workspace_id,
} if target_endpoint == &endpoint_id && workspace_id == "ws_1"
)
.then_some(*rect)
})
.expect("remote workspace target");
let outcome = state.handle_raw_events(vec![click(remote_workspace)]);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
}] if activated == &endpoint_id && workspace_id == "ws_1"
));
}
#[test]
fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() {
use crate::api::schema::AgentStatus;
let (mut state, endpoint_id) = state_with_remote();
state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint")
.snapshot
.as_mut()
.expect("remote snapshot")
.agents = vec![agent("remote agent", AgentStatus::Blocked, 2)];
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
state.open_navigator_overlay();
let selected = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
let rows =
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
let machine = rows
.iter()
.find(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
)
})
.expect("cached remote machine row");
assert!(machine.stale);
let row = rows
.iter()
.find(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Pane {
endpoint_id: target_endpoint,
pane_id,
} if target_endpoint == &endpoint_id && pane_id == "pane_1"
)
})
.expect("cached remote pane row");
assert!(row.stale);
assert!(!row.meta.contains("reconnecting"));
row.target.clone()
};
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
navigator.selected = Some(selected);
}
let mut navigator_outcome = ClientShellInput::default();
state.accept_navigator_selection(&mut navigator_outcome);
assert!(navigator_outcome.actions.is_empty());
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::Navigator(_))
));
state.overlay = None;
state.mode = ClientShellMode::Navigate;
let frame = state.compose(44, 30).expect("offline mobile switcher");
let mobile_target = state
.hits
.mobile_targets
.iter()
.find_map(|(rect, target)| {
matches!(
target,
ClientMobileTarget::Workspace {
endpoint_id: target_endpoint,
workspace_id,
} if target_endpoint == &endpoint_id && workspace_id == "ws_1"
)
.then_some(*rect)
})
.expect("cached remote workspace target");
let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer");
assert_eq!(
buffer[(mobile_target.x, mobile_target.y)].fg,
state.config.palette.overlay0
);
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: mobile_target.x,
row: mobile_target.y,
modifiers: KeyModifiers::empty(),
})]);
assert!(outcome.actions.is_empty());
assert_eq!(state.mode, ClientShellMode::Navigate);
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
}
#[test]
fn focus_agent_index_uses_online_aggregate_rows() {
use crate::api::schema::AgentStatus;
let (mut state, endpoint_id) = state_with_remote();
state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint")
.snapshot
.as_mut()
.expect("remote snapshot")
.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
let focus_agent =
|index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index));
assert!(state.indexed_navigation_target_exists(&focus_agent(0)));
assert!(!state.indexed_navigation_target_exists(&focus_agent(1)));
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
assert!(!state.indexed_navigation_target_exists(&focus_agent(0)));
}
#[test]
fn workspace_drag_rejects_foreign_endpoint_slots() {
let (mut state, endpoint_id) = state_with_remote();
state.compose(100, 28).expect("aggregate sidebar");
let local = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id.is_local())
.expect("local workspace")
.rect;
let remote = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote workspace")
.rect;
let mouse = |kind, rect: Rect| {
RawInputEvent::Mouse(MouseEvent {
kind,
column: rect.x.saturating_add(1),
row: rect.y,
modifiers: KeyModifiers::empty(),
})
};
state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]);
state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]);
assert!(state.chrome_drag.is_none());
}
#[test]
fn collapsed_aggregate_workspace_status_uses_its_status_color() {
use crate::api::schema::AgentStatus;
let (mut state, endpoint_id) = state_with_remote();
state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == endpoint_id)
.expect("remote endpoint")
.snapshot
.as_mut()
.expect("remote snapshot")
.workspaces[0]
.agent_status = AgentStatus::Blocked;
state.sidebar_collapsed = true;
let frame = state.compose(100, 28).expect("collapsed aggregate sidebar");
let workspace = state
.hits
.workspaces
.iter()
.find(|hit| hit.endpoint_id == endpoint_id)
.expect("remote workspace")
.rect;
let buffer = frame.to_ratatui_buffer().expect("frame buffer");
assert_eq!(
buffer[(workspace.x.saturating_add(2), workspace.y)].fg,
state.config.palette.red
);
}
#[test]
fn navigator_workspace_arrows_cross_machine_headings_without_activating_them() {
let (mut state, endpoint_id) = state_with_remote();
state.open_navigator_overlay();
for (key, expected_endpoint) in [
(KeyCode::Right, endpoint_id),
(KeyCode::Left, ClientEndpointId::Local),
] {
let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
crate::input::TerminalKey::new(key, KeyModifiers::empty()),
)]);
assert!(outcome.actions.is_empty());
let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
panic!("navigator");
};
assert_eq!(
navigator.selected,
Some(ClientNavigatorTarget::Pane {
endpoint_id: expected_endpoint,
pane_id: "pane_1".into(),
})
);
}
}
#[test]
fn navigator_foreign_workspace_heading_keeps_the_workspace_target() {
let (mut state, endpoint_id) = state_with_remote();
state.open_navigator_overlay();
let selected = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.iter()
.find(|row| {
matches!(
&row.target,
ClientNavigatorTarget::Workspace {
endpoint_id: target_endpoint,
workspace_id,
} if target_endpoint == &endpoint_id && workspace_id == "ws_1"
)
})
.map(|row| row.target.clone())
.expect("remote workspace heading")
};
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
navigator.selected = Some(selected);
}
let mut outcome = ClientShellInput::default();
state.accept_navigator_selection(&mut outcome);
assert!(matches!(
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
}] if activated == &endpoint_id && workspace_id == "ws_1"
));
}