diff options
Diffstat (limited to 'src/client')
62 files changed, 13940 insertions, 2032 deletions
diff --git a/src/client/attach.rs b/src/client/attach.rs index e385775c..2cabe5bb 100644 --- a/src/client/attach.rs +++ b/src/client/attach.rs @@ -9,7 +9,6 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; #[cfg(unix)] use super::write_to_server; #[cfg(unix)] -use crate::ipc::LocalStream; #[cfg(unix)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; @@ -260,7 +259,7 @@ fn attach_scroll_action( #[cfg(unix)] pub(super) fn write_attach_semantic_action( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, action: AttachSemanticAction, ) -> io::Result<()> { let message = match action { diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs new file mode 100644 index 00000000..21f33bea --- /dev/null +++ b/src/client/catalog_reload.rs @@ -0,0 +1,276 @@ +use super::*; + +pub(super) fn watch_profiles( + event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + should_quit: Arc<AtomicBool>, +) { + // One bounded read per second per client, independent of rendering and pane count. + std::thread::spawn(move || { + let mut previous = None; + while !should_quit.load(Ordering::Acquire) { + let current = endpoint::EndpointCatalog::load_profiles(); + if previous.as_ref() != Some(¤t) { + previous = Some(current.clone()); + if event_tx + .blocking_send(ClientLoopEvent::EndpointCatalog(current)) + .is_err() + { + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + } + }); +} + +// Only called between surface handoffs: removing a source must not invalidate an in-flight +// rollback. Connection attempts are independent and fenced by supervisor generations. +pub(super) fn apply_profiles( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + catalog: &mut endpoint::EndpointCatalog, + profiles: Vec<endpoint::SavedSshEndpoint>, + now: std::time::Instant, +) -> bool { + if catalog.ssh == profiles { + return false; + } + let previous_size = state + .shell + .as_ref() + .map(|shell| shell.surface_size(state.reported_size.0, state.reported_size.1)); + let retired = supervisors.reconcile_profiles(&profiles, now); + let active_removed = retired.contains(endpoints.active_id()); + for endpoint_id in retired { + endpoints.disconnect(&endpoint_id); + let cancelled = commands.disconnect(&endpoint_id); + #[cfg(unix)] + state.retire_endpoint_graphics(&endpoint_id); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.retire_endpoint(&endpoint_id); + } + } + catalog.ssh = profiles; + if active_removed { + endpoints.select_unavailable_local(); + catalog.select_local(); + state.freeze_presentation(); + if let Some(shell) = state.shell.as_mut() { + shell.select_unavailable_local(); + } + } else if catalog.selected_profile.as_ref().is_some_and(|selected| { + !catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + catalog.select_local(); + } + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_catalog(&catalog.ssh); + if endpoints.active_surface_available() + && previous_size + != Some(shell.surface_size(state.reported_size.0, state.reported_size.1)) + { + shell.invalidate_pane_surface(); + endpoints.send(&client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + )); + } + } + active_removed +} + +#[cfg(test)] +mod tests { + use super::*; + use endpoint::{ClientEndpointId, EndpointCatalog, EndpointRegistry, EndpointSupervisors}; + use std::sync::atomic::AtomicUsize; + use std::time::Instant; + + struct Transport(Arc<AtomicUsize>); + + impl endpoint::EndpointTransport for Transport { + fn send(&mut self, _: &ClientMessage) -> io::Result<()> { + Ok(()) + } + + fn disconnect(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + fn state() -> ClientState { + ClientState { + blit_encoder: render_ansi::BlitEncoder::new(), + mouse_capture_active: false, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), + direct_mouse_capture_preference: false, + shell_mouse_capture_preference: false, + direct_keyboard_protocol: Default::default(), + pane_keyboard_report_all: false, + keyboard_report_all_active: false, + reported_size: (100, 30), + reported_cell_size: (0, 0), + sound_config: Default::default(), + kitty_graphics_enabled: false, + pixel_geometry_enabled: false, + pixel_geometry_exact: false, + #[cfg(unix)] + direct_graphics_response: Default::default(), + #[cfg(unix)] + retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), + attach_escape: None, + #[cfg(unix)] + mouse_scroll_lines: 3, + remote_image_paste_key: None, + redraw_on_focus_gained: false, + repaint_pending: false, + presentation_frozen: false, + draw_host_cursor: false, + detached_process_children: Vec::new(), + shell: Some(shell::ClientShellState::new( + shell::ClientShellConfig::from_config(&crate::config::Config::default()), + )), + } + } + + #[test] + fn live_catalog_add_and_rename_keep_local_connection_and_selection() { + let now = Instant::now(); + let mut state = state(); + let disconnected = Arc::new(AtomicUsize::new(0)); + let mut endpoints = + EndpointRegistry::new(Transport(disconnected.clone()), 1, Default::default()); + let mut supervisors = EndpointSupervisors::new(&[], now); + let mut commands = endpoint_commands::EndpointCommands::default(); + let mut catalog = EndpointCatalog::default(); + let mut profile = endpoint::SavedSshEndpoint::new("Build", "build", "main").unwrap(); + for label in ["Build", "Renamed"] { + profile.label = label.into(); + assert!(!apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + vec![profile.clone()], + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(endpoints.active_surface_available()); + assert_eq!( + endpoints + .connection(&ClientEndpointId::Local) + .unwrap() + .generation, + 1 + ); + assert_eq!(catalog.selected_profile, None); + assert_eq!( + state + .shell + .as_ref() + .unwrap() + .endpoint_label(&ClientEndpointId::Ssh(profile.id.clone())), + label + ); + } + assert_eq!(disconnected.load(Ordering::Relaxed), 0); + } + + #[test] + fn live_catalog_remove_or_disable_active_machine_selects_local_without_input() { + for local_online in [false, true] { + for disable in [false, true] { + let now = Instant::now(); + let mut state = state(); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "main").unwrap(); + let remote = ClientEndpointId::Ssh(id.clone()); + catalog.select_ssh(&id); + state + .shell + .as_mut() + .unwrap() + .set_endpoint_catalog(&catalog.ssh); + let mut supervisors = EndpointSupervisors::new(&catalog.ssh, now); + let mut endpoints = EndpointRegistry::empty(); + let local_disconnects = Arc::new(AtomicUsize::new(0)); + if local_online { + endpoints.insert( + ClientEndpointId::Local, + Transport(local_disconnects.clone()), + 1, + Default::default(), + false, + ); + } + let remote_disconnects = Arc::new(AtomicUsize::new(0)); + endpoints.insert( + remote.clone(), + Transport(remote_disconnects.clone()), + 2, + Default::default(), + true, + ); + endpoints.set_active(&remote); + endpoints.unfreeze_input(); + let mut commands = endpoint_commands::EndpointCommands::default(); + let profiles = if disable { + let mut profiles = catalog.ssh.clone(); + profiles[0].enabled = false; + profiles + } else { + Vec::new() + }; + assert!(apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + profiles, + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert!(endpoints.connection(&remote).is_none()); + assert_eq!( + endpoints.connection(&ClientEndpointId::Local).is_some(), + local_online + ); + assert!(state + .shell + .as_ref() + .unwrap() + .endpoint_is_active(&ClientEndpointId::Local)); + assert!(!state.shell.as_ref().unwrap().has_presented_surface()); + assert!(state.presentation_frozen); + assert_eq!(catalog.selected_profile, None); + assert_eq!(remote_disconnects.load(Ordering::Relaxed), 1); + assert_eq!(local_disconnects.load(Ordering::Relaxed), 0); + assert!(!supervisors.record_status( + &remote, + 2, + endpoint::ClientEndpointStatus::Online, + now + )); + } + } + } +} diff --git a/src/client/clipboard_forwarding.rs b/src/client/clipboard_forwarding.rs new file mode 100644 index 00000000..2de5f49f --- /dev/null +++ b/src/client/clipboard_forwarding.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { + use base64::Engine; + + base64::engine::general_purpose::STANDARD.decode(data).ok() +} + +pub(super) fn forward_clipboard(data: &str) -> bool { + let Some(bytes) = decode_clipboard_payload(data) else { + warn!("received invalid clipboard payload from server"); + return false; + }; + crate::selection::write_osc52_bytes(&bytes); + true +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs index c1c49cbc..1faebf39 100644 --- a/src/client/clipboard_images.rs +++ b/src/client/clipboard_images.rs @@ -2,16 +2,15 @@ use std::path::PathBuf; use tracing::{info, warn}; -use crate::ipc::LocalStream; #[cfg(windows)] use crate::protocol::ClientInputEvent; use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; -use super::{is_remote_client_process, write_to_server, ClientError}; +use super::{write_to_server, ClientError}; pub(super) fn write_remote_image_to_server( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, target: ClientClipboardImageTarget, image: crate::platform::ClipboardImage, source: &'static str, @@ -46,10 +45,6 @@ pub(super) fn write_remote_image_to_server( pub(super) fn client_remote_image_paste_key( config: &crate::config::Config, ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - match config.remote_image_paste_key() { Ok(key) => key, Err(diagnostic) => { @@ -59,14 +54,25 @@ pub(super) fn client_remote_image_paste_key( } } +pub(super) fn endpoint_accepts_local_images( + remote_client_process: bool, + endpoint_id: &super::endpoint::ClientEndpointId, + active_surface_available: bool, +) -> bool { + active_surface_available && (remote_client_process || !endpoint_id.is_local()) +} + #[cfg(unix)] pub(super) fn should_bridge_clipboard_image_paste( data: &[u8], is_remote_client: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, ) -> bool { + if !is_remote_client { + return false; + } if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; + return true; } let Some(remote_image_paste_key) = remote_image_paste_key else { diff --git a/src/client/config_reload.rs b/src/client/config_reload.rs new file mode 100644 index 00000000..c20de760 --- /dev/null +++ b/src/client/config_reload.rs @@ -0,0 +1,129 @@ +use super::*; + +pub(super) fn init_logging() { + crate::logging::init_file_logging("herdr-client.log"); +} + +pub(super) fn apply_reload( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; + reload_local_client_config( + &mut state.sound_config, + &mut state.redraw_on_focus_gained, + &mut state.draw_host_cursor, + &mut state.remote_image_paste_key, + &mut mouse_capture, + ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + endpoints, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels).map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + if let Some(resize) = resize { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + write_to_server(endpoints, &resize).map_err(ClientError::ConnectionLost)?; + } + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(()) +} + +pub(super) fn reload_local_client_config( + sound_config: &mut crate::config::SoundConfig, + redraw_on_focus_gained: &mut bool, + draw_host_cursor: &mut bool, + remote_image_paste_key: &mut Option<( + crossterm::event::KeyCode, + crossterm::event::KeyModifiers, + )>, + mouse_capture: &mut bool, +) { + match crate::config::load_live_config() { + Ok(loaded) => { + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); + } + debug!("reloaded local client config"); + } + Err(diagnostics) => { + warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); + } + } +} diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs new file mode 100644 index 00000000..77aaae3b --- /dev/null +++ b/src/client/endpoint.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +mod activation; +mod catalog; +mod control; +mod health; +mod message_policy; +mod registry; +mod supervisor; +mod writer; + +pub(crate) use activation::*; +pub(crate) use catalog::*; +pub(crate) use control::*; +pub(crate) use message_policy::*; +pub(crate) use registry::*; +pub(crate) use supervisor::*; +pub(crate) use writer::NativeEndpointTransport; + +const PROFILE_ID_BYTES: usize = 16; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct ProfileId(String); + +impl ProfileId { + pub(crate) fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() != PROFILE_ID_BYTES * 2 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("endpoint profile id must be 32 lowercase hexadecimal characters".into()); + } + Ok(Self(value)) + } + + pub(crate) fn generate() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let digest = Sha256::digest(format!("{}:{now}:{sequence}", std::process::id()).as_bytes()); + Self( + digest[..PROFILE_ID_BYTES] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ClientEndpointId { + Local, + Ssh(ProfileId), +} + +impl ClientEndpointId { + pub(crate) fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + + pub(crate) fn storage_key(&self) -> String { + match self { + Self::Local => "local".into(), + Self::Ssh(profile_id) => format!("ssh:{profile_id}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointStatus { + Connecting, + Online, + Reconnecting, + Attention, + Disabled, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_ids_are_opaque_and_stable_when_parsed() { + let first = ProfileId::generate(); + let second = ProfileId::generate(); + assert_ne!(first, second); + assert_eq!(ProfileId::parse(first.to_string()).unwrap(), first); + assert_eq!(first.as_str().len(), 32); + } + + #[test] + fn endpoint_storage_keys_do_not_contain_ssh_targets() { + let profile = ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(); + assert_eq!( + ClientEndpointId::Ssh(profile).storage_key(), + "ssh:0123456789abcdef0123456789abcdef" + ); + } +} diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs new file mode 100644 index 00000000..a5b63344 --- /dev/null +++ b/src/client/endpoint/activation.rs @@ -0,0 +1,1152 @@ +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointRegistry, EndpointSendOutcome}; + +mod model; +mod protocol; +pub(crate) use model::{ + ActivationBeginError, ActivationCompletion, ActivationRollback, EndpointActivationIntent, + PendingEndpointActivation, SurfaceActivationProgress, +}; +use model::{ActivationEvidence, ActivationPhase, EndpointLease}; +use protocol::*; + +const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5); + +impl PendingEndpointActivation { + #[allow(clippy::too_many_arguments)] + pub(crate) fn begin( + shell: &crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + target: ClientEndpointId, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + resize: crate::protocol::ClientMessage, + serial: u64, + now: Instant, + ) -> Result<Self, ActivationBeginError> { + resize_geometry(&resize).ok_or_else(|| { + ActivationBeginError::Preflight( + "endpoint activation did not include a surface resize".to_owned(), + ) + })?; + let source_id = endpoints.active_id().clone(); + let source_has_live_surface = endpoints + .connection(&source_id) + .is_some_and(|connection| connection.surface_active); + let (source, source_available) = if source_has_live_surface { + ( + endpoint_lease(shell, endpoints, &source_id) + .map_err(ActivationBeginError::Preflight)?, + true, + ) + } else { + (disconnected_endpoint_lease(shell, &source_id), false) + }; + let target_lease = + endpoint_lease(shell, endpoints, &target).map_err(ActivationBeginError::Preflight)?; + let source_compatible = !source_available + || endpoints + .connection(&source_id) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + let target_compatible = endpoints + .connection(&target) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + if !source_compatible || !target_compatible { + return Err(ActivationBeginError::Preflight( + "endpoint must be updated before it can join the selected surface".into(), + )); + } + + let source_is_target = source.endpoint_id == target_lease.endpoint_id; + // Validate every typed lifecycle and optional focus envelope before the first transport + // write. Any error above this line is guaranteed not to have changed either endpoint. + let source_release_request = (source_available && !source_is_target) + .then(|| { + surface_interest_request( + &source.boot_id, + format!("client-shell-surface:{serial}:off"), + false, + ) + }) + .transpose() + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + surface_interest_request( + &target_lease.boot_id, + format!("client-shell-surface:{serial}:on"), + true, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + if let Some(target) = focus.as_ref() { + focus_request( + &target_lease.boot_id, + format!("client-shell-focus:{serial}:1"), + target, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + } + + endpoints.freeze_input(); + let mut activation = Self { + source, + source_available, + target: target_lease, + focus, + host_focused: shell.host_focus_baseline(), + resize: resize.clone(), + phase: ActivationPhase::ReleasingSource { + request_id: format!("client-shell-surface:{serial}:off"), + }, + deadline: now + ACTIVATION_TIMEOUT, + epoch: serial, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + + // Reconnecting the selected endpoint has no live source surface to release. All normal + // handoffs must make the source locally inactive before a target request is even sent. + if source_is_target || !source_available { + if let Err(error) = activation.start_target(endpoints, resize) { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error, + }); + } + } else { + // `surface.set(false)` removes the viewer, but old servers only emit the PTY focus + // loss while the viewer is still active. Revoke it explicitly before source-off. + if endpoints.send_to( + &activation.source.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused: false }, + ) != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint focus revoke could not be sent".into(), + }); + } + let request = source_release_request.expect("validated source release request"); + if endpoints.send_to(&activation.source.endpoint_id, &request) + != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint release could not be sent".into(), + }); + } + // This is deliberately before the acknowledgement: the source is no longer viewed + // locally while its release is in flight, and pane input is consequently blocked. + endpoints.set_surface_active(&activation.source.endpoint_id, false); + } + Ok(activation) + } + + pub(crate) fn target(&self) -> &ClientEndpointId { + &self.target.endpoint_id + } + + fn geometry(&self) -> crate::protocol::ClientSurfaceSize { + resize_geometry(&self.resize).expect("activation resize was validated before construction") + } + + pub(crate) fn presentation_sync_endpoint(&self) -> Option<&ClientEndpointId> { + match self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + _ => None, + } + } + + /// The complete source command lane cannot safely cross source-off into a later presentation + /// epoch. Other endpoint lanes are not part of this retirement. + pub(crate) fn source_command_lane(&self) -> Option<&ClientEndpointId> { + (self.source_available && self.source.endpoint_id != self.target.endpoint_id) + .then_some(&self.source.endpoint_id) + } + + pub(crate) fn can_retarget(&self, endpoint_id: &ClientEndpointId) -> bool { + self.target.endpoint_id == *endpoint_id + && self.successor.is_none() + && matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } | ActivationPhase::ActivatingTarget { .. } + ) + } + + /// Replace an in-flight handoff with the latest endpoint-qualified intent. The current + /// transaction is still reversed through target-off/source-on; the replacement is launched + /// by the caller only after the source's coherent restoration commits. + pub(crate) fn supersede( + &mut self, + endpoint_id: ClientEndpointId, + target: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> ActivationRollback { + self.successor = Some(EndpointActivationIntent { + endpoint_id, + target, + }); + // Source-on is already ordered and must finish before any replacement is allowed to + // begin. Later rapid selections only replace the retained intent; they never turn a + // safe restoration into an unavailable state. + let source_restoration_in_flight = matches!( + self.phase, + ActivationPhase::RestoringSource { .. } + ) || matches!( + &self.phase, + ActivationPhase::SynchronizingPresentation { completion, .. } + if matches!(completion.as_ref(), ActivationCompletion::RestoredSource { .. }) + ); + if source_restoration_in_flight { + return ActivationRollback::Pending; + } + self.rollback( + endpoints, + "endpoint handoff superseded by a newer selection".into(), + false, + ) + } + + pub(crate) fn accepts_endpoint(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + (self.source_available + && self.source.endpoint_id == *endpoint_id + && self.source.generation == generation) + || (self.target.endpoint_id == *endpoint_id && self.target.generation == generation) + } + + pub(crate) fn involves_endpoint(&self, endpoint_id: &ClientEndpointId) -> bool { + self.source.endpoint_id == *endpoint_id || self.target.endpoint_id == *endpoint_id + } + + pub(crate) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + ) -> bool { + match &self.phase { + ActivationPhase::ReleasingSource { + request_id: expected, + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::ActivatingTarget { + request_id: expected, + focus_request_id, + .. + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && (expected == request_id || focus_request_id.as_deref() == Some(request_id)) + } + ActivationPhase::ReleasingTargetForRollback { + request_id: expected, + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::RestoringSource { + request_id: expected, + .. + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::SynchronizingPresentation { + lease, + request_id: expected, + .. + } => { + endpoint_matches(lease, endpoint_id, generation, boot_id) && expected == request_id + } + ActivationPhase::AwaitingPresentationEffects { .. } => false, + } + } + + pub(crate) fn expired(&self, now: Instant) -> bool { + now >= self.deadline + } + + #[cfg(test)] + pub(crate) fn receive_response( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + let boot_id = if self.source.endpoint_id == *endpoint_id { + self.source.boot_id.clone() + } else { + self.target.boot_id.clone() + }; + self.receive_response_for_boot( + endpoint_id, + generation, + &boot_id, + request_id, + data, + endpoints, + ) + } + + pub(crate) fn receive_response_for_boot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + if !self.accepts_response(endpoint_id, generation, boot_id, request_id) { + return SurfaceActivationProgress::Stale; + } + let result = match decode_endpoint_response(request_id, data) { + Ok(result) => result, + Err(error) => { + return SurfaceActivationProgress::Rejected { + source_release_rejected: matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } + ) && error.code.is_some(), + message: error.message, + }; + } + }; + match &mut self.phase { + ActivationPhase::ReleasingSource { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + if let Err(message) = self.start_target(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::ActivatingTarget { + request_id: surface_request_id, + acknowledged_revision, + .. + } if surface_request_id == request_id => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } if focus_request_id.as_deref() == Some(request_id) => { + let requested = focus_request_target.clone(); + let Some(requested) = requested else { + return SurfaceActivationProgress::Stale; + }; + if !focus_result_matches(Some(&requested), &result) { + return SurfaceActivationProgress::Rejected { + message: "endpoint focus returned an unexpected result".into(), + source_release_rejected: false, + }; + } + *focus_request_id = None; + *focus_request_target = None; + if self.focus != Some(requested) { + *focus_acknowledged = false; + if let Err(message) = self.send_latest_focus(endpoints) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + return self.progress(); + } + *focus_acknowledged = true; + self.progress() + } + ActivationPhase::ReleasingTargetForRollback { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + endpoints.set_surface_active(&self.target.endpoint_id, false); + if !self.source_available { + return SurfaceActivationProgress::Rejected { + message: self.rollback_error.clone().unwrap_or_else(|| { + "the previous endpoint is no longer connected".into() + }), + source_release_rejected: false, + }; + } + if let Err(message) = self.start_source_restore(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::RestoringSource { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + _ => SurfaceActivationProgress::Stale, + } + } + + pub(crate) fn receive_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: &crate::protocol::ClientShellSnapshot, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) + || snapshot.revision < lease.minimum_revision + { + return SurfaceActivationProgress::Stale; + } + let evidence = match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + if endpoint_matches(&self.target, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::RestoringSource { evidence, .. } + if endpoint_matches(&self.source, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::SynchronizingPresentation { + lease, evidence, .. + } if endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) => evidence, + _ => return SurfaceActivationProgress::Stale, + }; + evidence.record_snapshot(snapshot); + self.progress() + } + + pub(crate) fn receive_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + surface: crate::protocol::PaneSurfaceFrame, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &surface.boot_id) { + return SurfaceActivationProgress::Stale; + } + if !surface_matches_geometry(&surface, self.geometry()) { + return SurfaceActivationProgress::Pending; + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.record_surface(surface) + } + _ => unreachable!("checked activation phase"), + } + self.progress() + } + + pub(crate) fn receive_presentation_effects_ready( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + token: &str, + ) -> SurfaceActivationProgress { + let ActivationPhase::AwaitingPresentationEffects { + lease, + token: expected, + ready, + .. + } = &mut self.phase + else { + return SurfaceActivationProgress::Stale; + }; + if lease.endpoint_id != *endpoint_id || lease.generation != generation || expected != token + { + return SurfaceActivationProgress::Stale; + } + *ready = true; + SurfaceActivationProgress::Ready + } + + /// A same-endpoint navigation request replaces the desired target but never joins the + /// in-flight focus RPC. Once that request resolves, `send_latest_focus` sends only the most + /// recent desired target. + pub(crate) fn retarget( + &mut self, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.focus = focus; + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = self.focus.is_none() && focus_request_id.is_none(); + } else { + // The latest target is retained and will be sent immediately after source release. + return Ok(()); + } + self.send_latest_focus(endpoints) + } + + pub(crate) fn update_resize( + &mut self, + resize: crate::protocol::ClientMessage, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + resize_geometry(&resize) + .ok_or_else(|| "endpoint activation did not include a surface resize".to_owned())?; + self.resize = resize.clone(); + let restart_effects_fence = match &self.phase { + ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + }; + if let Some((lease, completion)) = restart_effects_fence { + if endpoints.send_to(&lease.endpoint_id, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + return self.start_presentation_sync(endpoints, lease, completion); + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.invalidate_surface() + } + _ => {} + } + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } => Some(&lease.endpoint_id), + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to(destination, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + } + Ok(()) + } + + pub(crate) fn update_host_focus( + &mut self, + focused: bool, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.host_focused = focused; + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to( + destination, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("pending endpoint focus baseline could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + pub(crate) fn update_host_theme( + &mut self, + update: crate::protocol::ClientHostThemeUpdate, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + let message = crate::protocol::ClientMessage::ClientShellHostTheme { update }; + if endpoints.send_to(destination, &message) != EndpointSendOutcome::Sent { + return Err("pending endpoint host theme could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + fn presentation_restart(&self) -> Option<(EndpointLease, ActivationCompletion)> { + match &self.phase { + ActivationPhase::SynchronizingPresentation { + lease, completion, .. + } + | ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + } + } + + fn invalidate_current_evidence(&mut self) { + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } => evidence.invalidate_surface(), + _ => {} + } + } + + /// Losing the source revokes its surface and removes the rollback destination; it must not + /// cancel a healthy target. Losing the target restores the source when it is still available. + pub(crate) fn endpoint_disconnected( + &mut self, + endpoints: &mut EndpointRegistry, + endpoint_id: &ClientEndpointId, + error: String, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if self.target.endpoint_id == *endpoint_id && self.source.endpoint_id != *endpoint_id { + return match self.phase { + ActivationPhase::RestoringSource { .. } => ActivationRollback::Pending, + _ => match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {restore_error}" + )), + }, + }; + } + if self.source.endpoint_id != *endpoint_id { + return ActivationRollback::Unavailable(error); + } + self.source_available = false; + if self.source.endpoint_id != self.target.endpoint_id { + match &self.phase { + ActivationPhase::ReleasingSource { .. } => { + return match self.start_target(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(message) => ActivationRollback::Unavailable(message), + }; + } + ActivationPhase::ActivatingTarget { .. } => return ActivationRollback::Pending, + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } + if lease.endpoint_id == self.target.endpoint_id => + { + return ActivationRollback::Pending + } + _ => {} + } + } + match self.phase { + ActivationPhase::ReleasingSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::ActivatingTarget { .. } => { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } + ActivationPhase::ReleasingTargetForRollback { .. } => ActivationRollback::Pending, + ActivationPhase::RestoringSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } else { + ActivationRollback::Unavailable(error) + } + } + } + } + + pub(crate) fn rollback( + &mut self, + endpoints: &mut EndpointRegistry, + error: String, + source_release_rejected: bool, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if matches!(self.phase, ActivationPhase::ReleasingSource { .. }) && source_release_rejected + { + // Rejection proves source-off did not commit, but cached source metadata may have + // advanced while the frame was frozen. Restore through the same coherent on/sync + // path rather than immediately exposing a stale source projection. + return match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not resume: {restore_error}" + )), + }; + } + let result = match self.phase { + ActivationPhase::ReleasingSource { .. } => { + if self.source_available { + self.start_source_restore(endpoints, self.resize.clone()) + } else { + Err("the previous endpoint is no longer connected".into()) + } + } + ActivationPhase::ActivatingTarget { .. } => self.start_target_release(endpoints), + ActivationPhase::ReleasingTargetForRollback { .. } => { + // The target may have observed target-on or target-off. Closing this transport + // is the only safe local revocation when target-off is not acknowledged. + endpoints.fail( + &self.target.endpoint_id, + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "endpoint did not acknowledge surface revocation", + ), + ); + if !self.source_available { + return ActivationRollback::Unavailable(format!( + "{error}; the target connection was closed because no presentation owner could be proven" + )); + } + self.start_source_restore(endpoints, self.resize.clone()) + } + ActivationPhase::RestoringSource { .. } => { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored" + )); + } + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + self.start_target_release(endpoints) + } else { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint presentation could not be synchronized" + )); + } + } + }; + match result { + Ok(()) => ActivationRollback::Pending, + Err(rollback_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {rollback_error}" + )), + } + } + + pub(crate) fn complete( + &mut self, + shell: &mut crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + ) -> Result<ActivationCompletion, String> { + if let ActivationPhase::SynchronizingPresentation { + lease, + acknowledged_revision, + evidence, + completion, + .. + } = &self.phase + { + let lease = lease.clone(); + let completion = (**completion).clone(); + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + *acknowledged_revision, + self.geometry(), + )?; + if endpoints.active_id() != &lease.endpoint_id + || !shell.endpoint_projection_available(&lease.endpoint_id) + || !shell.activate_endpoint_projection(&lease.endpoint_id) + { + return Err( + "endpoint became unavailable during presentation synchronization".into(), + ); + } + shell.set_pane_surface(surface); + self.start_presentation_effects_fence(endpoints, lease, completion)?; + return Ok(ActivationCompletion::AwaitingPresentationEffects); + } + if let ActivationPhase::AwaitingPresentationEffects { + ready: true, + completion, + .. + } = &self.phase + { + return Ok((**completion).clone()); + } + + let (lease, evidence, acknowledgement_revision, completion) = match &self.phase { + ActivationPhase::ActivatingTarget { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::Activated; + ( + self.target.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + ActivationPhase::RestoringSource { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::RestoredSource { + error: self + .rollback_error + .clone() + .unwrap_or_else(|| "endpoint handoff was rolled back".into()), + successor: self.successor.clone(), + }; + ( + self.source.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + _ => return Err("endpoint activation completed in an invalid phase".into()), + }; + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + acknowledgement_revision, + self.geometry(), + )?; + endpoints.set_surface_active(&lease.endpoint_id, true); + shell.set_endpoint_status(&lease.endpoint_id, ClientEndpointStatus::Online); + if !shell.endpoint_projection_available(&lease.endpoint_id) + || !endpoints.set_active(&lease.endpoint_id) + { + return Err("endpoint became unavailable during activation".into()); + } + let activated = shell.activate_endpoint_projection(&lease.endpoint_id); + debug_assert!(activated, "preflighted endpoint projection must activate"); + debug_assert!(shell.endpoint_is_active(endpoints.active_id())); + shell.set_pane_surface(surface); + self.start_presentation_sync(endpoints, lease.clone(), completion)?; + Ok(ActivationCompletion::AwaitingPresentationSync { + previous: self.source.endpoint_id.clone(), + endpoint: lease.endpoint_id, + }) + } + + fn start_target( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:on", self.epoch); + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + // A transport may fail after writing any baseline or surface message. Enter the target + // phase first so every uncertain target write is reversed through target-off before + // source restoration is considered. + self.phase = ActivationPhase::ActivatingTarget { + request_id: request_id.clone(), + acknowledged_revision: None, + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: self.focus.is_none(), + evidence: ActivationEvidence::default(), + }; + send_surface_activation( + endpoints, + &self.target, + request_id, + &resize, + self.host_focused, + )?; + + // From this point the target may have processed surface.set(true). Optional navigation + // is serialized through one coalescing focus lane. + self.send_latest_focus(endpoints) + } + + fn start_presentation_sync( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:presentation-sync", self.epoch); + let request = surface_interest_request(&lease.boot_id, request_id.clone(), true) + .map_err(|error| error.to_string())?; + self.phase = ActivationPhase::SynchronizingPresentation { + lease: lease.clone(), + request_id, + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&lease.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint presentation synchronization could not be sent".into()); + } + Ok(()) + } + + fn start_presentation_effects_fence( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let token = format!("{}:{}:{}", self.epoch, lease.generation, lease.boot_id); + self.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease.clone(), + token: token.clone(), + ready: false, + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + let message = crate::protocol::ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND.into(), + data: token, + }; + if endpoints.send_to(&lease.endpoint_id, &message) != EndpointSendOutcome::Sent { + return Err("endpoint presentation effects fence could not be sent".into()); + } + Ok(()) + } + + fn start_target_release(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-target-off", self.epoch); + let request = surface_interest_request(&self.target.boot_id, request_id.clone(), false) + .map_err(|error| error.to_string())?; + // Set the rollback phase before the potentially observed target-off write. + self.phase = ActivationPhase::ReleasingTargetForRollback { request_id }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("target endpoint release could not be sent".into()); + } + Ok(()) + } + + fn start_source_restore( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-source-on", self.epoch); + // Source baseline writes can also be observed before their send reports an error. + self.phase = ActivationPhase::RestoringSource { + request_id: request_id.clone(), + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + send_surface_activation( + endpoints, + &self.source, + request_id, + &resize, + self.host_focused, + ) + } + + fn send_latest_focus(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let desired = self.focus.clone(); + let Some(desired) = desired else { + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + if focus_request_id.is_none() { + *focus_acknowledged = true; + } + } + return Ok(()); + }; + if !matches!( + &self.phase, + ActivationPhase::ActivatingTarget { + focus_request_id: None, + .. + } + ) { + return Ok(()); + } + let request_id = self + .next_focus_request_id() + .expect("desired focus creates a request id"); + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = false; + *focus_request_id = Some(request_id.clone()); + *focus_request_target = Some(desired.clone()); + } + let request = focus_request(&self.target.boot_id, request_id, &desired) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint focus could not be sent".into()); + } + Ok(()) + } + + fn next_focus_request_id(&mut self) -> Option<String> { + self.focus.as_ref()?; + self.next_focus_serial = self.next_focus_serial.saturating_add(1); + Some(format!( + "client-shell-focus:{}:{}", + self.epoch, self.next_focus_serial + )) + } + + fn progress(&self) -> SurfaceActivationProgress { + match &self.phase { + ActivationPhase::ActivatingTarget { + acknowledged_revision, + focus_acknowledged, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + *focus_acknowledged + && evidence + .coherent_surface(revision, self.geometry()) + .is_some_and(|surface| self.target_matches(surface)) + }) => + { + SurfaceActivationProgress::Ready + } + ActivationPhase::RestoringSource { + acknowledged_revision, + evidence, + .. + } + | ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + evidence + .coherent_surface(revision, self.geometry()) + .is_some() + }) => + { + SurfaceActivationProgress::Ready + } + _ => SurfaceActivationProgress::Pending, + } + } + + fn target_matches(&self, surface: &crate::protocol::PaneSurfaceFrame) -> bool { + let evidence = match &self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } => evidence, + _ => return false, + }; + match &self.focus { + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => { + evidence.focused_pane_id.as_deref() == Some(pane_id) + && surface + .panes + .iter() + .any(|pane| pane.focused && &pane.pane_id == pane_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id)) => { + evidence.focused_tab_id.as_deref() == Some(tab_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id)) => { + evidence.focused_workspace_id.as_deref() == Some(workspace_id) + } + None => true, + } + } +} + +#[cfg(test)] +#[path = "activation_tests.rs"] +mod tests; diff --git a/src/client/endpoint/activation/model.rs b/src/client/endpoint/activation/model.rs new file mode 100644 index 00000000..c94029e9 --- /dev/null +++ b/src/client/endpoint/activation/model.rs @@ -0,0 +1,174 @@ +use std::time::Instant; + +use super::super::ClientEndpointId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct EndpointLease { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, + pub(super) boot_id: String, + /// The endpoint cache may already be newer than the first activation event. Never let an + /// activation prove coherence with a revision that the monotonic cache has discarded. + pub(super) minimum_revision: u64, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct ActivationEvidence { + pub(super) snapshot_revision: Option<u64>, + pub(super) focused_workspace_id: Option<String>, + pub(super) focused_tab_id: Option<String>, + pub(super) focused_pane_id: Option<String>, + pub(super) surface: Option<crate::protocol::PaneSurfaceFrame>, +} + +impl ActivationEvidence { + pub(super) fn record_snapshot(&mut self, snapshot: &crate::protocol::ClientShellSnapshot) { + if self + .snapshot_revision + .is_none_or(|current| snapshot.revision >= current) + { + self.snapshot_revision = Some(snapshot.revision); + self.focused_workspace_id = snapshot.focused_workspace_id.clone(); + self.focused_tab_id = snapshot.focused_tab_id.clone(); + self.focused_pane_id = snapshot.focused_pane_id.clone(); + } + } + + pub(super) fn record_surface(&mut self, surface: crate::protocol::PaneSurfaceFrame) { + let replace = self.surface.as_ref().is_none_or(|current| { + surface.projection_revision > current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision >= current.surface_revision) + }); + if replace { + self.surface = Some(surface); + } + } + + pub(super) fn invalidate_surface(&mut self) { + self.surface = None; + } + + pub(super) fn coherent_surface( + &self, + minimum_revision: u64, + geometry: crate::protocol::ClientSurfaceSize, + ) -> Option<&crate::protocol::PaneSurfaceFrame> { + self.surface.as_ref().filter(|surface| { + self.snapshot_revision == Some(surface.projection_revision) + && surface.projection_revision >= minimum_revision + && super::surface_matches_geometry(surface, geometry) + }) + } +} + +#[derive(Clone, Debug)] +pub(super) enum ActivationPhase { + ReleasingSource { + request_id: String, + }, + ActivatingTarget { + request_id: String, + acknowledged_revision: Option<u64>, + /// At most one focus request may be in flight. Retargets only replace `focus` until + /// this response arrives, at which point the latest target is sent. + focus_request_id: Option<String>, + focus_request_target: Option<crate::client::shell::ClientEndpointFocusTarget>, + focus_acknowledged: bool, + evidence: ActivationEvidence, + }, + ReleasingTargetForRollback { + request_id: String, + }, + RestoringSource { + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + }, + SynchronizingPresentation { + lease: EndpointLease, + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + completion: Box<ActivationCompletion>, + }, + AwaitingPresentationEffects { + lease: EndpointLease, + token: String, + ready: bool, + completion: Box<ActivationCompletion>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SurfaceActivationProgress { + Pending, + Ready, + Rejected { + message: String, + source_release_rejected: bool, + }, + Stale, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationRollback { + /// A correlated rollback request is in flight; retain the frozen frame. + Pending, + /// Neither endpoint can be made safe to present. Keep pane input frozen while presenting + /// client chrome and this error. + Unavailable(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationCompletion { + AwaitingPresentationSync { + previous: ClientEndpointId, + endpoint: ClientEndpointId, + }, + AwaitingPresentationEffects, + Activated, + RestoredSource { + error: String, + /// A newer endpoint-qualified selection arrived while this handoff was frozen. It is + /// started only after the original source has been coherently restored. + successor: Option<EndpointActivationIntent>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointActivationIntent { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) target: Option<crate::client::shell::ClientEndpointFocusTarget>, +} + +/// Begin failures are separated by whether the transport may already have observed a lifecycle +/// write. A partial transaction must be kept and rolled back under a frozen presentation. +#[derive(Debug)] +pub(crate) enum ActivationBeginError { + Preflight(String), + Partial { + activation: Box<PendingEndpointActivation>, + error: String, + }, +} + +/// The only owner of an endpoint handoff. The registry's active endpoint remains the committed +/// endpoint until the target commits, while this object owns the uncommitted lifecycle lane. +#[derive(Debug)] +pub(crate) struct PendingEndpointActivation { + pub(super) source: EndpointLease, + pub(super) source_available: bool, + pub(super) target: EndpointLease, + pub(super) focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + pub(super) host_focused: bool, + pub(super) resize: crate::protocol::ClientMessage, + pub(super) phase: ActivationPhase, + pub(super) deadline: Instant, + pub(super) epoch: u64, + pub(super) next_focus_serial: u64, + pub(super) rollback_error: Option<String>, + /// A different endpoint was selected while this source-off-first transaction was in flight. + /// Keep only the latest intent until source restoration commits. + pub(super) successor: Option<EndpointActivationIntent>, +} diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs new file mode 100644 index 00000000..569d53f3 --- /dev/null +++ b/src/client/endpoint/activation/protocol.rs @@ -0,0 +1,219 @@ +use super::super::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; +use super::model::{ActivationEvidence, EndpointLease}; + +pub(super) fn focus_result_matches( + focus: Option<&crate::client::shell::ClientEndpointFocusTarget>, + result: &crate::api::schema::ResponseResult, +) -> bool { + match (focus, result) { + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)), + crate::api::schema::ResponseResult::PaneInfo { pane }, + ) => pane.focused && &pane.pane_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)), + crate::api::schema::ResponseResult::WorkspaceInfo { workspace }, + ) => workspace.focused && &workspace.workspace_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(expected)), + crate::api::schema::ResponseResult::TabInfo { tab }, + ) => tab.focused && &tab.tab_id == expected, + _ => false, + } +} + +pub(super) fn endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoints: &EndpointRegistry, + endpoint_id: &ClientEndpointId, +) -> Result<EndpointLease, String> { + let connection = endpoints + .connection(endpoint_id) + .ok_or_else(|| "endpoint connection is unavailable".to_owned())?; + let (boot_id, minimum_revision) = shell + .endpoint_snapshot_identity(endpoint_id, connection.generation) + .ok_or_else(|| "endpoint metadata is not ready for this connection".to_owned())?; + Ok(EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: connection.generation, + boot_id: boot_id.to_owned(), + minimum_revision, + }) +} + +pub(super) fn disconnected_endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoint_id: &ClientEndpointId, +) -> EndpointLease { + EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: 0, + boot_id: shell + .endpoint_boot_id(endpoint_id) + .unwrap_or_default() + .to_owned(), + minimum_revision: 0, + } +} + +pub(super) fn endpoint_matches( + lease: &EndpointLease, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, +) -> bool { + lease.endpoint_id == *endpoint_id && lease.generation == generation && lease.boot_id == boot_id +} + +pub(super) fn coherent_completion_surface( + shell: &crate::client::shell::ClientShellState, + lease: &EndpointLease, + evidence: &ActivationEvidence, + acknowledgement_revision: Option<u64>, + geometry: crate::protocol::ClientSurfaceSize, +) -> Result<crate::protocol::PaneSurfaceFrame, String> { + let acknowledgement_revision = acknowledgement_revision.ok_or_else(|| { + "endpoint activation completed without a surface acknowledgement".to_owned() + })?; + let surface = evidence + .surface + .clone() + .ok_or_else(|| "endpoint activation completed without a surface".to_owned())?; + if surface.projection_revision < acknowledgement_revision + || !surface_matches_geometry(&surface, geometry) + { + return Err("endpoint activation lost its acknowledged surface evidence".into()); + } + if !shell.endpoint_snapshot_matches( + &lease.endpoint_id, + lease.generation, + &lease.boot_id, + surface.projection_revision, + ) { + return Err("endpoint activation lost its coherent snapshot/surface pair".into()); + } + Ok(surface) +} + +pub(super) fn resize_geometry( + message: &crate::protocol::ClientMessage, +) -> Option<crate::protocol::ClientSurfaceSize> { + match message { + crate::protocol::ClientMessage::ClientShellResize { surface_size, .. } => { + Some(*surface_size) + } + _ => None, + } +} + +pub(super) fn surface_matches_geometry( + surface: &crate::protocol::PaneSurfaceFrame, + geometry: crate::protocol::ClientSurfaceSize, +) -> bool { + surface.frame.width == geometry.cols && surface.frame.height == geometry.rows +} + +pub(super) fn send_surface_activation( + endpoints: &mut EndpointRegistry, + target: &EndpointLease, + request_id: String, + resize: &crate::protocol::ClientMessage, + focused: bool, +) -> Result<(), String> { + if endpoints.send_to(&target.endpoint_id, resize) != EndpointSendOutcome::Sent { + return Err("endpoint resize could not be sent".into()); + } + let request = surface_interest_request(&target.boot_id, request_id, true) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint activation could not be sent".into()); + } + // Inactive endpoints reject focus events. Activate first, then establish the host baseline + // on the same ordered transport before navigation or presentation can commit. + if endpoints.send_to( + &target.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("endpoint focus baseline could not be sent".into()); + } + Ok(()) +} + +pub(super) fn decode_endpoint_response( + request_id: &str, + data: &[u8], +) -> Result<crate::api::schema::ResponseResult, crate::client::shell::ClientShellEndpointError> { + crate::client::endpoint_commands::parse_response(request_id, data) +} + +pub(super) fn surface_set_revision( + result: &crate::api::schema::ResponseResult, + expected_active: bool, +) -> Result<u64, String> { + match result { + crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + } if *active == expected_active => Ok(*projection_revision), + _ => Err("surface activation returned an invalid acknowledgement".into()), + } +} + +pub(super) fn focus_request( + boot_id: &str, + request_id: String, + focus: &crate::client::shell::ClientEndpointFocusTarget, +) -> std::io::Result<crate::protocol::ClientMessage> { + let method = match focus { + crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: workspace_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: tab_id.clone(), + }) + } + }; + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method, + }, + ) +} + +pub(super) fn surface_interest_request( + boot_id: &str, + request_id: String, + active: bool, +) -> std::io::Result<crate::protocol::ClientMessage> { + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method: crate::api::schema::Method::ClientShellSurfaceSet( + crate::api::schema::ClientShellSurfaceSetParams { active }, + ), + }, + ) +} + +fn endpoint_request( + boot_id: &str, + request: crate::api::schema::Request, +) -> std::io::Result<crate::protocol::ClientMessage> { + Ok(crate::protocol::ClientMessage::ClientShellEndpointRequest { + boot_id: boot_id.to_owned(), + request: serde_json::to_string(&request) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + }) +} diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs new file mode 100644 index 00000000..b66cb98b --- /dev/null +++ b/src/client/endpoint/activation_tests.rs @@ -0,0 +1,1305 @@ +use super::*; + +fn endpoint() -> ClientEndpointId { + ClientEndpointId::Ssh( + super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ) +} + +fn lease(id: ClientEndpointId, generation: u64, boot: &str) -> EndpointLease { + EndpointLease { + endpoint_id: id, + generation, + boot_id: boot.into(), + minimum_revision: 0, + } +} + +#[derive(Clone)] +struct FakeTransport { + sent: std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>, + fail_after_write: bool, +} + +impl super::super::EndpointTransport for FakeTransport { + fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + if self.fail_after_write { + Err(std::io::Error::other("simulated observed write failure")) + } else { + Ok(()) + } + } +} + +fn negotiation() -> super::super::EndpointNegotiation { + super::super::EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + ], + ) +} + +fn test_snapshot(boot_id: &str, revision: u64) -> crate::protocol::ClientShellSnapshot { + crate::protocol::ClientShellSnapshot { + boot_id: boot_id.into(), + revision, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + } +} + +type SentMessages = std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>; +type TestFixture = ( + crate::client::ClientShellState, + EndpointRegistry, + SentMessages, + SentMessages, +); + +fn shell_and_registry() -> TestFixture { + shell_and_registry_with_source_failure(false) +} + +fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> TestFixture { + let mut shell = crate::client::ClientShellState::new( + crate::client::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + let profile = super::super::SavedSshEndpoint { + id: super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Remote".into(), + target: "dev@example.com".into(), + session: "main".into(), + enabled: true, + }; + let target = ClientEndpointId::Ssh(profile.id.clone()); + shell.set_endpoint_catalog(&[profile]); + shell.set_snapshot(Box::new(test_snapshot("local-boot", 1))); + shell.set_endpoint_status(&target, ClientEndpointStatus::Online); + shell.set_endpoint_snapshot(&target, Box::new(test_snapshot("remote-boot", 1))); + + let local_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let remote_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + fail_after_write: source_fail_after_write, + }, + 1, + negotiation(), + ); + endpoints.insert( + target, + FakeTransport { + sent: remote_sent.clone(), + fail_after_write: false, + }, + 7, + negotiation(), + false, + ); + (shell, endpoints, local_sent, remote_sent) +} + +fn surface_success(id: &str, active: bool, projection_revision: u64) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + }, + }) + .unwrap() +} + +fn workspace_focus_success(id: &str, workspace_id: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::WorkspaceInfo { + workspace: crate::api::schema::WorkspaceInfo { + workspace_id: workspace_id.into(), + number: 1, + label: workspace_id.into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "tab".into(), + agent_status: crate::api::schema::AgentStatus::Unknown, + tokens: Default::default(), + worktree: None, + }, + }, + }) + .unwrap() +} + +fn failure(id: &str, message: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::ErrorResponse { + id: id.into(), + error: crate::api::schema::ErrorBody { + code: "surface_rejected".into(), + message: message.into(), + }, + }) + .unwrap() +} + +fn surface_set_active(message: &crate::protocol::ClientMessage) -> Option<bool> { + let crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } = message else { + return None; + }; + let request: crate::api::schema::Request = serde_json::from_str(request).ok()?; + match request.method { + crate::api::schema::Method::ClientShellSurfaceSet(params) => Some(params.active), + _ => None, + } +} + +fn resize() -> crate::protocol::ClientMessage { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: false, + } +} + +fn surface(boot_id: &str, revision: u64, pane: &str) -> crate::protocol::PaneSurfaceFrame { + crate::protocol::PaneSurfaceFrame { + boot_id: boot_id.into(), + projection_revision: revision, + surface_revision: revision, + frame: crate::protocol::FrameData { + cells: Vec::new(), + width: 80, + height: 24, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![crate::protocol::PaneSurfacePane { + pane_id: pane.into(), + content_revision: revision, + rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + inner_rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } +} + +fn machine() -> PendingEndpointActivation { + PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ActivatingTarget { + request_id: "client-shell-surface:3:on".into(), + acknowledged_revision: Some(1), + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: true, + evidence: ActivationEvidence::default(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 3, + next_focus_serial: 0, + rollback_error: None, + successor: None, + } +} + +#[test] +fn source_off_request_is_distinct_and_precedes_target_on_phase() { + let activation = PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ReleasingSource { + request_id: "client-shell-surface:9:off".into(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 9, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:9:off" + )); + assert!(!activation.accepts_response( + &endpoint(), + 7, + "remote-boot", + "client-shell-surface:9:on" + )); +} + +#[test] +fn active_source_requires_metadata_from_its_current_connection_generation() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 99, + Box::new(test_snapshot("stale-local-boot", 1)), + ); + + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 26, + Instant::now(), + ); + + assert!( + matches!(result, Err(ActivationBeginError::Preflight(message)) if message.contains("this connection")) + ); + assert!(local_sent.lock().unwrap().is_empty()); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(endpoints.active_surface_available()); +} + +#[test] +fn observed_begin_write_failure_returns_recoverable_partial_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = + shell_and_registry_with_source_failure(true); + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 10, + Instant::now(), + ); + + let ActivationBeginError::Partial { + mut activation, + error, + } = (match result { + Err(error) => error, + Ok(_) => panic!("an observed source write must return partial lifecycle state"), + }) + else { + panic!("an observed source write must return partial lifecycle state"); + }; + assert!(error.contains("focus revoke")); + assert_eq!( + local_sent.lock().unwrap().first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(matches!( + activation.rollback(&mut endpoints, error, false), + ActivationRollback::Unavailable(_) + )); +} + +#[test] +fn source_release_is_sent_and_acknowledged_before_target_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 11, + Instant::now(), + ) + .unwrap(); + let local = local_sent.lock().unwrap(); + assert_eq!( + local.first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }), + "source focus is revoked before source-off" + ); + assert_eq!( + local + .as_slice() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false], + "the source is released first" + ); + drop(local); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!( + !endpoints.active_surface_available(), + "pane input is blocked while frozen" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:11:off", + &surface_success("client-shell-surface:11:off", false, 1), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let remote = remote_sent.lock().unwrap(); + assert!(matches!( + remote[0], + crate::protocol::ClientMessage::ClientShellResize { .. } + )); + assert_eq!( + remote.get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); + assert_eq!(remote.get(1).and_then(surface_set_active), Some(true)); +} + +#[test] +fn activation_requires_an_exact_snapshot_surface_revision_pair() { + let mut activation = machine(); + let target = endpoint(); + let snapshot = crate::protocol::ClientShellSnapshot { + boot_id: "remote-boot".into(), + revision: 2, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + }; + assert_eq!( + activation.receive_snapshot(&target, 7, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn typed_target_ack_sets_a_floor_for_same_boot_activation_evidence() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 16, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:16:off", + &surface_success("client-shell-surface:16:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.receive_response( + &target, + 7, + "client-shell-surface:16:on", + &surface_success("client-shell-surface:16:on", true, 4), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 3)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 3, "pane")), + SurfaceActivationProgress::Pending, + "a delayed same-boot surface below the acknowledgement floor is not evidence" + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 4)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 4, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn stale_generation_and_boot_are_not_activation_evidence() { + let mut activation = machine(); + assert_eq!( + activation.receive_surface(&endpoint(), 6, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("old-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn stale_response_boot_is_not_consumed() { + let mut activation = machine(); + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + assert_eq!( + activation.receive_response_for_boot( + &endpoint(), + 7, + "old-boot", + "client-shell-surface:3:on", + &surface_success("client-shell-surface:3:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn same_target_retarget_is_latest_wins() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "old".into(), + )), + resize(), + 12, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:12:off", + &surface_success("client-shell-surface:12:off", false, 1), + &mut endpoints, + ); + let old_focus = remote_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if surface_set_active(message).is_none() => + { + Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ) + } + _ => None, + }) + .unwrap(); + let sent_before_retarget = remote_sent.lock().unwrap().len(); + activation + .retarget( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "new".into(), + )), + &mut endpoints, + ) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().len(), sent_before_retarget); + assert!(activation.accepts_response(&target, 7, "remote-boot", &old_focus)); + assert_eq!( + activation.receive_response( + &target, + 7, + &old_focus, + &workspace_focus_success(&old_focus, "old"), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let latest_focus = remote_sent + .lock() + .unwrap() + .last() + .and_then(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } => Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ), + _ => None, + }) + .unwrap(); + assert_ne!(latest_focus, old_focus); + assert!(activation.accepts_response(&target, 7, "remote-boot", &latest_focus)); +} + +#[test] +fn latest_host_focus_is_replayed_to_the_eventual_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 25, + Instant::now(), + ) + .unwrap(); + activation.update_host_focus(false, &mut endpoints).unwrap(); + assert!(remote_sent.lock().unwrap().is_empty()); + + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:25:off", + &surface_success("client-shell-surface:25:off", false, 1), + &mut endpoints, + ); + assert_eq!( + remote_sent.lock().unwrap().get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + + activation.update_host_focus(true, &mut endpoints).unwrap(); + assert_eq!( + remote_sent.lock().unwrap().last(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); +} + +#[test] +fn host_focus_change_restarts_an_issued_presentation_effects_fence() { + let (_shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = machine(); + activation.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease(endpoint(), 7, "remote-boot"), + token: "old-token".into(), + ready: false, + completion: Box::new(ActivationCompletion::Activated), + }; + + activation.update_host_focus(false, &mut endpoints).unwrap(); + + assert!(matches!( + activation.phase, + ActivationPhase::SynchronizingPresentation { .. } + )); + assert_eq!( + activation.receive_presentation_effects_ready(&endpoint(), 7, "old-token"), + SurfaceActivationProgress::Stale + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); +} + +#[test] +fn source_release_rejection_restores_the_source_coherently() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 13, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:13:off", + &failure("client-shell-surface:13:off", "source rejected release"), + &mut endpoints, + ), + SurfaceActivationProgress::Rejected { + message: "source rejected release".into(), + source_release_rejected: true, + } + ); + assert_eq!( + activation.rollback(&mut endpoints, "source rejected release".into(), true), + ActivationRollback::Pending + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn source_release_timeout_starts_an_acknowledged_source_restore() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 14, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.rollback(&mut endpoints, "source release timed out".into(), false), + ActivationRollback::Pending + ); + let sent = local_sent.lock().unwrap(); + assert_eq!( + sent.iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:14:rollback-source-on" + )); +} + +#[test] +fn resize_invalidates_already_recorded_surface_evidence() { + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let mut activation = machine(); + assert_eq!( + activation.receive_snapshot(&endpoint(), 7, &test_snapshot("remote-boot", 1)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Ready + ); + let resize = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation.update_resize(resize, &mut endpoints).unwrap(); + assert_eq!(activation.progress(), SurfaceActivationProgress::Pending); +} + +#[test] +fn resize_during_activation_reaches_the_pending_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 15, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:15:off", + &surface_success("client-shell-surface:15:off", false, 1), + &mut endpoints, + ); + let resized = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation + .update_resize(resized.clone(), &mut endpoints) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().last(), Some(&resized)); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending, + "a surface for the prior geometry cannot commit" + ); +} + +#[test] +fn rapid_a_to_b_to_a_restores_source_before_a_fresh_latest_epoch() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 20, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:off", + &surface_success("client-shell-surface:20:off", false, 1), + &mut endpoints, + ); + + // The latest A-qualified target replaces B while B may already have accepted target-on. + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true, false], + "B is released before A can be restored" + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Stale, + "delayed B activation evidence cannot satisfy A restoration" + ); + let _ = activation.receive_response( + &endpoint(), + 7, + "client-shell-surface:20:rollback-target-off", + &surface_success("client-shell-surface:20:rollback-target-off", false, 1), + &mut endpoints, + ); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true], + "source restoration is acknowledged rather than racing target ownership" + ); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:rollback-source-on", + &surface_success("client-shell-surface:20:rollback-source-on", true, 2), + &mut endpoints, + ); + let local_snapshot = test_snapshot("local-boot", 2); + shell.set_snapshot(Box::new(local_snapshot.clone())); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &local_snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + endpoint: ClientEndpointId::Local, + .. + }) + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:presentation-sync", + &surface_success("client-shell-surface:20:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "20:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::RestoredSource { + successor: Some(EndpointActivationIntent { + endpoint_id: ClientEndpointId::Local, + .. + }), + .. + }) + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + + // Runtime queues this successor with force=true, so even source==target receives a new + // activation epoch only after restoration committed. + let _fresh_epoch = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + resize(), + 21, + Instant::now(), + ) + .unwrap(); + assert!(local_sent.lock().unwrap().iter().any(|message| { + matches!( + message, + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if serde_json::from_str::<crate::api::schema::Request>(request) + .is_ok_and(|request| request.id == "client-shell-surface:21:on") + ) + })); +} + +#[test] +fn disconnected_committed_source_does_not_block_switching_to_a_live_endpoint() { + let (mut shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let disconnected = endpoint(); + endpoints.set_surface_active(&ClientEndpointId::Local, false); + endpoints.set_surface_active(&disconnected, true); + assert!(endpoints.set_active(&disconnected)); + assert!(shell.activate_endpoint_projection(&disconnected)); + endpoints.disconnect(&disconnected); + + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + resize(), + 22, + Instant::now(), + ) + .unwrap(); + assert_eq!(activation.source_command_lane(), None); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true], + "there is no unavailable source release to await" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:on", + &surface_success("client-shell-surface:22:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let snapshot = test_snapshot("local-boot", 2); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(snapshot.clone()), + ); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + previous, + endpoint: ClientEndpointId::Local, + }) if previous == disconnected + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:presentation-sync", + &surface_success("client-shell-surface:22:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "22:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::Activated) + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert_ne!(endpoints.active_id(), &disconnected); +} + +#[test] +fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 23, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:23:off", + &surface_success("client-shell-surface:23:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert!(!activation.can_retarget(&target)); + assert_eq!( + activation.supersede( + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + activation.successor, + Some(EndpointActivationIntent { + endpoint_id: target, + target: Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + }) + ); +} + +#[test] +fn unacknowledged_target_release_closes_target_before_restoring_source() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 24, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:24:off", + &surface_success("client-shell-surface:24:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.rollback(&mut endpoints, "target activation timed out".into(), false), + ActivationRollback::Pending + ); + assert_eq!( + activation.rollback(&mut endpoints, "target release timed out".into(), false), + ActivationRollback::Pending + ); + assert!(endpoints.connection(&target).is_none()); + let failures = endpoints.take_failures(); + assert_eq!( + failures.len(), + 1, + "rollback revocation must reach the reconnect owner" + ); + assert_eq!(failures[0].endpoint_id, target); + assert_eq!(failures[0].generation, 7); + assert_eq!(failures[0].kind, std::io::ErrorKind::TimedOut); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn target_loss_at_activation_deadline_restores_source_before_timeout() { + let (shell, mut endpoints, local_sent, _) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 30, + Instant::now(), + ) + .unwrap(); + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:30:off", + &surface_success("client-shell-surface:30:off", false, 1), + &mut endpoints, + ); + let now = Instant::now(); + activation.deadline = now; + assert!(activation.expired(now)); + endpoints.fail(&target, std::io::ErrorKind::UnexpectedEof.into()); + // Match the client timer: apply transport failures before checking phase expiry. + for failure in endpoints.take_failures() { + assert_eq!( + activation.endpoint_disconnected(&mut endpoints, &failure.endpoint_id, failure.message), + ActivationRollback::Pending + ); + } + assert!(matches!( + activation.phase, + ActivationPhase::RestoringSource { .. } + )); + assert!(!activation.expired(now)); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn losing_local_during_handoff_does_not_revoke_the_healthy_target() { + for source_released in [false, true] { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 29, + Instant::now(), + ) + .unwrap(); + if source_released { + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:29:off", + &surface_success("client-shell-surface:29:off", false, 1), + &mut endpoints, + ); + } + if !source_released { + activation.deadline = Instant::now() - Duration::from_millis(1); + } + endpoints.fail( + &ClientEndpointId::Local, + std::io::ErrorKind::BrokenPipe.into(), + ); + assert_eq!( + activation.endpoint_disconnected( + &mut endpoints, + &ClientEndpointId::Local, + "Local stopped".into() + ), + ActivationRollback::Pending + ); + assert!(!activation.source_available); + assert!( + !activation.expired(Instant::now()), + "starting the healthy target must get a fresh deadline" + ); + assert!(matches!( + activation.phase, + ActivationPhase::ActivatingTarget { .. } + )); + assert!(endpoints.connection(&target).is_some()); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); + } +} + +#[test] +fn resize_message_preserves_the_latest_surface_dimensions() { + assert_eq!( + resize_geometry(&resize()), + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }) + ); +} diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs new file mode 100644 index 00000000..23f26387 --- /dev/null +++ b/src/client/endpoint/catalog.rs @@ -0,0 +1,578 @@ +use std::collections::HashSet; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use super::ProfileId; + +const CATALOG_VERSION: u32 = 1; +const SELECTION_VERSION: u32 = 1; +const MAX_CATALOG_BYTES: u64 = 64 * 1024; +const MAX_PROFILES: usize = 64; +const MAX_LABEL_BYTES: usize = 128; +const MAX_TARGET_BYTES: usize = 1024; +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SavedSshEndpoint { + pub(crate) id: ProfileId, + pub(crate) label: String, + pub(crate) target: String, + pub(crate) session: String, + pub(crate) enabled: bool, +} + +impl SavedSshEndpoint { + pub(crate) fn new( + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<Self, String> { + let profile = Self { + id: ProfileId::generate(), + label: label.into(), + target: target.into(), + session: session.into(), + enabled: true, + }; + profile.validate()?; + Ok(profile) + } + + fn validate(&self) -> Result<(), String> { + ProfileId::parse(self.id.to_string())?; + let label = self.label.trim(); + if label.is_empty() { + return Err("SSH endpoint label cannot be empty".into()); + } + if label.len() > MAX_LABEL_BYTES || label.chars().any(char::is_control) { + return Err(format!( + "SSH endpoint label must be at most {MAX_LABEL_BYTES} bytes and contain no control characters" + )); + } + if self.target.len() > MAX_TARGET_BYTES || self.target.chars().any(char::is_control) { + return Err(format!( + "SSH target must be at most {MAX_TARGET_BYTES} bytes and contain no control characters" + )); + } + crate::remote::validate_remote_target(&self.target).map(|_| ())?; + let authority = self.target.strip_prefix("ssh://").unwrap_or(&self.target); + if authority + .rsplit_once('@') + .is_some_and(|(userinfo, _)| userinfo.contains(':')) + { + return Err("SSH target must not contain a password".into()); + } + crate::session::validate_name(&self.session)?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EndpointCatalog { + version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) selected_profile: Option<ProfileId>, + #[serde(default)] + pub(crate) ssh: Vec<SavedSshEndpoint>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EndpointSelection { + version: u32, + selected_profile: Option<ProfileId>, +} + +impl Default for EndpointCatalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + selected_profile: None, + ssh: Vec::new(), + } + } +} + +impl EndpointCatalog { + pub(crate) fn load() -> Result<Self, String> { + Self::load_from_paths(&catalog_path(), &selection_path()) + } + + pub(crate) fn load_profiles() -> Result<Vec<SavedSshEndpoint>, String> { + // Live clients keep their own selection, independent of other attached clients. + Self::load_from_path(&catalog_path()).map(|catalog| catalog.ssh) + } + + fn load_from_paths(catalog_path: &Path, selection_path: &Path) -> Result<Self, String> { + let mut catalog = Self::load_from_path(catalog_path)?; + match load_selection_from_path(selection_path) { + Ok(Some(selection)) => { + let valid = selection.selected_profile.as_ref().is_none_or(|selected| { + catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }); + if valid { + catalog.selected_profile = selection.selected_profile; + } else { + tracing::warn!( + path = %selection_path.display(), + "saved endpoint selection is absent or disabled; using Local" + ); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + %error, + path = %selection_path.display(), + "saved endpoint selection is unavailable; using Local" + ); + } + } + Ok(catalog) + } + + pub(crate) fn store_profiles(&self) -> Result<(), String> { + self.store_to_path(&catalog_path()) + } + + pub(crate) fn store_selection(&self) -> Result<(), String> { + self.store_selection_to_path(&selection_path()) + } + + fn store_selection_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: self.selected_profile.clone(), + }) + .map_err(|error| format!("failed to encode endpoint selection: {error}"))?; + store_private_json(path, &content, "endpoint selection") + } + + pub(crate) fn add_ssh( + &mut self, + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<ProfileId, String> { + if self.ssh.len() >= MAX_PROFILES { + return Err(format!("at most {MAX_PROFILES} SSH endpoints can be saved")); + } + let profile = SavedSshEndpoint::new(label, target, session)?; + let id = profile.id.clone(); + self.ssh.push(profile); + Ok(id) + } + + pub(crate) fn rename_ssh( + &mut self, + id: &ProfileId, + label: impl Into<String>, + ) -> Result<bool, String> { + let Some(index) = self.ssh.iter().position(|profile| &profile.id == id) else { + return Ok(false); + }; + let mut renamed = self.ssh[index].clone(); + renamed.label = label.into(); + renamed.validate()?; + self.ssh[index] = renamed; + Ok(true) + } + + pub(crate) fn remove_ssh(&mut self, id: &ProfileId) -> bool { + let previous_len = self.ssh.len(); + self.ssh.retain(|profile| &profile.id != id); + if self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + self.ssh.len() != previous_len + } + + pub(crate) fn select_local(&mut self) { + self.selected_profile = None; + } + + pub(crate) fn select_endpoint(&mut self, endpoint_id: &super::ClientEndpointId) -> bool { + match endpoint_id { + super::ClientEndpointId::Local => { + self.select_local(); + true + } + super::ClientEndpointId::Ssh(profile_id) => self.select_ssh(profile_id), + } + } + + pub(crate) fn select_ssh(&mut self, id: &ProfileId) -> bool { + if !self + .ssh + .iter() + .any(|profile| &profile.id == id && profile.enabled) + { + return false; + } + self.selected_profile = Some(id.clone()); + true + } + + pub(crate) fn has_enabled_ssh(&self) -> bool { + self.ssh.iter().any(|profile| profile.enabled) + } + + pub(crate) fn contains_enabled_target_session(&self, target: &str, session: &str) -> bool { + self.ssh.iter().any(|profile| { + profile.enabled && profile.target == target && profile.session == session + }) + } + + pub(crate) fn set_enabled(&mut self, id: &ProfileId, enabled: bool) -> bool { + let Some(profile) = self.ssh.iter_mut().find(|profile| &profile.id == id) else { + return false; + }; + profile.enabled = enabled; + if !enabled && self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + true + } + + fn validate(&self) -> Result<(), String> { + if self.version != CATALOG_VERSION { + return Err(format!( + "unsupported endpoint catalog version {}; expected {CATALOG_VERSION}", + self.version + )); + } + if self.ssh.len() > MAX_PROFILES { + return Err(format!( + "endpoint catalog contains more than {MAX_PROFILES} SSH profiles" + )); + } + let mut ids = HashSet::new(); + for profile in &self.ssh { + profile.validate()?; + if !ids.insert(profile.id.clone()) { + return Err(format!("duplicate endpoint profile id {}", profile.id)); + } + } + if self.selected_profile.as_ref().is_some_and(|selected| { + !self + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + return Err("selected SSH endpoint is absent or disabled in the catalog".into()); + } + Ok(()) + } + + fn load_from_path(path: &Path) -> Result<Self, String> { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(error) => { + return Err(format!( + "failed to open endpoint catalog {}: {error}", + path.display() + )) + } + }; + let metadata = file + .metadata() + .map_err(|error| format!("failed to inspect endpoint catalog: {error}"))?; + if metadata.len() > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let mut content = String::new(); + file.take(MAX_CATALOG_BYTES + 1) + .read_to_string(&mut content) + .map_err(|error| format!("failed to read endpoint catalog: {error}"))?; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let catalog: Self = serde_json::from_str(&content) + .map_err(|error| format!("stored endpoint catalog is invalid: {error}"))?; + catalog.validate()?; + Ok(catalog) + } + + fn store_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(self) + .map_err(|error| format!("failed to encode endpoint catalog: {error}"))?; + store_private_json(path, &content, "endpoint catalog") + } +} + +fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, String> { + let content = match std::fs::read(path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("failed to read endpoint selection: {error}")), + }; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint selection exceeds the storage limit".into()); + } + let selection: EndpointSelection = serde_json::from_slice(&content) + .map_err(|error| format!("stored endpoint selection is invalid: {error}"))?; + if selection.version != SELECTION_VERSION { + return Err(format!( + "unsupported endpoint selection version {}; expected {SELECTION_VERSION}", + selection.version + )); + } + Ok(Some(selection)) +} + +fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> { + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err(format!("{description} exceeds the storage limit")); + } + let parent = path + .parent() + .ok_or_else(|| format!("invalid {description} path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {description} directory: {error}"))?; + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "refusing to replace {description} through a non-file path" + )); + } + } + + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!(".endpoints-{}-{sequence}.tmp", std::process::id())); + let mut temp = crate::platform::create_private_state_file(&temp_path) + .map_err(|error| format!("failed to create {description}: {error}"))?; + if let Err(error) = temp.write_all(content).and_then(|()| temp.sync_all()) { + drop(temp); + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to write {description}: {error}")); + } + drop(temp); + if let Err(error) = crate::platform::replace_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to activate {description}: {error}")); + } + crate::platform::sync_parent_directory(parent) + .map_err(|error| format!("failed to persist {description} directory: {error}")) +} + +pub(crate) fn catalog_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoints.json") +} + +fn selection_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoint-selection.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(name: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "herdr-endpoint-catalog-{}-{name}", + std::process::id() + )) + .join("endpoints.json") + } + + #[test] + fn catalog_roundtrip_persists_profiles_without_secret_fields() { + let path = path("roundtrip"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog + .add_ssh("Build", "ssh://dev@build.example:2222", "agents") + .unwrap(); + assert!(catalog.select_ssh(&id)); + catalog.store_to_path(&path).unwrap(); + + let encoded = std::fs::read_to_string(&path).unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("private_key")); + assert!(!encoded.contains("control_socket")); + let loaded = EndpointCatalog::load_from_path(&path).unwrap(); + assert_eq!(loaded, catalog); + assert_eq!(loaded.ssh[0].id, id); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn duplicate_target_and_session_profiles_keep_distinct_opaque_ids() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "build", "default").unwrap(); + let second = catalog.add_ssh("Two", "build", "default").unwrap(); + assert_ne!(first, second); + } + + #[test] + fn catalog_rejects_passwords_embedded_in_ssh_targets() { + let mut catalog = EndpointCatalog::default(); + assert!(catalog + .add_ssh("Build", "ssh://dev:secret@build.example", "default") + .unwrap_err() + .contains("must not contain a password")); + assert!(catalog + .add_ssh("Build", "dev:secret@build.example", "default") + .is_err()); + assert!(catalog + .add_ssh("Build", "ssh://dev@[::1]:2222", "default") + .is_ok()); + } + + #[test] + fn interactive_bootstrap_matches_only_enabled_target_and_session() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + assert!(catalog.contains_enabled_target_session("build", "agents")); + assert!(!catalog.contains_enabled_target_session("build", "default")); + assert!(catalog.set_enabled(&id, false)); + assert!(!catalog.contains_enabled_target_session("build", "agents")); + } + + #[test] + fn rename_changes_only_the_machine_label() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Old", "build", "agents").unwrap(); + let original = catalog.ssh[0].clone(); + + assert!(catalog.rename_ssh(&id, "New").unwrap()); + assert_eq!(catalog.ssh[0].label, "New"); + assert_eq!(catalog.ssh[0].id, original.id); + assert_eq!(catalog.ssh[0].target, original.target); + assert_eq!(catalog.ssh[0].session, original.session); + assert!(catalog.rename_ssh(&id, "\n").is_err()); + assert_eq!(catalog.ssh[0].label, "New"); + } + + #[test] + fn removal_and_disable_return_selection_to_local() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "one", "default").unwrap(); + assert!(catalog.select_ssh(&first)); + assert!(catalog.set_enabled(&first, false)); + assert_eq!(catalog.selected_profile, None); + + assert!(catalog.set_enabled(&first, true)); + assert!(catalog.select_ssh(&first)); + assert!(catalog.remove_ssh(&first)); + assert_eq!(catalog.selected_profile, None); + } + + #[test] + fn catalog_rejects_unknown_fields_instead_of_retaining_possible_secrets() { + let path = path("unknown-field"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{ + "version": 1, + "ssh": [{ + "id": "0123456789abcdef0123456789abcdef", + "label": "Build", + "target": "build", + "session": "default", + "enabled": true, + "password": "must-not-be-accepted" + }] + }"#, + ) + .unwrap(); + assert!(EndpointCatalog::load_from_path(&path) + .unwrap_err() + .contains("unknown field")); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn storing_selection_does_not_rewrite_profile_membership() { + let catalog_path = path("separate-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let profiles_before = std::fs::read(&catalog_path).unwrap(); + + assert!(catalog.select_ssh(&id)); + catalog.store_selection_to_path(&selection_path).unwrap(); + + assert_eq!(std::fs::read(&catalog_path).unwrap(), profiles_before); + assert_eq!( + load_selection_from_path(&selection_path) + .unwrap() + .unwrap() + .selected_profile, + Some(id) + ); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn malformed_selection_does_not_discard_saved_profiles() { + let catalog_path = path("malformed-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + std::fs::write(&selection_path, b"not json").unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh.len(), 1); + assert_eq!(loaded.ssh[0].id, id); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn absent_selected_profile_falls_back_without_discarding_catalog() { + let catalog_path = path("absent-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let saved = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let missing = ProfileId::parse("fedcba9876543210fedcba9876543210").unwrap(); + store_private_json( + &selection_path, + &serde_json::to_vec(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: Some(missing), + }) + .unwrap(), + "endpoint selection", + ) + .unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh[0].id, saved); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn invalid_or_missing_selected_profile_is_rejected() { + let catalog = EndpointCatalog { + selected_profile: Some(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()), + ..EndpointCatalog::default() + }; + assert!(catalog.validate().is_err()); + } +} diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs new file mode 100644 index 00000000..7cf1159f --- /dev/null +++ b/src/client/endpoint/control.rs @@ -0,0 +1,63 @@ +use super::ClientEndpointId; + +pub(crate) enum EndpointControlMessage { + HealthPong, + Snapshot(Box<crate::protocol::ClientShellSnapshot>), + Ignored, +} + +pub(crate) fn decode_endpoint_control( + kind: &str, + data: &str, +) -> Result<EndpointControlMessage, String> { + if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { + return Ok(EndpointControlMessage::HealthPong); + } + if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { + let snapshot = serde_json::from_str(data) + .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; + return Ok(EndpointControlMessage::Snapshot(Box::new(snapshot))); + } + if kind.starts_with("shell.snapshot.") { + return Err(format!( + "unsupported mandatory endpoint snapshot codec {kind:?}" + )); + } + Ok(EndpointControlMessage::Ignored) +} + +pub(crate) fn protocol_failure_is_fatal(endpoint_id: &ClientEndpointId) -> bool { + endpoint_id.is_local() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + #[test] + fn unknown_optional_controls_are_ignored() { + assert!(matches!( + decode_endpoint_control("future.optional", "not json").unwrap(), + EndpointControlMessage::Ignored + )); + } + + #[test] + fn unknown_snapshot_codecs_are_rejected() { + assert_eq!( + decode_endpoint_control("shell.snapshot.v2", "{}") + .err() + .as_deref(), + Some("unsupported mandatory endpoint snapshot codec \"shell.snapshot.v2\"") + ); + } + + #[test] + fn only_local_protocol_failures_end_the_client() { + let remote = + ClientEndpointId::Ssh(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()); + assert!(protocol_failure_is_fatal(&ClientEndpointId::Local)); + assert!(!protocol_failure_is_fatal(&remote)); + } +} diff --git a/src/client/endpoint/health.rs b/src/client/endpoint/health.rs new file mode 100644 index 00000000..87c08952 --- /dev/null +++ b/src/client/endpoint/health.rs @@ -0,0 +1,99 @@ +use std::time::{Duration, Instant}; + +pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +pub(super) const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HealthAction { + None, + Ping, + Expired, +} + +pub(super) struct EndpointHealth { + connected_at: Instant, + last_received: Instant, + ping_sent_at: Option<Instant>, + ready: bool, +} + +impl EndpointHealth { + pub(super) fn new(now: Instant) -> Self { + Self { + connected_at: now, + last_received: now, + ping_sent_at: None, + ready: false, + } + } + + pub(super) fn received(&mut self, now: Instant) { + self.last_received = now; + self.ping_sent_at = None; + } + + pub(super) fn ready(&mut self) { + self.ready = true; + } + + pub(super) fn action(&self, now: Instant) -> HealthAction { + let initial_snapshot_expired = + !self.ready && now.saturating_duration_since(self.connected_at) >= HEARTBEAT_TIMEOUT; + let probe_expired = self + .ping_sent_at + .is_some_and(|sent_at| now.saturating_duration_since(sent_at) >= HEARTBEAT_TIMEOUT); + if initial_snapshot_expired || probe_expired { + HealthAction::Expired + } else if self.ping_sent_at.is_none() + && now.saturating_duration_since(self.last_received) >= HEARTBEAT_INTERVAL + { + HealthAction::Ping + } else { + HealthAction::None + } + } + + pub(super) fn ping_sent(&mut self, now: Instant) { + self.ping_sent_at = Some(now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quiet_connection_is_probed_then_expires_without_a_reply() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + assert_eq!(health.action(now), HealthAction::None); + assert_eq!(health.action(now + HEARTBEAT_INTERVAL), HealthAction::Ping); + health.ping_sent(now + HEARTBEAT_INTERVAL); + assert_eq!( + health.action(now + HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } + + #[test] + fn any_incoming_message_satisfies_an_outstanding_probe() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ready(); + health.ping_sent(now); + health.received(now + HEARTBEAT_TIMEOUT - Duration::from_millis(1)); + assert_eq!(health.action(now + HEARTBEAT_TIMEOUT), HealthAction::None); + } + + #[test] + fn heartbeats_do_not_hide_a_missing_initial_snapshot() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ping_sent(now + HEARTBEAT_INTERVAL); + health.received(now + HEARTBEAT_INTERVAL + Duration::from_secs(1)); + assert_eq!( + health.action(now + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } +} diff --git a/src/client/endpoint/message_policy.rs b/src/client/endpoint/message_policy.rs new file mode 100644 index 00000000..5e84325a --- /dev/null +++ b/src/client/endpoint/message_policy.rs @@ -0,0 +1,124 @@ +use crate::protocol::ServerMessage; + +/// Effects that are meaningful only for the endpoint currently holding the host presentation +/// lease. During a frozen handoff they are intentionally dropped and the committed target asks +/// for one bounded replay, rather than allowing target graphics or modes onto the source frame. +pub(crate) fn is_presentation_effect(message: &ServerMessage) -> bool { + matches!( + message, + ServerMessage::MouseCapture { .. } + | ServerMessage::ClientShellKeyboardReportAll { .. } + | ServerMessage::WindowTitle { .. } + | ServerMessage::Graphics { .. } + | ServerMessage::GraphicsFile { .. } + | ServerMessage::GraphicsTransmissionRetired { .. } + | ServerMessage::TerminalBell { .. } + ) +} + +pub(crate) fn accepts_endpoint_message( + endpoint_active: bool, + activation_message: bool, + command_response: bool, + message: &ServerMessage, +) -> bool { + endpoint_active + || matches!( + message, + ServerMessage::EndpointControl { .. } + | ServerMessage::SemanticNotification(_) + | ServerMessage::ServerShutdown { .. } + | ServerMessage::ClientShellSnapshot(_) + | ServerMessage::Welcome { .. } + ) + || (activation_message + && matches!( + message, + ServerMessage::PaneSurface(_) + | ServerMessage::ClientShellEndpointResponseChunk { .. } + )) + || (command_response + && matches!( + message, + ServerMessage::ClientShellEndpointResponseChunk { .. } + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_endpoints_deliver_metadata_but_not_presentation_effects() { + assert!(accepts_endpoint_message( + false, + false, + false, + &ServerMessage::EndpointControl { + kind: "snapshot".into(), + data: "{}".into(), + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::Clipboard { + data: "text".into() + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::WindowTitle { + title: Some("remote".into()) + } + )); + } + + #[test] + fn frozen_handoffs_drop_target_effects_until_the_post_commit_resync() { + assert!(is_presentation_effect(&ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + })); + assert!(is_presentation_effect(&ServerMessage::GraphicsFile { + path: "asset".into(), + expected_len: 1, + image_id: 1, + transfer_id: 1, + leading: Vec::new(), + control: "a=t".into(), + surface_asset: None, + })); + assert!(!is_presentation_effect(&ServerMessage::EndpointControl { + kind: "shell.snapshot.v1".into(), + data: "{}".into(), + })); + } + + #[test] + fn pending_activation_accepts_only_its_surface_lane() { + assert!(accepts_endpoint_message( + false, + true, + false, + &ServerMessage::ClientShellEndpointResponseChunk { + boot_id: "boot".into(), + request_id: "surface".into(), + final_chunk: true, + data: Vec::new(), + } + )); + assert!(!accepts_endpoint_message( + false, + true, + false, + &ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + } + )); + } +} diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs new file mode 100644 index 00000000..437f39f3 --- /dev/null +++ b/src/client/endpoint/registry.rs @@ -0,0 +1,629 @@ +use std::collections::{HashMap, HashSet}; +use std::io; +use std::time::Instant; + +use super::health::{EndpointHealth, HealthAction}; +use super::ClientEndpointId; +use crate::protocol::ClientMessage; + +pub(crate) trait EndpointTransport: Send { + fn send(&mut self, message: &ClientMessage) -> io::Result<()>; + + fn disconnect(&mut self) {} + + fn flush(&mut self, _deadline: Instant) -> io::Result<()> { + Ok(()) + } + + fn take_error(&mut self) -> Option<io::Error> { + None + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EndpointNegotiation { + methods: HashSet<String>, + capabilities: HashSet<String>, +} + +impl EndpointNegotiation { + pub(crate) fn new(methods: Vec<String>, capabilities: Vec<String>) -> Self { + Self { + methods: methods.into_iter().collect(), + capabilities: capabilities.into_iter().collect(), + } + } + + pub(crate) fn methods(&self) -> Vec<String> { + self.methods.iter().cloned().collect() + } + + pub(crate) fn supports_method(&self, method: &str) -> bool { + self.methods.contains(method) + } + + pub(crate) fn supports_capability(&self, capability: &str) -> bool { + self.capabilities.contains(capability) + } + + pub(crate) fn supports_surface_interest(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY) + && self.supports_capability( + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + ) + && self.supports_method("client_shell.surface.set") + } + + pub(crate) fn supports_health_check(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY) + } +} + +pub(crate) struct EndpointConnection { + transport: Box<dyn EndpointTransport>, + pub(crate) generation: u64, + pub(crate) surface_active: bool, + pub(crate) negotiation: EndpointNegotiation, + health: Option<EndpointHealth>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointTransportFailure { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) generation: u64, + pub(crate) kind: io::ErrorKind, + pub(crate) message: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EndpointSendOutcome { + Sent, + NotSent, +} + +pub(crate) struct EndpointRegistry { + active: ClientEndpointId, + input_enabled: bool, + connections: HashMap<ClientEndpointId, EndpointConnection>, + failures: Vec<EndpointTransportFailure>, +} + +impl EndpointRegistry { + pub(crate) fn empty() -> Self { + Self { + active: ClientEndpointId::Local, + input_enabled: false, + connections: HashMap::new(), + failures: Vec::new(), + } + } + + pub(crate) fn new( + local: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + ) -> Self { + let mut registry = Self::empty(); + registry.input_enabled = true; + registry.insert( + ClientEndpointId::Local, + local, + generation, + negotiation, + true, + ); + registry + } + + pub(crate) fn active_id(&self) -> &ClientEndpointId { + &self.active + } + + pub(crate) fn active_surface_available(&self) -> bool { + self.input_enabled + && self + .connections + .get(&self.active) + .is_some_and(|connection| connection.surface_active) + } + + pub(crate) fn select_unavailable_local(&mut self) { + self.active = ClientEndpointId::Local; + self.freeze_input(); + } + + pub(crate) fn freeze_input(&mut self) { + self.input_enabled = false; + } + + pub(crate) fn unfreeze_input(&mut self) { + self.input_enabled = true; + } + + pub(crate) fn connection(&self, endpoint_id: &ClientEndpointId) -> Option<&EndpointConnection> { + self.connections.get(endpoint_id) + } + + pub(crate) fn insert( + &mut self, + endpoint_id: ClientEndpointId, + transport: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + surface_active: bool, + ) { + let health = (!endpoint_id.is_local() && negotiation.supports_health_check()) + .then(|| EndpointHealth::new(Instant::now())); + if let Some(mut previous) = self.connections.insert( + endpoint_id, + EndpointConnection { + transport: Box::new(transport), + generation, + surface_active, + negotiation, + health, + }, + ) { + previous.transport.disconnect(); + } + } + + pub(crate) fn accepts(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + self.connections + .get(endpoint_id) + .is_some_and(|connection| connection.generation == generation) + } + + pub(crate) fn received( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.received(now); + } + } + + pub(crate) fn mark_ready(&mut self, endpoint_id: &ClientEndpointId, generation: u64) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.ready(); + } + } + + pub(crate) fn tick_health(&mut self, now: Instant) { + let actions = self + .connections + .iter() + .filter_map(|(endpoint_id, connection)| { + connection + .health + .as_ref() + .map(|health| (endpoint_id.clone(), health.action(now))) + }) + .filter(|(_, action)| *action != HealthAction::None) + .collect::<Vec<_>>(); + for (endpoint_id, action) in actions { + match action { + HealthAction::None => {} + HealthAction::Ping => { + let ping = ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::HEALTH_PING_KIND.into(), + data: String::new(), + }; + if self.send_to(&endpoint_id, &ping) == EndpointSendOutcome::Sent { + if let Some(health) = self + .connections + .get_mut(&endpoint_id) + .and_then(|connection| connection.health.as_mut()) + { + health.ping_sent(now); + } + } + } + HealthAction::Expired => self.record_failure( + endpoint_id, + io::Error::new(io::ErrorKind::TimedOut, "endpoint health check timed out"), + ), + } + } + } + + pub(crate) fn set_active(&mut self, endpoint_id: &ClientEndpointId) -> bool { + if !self + .connections + .get(endpoint_id) + .is_some_and(|connection| connection.surface_active) + { + return false; + } + self.active = endpoint_id.clone(); + true + } + + pub(crate) fn set_surface_active( + &mut self, + endpoint_id: &ClientEndpointId, + active: bool, + ) -> bool { + let Some(connection) = self.connections.get_mut(endpoint_id) else { + return false; + }; + let changed = connection.surface_active != active; + connection.surface_active = active; + changed + } + + pub(crate) fn send(&mut self, message: &ClientMessage) -> EndpointSendOutcome { + let endpoint_id = self.active.clone(); + self.send_to(&endpoint_id, message) + } + + pub(crate) fn send_to( + &mut self, + endpoint_id: &ClientEndpointId, + message: &ClientMessage, + ) -> EndpointSendOutcome { + let result = self + .connections + .get_mut(endpoint_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "endpoint is unavailable")) + .and_then(|connection| connection.transport.send(message)); + match result { + Ok(()) => EndpointSendOutcome::Sent, + Err(error) => { + self.record_failure(endpoint_id.clone(), error); + EndpointSendOutcome::NotSent + } + } + } + + pub(crate) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) { + self.failures + .retain(|failure| &failure.endpoint_id != endpoint_id); + if let Some(mut connection) = self.connections.remove(endpoint_id) { + connection.transport.disconnect(); + } + } + + pub(crate) fn fail(&mut self, endpoint_id: &ClientEndpointId, error: io::Error) { + self.record_failure(endpoint_id.clone(), error); + } + + pub(crate) fn take_failures(&mut self) -> Vec<EndpointTransportFailure> { + let errors = self + .connections + .iter_mut() + .filter_map(|(id, connection)| { + connection + .transport + .take_error() + .map(|error| (id.clone(), error)) + }) + .collect::<Vec<_>>(); + for (endpoint_id, error) in errors { + self.record_failure(endpoint_id, error); + } + std::mem::take(&mut self.failures) + } + + fn record_failure(&mut self, endpoint_id: ClientEndpointId, error: io::Error) { + let Some(generation) = self + .connections + .get(&endpoint_id) + .map(|connection| connection.generation) + else { + return; + }; + let failure = EndpointTransportFailure { + endpoint_id: endpoint_id.clone(), + generation, + kind: error.kind(), + message: error.to_string(), + }; + if let Some(mut connection) = self.connections.remove(&endpoint_id) { + connection.transport.disconnect(); + } + if let Some(existing) = self + .failures + .iter_mut() + .find(|existing| existing.endpoint_id == endpoint_id) + { + *existing = failure; + } else { + self.failures.push(failure); + } + } +} + +impl Drop for EndpointRegistry { + fn drop(&mut self) { + let deadline = Instant::now() + std::time::Duration::from_millis(250); + for connection in self.connections.values_mut() { + let _ = connection.transport.send(&ClientMessage::Detach); + } + for connection in self.connections.values_mut() { + let _ = connection.transport.flush(deadline); + connection.transport.disconnect(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + struct FakeTransport { + sent: Arc<Mutex<Vec<ClientMessage>>>, + error: Option<io::ErrorKind>, + } + + impl EndpointTransport for FakeTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if let Some(kind) = self.error { + return Err(io::Error::new(kind, "fake transport failure")); + } + self.sent.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + fn negotiation() -> EndpointNegotiation { + EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), + ], + ) + } + + fn profile() -> crate::client::endpoint::ProfileId { + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + fn endpoint_failures_do_not_remove_other_connections() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: Some(io::ErrorKind::BrokenPipe), + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::NotSent + ); + assert!(registry.connection(&ssh_id).is_none()); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert_eq!(registry.take_failures()[0].endpoint_id, ssh_id); + + assert!(registry.set_active(&ClientEndpointId::Local)); + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::Sent + ); + assert_eq!(local_sent.lock().unwrap().len(), 1); + } + + #[test] + fn reconnecting_active_identity_does_not_count_as_an_active_surface() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + assert!(registry.active_surface_available()); + registry.disconnect(&ssh_id); + registry.insert( + ssh_id, + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 3, + negotiation(), + false, + ); + assert!(!registry.active_surface_available()); + } + + #[test] + fn recovered_local_uses_transport_failure_not_remote_health_probes() { + let mut registry = EndpointRegistry::empty(); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ClientEndpointId::Local, + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + registry.tick_health(Instant::now() + std::time::Duration::from_secs(300)); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert!(sent.lock().unwrap().is_empty()); + assert!(registry.take_failures().is_empty()); + } + + #[test] + fn negotiated_remote_health_probe_expires_the_connection() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.tick_health(now + super::super::health::HEARTBEAT_INTERVAL); + assert!(matches!( + sent.lock().unwrap().as_slice(), + [ClientMessage::EndpointControl { kind, .. }] + if kind == crate::protocol::endpoint::HEALTH_PING_KIND + )); + + registry.tick_health( + now + super::super::health::HEARTBEAT_INTERVAL + + super::super::health::HEARTBEAT_TIMEOUT, + ); + assert!(registry.connection(&ssh_id).is_none()); + assert_eq!(registry.take_failures()[0].kind, io::ErrorKind::TimedOut); + } + + #[test] + fn a_ready_endpoint_can_stay_connected_after_the_initial_deadline() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.mark_ready(&ssh_id, 2); + registry.received(&ssh_id, 2, now + super::super::health::HEARTBEAT_INTERVAL); + registry.tick_health(now + super::super::health::HEARTBEAT_TIMEOUT); + assert!(registry.connection(&ssh_id).is_some()); + } + + #[test] + fn negotiated_surface_interest_requires_capability_and_method() { + assert!(negotiation().supports_surface_interest()); + assert!(negotiation().supports_health_check()); + assert!( + !EndpointNegotiation::new(vec!["client_shell.surface.set".into()], Vec::new()) + .supports_surface_interest() + ); + assert!(!EndpointNegotiation::new( + Vec::new(), + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + assert!(!EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + } + + #[test] + fn dropping_registry_detaches_every_connected_endpoint() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let remote_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + registry.insert( + ClientEndpointId::Ssh(profile()), + FakeTransport { + sent: remote_sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + + drop(registry); + + assert!(matches!( + local_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + assert!(matches!( + remote_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + } + + #[test] + fn stale_generations_are_rejected() { + let registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 7, + negotiation(), + ); + assert!(registry.accepts(&ClientEndpointId::Local, 7)); + assert!(!registry.accepts(&ClientEndpointId::Local, 6)); + } +} diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs new file mode 100644 index 00000000..458ce927 --- /dev/null +++ b/src/client/endpoint/supervisor.rs @@ -0,0 +1,492 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, NativeEndpointTransport}; +use crate::protocol::{ClientSurfaceSize, RenderEncoding}; +use interprocess::TryClone as _; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +pub(crate) struct EndpointConnectOptions { + pub(crate) cols: u16, + pub(crate) rows: u16, + pub(crate) cell_width_px: u32, + pub(crate) cell_height_px: u32, + pub(crate) pixel_geometry_exact: bool, + pub(crate) surface_size: ClientSurfaceSize, + pub(crate) endpoint_keybindings: bool, + pub(crate) mouse_capture: bool, +} + +pub(crate) enum EndpointSupervisorEvent { + Status { + endpoint_id: ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + message: String, + }, + Connected { + endpoint_id: ClientEndpointId, + generation: u64, + reader: crate::ipc::LocalStream, + writer: NativeEndpointTransport, + negotiation: EndpointNegotiation, + }, +} + +#[derive(Clone)] +enum ConnectTarget { + Local(PathBuf), + Ssh(super::SavedSshEndpoint), +} + +struct ReconnectState { + target: ConnectTarget, + attempts: u32, + next_attempt: Option<Instant>, + in_flight: bool, + generation: Option<u64>, +} + +impl ReconnectState { + fn new(target: ConnectTarget, now: Instant) -> Self { + Self { + target, + attempts: 0, + next_attempt: Some(now), + in_flight: false, + generation: None, + } + } +} + +pub(crate) struct EndpointSupervisors { + endpoints: HashMap<ClientEndpointId, ReconnectState>, + next_generation: u64, + shutdown: Arc<AtomicBool>, +} + +impl EndpointSupervisors { + pub(crate) fn new(profiles: &[super::SavedSshEndpoint], now: Instant) -> Self { + let endpoints = profiles + .iter() + .filter(|profile| profile.enabled) + .map(|profile| { + ( + ClientEndpointId::Ssh(profile.id.clone()), + ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now), + ) + }) + .collect(); + Self { + endpoints, + next_generation: 2, + shutdown: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn add_local(&mut self, path: PathBuf, generation: Option<u64>, now: Instant) { + let mut state = ReconnectState::new(ConnectTarget::Local(path), now); + state.generation = generation; + if generation.is_some() { + state.next_attempt = None; + } + self.endpoints.insert(ClientEndpointId::Local, state); + } + + pub(crate) fn reconcile_profiles( + &mut self, + profiles: &[super::SavedSshEndpoint], + now: Instant, + ) -> Vec<ClientEndpointId> { + let mut retired = Vec::new(); + self.endpoints.retain(|endpoint_id, state| { + let ConnectTarget::Ssh(previous) = &state.target else { + return true; + }; + let keep = profiles.iter().any(|profile| { + profile.id == previous.id + && profile.enabled + && profile.target == previous.target + && profile.session == previous.session + }); + if !keep { + retired.push(endpoint_id.clone()); + } + keep + }); + for profile in profiles.iter().filter(|profile| profile.enabled) { + let state = self + .endpoints + .entry(ClientEndpointId::Ssh(profile.id.clone())) + .or_insert_with(|| ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now)); + state.target = ConnectTarget::Ssh(profile.clone()); + } + retired + } + + pub(crate) fn spawn_due( + &mut self, + now: Instant, + options: EndpointConnectOptions, + event_tx: &tokio::sync::mpsc::Sender<EndpointSupervisorEvent>, + ) { + for (endpoint_id, state) in &mut self.endpoints { + if state.in_flight || state.next_attempt.is_none_or(|deadline| deadline > now) { + continue; + } + state.in_flight = true; + state.next_attempt = None; + let generation = self.next_generation; + state.generation = Some(generation); + self.next_generation = self.next_generation.saturating_add(1); + let endpoint_id = endpoint_id.clone(); + let target = state.target.clone(); + let event_tx = event_tx.clone(); + let shutdown = self.shutdown.clone(); + tokio::spawn(async move { + if shutdown.load(Ordering::Acquire) { + return; + } + let task_endpoint_id = endpoint_id.clone(); + let result = tokio::task::spawn_blocking(move || { + connect_once(&target, options, endpoint_id, generation) + }) + .await; + let event = match result { + Ok(Ok(event)) => event, + Ok(Err(error)) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: if failure_needs_attention(&error) { + ClientEndpointStatus::Attention + } else { + ClientEndpointStatus::Reconnecting + }, + message: error.to_string(), + }, + Err(error) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: ClientEndpointStatus::Reconnecting, + message: format!("endpoint connection task stopped unexpectedly: {error}"), + }, + }; + if !shutdown.load(Ordering::Acquire) { + let _ = event_tx.send(event).await; + } + }); + } + } + + pub(crate) fn record_status( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + now: Instant, + ) -> bool { + let Some(state) = self.endpoints.get_mut(endpoint_id) else { + return false; + }; + if state.generation != Some(generation) { + return false; + } + state.in_flight = false; + match status { + ClientEndpointStatus::Online => { + state.attempts = 0; + state.next_attempt = None; + } + ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => { + state.next_attempt = None + } + ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => { + state.attempts = state.attempts.saturating_add(1); + state.next_attempt = Some(now + retry_delay(state.attempts)); + } + } + true + } + + pub(crate) fn disconnected( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) -> bool { + self.record_status( + endpoint_id, + generation, + ClientEndpointStatus::Reconnecting, + now, + ) + } +} + +impl Drop for EndpointSupervisors { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + } +} + +fn connect_once( + target: &ConnectTarget, + options: EndpointConnectOptions, + endpoint_id: ClientEndpointId, + generation: u64, +) -> Result<EndpointSupervisorEvent, std::io::Error> { + let (mut stream, lifetime): (_, Box<dyn Send>) = match target { + ConnectTarget::Local(path) => { + let stream = crate::ipc::connect_local_stream(path).map_err(|error| { + // An absent Local socket is transient, unlike a missing SSH install. + if error.kind() == std::io::ErrorKind::NotFound { + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "Local is unavailable; start its server to reconnect", + ) + } else { + error + } + })?; + (stream, Box::new(())) + } + ConnectTarget::Ssh(profile) => { + let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| { + if failure_needs_attention(&error) { + std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session))) + } else { error } + })?; + (connected.stream, Box::new(connected.bridge)) + } + }; + let handshake = super::super::do_handshake( + &mut stream, + options.cols, + options.rows, + options.cell_width_px, + options.cell_height_px, + options.pixel_geometry_exact, + Some(options.surface_size), + options.endpoint_keybindings, + options.mouse_capture, + false, + ) + .map_err(handshake_error)?; + if handshake.encoding != RenderEncoding::SemanticFrame { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "endpoint did not negotiate the semantic client shell", + )); + } + let negotiation = EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + ); + if !negotiation.supports_surface_interest() + || (!endpoint_id.is_local() && !negotiation.supports_health_check()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "this machine needs a server update before it can participate in multi-machine viewing", + )); + } + let reader = stream.try_clone()?; + let writer = NativeEndpointTransport::with_lifetime(stream, lifetime)?; + Ok(EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + }) +} + +fn failure_needs_attention(error: &std::io::Error) -> bool { + crate::remote::saved_ssh_failure_needs_attention(error) +} + +fn handshake_error(error: crate::client::ClientError) -> std::io::Error { + use crate::client::ClientError; + use crate::protocol::FramingError; + match error { + ClientError::ConnectionFailed(error) | ClientError::ConnectionLost(error) => error, + ClientError::HandshakeRejected { error, .. } => { + std::io::Error::new(std::io::ErrorKind::Unsupported, error) + } + ClientError::Protocol(FramingError::Io(error)) => error, + ClientError::Protocol(error) => { + std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()) + } + ClientError::ServerShutdown { reason } => std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server shut down during handshake".into()), + ), + } +} + +fn retry_delay(attempt: u32) -> Duration { + INITIAL_RETRY_DELAY + .saturating_mul( + 1_u32 + .checked_shl(attempt.saturating_sub(1).min(6)) + .unwrap_or(u32::MAX), + ) + .min(MAX_RETRY_DELAY) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + fn profile() -> super::super::SavedSshEndpoint { + super::super::SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + } + } + + #[test] + fn live_catalog_preserves_renamed_connections_and_local_recovery() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[profile.clone()], now); + supervisors.add_local(PathBuf::from("local"), Some(1), now); + let state = supervisors.endpoints.get_mut(&id).unwrap(); + state.generation = Some(7); + state.attempts = 3; + state.next_attempt = Some(now + Duration::from_secs(4)); + profile.label = "Renamed".into(); + assert!(supervisors.reconcile_profiles(&[profile], now).is_empty()); + let state = &supervisors.endpoints[&id]; + assert_eq!(state.generation, Some(7)); + assert_eq!(state.attempts, 3); + assert_eq!(state.next_attempt, Some(now + Duration::from_secs(4))); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].generation, + Some(1) + ); + } + + #[test] + fn live_catalog_add_disable_enable_and_remove_fence_late_connections() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[], now); + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert_eq!(supervisors.endpoints[&id].next_attempt, Some(now)); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(7); + supervisors.next_generation = 8; + profile.enabled = false; + assert_eq!( + supervisors.reconcile_profiles(&[profile.clone()], now), + vec![id.clone()] + ); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + profile.enabled = true; + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + assert_eq!(supervisors.next_generation, 8); + assert_eq!(supervisors.reconcile_profiles(&[], now), vec![id.clone()]); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + } + + #[test] + fn live_catalog_destination_change_retires_only_that_machine() { + let now = Instant::now(); + let mut changed = profile(); + let other = super::super::SavedSshEndpoint::new("Other", "other", "main").unwrap(); + let id = ClientEndpointId::Ssh(changed.id.clone()); + let other_id = ClientEndpointId::Ssh(other.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[changed.clone(), other.clone()], now); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2); + supervisors.endpoints.get_mut(&other_id).unwrap().generation = Some(3); + changed.session = "another-session".into(); + assert_eq!( + supervisors.reconcile_profiles(&[changed, other], now), + vec![id.clone()] + ); + assert_eq!(supervisors.endpoints[&id].generation, None); + assert_eq!(supervisors.endpoints[&other_id].generation, Some(3)); + } + + #[test] + fn retry_backoff_is_bounded() { + assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY); + assert_eq!(retry_delay(100), MAX_RETRY_DELAY); + } + + #[test] + fn handshake_network_failures_retry_but_incompatibility_needs_attention() { + let timeout = handshake_error(crate::client::ClientError::ConnectionLost( + std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"), + )); + assert!(!failure_needs_attention(&timeout)); + let rejected = handshake_error(crate::client::ClientError::HandshakeRejected { + version: 1, + error: "surface capability missing".into(), + }); + assert_eq!(rejected.kind(), std::io::ErrorKind::Unsupported); + assert!(failure_needs_attention(&rejected)); + } + + #[test] + fn healthy_local_only_retries_after_its_connection_fails() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + supervisors.add_local(PathBuf::from("local.sock"), Some(1), now); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(!supervisors.disconnected(&ClientEndpointId::Local, 0, now)); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(supervisors.disconnected(&ClientEndpointId::Local, 1, now)); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Ssh(profile().id)].next_attempt, + Some(now) + ); + } + + #[test] + fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + let endpoint_id = ClientEndpointId::Ssh(profile().id); + supervisors + .endpoints + .get_mut(&endpoint_id) + .unwrap() + .generation = Some(4); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Online, now)); + assert!(!supervisors.disconnected(&endpoint_id, 3, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + assert!(supervisors.disconnected(&endpoint_id, 4, now)); + assert_eq!( + supervisors.endpoints[&endpoint_id].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + } +} diff --git a/src/client/endpoint/writer.rs b/src/client/endpoint/writer.rs new file mode 100644 index 00000000..973ff234 --- /dev/null +++ b/src/client/endpoint/writer.rs @@ -0,0 +1,400 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::traits::Stream as _; + +use super::EndpointTransport; +use crate::ipc::LocalStream; +use crate::protocol::ClientMessage; + +const MAX_QUEUED_MESSAGES: usize = 256; +const MAX_QUEUED_BYTES: usize = 2 * crate::protocol::MAX_GRAPHICS_FRAME_SIZE; +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const IO_POLL_INTERVAL: Duration = Duration::from_millis(2); + +enum WriterCommand { + Frame(Vec<u8>), + Flush(mpsc::Sender<()>), +} + +/// The UI only enqueues complete frames. A worker owns partial writes, cancellation, and the +/// bridge lifetime, so neither socket backpressure nor bridge teardown can block other endpoints. +pub(crate) struct NativeEndpointTransport { + sender: mpsc::SyncSender<WriterCommand>, + queued_bytes: Arc<AtomicUsize>, + stopped: Arc<AtomicBool>, + error: Arc<Mutex<Option<io::Error>>>, +} + +impl NativeEndpointTransport { + pub(crate) fn with_lifetime( + mut stream: LocalStream, + lifetime: impl Send + 'static, + ) -> io::Result<Self> { + stream.set_nonblocking(true)?; + let (sender, receiver) = mpsc::sync_channel::<WriterCommand>(MAX_QUEUED_MESSAGES); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicBool::new(false)); + let error = Arc::new(Mutex::new(None)); + let worker_bytes = queued_bytes.clone(); + let worker_stop = stopped.clone(); + let worker_error = error.clone(); + std::thread::Builder::new() + .name("endpoint-writer".into()) + .spawn(move || { + let _lifetime = lifetime; + while let Ok(command) = receiver.recv() { + if worker_stop.load(Ordering::Acquire) { + break; + } + let frame = match command { + WriterCommand::Frame(frame) => frame, + WriterCommand::Flush(done) => { + let _ = done.send(()); + continue; + } + }; + let result = write_frame(&mut stream, &frame, &worker_stop); + worker_bytes.fetch_sub(frame.len(), Ordering::AcqRel); + if let Err(error) = result { + if let Ok(mut slot) = worker_error.lock() { + *slot = Some(error); + } + worker_stop.store(true, Ordering::Release); + break; + } + } + })?; + Ok(Self { + sender, + queued_bytes, + stopped, + error, + }) + } + + pub(crate) fn stop_handle(&self) -> Arc<AtomicBool> { + self.stopped.clone() + } +} + +impl EndpointTransport for NativeEndpointTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if self.stopped.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "endpoint writer stopped", + )); + } + let mut frame = Vec::new(); + crate::protocol::write_message(&mut frame, message) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + let len = frame.len(); + if self + .queued_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bytes| { + bytes + .checked_add(len) + .filter(|total| *total <= MAX_QUEUED_BYTES) + }) + .is_err() + { + return Err(queue_full()); + } + self.sender + .try_send(WriterCommand::Frame(frame)) + .map_err(|error| { + self.queued_bytes.fetch_sub(len, Ordering::AcqRel); + match error { + mpsc::TrySendError::Full(_) => queue_full(), + mpsc::TrySendError::Disconnected(_) => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + } + }) + } + + fn disconnect(&mut self) { + self.stopped.store(true, Ordering::Release); + } + + fn flush(&mut self, deadline: Instant) -> io::Result<()> { + let (done, completion) = mpsc::channel(); + self.sender + .try_send(WriterCommand::Flush(done)) + .map_err(|_| queue_full())?; + completion + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => { + io::Error::new(io::ErrorKind::TimedOut, "endpoint flush timed out") + } + mpsc::RecvTimeoutError::Disconnected => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + }) + } + + fn take_error(&mut self) -> Option<io::Error> { + self.error.lock().ok()?.take() + } +} + +impl Drop for NativeEndpointTransport { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + } +} + +fn queue_full() -> io::Error { + // A message may already be partially written. Retrying or dropping only this message would + // lose input ordering; revoke the connection and recover through the normal lifecycle. + io::Error::new( + io::ErrorKind::ConnectionAborted, + "endpoint output queue is full", + ) +} + +fn write_frame( + writer: &mut impl io::Write, + mut frame: &[u8], + stopped: &AtomicBool, +) -> io::Result<()> { + let deadline = Instant::now() + WRITE_TIMEOUT; + while !frame.is_empty() && !stopped.load(Ordering::Acquire) { + match writer.write(frame) { + Ok(0) => {} + Ok(written) => { + frame = &frame[written..]; + continue; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "endpoint write timed out", + )); + } + std::thread::sleep(IO_POLL_INTERVAL); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn streams() -> (LocalStream, LocalStream, std::path::PathBuf) { + use interprocess::local_socket::traits::Listener as _; + let path = std::env::temp_dir().join(format!( + "herdr-writer-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = crate::ipc::bind_private_local_listener(&path).unwrap(); + let accepting = std::thread::spawn(move || listener.accept().unwrap()); + let client = crate::ipc::connect_local_stream(&path).unwrap(); + (client, accepting.join().unwrap(), path) + } + + #[test] + fn native_endpoint_writer_delivers_ordered_protocol_frames() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + transport + .send(&ClientMessage::ClientShellFocus { focused: true }) + .unwrap(); + transport + .send(&ClientMessage::ClientShellFocus { focused: false }) + .unwrap(); + let messages = received.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!( + messages, + ( + ClientMessage::ClientShellFocus { focused: true }, + ClientMessage::ClientShellFocus { focused: false } + ) + ); + reader.join().unwrap(); + drop(transport); + let _ = std::fs::remove_file(path); + } + + #[test] + fn registry_exit_flushes_queued_input_and_a_complete_detach() { + let (stream, mut peer, path) = streams(); + let transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let result = (|| { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + Ok::<_, crate::protocol::FramingError>((first, second)) + })(); + done.send(result).unwrap(); + }); + let mut registry = super::super::EndpointRegistry::new( + transport, + 1, + super::super::EndpointNegotiation::default(), + ); + let input = ClientMessage::Input { + data: b"queued input".to_vec(), + }; + assert_eq!( + registry.send(&input), + super::super::EndpointSendOutcome::Sent + ); + drop(registry); + let (first, second) = received + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .expect("clean exit must flush complete frames before closing"); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_flush_drains_large_frames_before_detach() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + let input = ClientMessage::Input { + data: vec![b'x'; 1024 * 1024], + }; + transport.send(&input).unwrap(); + transport.send(&ClientMessage::Detach).unwrap(); + // Large-frame correctness must not depend on the registry's short exit grace period. + transport + .flush(Instant::now() + Duration::from_secs(10)) + .unwrap(); + drop(transport); + let (first, second) = received.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_teardown_does_not_wait_for_a_stalled_peer() { + struct Lifetime(mpsc::Sender<()>); + impl Drop for Lifetime { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let (stream, peer, path) = streams(); + let (done, dropped) = mpsc::channel(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, Lifetime(done)).unwrap(); + transport + .send(&ClientMessage::Input { + data: vec![b'x'; 2 * 1024 * 1024], + }) + .unwrap(); + drop(transport); + dropped + .recv_timeout(Duration::from_secs(3)) + .expect("worker lifetime is released without peer reads"); + drop(peer); + let _ = std::fs::remove_file(path); + } + + #[test] + fn partial_writes_preserve_frame_bytes() { + struct PartialWriter(Vec<u8>); + impl io::Write for PartialWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { + let count = bytes.len().min(3); + self.0.extend_from_slice(&bytes[..count]); + Ok(count) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = PartialWriter(Vec::new()); + write_frame(&mut writer, b"first frame", &AtomicBool::new(false)).unwrap(); + write_frame(&mut writer, b"second frame", &AtomicBool::new(false)).unwrap(); + assert_eq!(writer.0, b"first framesecond frame"); + } + + #[test] + fn a_stalled_write_is_cancellable_without_peer_progress() { + struct StalledWriter(mpsc::Sender<()>); + impl io::Write for StalledWriter { + fn write(&mut self, _: &[u8]) -> io::Result<usize> { + let _ = self.0.send(()); + Err(io::ErrorKind::WouldBlock.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let (attempted, attempts) = mpsc::channel(); + let (done, completion) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = write_frame(&mut StalledWriter(attempted), b"input", &worker_stop); + done.send(result).unwrap(); + }); + attempts.recv_timeout(Duration::from_secs(2)).unwrap(); + stop.store(true, Ordering::Release); + completion + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + worker.join().unwrap(); + } + + #[test] + fn a_full_queue_is_a_connection_failure_not_silent_input_loss() { + let (sender, _receiver) = mpsc::sync_channel(1); + let mut transport = NativeEndpointTransport { + sender, + queued_bytes: Arc::new(AtomicUsize::new(0)), + stopped: Arc::new(AtomicBool::new(false)), + error: Arc::new(Mutex::new(None)), + }; + transport.send(&ClientMessage::Detach).unwrap(); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + transport + .queued_bytes + .store(MAX_QUEUED_BYTES, Ordering::Release); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + } +} diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs index d07b3d42..8e77b796 100644 --- a/src/client/endpoint_commands.rs +++ b/src/client/endpoint_commands.rs @@ -1,127 +1,269 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::io; use std::time::{Duration, Instant}; use crate::api::client::ApiClientError; use crate::api::schema::{Request, ResponseResult}; -use crate::ipc::LocalStream; use crate::protocol::ClientMessage; +use super::endpoint::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; use super::shell::ClientShellEndpointError; const ENDPOINT_COMMAND_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_RETIRED_REQUESTS_PER_ENDPOINT: usize = 128; + +struct QueuedCommand { + generation: u64, + boot_id: String, + request: Box<Request>, +} struct InFlightCommand { + generation: u64, boot_id: String, request_id: String, response: Vec<u8>, sent_at: Instant, - timed_out: bool, } pub(super) struct EndpointCommandResult { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, pub(super) boot_id: String, pub(super) request_id: String, pub(super) result: Result<ResponseResult, ClientShellEndpointError>, } #[derive(Default)] -pub(super) struct EndpointCommands { - queued: VecDeque<(String, Box<Request>)>, +struct EndpointCommandLane { + queued: VecDeque<QueuedCommand>, in_flight: Option<InFlightCommand>, + retired: VecDeque<(u64, String, String)>, +} + +impl EndpointCommandLane { + fn retire(&mut self, request: (u64, String, String)) { + if self.retired.contains(&request) { + return; + } + if self.retired.len() == MAX_RETIRED_REQUESTS_PER_ENDPOINT { + self.retired.pop_front(); + } + self.retired.push_back(request); + } + + fn consume_retired(&mut self, request: &(u64, String, String), final_chunk: bool) -> bool { + let Some(index) = self.retired.iter().position(|retired| retired == request) else { + return false; + }; + if final_chunk { + self.retired.remove(index); + } + true + } +} + +#[derive(Default)] +pub(super) struct EndpointCommands { + lanes: HashMap<ClientEndpointId, EndpointCommandLane>, } impl EndpointCommands { - pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) { - self.queued.push_back((boot_id, request)); + pub(super) fn enqueue( + &mut self, + endpoint_id: ClientEndpointId, + generation: u64, + boot_id: String, + request: Box<Request>, + ) { + self.lanes + .entry(endpoint_id) + .or_default() + .queued + .push_back(QueuedCommand { + generation, + boot_id, + request, + }); } - pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> { - if self.in_flight.is_some() { - return Ok(()); + pub(super) fn send_next( + &mut self, + endpoint_id: &ClientEndpointId, + endpoints: &mut EndpointRegistry, + ) -> Vec<String> { + let lane = self.lanes.entry(endpoint_id.clone()).or_default(); + let mut cancelled = Vec::new(); + if lane.in_flight.is_some() { + return cancelled; } - let Some((boot_id, request)) = self.queued.pop_front() else { - return Ok(()); - }; - let request_id = request.id.clone(); - let request = serde_json::to_string(&request) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - super::write_to_server( - stream, - &ClientMessage::ClientShellEndpointRequest { - boot_id: boot_id.clone(), + while let Some(queued) = lane.queued.pop_front() { + let request_id = queued.request.id.clone(); + if !endpoints.accepts(endpoint_id, queued.generation) { + cancelled.push(request_id); + continue; + } + let request = match serde_json::to_string(&queued.request) { + Ok(request) => request, + Err(error) => { + tracing::warn!(%error, %request_id, "could not encode endpoint request"); + cancelled.push(request_id); + continue; + } + }; + let message = ClientMessage::ClientShellEndpointRequest { + boot_id: queued.boot_id.clone(), request, - }, - )?; - self.in_flight = Some(InFlightCommand { - boot_id, - request_id, - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }); - Ok(()) + }; + if endpoints.send_to(endpoint_id, &message) != EndpointSendOutcome::Sent { + cancelled.push(request_id); + continue; + } + lane.in_flight = Some(InFlightCommand { + generation: queued.generation, + boot_id: queued.boot_id, + request_id, + response: Vec::new(), + sent_at: Instant::now(), + }); + break; + } + cancelled } - pub(super) fn expire(&mut self, now: Instant) -> Option<EndpointCommandResult> { - let command = self.in_flight.as_mut()?; - if command.timed_out - || now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT - { - return None; + pub(super) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + response_generation: u64, + response_boot_id: &str, + response_request_id: &str, + ) -> bool { + self.lanes + .get(endpoint_id) + .and_then(|lane| lane.in_flight.as_ref()) + .is_some_and(|command| { + command.generation == response_generation + && command.boot_id == response_boot_id + && command.request_id == response_request_id + }) + } + + /// Retire the complete source lane at source-off. The in-flight request is tombstoned for a + /// late endpoint-local response; every queued request is cancelled before it can run in a + /// later presentation epoch. Other endpoint lanes are deliberately untouched. + pub(super) fn retire_lane(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = Vec::new(); + if let Some(command) = lane.in_flight.take() { + lane.retire(( + command.generation, + command.boot_id, + command.request_id.clone(), + )); + request_ids.push(command.request_id); } - command.timed_out = true; - Some(EndpointCommandResult { - boot_id: command.boot_id.clone(), - request_id: command.request_id.clone(), - result: Err(ClientShellEndpointError { - code: Some("endpoint_timeout".into()), - message: "this server did not respond to the action".into(), - }), - }) + request_ids.extend(lane.queued.drain(..).map(|command| command.request.id)); + request_ids + } + + pub(super) fn expire(&mut self, now: Instant) -> Vec<EndpointCommandResult> { + self.lanes + .iter_mut() + .filter_map(|(endpoint_id, lane)| { + let command = lane.in_flight.as_ref()?; + if now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT { + return None; + } + let command = lane.in_flight.take().expect("checked in-flight command"); + lane.retire(( + command.generation, + command.boot_id.clone(), + command.request_id.clone(), + )); + Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: command.generation, + boot_id: command.boot_id, + request_id: command.request_id, + result: Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "this server did not respond to the action".into(), + }), + }) + }) + .collect() } pub(super) fn receive_chunk( &mut self, + endpoint_id: &ClientEndpointId, + response_generation: u64, response_boot_id: &str, response_request_id: &str, final_chunk: bool, data: Vec<u8>, ) -> io::Result<Option<EndpointCommandResult>> { - let Some(in_flight) = self.in_flight.as_mut() else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response arrived without an in-flight command", - )); + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Ok(None); }; - if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response correlation did not match the in-flight command", - )); + let retired = ( + response_generation, + response_boot_id.to_owned(), + response_request_id.to_owned(), + ); + if lane.consume_retired(&retired, final_chunk) { + return Ok(None); + } + let Some(in_flight) = lane.in_flight.as_mut() else { + return Ok(None); + }; + if response_generation != in_flight.generation + || response_boot_id != in_flight.boot_id + || response_request_id != in_flight.request_id + { + return Ok(None); } in_flight.response.extend(data); if !final_chunk { return Ok(None); } - let in_flight = self.in_flight.take().expect("checked in-flight command"); - let response = String::from_utf8(in_flight.response) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let result = parse_response(&in_flight.request_id, &response); + let in_flight = lane.in_flight.take().expect("checked in-flight command"); + let result = parse_response(&in_flight.request_id, &in_flight.response); Ok(Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: in_flight.generation, boot_id: in_flight.boot_id, request_id: in_flight.request_id, result, })) } + + /// Disconnecting an endpoint also cancels its shell-pending requests. Connection generation + /// rejection handles any late wire response after the lane itself is removed. + pub(super) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.remove(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = lane + .queued + .into_iter() + .map(|command| command.request.id) + .collect::<Vec<_>>(); + if let Some(command) = lane.in_flight { + request_ids.push(command.request_id); + } + request_ids + } } -fn parse_response( +pub(super) fn parse_response( expected_id: &str, - response: &str, + response: &[u8], ) -> Result<ResponseResult, ClientShellEndpointError> { - let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError { + let value = serde_json::from_slice(response).map_err(|error| ClientShellEndpointError { code: None, message: format!("invalid endpoint response: {error}"), })?; @@ -159,19 +301,35 @@ mod tests { use super::*; use crate::api::schema::{ResponseResult, SuccessResponse}; + fn endpoint() -> ClientEndpointId { + ClientEndpointId::Local + } + fn commands_with_in_flight() -> EndpointCommands { EndpointCommands { - in_flight: Some(InFlightCommand { - boot_id: "boot-a".into(), - request_id: "request-a".into(), - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }), - ..EndpointCommands::default() + lanes: HashMap::from([( + endpoint(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 1, + boot_id: "boot-a".into(), + request_id: "request-a".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + )]), } } + fn has_in_flight(commands: &EndpointCommands) -> bool { + commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.in_flight.is_some()) + } + #[test] fn chunked_response_completion_is_correlated_and_clears_the_lane() { let mut commands = commands_with_in_flight(); @@ -184,6 +342,8 @@ mod tests { assert!(commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", false, @@ -193,6 +353,8 @@ mod tests { .is_none()); let completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", true, @@ -201,10 +363,12 @@ mod tests { .unwrap() .unwrap(); + assert_eq!(completed.endpoint_id, endpoint()); + assert_eq!(completed.generation, 1); assert_eq!(completed.boot_id, "boot-a"); assert_eq!(completed.request_id, "request-a"); assert!(matches!(completed.result, Ok(ResponseResult::Ok {}))); - assert!(commands.in_flight.is_none()); + assert!(!has_in_flight(&commands)); } #[test] @@ -224,6 +388,8 @@ mod tests { for (index, chunk) in response.chunks(128 * 1024).enumerate() { completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", index + 1 == chunk_count, @@ -243,8 +409,10 @@ mod tests { let mut commands = commands_with_in_flight(); let expired = commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .pop() .expect("expired endpoint command"); + assert_eq!(expired.endpoint_id, endpoint()); assert_eq!(expired.boot_id, "boot-a"); assert_eq!(expired.request_id, "request-a"); assert!(matches!( @@ -254,30 +422,184 @@ mod tests { .. }) if code == "endpoint_timeout" )); - assert!(commands.in_flight.is_some()); + assert!(!has_in_flight(&commands)); assert!(commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .is_empty()); + let late_response = serde_json::to_vec(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .expect("late retired response is ignored") .is_none()); + assert!(!has_in_flight(&commands)); + } + + #[test] + fn endpoint_lanes_complete_independently() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 2, + boot_id: "boot-b".into(), + request_id: "request-b".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + ); + let response = serde_json::to_vec(&SuccessResponse { + id: "request-b".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + + let completed = commands + .receive_chunk(&remote, 2, "boot-b", "request-b", true, response) + .unwrap() + .expect("remote response"); + + assert_eq!(completed.endpoint_id, remote); + assert!(has_in_flight(&commands)); + assert!(commands + .lanes + .get(&completed.endpoint_id) + .is_some_and(|lane| lane.in_flight.is_none())); + } + + #[test] + fn retiring_complete_source_lane_cancels_queued_ids_and_keeps_other_lanes() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-source".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + queued: VecDeque::from([QueuedCommand { + generation: 2, + boot_id: "boot-b".into(), + request: Box::new(Request { + id: "request-b".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }]), + ..EndpointCommandLane::default() + }, + ); + + assert_eq!( + commands.retire_lane(&endpoint()), + vec!["request-a", "queued-source"] + ); + assert!(!has_in_flight(&commands)); + assert!(commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.queued.is_empty())); + assert!(commands + .lanes + .get(&remote) + .is_some_and(|lane| !lane.queued.is_empty())); + let late_response = serde_json::to_vec(&SuccessResponse { id: "request-a".into(), result: ResponseResult::Ok {}, }) .unwrap(); assert!(commands - .receive_chunk("boot-a", "request-a", true, late_response) - .expect("late response releases the synchronized lane") - .is_some()); - assert!(commands.in_flight.is_none()); + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .unwrap() + .is_none()); } #[test] - fn response_from_another_boot_is_rejected() { + fn disconnect_returns_every_request_that_must_be_discarded() { let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-a".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + assert_eq!( + commands.disconnect(&endpoint()), + vec!["queued-a", "request-a"] + ); + assert!(!commands.lanes.contains_key(&endpoint())); + } - let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else { - panic!("mismatched boot should fail"); - }; + #[test] + fn retired_request_tombstones_are_bounded() { + let mut lane = EndpointCommandLane::default(); + for serial in 0..MAX_RETIRED_REQUESTS_PER_ENDPOINT + 10 { + lane.retire((1, "boot".into(), format!("request-{serial}"))); + } + assert_eq!(lane.retired.len(), MAX_RETIRED_REQUESTS_PER_ENDPOINT); + assert!(!lane + .retired + .contains(&(1, "boot".into(), "request-0".into()))); + assert!(lane.retired.contains(&( + 1, + "boot".into(), + format!("request-{}", MAX_RETIRED_REQUESTS_PER_ENDPOINT + 9) + ))); + } + + #[test] + fn stale_or_unknown_responses_do_not_damage_the_live_lane() { + let mut commands = commands_with_in_flight(); + let unknown = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(commands + .receive_chunk(&endpoint(), 2, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-b", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&unknown, 1, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(has_in_flight(&commands)); } } diff --git a/src/client/events.rs b/src/client/events.rs new file mode 100644 index 00000000..43f7b8ad --- /dev/null +++ b/src/client/events.rs @@ -0,0 +1,34 @@ +use super::*; + +/// Internal events for the client event loop. +pub(super) enum ClientLoopEvent { + #[cfg(unix)] + StdinInput(Vec<u8>), + #[cfg(unix)] + PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), + #[cfg(unix)] + DirectGraphicsResponse(direct_graphics::Response), + #[cfg(windows)] + StdinEvents(Vec<crate::protocol::ClientInputEvent>), + Resize(u16, u16, u32, u32, bool), + TerminalUnavailable(io::Error), + ServerMessage { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + message: Box<ServerMessage>, + }, + ServerDisconnected { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + }, + EndpointSupervisor(endpoint::EndpointSupervisorEvent), + EndpointCatalog(Result<Vec<endpoint::SavedSshEndpoint>, String>), + ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + /// A superseded handoff deliberately starts a fresh target-on epoch even when source and + /// latest target have the same identity after restoration. + force: bool, + }, + Timer, +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 84dd6643..937f92ac 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -121,6 +121,29 @@ fn set_handshake_recv_timeout( pub(super) struct HandshakeResult { pub(super) encoding: RenderEncoding, pub(super) endpoint_methods: Option<Vec<String>>, + pub(super) endpoint_capabilities: Option<Vec<String>>, +} + +pub(crate) fn probe_endpoint_negotiation( + stream: &mut LocalStream, +) -> io::Result<super::endpoint::EndpointNegotiation> { + let handshake = do_handshake( + stream, + 80, + 24, + 0, + 0, + false, + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }), + false, + false, + false, + ) + .map_err(io::Error::other)?; + Ok(super::endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + )) } /// Performs the client→server handshake. @@ -138,6 +161,7 @@ pub(super) fn do_handshake( shell_surface_size: Option<crate::protocol::ClientSurfaceSize>, endpoint_keybindings: bool, mouse_capture: bool, + surface_active: bool, ) -> Result<HandshakeResult, ClientError> { stream .set_nonblocking(false) @@ -157,6 +181,7 @@ pub(super) fn do_handshake( && direct_graphics_profile_allowed(), endpoint_keybindings, mouse_capture, + surface_active, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -181,9 +206,14 @@ pub(super) fn do_handshake( protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + let read_timeout = if endpoint_shell && !surface_active { + REMOTE_HANDSHAKE_READ_TIMEOUT + } else { + handshake_read_timeout() + }; set_handshake_recv_timeout( stream, - Some(handshake_read_timeout()), + Some(read_timeout), "client handshake read timeout unavailable", )?; let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; @@ -238,6 +268,7 @@ pub(super) fn do_handshake( return Ok(HandshakeResult { encoding: RenderEncoding::SemanticFrame, endpoint_methods: Some(welcome.methods), + endpoint_capabilities: Some(welcome.capabilities), }); } @@ -254,6 +285,7 @@ pub(super) fn do_handshake( Ok(HandshakeResult { encoding, endpoint_methods: None, + endpoint_capabilities: None, }) } _ => Err(ClientError::Protocol(protocol::FramingError::Io( diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs new file mode 100644 index 00000000..53ef9f38 --- /dev/null +++ b/src/client/loop_config.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) struct ClientLoopConfig { + pub(super) sound_config: crate::config::SoundConfig, + pub(super) mouse_scroll_lines: usize, + pub(super) redraw_on_focus_gained: bool, + pub(super) host_cursor: crate::config::HostCursorModeConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_fallback: bool, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_keybindings: bool, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) shell_config: Option<shell::ClientShellConfig>, +} diff --git a/src/client/mod.rs b/src/client/mod.rs index ea789431..b815a057 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -13,23 +13,46 @@ //! - Displays sound/toast notifications forwarded from server mod attach; +mod catalog_reload; +mod clipboard_forwarding; mod clipboard_images; +mod config_reload; #[cfg(unix)] mod direct_graphics; +pub(crate) mod endpoint; mod endpoint_commands; mod errors; +mod events; mod frame_output; mod handshake; mod input; +mod loop_config; mod notifications; mod shell; +mod shell_runtime; +mod startup; +mod state; mod terminal_geometry; mod terminal_sessions; mod terminal_setup; mod timer; +mod transport; + +#[cfg(test)] +use clipboard_forwarding::decode_clipboard_payload; +use clipboard_forwarding::forward_clipboard; +#[cfg(test)] +use config_reload::reload_local_client_config; +use config_reload::{apply_reload, init_logging}; +use events::ClientLoopEvent; +use loop_config::ClientLoopConfig; +use shell_runtime::*; +use state::ClientState; +use transport::*; #[cfg(test)] pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use startup::{run_client, run_terminal_attach}; pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; #[cfg(not(windows))] @@ -65,7 +88,9 @@ use attach::direct_attach_pixel_mouse; use attach::AttachEscapeState; #[cfg(unix)] use attach::{write_attach_semantic_action, AttachInputAction}; -use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +use clipboard_images::{ + client_remote_image_paste_key, endpoint_accepts_local_images, write_remote_image_to_server, +}; #[cfg(windows)] use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; #[cfg(unix)] @@ -77,6 +102,7 @@ use frame_output::{ contains_kitty_graphics_bytes, record_received_kitty_graphics, write_encoded_frame_with_graphics, }; +pub(crate) use handshake::probe_endpoint_negotiation; use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; #[cfg(test)] use handshake::{ @@ -103,243 +129,11 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::{ - self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, -}; +use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; #[cfg(test)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -// --------------------------------------------------------------------------- -// Client state -// --------------------------------------------------------------------------- - -struct ClientLoopConfig { - sound_config: crate::config::SoundConfig, - mouse_scroll_lines: usize, - redraw_on_focus_gained: bool, - host_cursor: crate::config::HostCursorModeConfig, - kitty_graphics_enabled: bool, - pixel_geometry_enabled: bool, - pixel_geometry_fallback: bool, - mouse_capture_active: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - shell_config: Option<shell::ClientShellConfig>, -} - -/// State tracking for the thin client. -struct ClientState { - /// Stateful semantic-frame encoder used when the server sends FrameData. - blit_encoder: render_ansi::BlitEncoder, - /// Whether host mouse capture is currently active. - mouse_capture_active: bool, - /// Last mouse-capture demand published by the endpoint. - endpoint_mouse_capture_requested: bool, - /// Last exact-pixel mouse demand published by the endpoint. - endpoint_sgr_pixels_requested: bool, - /// Client-local direct-attach preference, combined with child mouse demand. - direct_mouse_capture_preference: bool, - /// Client-owned shell mouse-capture preference. - shell_mouse_capture_preference: bool, - /// Host keyboard protocol state currently owned for a direct terminal attach. - direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, - /// Focused endpoint pane/popup demand for Kitty report-all input. - pane_keyboard_report_all: bool, - /// Whether the client-owned shell currently enabled host report-all input. - keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last handshake/Resize. - reported_size: (u16, u16), - /// Last exact host cell size used by client-rendered surfaces. - reported_cell_size: (u32, u32), - /// Client-local sound playback config, refreshed on server request. - sound_config: crate::config::SoundConfig, - /// Whether this client may write Kitty graphics bytes to its host terminal. - kitty_graphics_enabled: bool, - /// Whether resize reports inspect host pixel geometry. - pixel_geometry_enabled: bool, - /// Whether the latest host pixel geometry is exact enough for pixel mouse input. - pixel_geometry_exact: bool, - /// One bounded matcher, inactive unless a direct transmission is armed. - #[cfg(unix)] - direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, - /// One server-retired direct transfer to suppress if it was still queued. - #[cfg(unix)] - retired_direct_graphics: Option<(u64, u32)>, - /// ClientShell assets waiting for the host terminal's direct-upload response. - #[cfg(unix)] - pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>, - /// Direct attach prefix escape state. None for ClientShell connections. - attach_escape: Option<AttachEscapeState>, - /// Rows scrolled for one direct-attach wheel notch. - #[cfg(unix)] - mouse_scroll_lines: usize, - /// Local-client shortcut that sends a clipboard image to a remote Herdr session. - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - /// Whether outer focus gain should force a full host-terminal redraw. - redraw_on_focus_gained: bool, - /// Whether the next semantic frame must repaint every cell without clearing the surface. - repaint_pending: bool, - /// Whether this client draws the cursor into frame cells instead of using the host cursor. - draw_host_cursor: bool, - /// Browser opener processes launched by client-owned link activation. - detached_process_children: Vec<std::process::Child>, - /// Experimental client-owned shell state. - shell: Option<shell::ClientShellState>, -} - -impl Drop for ClientState { - fn drop(&mut self) { - if self.attach_escape.is_some() { - let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut self.direct_keyboard_protocol, - 0, - 0, - ); - } - } -} - -impl ClientState { - fn request_repaint(&mut self) { - self.repaint_pending = true; - } - - fn present_graphics(&mut self, graphics: &[u8]) { - if graphics.is_empty() || !self.kitty_graphics_enabled { - return; - } - let mut stdout = io::stdout(); - let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); - let _ = stdout.flush(); - } - - fn present_surface_patch( - &mut self, - patch: shell::ClientComposedSurfacePatch, - ) -> io::Result<bool> { - if self.repaint_pending { - crate::render_prof::event("client_surface_patch.fallback.repaint"); - return Ok(false); - } - let rows = if self.draw_host_cursor { - let Some(rows) = self - .blit_encoder - .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) - else { - crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); - return Ok(false); - }; - rows - } else { - patch.rows - }; - let encode_started = crate::render_prof::timer(); - let Some(encoded) = - self.blit_encoder - .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) - else { - crate::render_prof::event("client_surface_patch.fallback.encode"); - return Ok(false); - }; - crate::render_prof::duration_since("client_surface_patch.encode", encode_started); - let write_started = crate::render_prof::timer(); - let mut stdout = io::stdout(); - stdout.write_all(&encoded.bytes)?; - stdout.flush()?; - crate::render_prof::duration_since("client_surface_patch.write", write_started); - let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); - crate::render_prof::event(if committed { - "client_surface_patch.success" - } else { - "client_surface_patch.fallback.commit" - }); - Ok(committed) - } - - fn present_frame(&mut self, frame_data: FrameData) { - let frame_data = if self.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if self.draw_host_cursor { - self.blit_encoder - .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) - } else { - self.blit_encoder.encode(&frame_data, self.repaint_pending) - }; - let mut stdout = io::stdout(); - let graphics = if self.kitty_graphics_enabled { - frame_data.graphics.as_slice() - } else { - &[] - }; - let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - self.blit_encoder.commit(frame_data, encoded); - self.repaint_pending = false; - } -} - -// --------------------------------------------------------------------------- -// Client event loop -// --------------------------------------------------------------------------- - -/// Internal events for the client event loop. -enum ClientLoopEvent { - /// Raw input bytes from stdin. - #[cfg(unix)] - StdinInput(Vec<u8>), - /// One confirmed SGR pixel report with geometry captured by the reader. - #[cfg(unix)] - PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), - #[cfg(unix)] - DirectGraphicsResponse(direct_graphics::Response), - /// Structured input events from platforms without Unix-style stdin bytes. - #[cfg(windows)] - StdinEvents(Vec<crate::protocol::ClientInputEvent>), - /// Terminal resize detected, including current exact-pixel eligibility. - Resize(u16, u16, u32, u32, bool), - /// The client's host terminal can no longer report a valid grid. - TerminalUnavailable(io::Error), - /// Server message received. - ServerMessage(Box<ServerMessage>), - /// Server reader thread exited (connection lost). - ServerDisconnected, - /// Timer tick. - Timer, -} - -/// Runs the thin client: connects to the server, performs the handshake, -/// and enters the main event loop. -/// -/// This is the entry point called from `main.rs` when running in client mode. -pub fn run_client() -> io::Result<()> { - run_client_with_mode(None, None, "connecting to server") -} - -/// Runs a direct terminal attach client. -#[cfg(unix)] -pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { - run_client_with_mode( - Some((terminal_id, takeover)), - Some(AttachEscapeState::default()), - "attaching to terminal", - ) -} - -/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path. -#[cfg(windows)] -pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { - debug_assert!(!crate::platform::capabilities().direct_terminal_attach); - Err(io::Error::new( - io::ErrorKind::Unsupported, - "direct terminal attach is not supported on Windows yet", - )) -} - fn run_client_with_mode( attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, @@ -373,6 +167,9 @@ fn run_client_with_mode( let kitty_graphics_enabled = loaded_config.config.kitty_graphics_enabled() && client_rendered_shell; let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); + let endpoint_keybindings = shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, @@ -382,6 +179,7 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + endpoint_keybindings, remote_image_paste_key, shell_config, }; @@ -389,14 +187,26 @@ fn run_client_with_mode( crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); - // Try to connect to the server. - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(s) => s, - Err(err) => { - // Server unreachable — show clear error and exit. - let client_err = ClientError::ConnectionFailed(err); - eprintln!("herdr: {client_err}"); - std::process::exit(1); + let endpoint_catalog = if client_rendered_shell && !is_remote_client_process() { + endpoint::EndpointCatalog::load().unwrap_or_else(|error| { + warn!(%error, "saved SSH endpoint catalog is unavailable"); + endpoint::EndpointCatalog::default() + }) + } else { + endpoint::EndpointCatalog::default() + }; + let federated = endpoint_catalog.has_enabled_ssh(); + + let initial_stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => Some(stream), + Err(error) if federated => { + warn!(%error, "Local is unavailable; keeping saved machines available"); + None + } + Err(error) => { + return Err(io::Error::other( + ClientError::ConnectionFailed(error).to_string(), + )); } }; @@ -408,43 +218,56 @@ fn run_client_with_mode( .shell_config .as_ref() .map(|shell| shell.initial_surface_size(cols, rows)); - let endpoint_keybindings = loop_config - .shell_config - .as_ref() - .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - - // Perform handshake while the stream is still in blocking mode. - let handshake = match do_handshake( - &mut stream, - cols, - rows, - cell_width_px, - cell_height_px, - exact_cell_size, - shell_surface_size, - endpoint_keybindings, - loop_config.mouse_capture_active, - ) { - Ok(encoding) => encoding, - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); + // Healthy Local attaches directly; only an actual failure enters background recovery. + let initial = initial_stream + .map(|mut stream| { + let handshake = do_handshake( + &mut stream, + cols, + rows, + cell_width_px, + cell_height_px, + exact_cell_size, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, + true, + ) + .map_err(|error| io::Error::other(error.to_string()))?; + if federated + && !endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.clone().unwrap_or_default(), + handshake.endpoint_capabilities.clone().unwrap_or_default(), + ) + .supports_surface_interest() + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Local needs a server update before it can participate in multi-machine viewing", + )); + } + if let Some((terminal_id, takeover)) = attach_request { + write_to_server( + &mut stream, + &ClientMessage::AttachTerminal { + terminal_id, + takeover, + }, + )?; + } + Ok((stream, handshake)) + }) + .transpose(); + let initial = match initial { + Ok(initial) => initial, + Err(error) if federated => { + warn!(%error, "Local handshake failed; keeping saved machines available"); + None } + Err(error) => return Err(error), }; - if let Some((terminal_id, takeover)) = attach_request { - let attach = ClientMessage::AttachTerminal { - terminal_id, - takeover, - }; - if let Err(err) = write_to_server(&mut stream, &attach) { - eprintln!("herdr: failed to request terminal attach: {err}"); - std::process::exit(1); - } - } - - // Now set up the terminal. This must happen AFTER the handshake succeeds, - // so we don't leave the terminal in raw mode if the server rejects us. + // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); let terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) @@ -483,7 +306,8 @@ fn run_client_with_mode( let result = rt.block_on(async { run_client_loop( - stream, + initial, + endpoint_catalog, cols, rows, cell_width_px, @@ -491,8 +315,6 @@ fn run_client_with_mode( exact_cell_size, should_quit, loop_config, - handshake.encoding, - handshake.endpoint_methods, attach_escape, ) .await @@ -526,187 +348,6 @@ fn run_client_with_mode( Ok(()) } -fn dispatch_client_shell_actions( - actions: Vec<shell::ClientShellAction>, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - write_stream: &mut LocalStream, - detached_process_children: &mut Vec<std::process::Child>, -) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> { - let mut replay_mouse = Vec::new(); - for action in actions { - match action { - shell::ClientShellAction::Endpoint { boot_id, request } => { - endpoint_commands.enqueue(boot_id, request); - } - shell::ClientShellAction::ClipboardWrite(bytes) => { - crate::selection::write_osc52_bytes(&bytes); - } - shell::ClientShellAction::Request(request) => { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - shell::ClientShellAction::OpenSafeWebUrl(url) => { - if crate::app::actions::safe_web_url(&url).is_some() { - match crate::platform::open_url(&url) { - Ok(Some(child)) => detached_process_children.push(child), - Ok(None) => {} - Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), - } - } - } - shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), - shell::ClientShellAction::Keybind(action) => { - debug!( - ?action, - "client shell action awaits its presentation family" - ); - } - } - } - endpoint_commands - .send_next(write_stream) - .map_err(ClientError::ConnectionLost)?; - Ok(replay_mouse) -} - -fn client_shell_resize_message( - shell: &shell::ClientShellState, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - pixel_mouse: bool, -) -> ClientMessage { - ClientMessage::ClientShellResize { - cell_width_px, - cell_height_px, - surface_size: shell.surface_size(cols, rows), - pixel_mouse, - } -} - -fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { - let Some(shell) = state.shell.as_ref() else { - return Ok(()); - }; - let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); - if desired == state.keyboard_report_all_active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) - .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = desired; - Ok(()) -} - -fn apply_client_shell_input_source_changes( - state: &mut ClientState, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) { - let changes = state - .shell - .as_mut() - .map(shell::ClientShellState::take_input_source_changes) - .unwrap_or_default(); - for active in changes { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); - } - } -} - -fn install_client_shell_snapshot( - state: &mut ClientState, - snapshot: Box<crate::protocol::ClientShellSnapshot>, - write_stream: &mut LocalStream, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<(), ClientError> { - let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { - let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.set_snapshot(snapshot); - let graphics_cleanup = shell.take_pending_graphics_cleanup(); - let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - (previous_size != next_size).then(|| { - client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, - ) - }), - graphics_cleanup, - ) - } else { - (None, None, Vec::new()) - }; - apply_client_shell_input_source_changes(state, prefix_input_source); - state.present_graphics(&graphics_cleanup); - if let Some(resize) = resize { - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = composed { - state.present_frame(frame); - } - Ok(()) -} - -fn finish_client_shell_input( - state: &mut ClientState, - outcome: shell::ClientShellInput, - frame: Option<FrameData>, - write_stream: &mut LocalStream, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<bool, ClientError> { - apply_client_shell_input_source_changes(state, prefix_input_source); - if outcome.detach { - let _ = write_to_server(write_stream, &ClientMessage::Detach); - return Ok(true); - } - if outcome.resize { - let shell = state.shell.as_ref().expect("shell mode remains active"); - let resize = client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, - ); - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - #[cfg(not(windows))] - if outcome.query_host_appearance { - query_host_terminal_appearance(); - } - if outcome.query_host_theme { - query_host_terminal_theme(); - } - sync_client_shell_keyboard_report_all(state)?; - let replay = dispatch_client_shell_actions( - outcome.actions, - endpoint_commands, - write_stream, - &mut state.detached_process_children, - )?; - debug_assert!( - replay.is_empty(), - "mouse replay only follows endpoint results" - ); - for request in outcome.requests { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - Ok(false) -} - /// The main client event loop. /// /// Uses a threaded architecture: @@ -715,7 +356,8 @@ fn finish_client_shell_input( /// - server reader thread → reads ServerMessages and sends to main loop /// - main loop: coordinates input, output, and server communication async fn run_client_loop( - stream: LocalStream, + initial: Option<(LocalStream, handshake::HandshakeResult)>, + mut endpoint_catalog: endpoint::EndpointCatalog, cols: u16, rows: u16, initial_cell_width_px: u32, @@ -723,20 +365,20 @@ async fn run_client_loop( initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, - negotiated_encoding: RenderEncoding, - endpoint_methods: Option<Vec<String>>, attach_escape: Option<AttachEscapeState>, ) -> Result<(), ClientError> { #[cfg(windows)] let _ = config.mouse_scroll_lines; let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor); let is_remote_client = is_remote_client_process(); + let local_unavailable = initial.is_none(); let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, endpoint_mouse_capture_requested: false, endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, shell_mouse_capture_preference: config.mouse_capture_active, direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), @@ -760,15 +402,28 @@ async fn run_client_loop( remote_image_paste_key: config.remote_image_paste_key, redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, + presentation_frozen: false, draw_host_cursor, detached_process_children: Vec::new(), shell: config.shell_config.map(shell::ClientShellState::new), }; + let mut federated = endpoint_catalog.has_enabled_ssh(); if let Some(shell) = state.shell.as_mut() { shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); - shell.set_endpoint_methods(endpoint_methods); + shell.set_endpoint_catalog(&endpoint_catalog.ssh); + shell.set_endpoint_methods_for( + &endpoint::ClientEndpointId::Local, + initial + .as_ref() + .and_then(|(_, handshake)| handshake.endpoint_methods.clone()), + ); + if local_unavailable { + shell.set_endpoint_status( + &endpoint::ClientEndpointId::Local, + endpoint::ClientEndpointStatus::Connecting, + ); + } } - debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. // Zero means the host has not reported one. @@ -777,6 +432,8 @@ async fn run_client_loop( // Channel for events from the resize and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); + let (supervisor_tx, mut supervisor_rx) = + tokio::sync::mpsc::channel::<endpoint::EndpointSupervisorEvent>(64); // Keep Windows console draining independent of server-frame backpressure. #[cfg(windows)] let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -833,7 +490,6 @@ async fn run_client_loop( let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); - let kitty_graphics_enabled = state.kitty_graphics_enabled; let pixel_geometry_enabled = state.pixel_geometry_enabled; let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { @@ -851,31 +507,59 @@ async fn run_client_loop( ); }); - // Spawn the server reader thread (blocking reads from the socket). - // Clone the stream's file descriptor so we can read from a blocking stream. - let server_read_quit = should_quit.clone(); - let server_read_tx = event_tx.clone(); - let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?; - std::thread::spawn(move || { - let max_frame_size = if kitty_graphics_enabled { + let mut write_stream = if let Some((stream, handshake)) = initial { + let max_frame_size = if state.kitty_graphics_enabled { MAX_GRAPHICS_FRAME_SIZE } else { - MAX_FRAME_SIZE + crate::protocol::MAX_FRAME_SIZE }; - server_reader_thread( - read_stream, - server_read_tx, - &server_read_quit, + let transport = start_endpoint_transport( + stream, + (), + &event_tx, + endpoint::ClientEndpointId::Local, + 1, max_frame_size, + )?; + let negotiation = endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), ); - }); - - // Use the original stream for writing (blocking is fine since we write - // from the async loop). - let mut write_stream = stream; - write_stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; + let mut registry = endpoint::EndpointRegistry::new(transport, 1, negotiation); + if state.shell.is_some() { + registry.send(&ClientMessage::ClientShellFocus { focused: true }); + } + registry + } else { + endpoint::EndpointRegistry::empty() + }; + let mut supervisors = + endpoint::EndpointSupervisors::new(&endpoint_catalog.ssh, std::time::Instant::now()); + if federated { + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + std::time::Instant::now(), + ); + } + if local_unavailable { + if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(cols, rows)) + { + state.present_frame(frame); + } + } + let mut next_surface_serial = 1_u64; + let mut pending_activation: Option<endpoint::PendingEndpointActivation> = None; + let mut scheduled_activation = None; + let mut pending_catalog: Option<Result<Vec<endpoint::SavedSshEndpoint>, String>> = None; + if state.shell.is_some() && !is_remote_client && state.attach_escape.is_none() { + catalog_reload::watch_profiles(event_tx.clone(), should_quit.clone()); + } // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). @@ -886,6 +570,111 @@ async fn run_client_loop( #[cfg(windows)] let mut stdin_open = true; while !should_quit.load(Ordering::Acquire) { + if pending_activation.is_none() { + if let Some(reload) = pending_catalog.take() { + match reload { + Ok(profiles) => { + let now = std::time::Instant::now(); + if !federated && profiles.iter().any(|profile| profile.enabled) { + // Keep Local recovery once enabled, even after removing the last SSH profile. + federated = true; + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + now, + ); + if write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some_and(|connection| { + !connection.negotiation.supports_surface_interest() + }) + { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable( + "Update the Local server before switching between machines" + .into(), + ); + } + } + } + let active_removed = catalog_reload::apply_profiles( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut endpoint_catalog, + profiles, + now, + ); + if active_removed { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + scheduled_activation = None; + if state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_projection_available( + &endpoint::ClientEndpointId::Local, + ) + }) && write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some() + { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId::Local, + target: None, + force: true, + }); + } else { + present_handoff_unavailable( + &mut state, + "Local is unavailable; reconnecting".into(), + ); + } + } + } + Err(error) => { + warn!(%error, "saved machines could not be reloaded; keeping current connections"); + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "Saved machines could not be reloaded; keeping current connections: {error}" + )); + } + } + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(shell) = state.shell.as_mut() { + let cleanup = shell.take_pending_graphics_cleanup(); + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + let frozen = state.presentation_frozen; + state.presentation_frozen = false; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + state.presentation_frozen = frozen; + } + } + } + if let Some(shell) = state.shell.as_ref() { + supervisors.spawn_due( + std::time::Instant::now(), + endpoint::EndpointConnectOptions { + cols: state.reported_size.0, + rows: state.reported_size.1, + cell_width_px: state.reported_cell_size.0, + cell_height_px: state.reported_cell_size.1, + pixel_geometry_exact: state.pixel_geometry_exact, + surface_size: shell.surface_size(state.reported_size.0, state.reported_size.1), + endpoint_keybindings: config.endpoint_keybindings, + mouse_capture: state.shell_mouse_capture_preference, + }, + &supervisor_tx, + ); + } let timer_delay = state .shell .as_ref() @@ -893,23 +682,34 @@ async fn run_client_loop( shell.timer_delay(std::time::Instant::now()) }); let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); + let immediate_event = scheduled_activation.take(); #[cfg(windows)] - let event = tokio::select! { - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = stdin_rx.recv(), if stdin_open => match ev { - Some(event) => event, - None => { - stdin_open = false; - ClientLoopEvent::Timer - } - }, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = stdin_rx.recv(), if stdin_open => match ev { + Some(event) => event, + None => { + stdin_open = false; + ClientLoopEvent::Timer + } + }, + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + } }; #[cfg(unix)] - let event = tokio::select! { - biased; - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + } }; let now = std::time::Instant::now(); if let Some(shell) = state.shell.as_mut() { @@ -917,8 +717,14 @@ async fn run_client_loop( } match event { + ClientLoopEvent::EndpointCatalog(reload) => pending_catalog = Some(reload), #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { + let image_bridge_active = endpoint_accepts_local_images( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { if will_query_host_cell_size { let events = crate::raw_input::parse_raw_input_bytes_sync(&data); @@ -934,7 +740,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -951,7 +757,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_terminal_drop(&data, is_remote_client) + read_image_file_from_terminal_drop(&data, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -976,8 +782,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1048,7 +856,7 @@ async fn run_client_loop( }; if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1064,7 +872,8 @@ async fn run_client_loop( "clipboard image paste trigger received, but local clipboard has no image" ); } - if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { + if let Some(image) = read_image_file_from_terminal_drop(&data, image_bridge_active) + { write_remote_image_to_server( &mut write_stream, crate::protocol::ClientClipboardImageTarget::DirectTerminal, @@ -1080,11 +889,28 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { - let composed = state + let pending_key = state .pending_surface_graphics - .remove(&response.transfer_id) + .keys() + .find(|(_, transfer_id, image_id)| { + *transfer_id == response.transfer_id && *image_id == response.image_id + }) + .cloned(); + let owner = pending_key + .as_ref() + .map(|(endpoint_id, _, _)| endpoint_id.clone()); + let composed = pending_key + .and_then(|key| { + state + .pending_surface_graphics + .remove(&key) + .map(|asset| (key, asset)) + }) .filter(|_| response.success) - .and_then(|asset| { + .and_then(|((endpoint_id, _, _), asset)| { + if write_stream.active_id() != &endpoint_id { + return None; + } let shell = state.shell.as_mut()?; shell .trust_direct_graphics_asset(&asset, response.image_id) @@ -1096,8 +922,8 @@ async fn run_client_loop( image_id: response.image_id, success: response.success, }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if let Some(owner) = owner { + write_stream.send_to(&owner, &message); } if let Some(frame) = composed { state.present_frame(frame); @@ -1120,8 +946,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1159,6 +987,11 @@ async fn run_client_loop( } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { + let image_bridge_active = endpoint_accepts_local_images( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { let image_target = state .shell @@ -1167,7 +1000,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_events( &events, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1184,7 +1017,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_client_events(&events, is_remote_client) + read_image_file_from_client_events(&events, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -1209,8 +1042,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1262,181 +1097,291 @@ async fn run_client_loop( pixel_mouse: pixel_geometry_exact, } }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(msg, &mut write_stream) { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + error, + false, + ); + } + } else if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::ServerMessage(msg) => match *msg { - ServerMessage::ClientShellSnapshot(_) => { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - "server sent an unnegotiated binary endpoint snapshot", - ), - ))); + ClientLoopEvent::EndpointSupervisor(event) => match event { + endpoint::EndpointSupervisorEvent::Status { + endpoint_id, + generation, + status, + message, + } => { + if !supervisors.record_status(&endpoint_id, generation, status, now) { + continue; + } + if status == endpoint::ClientEndpointStatus::Attention { + warn!(endpoint = %endpoint_id.storage_key(), generation, error = %message, "endpoint needs attention"); + } + let unavailable = state.shell.as_mut().and_then(|shell| { + shell.set_endpoint_status(&endpoint_id, status); + (status == endpoint::ClientEndpointStatus::Attention + && shell.endpoint_is_active(&endpoint_id)) + .then(|| format!("{}: {message}", shell.endpoint_label(&endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(&mut state, message); + } else if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } } - ServerMessage::PaneSurface(surface) => { - let composed = if let Some(shell) = &mut state.shell { - shell.set_pane_surface(surface); + endpoint::EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + } => { + if !supervisors.record_status( + &endpoint_id, + generation, + endpoint::ClientEndpointStatus::Online, + now, + ) { + continue; + } + let frame = state.shell.as_mut().and_then(|shell| { + shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods())); shell.compose(state.reported_size.0, state.reported_size.1) - } else { - None - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(frame) = composed { + }); + let reader_quit = writer.stop_handle(); + write_stream.insert( + endpoint_id.clone(), + writer, + generation, + negotiation, + false, + ); + if let Some(frame) = frame { state.present_frame(frame); } + let reader_tx = event_tx.clone(); + std::thread::spawn(move || { + server_reader_thread( + reader, + reader_tx, + &reader_quit, + MAX_GRAPHICS_FRAME_SIZE, + endpoint_id, + generation, + ); + }); } - ServerMessage::PaneSurfacePatch(patch) => { - let patch_started = crate::render_prof::timer(); - let apply_started = crate::render_prof::timer(); - let outcome = state - .shell - .as_mut() - .map(|shell| shell.apply_pane_surface_patch(patch)); - crate::render_prof::duration_since("client_surface_patch.apply", apply_started); - let compose_fallback = match outcome { - Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { - match state.present_surface_patch(patch) { - Ok(presented) => !presented, - Err(error) => { - warn!(%error, "failed to present retained pane surface patch"); - state.request_repaint(); - false + }, + ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force, + } => { + if !endpoint_catalog.select_endpoint(&endpoint_id) { + continue; + } + if let Err(error) = endpoint_catalog.store_selection() { + warn!(%error, "failed to persist desired endpoint selection"); + } + begin_endpoint_activation( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut pending_activation, + &mut next_surface_serial, + endpoint_id, + target, + force, + now, + &event_tx, + )?; + } + ClientLoopEvent::ServerMessage { + endpoint_id, + generation, + message, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; + } + write_stream.received(&endpoint_id, generation, now); + let endpoint_active = write_stream.active_id() == &endpoint_id + && write_stream + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + let activation_message = pending_activation + .as_ref() + .is_some_and(|pending| pending.accepts_endpoint(&endpoint_id, generation)); + let command_response = match message.as_ref() { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + .. + } => endpoint_commands.accepts_response( + &endpoint_id, + generation, + boot_id, + request_id, + ), + _ => false, + }; + if !endpoint::accepts_endpoint_message( + endpoint_active, + activation_message, + command_response, + message.as_ref(), + ) { + continue; + } + // Target presentation effects may arrive as soon as surface.set(true) is + // acknowledged. They cannot be applied while the source frame is frozen; the + // target receives one explicit replay after the coherent commit instead. + if state.presentation_frozen + && activation_message + && endpoint::is_presentation_effect(message.as_ref()) + { + continue; + } + match *message { + ServerMessage::ClientShellSnapshot(_) => { + let message = "server sent an unnegotiated binary endpoint snapshot"; + if federated || !endpoint_id.is_local() { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message.into(), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + ServerMessage::PaneSurface(surface) => { + if activation_message { + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_surface(&endpoint_id, generation, surface) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); } } + continue; } - Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, - Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if compose_fallback { - let composed = state.shell.as_mut().and_then(|shell| { + if !endpoint_active { + continue; + } + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); shell.compose(state.reported_size.0, state.reported_size.1) - }); + } else { + None + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); if let Some(frame) = composed { state.present_frame(frame); } } - crate::render_prof::duration_since("client_surface_patch.total", patch_started); - crate::render_prof::flush_if_due(); - } - ServerMessage::Terminal(frame) => { - if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { - record_received_kitty_graphics(&frame.bytes); + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since( + "client_surface_patch.apply", + apply_started, + ); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since( + "client_surface_patch.total", + patch_started, + ); + crate::render_prof::flush_if_due(); } - let mut stdout = io::stdout(); - let _ = stdout.write_all(&frame.bytes); - let _ = stdout.flush(); - } - ServerMessage::Graphics { bytes } => { - if state.kitty_graphics_enabled { - record_received_kitty_graphics(&bytes); + ServerMessage::Terminal(frame) => { + if state.kitty_graphics_enabled + && contains_kitty_graphics_bytes(&frame.bytes) + { + record_received_kitty_graphics(&frame.bytes); + } let mut stdout = io::stdout(); - let _ = stdout.write_all(&bytes); + let _ = stdout.write_all(&frame.bytes); let _ = stdout.flush(); } - } - ServerMessage::TerminalBell { count } => { - if let Err(err) = - crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) - { - warn!(err = %err, "failed to emit terminal bell"); - } - } - ServerMessage::GraphicsFile { - path, - expected_len, - image_id, - transfer_id, - leading, - control, - surface_asset, - } => { - #[cfg(unix)] - { - if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { - continue; - } - let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { - (Some(shell), Some(asset)) => { - crate::kitty_graphics::surface::host_image_id( - shell.graphics_scope(), - asset, - ) == image_id - } - (None, None) => true, - _ => false, - }; - let valid = state.kitty_graphics_enabled - && surface_asset_valid - && usize::try_from(expected_len).ok().is_some_and(|len| { - crate::pane_graphics_files::validate_direct_source( - std::path::Path::new(&path), - len, - ) - .is_ok() - && direct_graphics::valid_control(&control, image_id, len) - }) - && state - .direct_graphics_response - .lock() - .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); - let sent = if valid { - let mut command = Vec::new(); - crate::kitty_graphics::encode_kitty_regular_file( - &mut command, - &leading, - &control, - &path, - ); + ServerMessage::Graphics { bytes } => { + if state.kitty_graphics_enabled { + record_received_kitty_graphics(&bytes); let mut stdout = io::stdout(); - let written = stdout - .write_all(&command) - .and_then(|()| stdout.flush()) - .is_ok(); - if written { - record_received_kitty_graphics(&command); - } - written - } else { - false - }; - if sent { - if let Some(asset) = surface_asset { - state.pending_surface_graphics.insert(transfer_id, asset); - } - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.start(transfer_id); - } - let started = ClientMessage::GraphicsTransmissionStarted { - transfer_id, - image_id, - }; - if let Err(err) = write_to_server(&mut write_stream, &started) { - return Err(ClientError::ConnectionLost(err)); - } - } else { - state.pending_surface_graphics.remove(&transfer_id); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - if valid { - matcher.retire(transfer_id); - } else { - matcher.cancel(transfer_id); - } - } - let result = ClientMessage::GraphicsTransmissionResult { - transfer_id, - image_id, - success: false, - }; - if let Err(err) = write_to_server(&mut write_stream, &result) { - return Err(ClientError::ConnectionLost(err)); - } + let _ = stdout.write_all(&bytes); + let _ = stdout.flush(); + } + } + ServerMessage::TerminalBell { count } => { + if let Err(err) = + crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) + { + warn!(err = %err, "failed to emit terminal bell"); } } - #[cfg(not(unix))] - let _ = ( + ServerMessage::GraphicsFile { path, expected_len, image_id, @@ -1444,319 +1389,546 @@ async fn run_client_loop( leading, control, surface_asset, - ); - } - ServerMessage::GraphicsTransmissionRetired { - transfer_id, - image_id, - } => { - #[cfg(unix)] - { - state.retired_direct_graphics = Some((transfer_id, image_id)); - state.pending_surface_graphics.remove(&transfer_id); - let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { - shell.retire_direct_graphics_image(image_id); - shell - .compose(state.reported_size.0, state.reported_size.1) - .map(|frame| frame.graphics) - .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) - }); - state.present_graphics(&cleanup); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.retire(transfer_id); + } => { + #[cfg(unix)] + { + if state.retired_direct_graphics.take() + == Some((endpoint_id.clone(), transfer_id, image_id)) + { + continue; + } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; + let valid = state.kitty_graphics_enabled + && surface_asset_valid + && usize::try_from(expected_len).ok().is_some_and(|len| { + crate::pane_graphics_files::validate_direct_source( + std::path::Path::new(&path), + len, + ) + .is_ok() + && direct_graphics::valid_control(&control, image_id, len) + }) + && state + .direct_graphics_response + .lock() + .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); + let sent = if valid { + let mut command = Vec::new(); + crate::kitty_graphics::encode_kitty_regular_file( + &mut command, + &leading, + &control, + &path, + ); + let mut stdout = io::stdout(); + let written = stdout + .write_all(&command) + .and_then(|()| stdout.flush()) + .is_ok(); + if written { + record_received_kitty_graphics(&command); + } + written + } else { + false + }; + if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert( + (endpoint_id.clone(), transfer_id, image_id), + asset, + ); + } + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.start(transfer_id); + } + let started = ClientMessage::GraphicsTransmissionStarted { + transfer_id, + image_id, + }; + if let Err(err) = write_to_server(&mut write_stream, &started) { + return Err(ClientError::ConnectionLost(err)); + } + } else { + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + if valid { + matcher.retire(transfer_id); + } else { + matcher.cancel(transfer_id); + } + } + let result = ClientMessage::GraphicsTransmissionResult { + transfer_id, + image_id, + success: false, + }; + if let Err(err) = write_to_server(&mut write_stream, &result) { + return Err(ClientError::ConnectionLost(err)); + } + } } + #[cfg(not(unix))] + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } - #[cfg(not(unix))] - let _ = (transfer_id, image_id); - } - ServerMessage::ServerShutdown { reason } => { - return Err(ClientError::ServerShutdown { reason }); - } - ServerMessage::Notify { - kind, - message, - body, - } => { - if state.shell.is_none() { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + } => { + #[cfg(unix)] + { + state.retired_direct_graphics = + Some((endpoint_id.clone(), transfer_id, image_id)); + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.retire(transfer_id); + } + } + #[cfg(not(unix))] + let _ = (transfer_id, image_id); } - } - ServerMessage::SemanticNotification(event) => { - if state.shell.is_some() { - let (effects, frame) = { - let shell = state.shell.as_mut().expect("checked shell mode"); - let (effects, repaint) = - shell.receive_notification(event, std::time::Instant::now()); - let frame = repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (effects, frame) - }; - handle_shell_notification_effects(effects, &state.sound_config); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::ServerShutdown { reason } => { + if !federated && endpoint_id.is_local() { + return Err(ClientError::ServerShutdown { reason }); } + write_stream.fail( + &endpoint_id, + io::Error::new( + io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server stopped".into()), + ), + ); } - } - ServerMessage::ClientShellError { message } => { - if let Some(shell) = state.shell.as_mut() { - if shell.receive_endpoint_error(message) { - let frame = shell.compose(state.reported_size.0, state.reported_size.1); + ServerMessage::Notify { + kind, + message, + body, + } => { + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = shell.receive_notification( + &endpoint_id, + event, + std::time::Instant::now(), + ); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); if let Some(frame) = frame { state.present_frame(frame); } } } - } - ServerMessage::ClientShellEndpointResponseChunk { - boot_id, - request_id, - final_chunk, - data, - } => { - let completed = endpoint_commands - .receive_chunk(&boot_id, &request_id, final_chunk, data) - .map_err(ClientError::ConnectionLost)?; - let Some(completed) = completed else { - continue; - }; - let (repaint, actions) = state.shell.as_mut().map_or_else( - || (false, Vec::new()), - |shell| { - shell.handle_endpoint_result( - &completed.boot_id, - &completed.request_id, - completed.result, - ) - }, - ); - if let Some(shell) = state.shell.as_mut() { - shell.reconcile_input_source(); + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = + shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } } - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - let replay_mouse = dispatch_client_shell_actions( - actions, - &mut endpoint_commands, - &mut write_stream, - &mut state.detached_process_children, - )?; - if replay_mouse.is_empty() { - if repaint { - if let Some(frame) = state.shell.as_mut().and_then(|shell| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) { - state.present_frame(frame); + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + if pending_activation.as_ref().is_some_and(|pending| { + pending.accepts_response( + &endpoint_id, + generation, + &boot_id, + &request_id, + ) + }) { + if !final_chunk { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + "endpoint returned a chunked activation acknowledgement".into(), + false, + ); + continue; + } + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_response_for_boot( + &endpoint_id, + generation, + &boot_id, + &request_id, + &data, + &mut write_stream, + ) + }); + match progress { + Some(endpoint::SurfaceActivationProgress::Ready) => { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + Some(endpoint::SurfaceActivationProgress::Rejected { + message, + source_release_rejected, + }) => { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + message, + source_release_rejected, + ); + } + _ => {} } + continue; } - } else { - let (outcome, frame) = { - let shell = state.shell.as_mut().expect("shell endpoint response"); - let mut outcome = shell.replay_mouse_events(replay_mouse); - outcome.repaint |= repaint; - let frame = outcome - .repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (outcome, frame) + if request_id.starts_with("client-shell-surface:") { + continue; + } + let completed = endpoint_commands + .receive_chunk( + &endpoint_id, + generation, + &boot_id, + &request_id, + final_chunk, + data, + ) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; }; - if finish_client_shell_input( + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + if completed.generation == generation + && shell.endpoint_is_active(&completed.endpoint_id) + { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + } else { + ( + shell.cancel_endpoint_request(&completed.request_id), + Vec::new(), + ) + } + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes( &mut state, - outcome, - frame, - &mut write_stream, - &mut endpoint_commands, &mut prefix_input_source, - )? { - return Ok(()); + ); + let (replay_mouse, dispatch_repaint) = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + state.shell.as_mut(), + &mut state.detached_process_children, + &event_tx, + )?; + let repaint = repaint || dispatch_repaint; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &event_tx, + )? { + return Ok(()); + } } } - } - ServerMessage::Clipboard { data } => { - if forward_clipboard(&data) { - let (width, height) = state.reported_size; - let frame = state.shell.as_mut().and_then(|shell| { - shell - .show_copy_feedback(std::time::Instant::now()) - .then(|| shell.compose(width, height)) - .flatten() - }); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::Clipboard { data } => { + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } } + let _ = io::stdout().flush(); } - let _ = io::stdout().flush(); - } - ServerMessage::WindowTitle { title } => { - let _ = crate::terminal_effects::write_window_title( - &mut io::stdout(), - title.as_deref(), - ); - } - ServerMessage::ReloadSoundConfig => { - let previous_mouse_capture = state.shell_mouse_capture_preference; - let mut mouse_capture = previous_mouse_capture; - reload_local_client_config( - &mut state.sound_config, - &mut state.redraw_on_focus_gained, - &mut state.draw_host_cursor, - &mut state.remote_image_paste_key, - &mut mouse_capture, - ); - state.shell_mouse_capture_preference = mouse_capture; - state.direct_mouse_capture_preference = - state.attach_escape.is_some() && mouse_capture; - if state.shell.is_some() && previous_mouse_capture != mouse_capture { - write_to_server( - &mut write_stream, - &ClientMessage::ClientShellMouseCapture { - enabled: mouse_capture, - }, - ) - .map_err(ClientError::ConnectionLost)?; - } - if state.attach_escape.is_some() { - let enabled = effective_mouse_capture( - state.endpoint_mouse_capture_requested, - state.direct_mouse_capture_preference, + ServerMessage::WindowTitle { title } => { + let _ = crate::terminal_effects::write_window_title( + &mut io::stdout(), + title.as_deref(), ); - let sgr_pixels = effective_sgr_pixel_mouse( + } + ServerMessage::ReloadSoundConfig => apply_reload( + &mut state, + &mut write_stream, + &mut pending_activation, + &host_mouse_capture_active, + &host_sgr_pixels_active, + &mut prefix_input_source, + )?, + ServerMessage::MouseCapture { + enabled, + sgr_pixels, + } => { + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = effective_sgr_pixel_mouse( enabled, - state.endpoint_sgr_pixels_requested, + sgr_pixels, state.pixel_geometry_exact, ); - if enabled != state.mouse_capture_active - || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) - { - set_mouse_capture(enabled, sgr_pixels) + let mouse_mode_changed = enabled != state.mouse_capture_active + || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); + if mouse_mode_changed { + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } + set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } } state.mouse_capture_active = enabled; host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - let (frame, resize) = if let Some(shell) = state.shell.as_mut() { - let previous_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.reload_client_config(); - let next_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - let resize = (previous_size != next_size).then(|| { - shell.invalidate_pane_surface(); - client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) - }); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - resize, - ) - } else { - (None, None) - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(resize) = resize { - write_to_server(&mut write_stream, &resize) - .map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - } - ServerMessage::MouseCapture { - enabled, - sgr_pixels, - } => { - state.endpoint_mouse_capture_requested = enabled; - state.endpoint_sgr_pixels_requested = sgr_pixels; - let enabled = - effective_mouse_capture(enabled, state.direct_mouse_capture_preference); - let next_sgr_pixels = - effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); - let mouse_mode_changed = enabled != state.mouse_capture_active - || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); - if mouse_mode_changed { - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); - } - set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); } } - state.mouse_capture_active = enabled; - host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); - } - ServerMessage::DirectTerminalKeyboardProtocol { - flags, - modify_other_keys_level, - } => { - if state.attach_escape.is_some() { - crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut state.direct_keyboard_protocol, - flags, - modify_other_keys_level, - ) - .map_err(ClientError::ConnectionFailed)?; - } - } - ServerMessage::ClientShellKeyboardReportAll { enabled } => { - if state.shell.is_some() { - state.pane_keyboard_report_all = enabled; - sync_client_shell_keyboard_report_all(&mut state)?; + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; + } } - } - ServerMessage::EndpointControl { kind, data } => { - if kind != crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { - if kind.starts_with("shell.snapshot.") { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "unsupported mandatory endpoint snapshot codec {kind:?}" - ), - ), - ))); + ServerMessage::EndpointControl { kind, data } => { + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND { + let progress = pending_activation.as_mut().map(|activation| { + activation.receive_presentation_effects_ready( + &endpoint_id, + generation, + &data, + ) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + continue; + } + let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { + Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::Ignored) => { + debug!(%kind, "ignoring unknown endpoint control message"); + continue; + } + Ok(endpoint::EndpointControlMessage::Snapshot(snapshot)) => snapshot, + Err(message) + if federated + || !endpoint::protocol_failure_is_fatal(&endpoint_id) => + { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message, + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + Err(message) => { + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + }; + let projection_pending = activation_message; + let activation_progress = activation_message + .then(|| { + pending_activation.as_mut().map(|pending| { + pending.receive_snapshot(&endpoint_id, generation, &snapshot) + }) + }) + .flatten(); + install_client_shell_snapshot( + &mut state, + &endpoint_id, + snapshot, + projection_pending, + &mut write_stream, + &mut prefix_input_source, + )?; + if matches!( + activation_progress, + Some(endpoint::SurfaceActivationProgress::Ready) + ) { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + write_stream.mark_ready(&endpoint_id, generation); + let selected_endpoint = endpoint_catalog + .selected_profile + .as_ref() + .map_or(endpoint::ClientEndpointId::Local, |profile_id| { + endpoint::ClientEndpointId::Ssh(profile_id.clone()) + }); + let activation_ready = state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_has_snapshot(&selected_endpoint) + && (!write_stream + .connection(write_stream.active_id()) + .is_some_and(|connection| connection.surface_active) + || shell.endpoint_boot_id(write_stream.active_id()).is_some()) + }); + let needs_surface = write_stream + .connection(&selected_endpoint) + .is_some_and(|connection| !connection.surface_active); + if activation_ready && needs_surface && pending_activation.is_none() { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: selected_endpoint, + target: None, + force: false, + }); } - debug!(%kind, "ignoring unknown endpoint control message"); - continue; } - let snapshot: crate::protocol::ClientShellSnapshot = - serde_json::from_str(&data).map_err(|error| { - ClientError::Protocol(protocol::FramingError::Io(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid endpoint snapshot: {error}"), - ))) - })?; - install_client_shell_snapshot( - &mut state, - Box::new(snapshot), - &mut write_stream, - &mut prefix_input_source, - )?; + ServerMessage::Welcome { .. } => { + debug!("received unexpected Welcome in main loop"); + } } - ServerMessage::Welcome { .. } => { - debug!("received unexpected Welcome in main loop"); + } + ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; } - }, - ClientLoopEvent::ServerDisconnected => { - return Err(ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - ))); + write_stream.fail( + &endpoint_id, + io::Error::new(io::ErrorKind::UnexpectedEof, "connection was lost"), + ); } ClientLoopEvent::Timer => { client_timer.fired(); @@ -1767,12 +1939,80 @@ async fn run_client_loop( state .detached_process_children .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + write_stream.tick_health(now); + for failure in write_stream.take_failures() { + if write_stream.connection(&failure.endpoint_id).is_some() + && !write_stream.accepts(&failure.endpoint_id, failure.generation) + { + continue; + } + warn!( + endpoint = %failure.endpoint_id.storage_key(), + error = %failure.message, + "endpoint transport failed" + ); + if !federated && failure.endpoint_id.is_local() { + return Err(ClientError::ConnectionLost(io::Error::new( + failure.kind, + failure.message, + ))); + } + if handle_endpoint_disconnect( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &failure.endpoint_id, + failure.generation, + now, + &format!("{}; reconnecting", failure.message), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + } + // A revoked transport changes the safe rollback destination. Handle those + // failures before applying a timeout to the remaining activation phase. + if pending_activation + .as_ref() + .is_some_and(|activation| activation.expired(now)) + { + let endpoint_id = pending_activation + .as_ref() + .map(|activation| activation.target().clone()) + .expect("checked pending activation"); + let label = state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| "Endpoint".into()); + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + format!("{label} did not produce a coherent surface in time"), + false, + ); + } if state.shell.is_some() { - let expired_endpoint = endpoint_commands.expire(now); + let expired_endpoints = endpoint_commands + .expire(now) + .into_iter() + .filter(|expired| { + write_stream.accepts(&expired.endpoint_id, expired.generation) + }) + .collect::<Vec<_>>(); let (effects, outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); let mut outcome = shell.tick_selection_autoscroll(now); - if let Some(expired) = expired_endpoint { + for expired in expired_endpoints { + if !shell.endpoint_is_active(&expired.endpoint_id) { + continue; + } let (repaint, actions) = shell.handle_endpoint_result( &expired.boot_id, &expired.request_id, @@ -1795,8 +2035,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1813,141 +2055,5 @@ async fn run_client_loop( Ok(()) } -// --------------------------------------------------------------------------- -// Server reader thread -// --------------------------------------------------------------------------- - -/// Blocking thread that reads ServerMessages from the server and sends them -/// to the main event loop. -fn server_reader_thread( - mut stream: LocalStream, - event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, - should_quit: &Arc<AtomicBool>, - max_frame_size: usize, -) { - // Ensure the read stream is in blocking mode to avoid WouldBlock errors - // from read_exact inside read_message. The stream should already be - // blocking after handshake, but we enforce it here as a safety measure. - if stream.set_nonblocking(false).is_err() { - // If we can't set blocking mode, the stream is likely broken. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - return; - } - - loop { - if should_quit.load(Ordering::Acquire) { - break; - } - - match protocol::read_message(&mut stream, max_frame_size) { - Ok(msg) => { - if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) - .is_err() - { - break; // Main loop gone. - } - } - Err(protocol::FramingError::UnexpectedEof) => { - // Server closed connection. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { - // Should not happen with blocking mode, but handle gracefully - // in case the stream was set nonblocking by another clone. - std::thread::sleep(Duration::from_millis(1)); - continue; - } - Err(err) => { - warn!(err = %err, "server read error"); - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - } - } -} - -// --------------------------------------------------------------------------- -// Write helper -// --------------------------------------------------------------------------- - -/// Writes a message to the server stream (blocking). -fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> { - protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) -} - -// --------------------------------------------------------------------------- -// Notifications -// --------------------------------------------------------------------------- - -fn reload_local_client_config( - sound_config: &mut crate::config::SoundConfig, - redraw_on_focus_gained: &mut bool, - draw_host_cursor: &mut bool, - remote_image_paste_key: &mut Option<( - crossterm::event::KeyCode, - crossterm::event::KeyModifiers, - )>, - mouse_capture: &mut bool, -) { - match crate::config::load_live_config() { - Ok(loaded) => { - let invalid_section = |section: &str| { - loaded - .invalid_sections - .iter() - .any(|invalid| invalid == section) - }; - if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() - { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); - } - *sound_config = loaded.config.ui.sound.clone(); - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *mouse_capture = loaded.config.ui.mouse_capture; - } - if !invalid_section("keys") { - *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - } - debug!("reloaded local client config"); - } - Err(diagnostics) => { - warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); - } - } -} - -// --------------------------------------------------------------------------- -// Clipboard forwarding -// --------------------------------------------------------------------------- - -/// Decode a clipboard payload forwarded by the server. -fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(data).ok() -} - -/// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) -> bool { - let Some(bytes) = decode_clipboard_payload(data) else { - warn!("received invalid clipboard payload from server"); - return false; - }; - - crate::selection::write_osc52_bytes(&bytes); - true -} - -fn init_logging() { - crate::logging::init_file_logging("herdr-client.log"); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests; diff --git a/src/client/shell.rs b/src/client/shell.rs index 9a877744..14f4f660 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -2,15 +2,25 @@ use std::collections::{HashMap, HashSet, VecDeque}; mod actions; mod agent_sidebar; +mod aggregate_navigation; mod composition; mod config; mod context_menu; mod copy_mode; +mod endpoint_agent_state; +mod endpoint_agents; +mod endpoint_navigation; +mod endpoint_notices; +mod endpoint_sidebar; +mod endpoints; +pub(super) use endpoints::*; mod global_menu; mod graphics; mod input; +mod input_source; mod mobile; mod mouse; +mod notification_policy; mod notifications; mod overlay_input; mod preferences; @@ -21,6 +31,7 @@ mod state; mod surface_patch; mod worktrees; +pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; #[cfg(test)] pub(super) use surface_patch::apply_composed_surface_patch; @@ -34,6 +45,7 @@ use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; +use super::endpoint::{ClientEndpointId, ClientEndpointStatus, SavedSshEndpoint}; use crate::app::state::Palette; use crate::config::{ Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 1ebd4e29..f99d8f3a 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -147,6 +147,9 @@ impl ClientShellState { } return; } + if self.handle_endpoint_navigation(action, outcome) { + return; + } if let Some(method) = self.endpoint_method_for_action(action) { self.push_endpoint_method(method, outcome); return; @@ -408,7 +411,9 @@ impl ClientShellState { kind: PendingEndpointKind, outcome: &mut ClientShellInput, ) -> bool { - if self.snapshot.is_none() { + if !self.endpoint_is_online(&self.active_endpoint_id) { + let label = self.active_endpoint_label().to_owned(); + outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready")); return false; } let method_name = crate::api::api_method_name(&method).to_owned(); @@ -423,7 +428,9 @@ impl ClientShellState { ); return false; } - let snapshot = self.snapshot.as_deref().expect("checked snapshot"); + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; let confirmation_workspace_id = match &method { crate::api::schema::Method::TabClose(target) => snapshot .tabs @@ -450,6 +457,7 @@ impl ClientShellState { }, ); outcome.actions.push(ClientShellAction::Endpoint { + endpoint_id: self.active_endpoint_id.clone(), boot_id: snapshot.boot_id.clone(), request: Box::new(crate::api::schema::Request { id: request_id, @@ -468,6 +476,58 @@ impl ClientShellState { ) } + pub(crate) fn receive_endpoint_unavailable(&mut self, message: String) -> bool { + self.push_endpoint_notice( + ClientEndpointNoticeKind::Unavailable, + message.clone(), + "Endpoint unavailable", + message, + ) + } + + pub(crate) fn focus_endpoint_target( + &mut self, + target: ClientEndpointFocusTarget, + ) -> Vec<ClientShellAction> { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + let mut outcome = ClientShellInput::default(); + self.push_endpoint_method(method, &mut outcome); + outcome.actions + } + + pub(crate) fn cancel_endpoint_request(&mut self, request_id: &str) -> bool { + let Some(pending) = self.pending_requests.get(request_id) else { + return false; + }; + let boot_id = pending.boot_id.clone(); + let (repaint, actions) = self.handle_endpoint_result( + &boot_id, + request_id, + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "This server action was interrupted. Check its state before retrying." + .into(), + }), + ); + debug_assert!( + actions.is_empty(), + "cancellation must not start another action" + ); + repaint + } + pub(crate) fn handle_endpoint_result( &mut self, boot_id: &str, @@ -506,6 +566,12 @@ impl ClientShellState { "Server timed out", format!("This server did not respond to {}.", pending.method_name), ), + "endpoint_cancelled" => ( + ClientEndpointNoticeKind::Unavailable, + "cancelled".to_owned(), + "Action interrupted", + error.message.clone(), + ), "server_unavailable" => ( ClientEndpointNoticeKind::Unavailable, "server".to_owned(), @@ -613,6 +679,9 @@ impl ClientShellState { Some("endpoint returned an unexpected selection result".to_owned()); (true, fallback()) } + Err(error) if error.code.as_deref() == Some("endpoint_cancelled") => { + (true, Vec::new()) + } Err(_) => (true, fallback()), }; } @@ -724,7 +793,7 @@ impl ClientShellState { Err(error) if matches!( error.code.as_deref(), - Some("stale_content" | "stale_target") + Some("stale_content" | "stale_target" | "endpoint_cancelled") ) => { self.url_click_consumes_until_up = completed_before_release; diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index 8f87b6ad..d6fe3352 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -10,11 +10,11 @@ use ratatui::{ use super::*; -struct AgentRow { - pane_id: String, - status: crate::api::schema::AgentStatus, - focused: bool, - rows: Vec<Vec<crate::ui::ResolvedToken>>, +pub(super) struct AgentRow { + pub(super) pane_id: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) focused: bool, + pub(super) rows: Vec<Vec<crate::ui::ResolvedToken>>, } pub(super) fn ordered_agent_pane_ids( @@ -57,9 +57,46 @@ pub(super) fn render_agent_panel( agent_scroll: &mut usize, hits: &mut ShellHitMap, ) { - if area.height == 0 { + if !render_agent_panel_header( + buffer, + area, + snapshot.agent_view_label.as_deref(), + config, + hits, + ) { return; } + + let rows = agent_rows(snapshot, config, None); + render_agent_list( + buffer, + area, + &rows, + snapshot + .agent_view_label + .as_ref() + .map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.rows.len(), + |buffer, rect, row, hits| { + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + }, + ); +} + +pub(super) fn render_agent_panel_header( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) -> bool { + if area.height == 0 { + return false; + } put_text( buffer, area.x, @@ -69,7 +106,7 @@ pub(super) fn render_agent_panel( Style::default().fg(config.palette.surface_dim), ); if area.height < 2 { - return; + return false; } put_text( buffer, @@ -81,14 +118,10 @@ pub(super) fn render_agent_panel( .fg(config.palette.overlay0) .add_modifier(Modifier::BOLD), ); - let sort_label = - snapshot - .agent_view_label - .as_deref() - .unwrap_or(match config.agent_panel_sort { - crate::config::AgentPanelSortConfig::Spaces => "grouped", - crate::config::AgentPanelSortConfig::Priority => "priority", - }); + let sort_label = agent_view_label.unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); let sort_width = display_width(sort_label).min(area.width as usize) as u16; let sort_rect = Rect::new( area.right().saturating_sub(sort_width), @@ -96,7 +129,7 @@ pub(super) fn render_agent_panel( sort_width, 1, ); - hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + hits.agent_sort_toggle = if config.mouse_capture && agent_view_label.is_none() { sort_rect } else { Rect::default() @@ -108,15 +141,27 @@ pub(super) fn render_agent_panel( sort_rect.width, sort_label, Style::default() - .fg(if snapshot.agent_view_label.is_some() { + .fg(if agent_view_label.is_some() { config.palette.accent } else { config.palette.overlay0 }) .add_modifier(Modifier::BOLD), ); + true +} - let rows = agent_rows(snapshot, config); +pub(super) fn render_agent_list<T>( + buffer: &mut Buffer, + area: Rect, + rows: &[T], + empty_message: Option<&str>, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, + row_lines: impl Fn(&T) -> usize, + mut render_row: impl FnMut(&mut Buffer, Rect, &T, &mut ShellHitMap), +) { let body = Rect::new( area.x, area.y.saturating_add(3), @@ -126,13 +171,13 @@ pub(super) fn render_agent_panel( hits.agent_body = body; if body.is_empty() || rows.is_empty() { *agent_scroll = 0; - if !body.is_empty() && snapshot.agent_view_label.is_some() { + if let Some(message) = empty_message.filter(|_| !body.is_empty()) { put_text( buffer, body.x, body.y, body.width, - " no matching agents", + message, Style::default() .fg(config.palette.overlay0) .add_modifier(Modifier::DIM), @@ -143,7 +188,7 @@ pub(super) fn render_agent_panel( let row_heights = rows .iter() - .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .map(|row| row_lines(row).max(1).min(u16::MAX as usize) as u16) .collect::<Vec<_>>(); let gaps = rows .iter() @@ -167,13 +212,12 @@ pub(super) fn render_agent_panel( let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); let mut y = body.y; for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { - let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + let height = row_heights[index].min(body.height); if y.saturating_add(height) > body.bottom() { break; } let rect = Rect::new(body.x, y, content_width, height); - hits.agents.push((rect, row.pane_id.clone())); - render_agent_row(buffer, rect, row, config); + render_row(buffer, rect, row, hits); y = y .saturating_add(height) .saturating_add(if index + 1 < rows.len() { @@ -190,7 +234,11 @@ pub(super) fn render_agent_panel( } } -fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { +pub(super) fn agent_rows( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + machine: Option<&str>, +) -> Vec<AgentRow> { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() .filter_map(|pane_id| { @@ -238,6 +286,7 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec let rows = crate::ui::sidebar_agent_rows( &config.agents, crate::ui::AgentTokenContext { + machine, workspace: &workspace.label, tab: tab_label, pane: agent @@ -262,7 +311,12 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec .collect() } -fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { +pub(super) fn render_agent_row( + buffer: &mut Buffer, + rect: Rect, + row: &AgentRow, + config: &ClientShellConfig, +) { let palette = &config.palette; let row_style = if row.focused { Style::default().bg(palette.active_row_bg) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs new file mode 100644 index 00000000..5c826bf3 --- /dev/null +++ b/src/client/shell/aggregate_navigation.rs @@ -0,0 +1,262 @@ +//! Endpoint-qualified rows shared by aggregate navigation surfaces. + +use super::*; +use crate::protocol::ClientShellAgent; + +#[derive(Clone, Copy)] +pub(super) struct CachedEndpointSnapshot<'a> { + pub(super) endpoint_id: &'a ClientEndpointId, + pub(super) label: &'a str, + pub(super) status: ClientEndpointStatus, + pub(super) snapshot: &'a ClientShellSnapshot, + pub(super) agent_recency: &'a HashMap<String, u64>, +} + +impl CachedEndpointSnapshot<'_> { + pub(super) fn stale(self) -> bool { + self.status != ClientEndpointStatus::Online + } +} + +pub(super) fn cached_endpoint_snapshots( + endpoints: &[ClientShellEndpoint], +) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> { + endpoints.iter().filter_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| CachedEndpointSnapshot { + endpoint_id: &endpoint.endpoint_id, + label: &endpoint.label, + status: endpoint.status, + snapshot, + agent_recency: &endpoint.agent_recency, + }) + }) +} + +pub(super) struct AggregateAgentRow<'a> { + pub(super) endpoint: CachedEndpointSnapshot<'a>, + pub(super) agent: &'a ClientShellAgent, + pub(super) recency: u64, +} + +pub(super) struct AggregateAgentTarget { + pub(super) endpoint_id: ClientEndpointId, + pub(super) pane_id: String, +} + +pub(super) fn aggregate_agent_rows( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentRow<'_>> { + let mut rows = cached_endpoint_snapshots(endpoints) + .flat_map(|endpoint| { + super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort) + .into_iter() + .filter_map(move |pane_id| { + let agent = endpoint + .snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + Some(AggregateAgentRow { + recency: endpoint + .agent_recency + .get(&pane_id) + .copied() + .unwrap_or_default(), + endpoint, + agent, + }) + }) + }) + .collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + rows.sort_by_key(|row| { + ( + row.endpoint.stale(), + std::cmp::Reverse(status_priority(row.agent.agent_status)), + std::cmp::Reverse(row.recency), + ) + }); + } + rows +} + +pub(super) fn online_agent_targets( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentTarget> { + aggregate_agent_rows(endpoints, sort) + .into_iter() + .filter(|row| !row.endpoint.stale()) + .map(|row| AggregateAgentTarget { + endpoint_id: row.endpoint.endpoint_id.clone(), + pane_id: row.agent.pane_id.clone(), + }) + .collect() +} + +pub(super) fn navigator_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + navigator: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let query = navigator.query.trim().to_lowercase(); + let filter = |status| match navigator.filter { + Some(ClientNavigatorFilter::Blocked) => status == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => status == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => status == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query); + let filtering = navigator.filter.is_some() || !query.is_empty(); + let federated = endpoints.len() > 1; + let depth_offset = u8::from(federated); + let mut rows = Vec::new(); + + for endpoint in endpoints { + let stale = endpoint.status != ClientEndpointStatus::Online; + let endpoint_query_matches = !query.is_empty() && text(&endpoint.label); + let mut endpoint_rows = Vec::new(); + if let Some(snapshot) = endpoint.snapshot.as_deref() { + for workspace in &snapshot.workspaces { + let workspace_meta = workspace.branch.clone().unwrap_or_default(); + let mut children = Vec::new(); + for tab in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + { + let mut panes = Vec::new(); + for (index, pane) in snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .enumerate() + { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id); + let status = agent + .map_or(crate::api::schema::AgentStatus::Unknown, |agent| { + agent.agent_status + }); + let label = pane + .label + .clone() + .or_else(|| agent.and_then(|agent| agent.name.clone())) + .or_else(|| agent.and_then(|agent| agent.display_agent.clone())) + .or_else(|| agent.and_then(|agent| agent.title.clone())) + .unwrap_or_else(|| format!("pane {}", index + 1)); + let meta = pane + .foreground_cwd + .clone() + .or_else(|| pane.cwd.clone()) + .unwrap_or_default(); + if !filtering + || filter(status) + && (endpoint_query_matches || text(&label) || text(&meta)) + { + panes.push(ClientNavigatorRow { + depth: 2 + depth_offset, + label, + meta, + status: Some(status), + stale, + current: endpoint.endpoint_id == *active_endpoint_id + && snapshot.focused_pane_id.as_deref() == Some(&pane.pane_id), + target: ClientNavigatorTarget::Pane { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: pane.pane_id.clone(), + }, + }); + } + } + if !filtering + || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label)) + || !panes.is_empty() + { + children.push(ClientNavigatorRow { + depth: 1 + depth_offset, + label: tab.label.clone(), + meta: format!( + "{} panes", + snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .count() + ), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Tab { + endpoint_id: endpoint.endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }, + }); + children.extend(panes); + } + } + let workspace_matches = filter(workspace.agent_status) + && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta)); + if !filtering || workspace_matches || !children.is_empty() { + let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()); + endpoint_rows.push(ClientNavigatorRow { + depth: depth_offset, + label: workspace.label.clone(), + meta: workspace_meta, + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }, + }); + if navigator.expanded_workspaces.contains(&key) || filtering { + endpoint_rows.extend(children); + } + } + } + } + if !filtering || endpoint_query_matches || !endpoint_rows.is_empty() { + if federated { + rows.push(ClientNavigatorRow { + depth: 0, + label: endpoint.label.to_owned(), + meta: String::new(), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Machine { + endpoint_id: endpoint.endpoint_id.clone(), + }, + }); + } + rows.extend(endpoint_rows); + } + } + rows +} + +pub(super) fn navigator_selected_index( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<usize> { + match navigator.selected.as_ref() { + Some(target) => rows.iter().position(|row| row.target == *target), + None => (!rows.is_empty()).then_some(0), + } +} + +pub(super) fn selected_navigator_target( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<ClientNavigatorTarget> { + navigator_selected_index(rows, navigator).map(|index| rows[index].target.clone()) +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index bcbf92fa..a80ebb3f 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -20,9 +20,95 @@ fn restore_mode_bar( } impl ClientShellState { + fn compose_unavailable(&mut self, cols: u16, rows: u16) -> FrameData { + let layout = self.layout(cols, rows); + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + buffer.set_style( + buffer.area, + Style::default() + .fg(self.config.palette.text) + .bg(self.config.palette.panel_bg), + ); + self.hits = ShellHitMap::default(); + let sidebar = if layout.sidebar.width > 0 { + layout.sidebar + } else { + Rect::new(0, 1, cols, rows.saturating_sub(2)) + }; + super::endpoint_sidebar::render_expanded( + &mut buffer, + sidebar, + self.snapshot.as_deref(), + &self.config, + &mut render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_workspace: &mut self.reveal_focused_workspace, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: false, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index: None, + selected_workspace_id: self.navigate_workspace_id.as_deref(), + dragged_workspace_id: None, + workspace_drop_indicator_row: None, + }, + &mut self.hits, + ); + if !self.config.mouse_capture { + self.hits = ShellHitMap::default(); + } + let message = self.endpoint_error.clone().unwrap_or_else(|| { + let status = self + .endpoint_status(&self.active_endpoint_id) + .unwrap_or(ClientEndpointStatus::Connecting); + let (_, label, _) = endpoint_status_presentation(status, &self.config.palette); + format!( + "{}: {label}. Select a connected machine.", + self.active_endpoint_label() + ) + }); + let message_area = if layout.sidebar.width > 0 { + layout.pane_surface + } else { + Rect::new(0, 0, cols, 1) + }; + render::put_text( + &mut buffer, + message_area.x, + message_area.y, + message_area.width, + &message, + Style::default().fg(self.config.palette.overlay0), + ); + render::render_mode_bar( + &mut buffer, + Rect::new(0, 0, cols, rows), + self.mode, + None, + self.endpoint_error.as_deref(), + false, + &self.config.keybinds, + &self.config.palette, + ); + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]) + } + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { self.last_composed_size = Some((cols, rows)); + if self.snapshot.is_none() || self.pane_surface.is_none() { + return Some(self.compose_unavailable(cols, rows)); + } let snapshot = self.snapshot.as_deref()?; + // A one-step successor is retained separately until its exact snapshot arrives; do not + // keep composing the now-superseded current pair while it is pending. + if self.pending_pane_surface.is_some() { + return None; + } let surface = self.pane_surface.as_ref()?; if snapshot.revision != surface.projection_revision { return None; @@ -53,6 +139,9 @@ impl ClientShellState { snapshot, &self.config, render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, collapsed_groups: &self.collapsed_groups, workspace_scroll: &mut self.workspace_scroll, agent_scroll: &mut self.agent_scroll, @@ -270,7 +359,14 @@ impl ClientShellState { restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); self.hits.notification_toast = Rect::default(); let has_config_diagnostic = self.config_diagnostic.is_some(); + let active_lifecycle = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .filter(|endpoint| endpoint.status != ClientEndpointStatus::Online) + .map(|endpoint| (endpoint.label.clone(), endpoint.status)); if has_config_diagnostic + || active_lifecycle.is_some() || self.visible_endpoint_notice.is_some() || self.visible_notification.is_some() { @@ -289,21 +385,32 @@ impl ClientShellState { &self.config.palette, ); } + let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + u16::from(has_config_diagnostic) + layout.mobile_header.height, + &self.config.palette, + ); + 1 + }); if let Some(notice) = self.visible_endpoint_notice.as_ref() { self.hits.notification_toast = if layout.mobile_header.is_empty() { - notifications::render_endpoint_notice( + endpoint_notices::render_notice( &mut composed, Rect::new(0, 0, cols, rows), notice, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { - notifications::render_mobile_endpoint_notice_banner( + endpoint_notices::render_mobile_banner( &mut composed, Rect::new(0, 0, cols, rows), notice, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -314,7 +421,7 @@ impl ClientShellState { Rect::new(0, 0, cols, rows), notification, self.config.toast_position, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { @@ -322,7 +429,7 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), notification, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -399,12 +506,33 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config, self.navigate_workspace_id.as_deref(), &mut self.mobile_switcher_scroll, &mut self.reveal_mobile_workspace, &mut self.hits, ); + if let Some((label, status)) = active_lifecycle.as_ref() { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + 2, + &self.config.palette, + ); + } + if let Some(notice) = self.visible_endpoint_notice.as_ref() { + self.hits.notification_toast = endpoint_notices::render_mobile_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notice, + active_lifecycle.is_some(), + &self.config.palette, + ); + } frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); self.hits.panes.clear(); self.hits.pane_splits.clear(); @@ -431,6 +559,8 @@ impl ClientShellState { &mut composed, overlay, snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config.keybinds, &self.config.palette, )?; @@ -474,6 +604,12 @@ impl ClientShellState { .scroll .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); } + if self.endpoint_status(&self.active_endpoint_id) != Some(ClientEndpointStatus::Online) { + frame.cursor = None; + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } self.compose_graphics(&mut frame, layout); Some(frame) } diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs new file mode 100644 index 00000000..fdcd7478 --- /dev/null +++ b/src/client/shell/endpoint_agent_state.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use crate::api::schema::AgentStatus; +use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; + +#[derive(Clone, Debug, Default)] +pub(super) struct EndpointAgentPresentation { + boot_id: Option<String>, + acknowledged: HashMap<String, u64>, +} + +impl EndpointAgentPresentation { + pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) { + if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) { + self.boot_id = Some(snapshot.boot_id.clone()); + self.acknowledged.clear(); + self.acknowledged.extend( + snapshot + .agents + .iter() + .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)), + ); + } + self.acknowledged.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + + pub(super) fn acknowledge_surface( + &mut self, + snapshot: &mut ClientShellSnapshot, + surface: &PaneSurfaceFrame, + outer_focused: Option<bool>, + ) -> bool { + if outer_focused == Some(false) + || self.boot_id.as_deref() != Some(surface.boot_id.as_str()) + || snapshot.boot_id != surface.boot_id + || snapshot.revision != surface.projection_revision + { + return false; + } + + let mut changed = false; + for pane in &surface.panes { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id) + else { + continue; + }; + let acknowledged = self.acknowledged.entry(agent.pane_id.clone()).or_default(); + if *acknowledged < agent.state_change_seq { + *acknowledged = agent.state_change_seq; + changed = true; + } + } + if changed { + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + changed + } + + fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { + match agent.agent_status { + AgentStatus::Idle | AgentStatus::Done => { + if self + .acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| *sequence >= agent.state_change_seq) + { + AgentStatus::Idle + } else { + AgentStatus::Done + } + } + status => status, + } + } +} + +fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) { + for tab in &mut snapshot.tabs { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.tab_id == tab.tab_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + tab.agent_status = status; + } + } + for workspace in &mut snapshot.workspaces { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.workspace_id == workspace.workspace_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + workspace.agent_status = status; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect}; + + fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent { + ClientShellAgent { + pane_id: "agent-pane".into(), + workspace_id: "workspace".into(), + tab_id: "tab".into(), + name: None, + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq: sequence, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } + } + + fn snapshot(status: AgentStatus, sequence: u64, revision: u64) -> ClientShellSnapshot { + let mut snapshot = crate::client::shell::tests::snapshot(); + snapshot.boot_id = "endpoint-boot".into(); + snapshot.revision = revision; + snapshot.agents = vec![agent(status, sequence)]; + snapshot + } + + fn surface(revision: u64) -> PaneSurfaceFrame { + let rect = SurfaceRect { + x: 0, + y: 0, + width: 1, + height: 1, + }; + PaneSurfaceFrame { + boot_id: "endpoint-boot".into(), + projection_revision: revision, + surface_revision: 1, + frame: FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![PaneSurfacePane { + pane_id: "agent-pane".into(), + content_revision: 1, + rect, + inner_rect: rect, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } + } + + #[test] + fn first_snapshot_establishes_an_idle_baseline_without_server_seen_authority() { + let mut presentation = EndpointAgentPresentation::default(); + let mut snapshot = snapshot(AgentStatus::Done, 4, 1); + + presentation.project_snapshot(&mut snapshot); + + assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn unpresented_working_completion_projects_done() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + + presentation.project_snapshot(&mut completed); + + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } + + #[test] + fn coherent_presented_surface_acknowledges_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn clients_acknowledge_the_same_endpoint_completion_independently() { + let mut viewing_client = EndpointAgentPresentation::default(); + let mut background_client = EndpointAgentPresentation::default(); + let mut initial_for_viewer = snapshot(AgentStatus::Working, 4, 1); + let mut initial_for_background = initial_for_viewer.clone(); + viewing_client.project_snapshot(&mut initial_for_viewer); + background_client.project_snapshot(&mut initial_for_background); + let mut completed_for_viewer = snapshot(AgentStatus::Idle, 5, 2); + let mut completed_for_background = completed_for_viewer.clone(); + viewing_client.project_snapshot(&mut completed_for_viewer); + background_client.project_snapshot(&mut completed_for_background); + + assert!(viewing_client.acknowledge_surface( + &mut completed_for_viewer, + &surface(2), + Some(true) + )); + + assert_eq!( + completed_for_viewer.agents[0].agent_status, + AgentStatus::Idle + ); + assert_eq!( + completed_for_background.agents[0].agent_status, + AgentStatus::Done + ); + } + + #[test] + fn stale_or_unfocused_surface_does_not_acknowledge_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(!presentation.acknowledge_surface(&mut completed, &surface(1), Some(true))); + assert!(!presentation.acknowledge_surface(&mut completed, &surface(2), Some(false))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } +} diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs new file mode 100644 index 00000000..a49fccf9 --- /dev/null +++ b/src/client/shell/endpoint_agents.rs @@ -0,0 +1,129 @@ +use super::render::put_text; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + let rows = agent_rows(endpoints, active_endpoint_id, config); + for (index, row) in rows.into_iter().take(area.height as usize).enumerate() { + let rect = Rect::new(area.x, area.y + index as u16, area.width, 1); + if row.agent.focused { + buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg)); + } + let initial = row.machine_label.chars().next().unwrap_or('?'); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!( + "{initial}{}", + status_icon(row.agent.status, config.status_indicators) + ), + Style::default() + .fg(if row.stale { + config.palette.overlay0 + } else { + status_color(row.agent.status, &config.palette) + }) + .add_modifier(if row.stale { + Modifier::DIM + } else { + Modifier::empty() + }), + ); + hits.endpoint_agents + .push((rect, row.endpoint_id, row.agent.pane_id)); + } +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if !super::agent_sidebar::render_agent_panel_header( + buffer, + area, + agent_view_label, + config, + hits, + ) { + return; + } + let rows = agent_rows(endpoints, active_endpoint_id, config); + super::agent_sidebar::render_agent_list( + buffer, + area, + &rows, + agent_view_label.map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.agent.rows.len(), + |buffer, rect, row, hits| { + super::agent_sidebar::render_agent_row(buffer, rect, &row.agent, config); + if row.stale { + buffer.set_style( + rect, + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.endpoint_agents + .push((rect, row.endpoint_id.clone(), row.agent.pane_id.clone())); + }, + ); +} + +struct EndpointAgentRow { + endpoint_id: ClientEndpointId, + machine_label: String, + stale: bool, + agent: super::agent_sidebar::AgentRow, +} + +fn agent_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, +) -> Vec<EndpointAgentRow> { + let mut rendered_rows = endpoints + .iter() + .filter_map(|endpoint| { + endpoint.snapshot.as_deref().map(|snapshot| { + super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label)) + .into_iter() + .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent)) + .collect::<Vec<_>>() + }) + }) + .flatten() + .collect::<HashMap<_, _>>(); + + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort) + .into_iter() + .filter_map(|row| { + let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); + let mut agent = rendered_rows.remove(&key)?; + agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; + Some(EndpointAgentRow { + endpoint_id: row.endpoint.endpoint_id.clone(), + machine_label: row.endpoint.label.to_owned(), + stale: row.endpoint.stale(), + agent, + }) + }) + .collect() +} diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs new file mode 100644 index 00000000..adb29c93 --- /dev/null +++ b/src/client/shell/endpoint_navigation.rs @@ -0,0 +1,290 @@ +use super::*; + +impl ClientShellState { + pub(super) fn active_endpoint_workspace_at(&self, point: (u16, u16)) -> Option<String> { + self.hits + .workspaces + .iter() + .find(|hit| { + hit.endpoint_id == self.active_endpoint_id && super::contains(hit.rect, point) + }) + .map(|hit| hit.workspace_id.clone()) + } + + pub(super) fn endpoint_workspace_is_draggable(&self, press: &ClientWorkspacePress) -> bool { + press.endpoint_id == self.active_endpoint_id + && self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == press.workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + } + + pub(super) fn finish_endpoint_workspace_press( + &mut self, + press: ClientWorkspacePress, + outcome: &mut ClientShellInput, + ) { + if press.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }), + outcome, + ); + } else if self.endpoint_is_online(&press.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: press.endpoint_id, + target: Some(ClientEndpointFocusTarget::Workspace(press.workspace_id)), + }); + } else { + let label = self.endpoint_label(&press.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + } + + pub(super) fn handle_endpoint_machine_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some(endpoint_id) = self + .hits + .machines + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.endpoint_id.clone()) + else { + return false; + }; + if endpoint_id == self.active_endpoint_id { + if !self.collapsed_endpoints.remove(&endpoint_id) { + self.collapsed_endpoints.insert(endpoint_id); + } + outcome.repaint = true; + } else if self.endpoint_is_online(&endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + true + } + + pub(super) fn handle_endpoint_agent_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some((endpoint_id, pane_id)) = self + .hits + .endpoint_agents + .iter() + .find(|(rect, _, _)| super::contains(*rect, point)) + .map(|(_, endpoint_id, pane_id)| (endpoint_id.clone(), pane_id.clone())) + else { + return false; + }; + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is reconnecting")); + outcome.repaint = true; + } else if endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + true + } + + pub(super) fn handle_endpoint_navigation( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) -> bool { + use crate::input::KeybindAction; + if !self.multi_endpoint_active() { + return false; + } + if matches!( + action, + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace + ) { + let workspaces = self + .endpoints + .iter() + .filter(|endpoint| endpoint.status == ClientEndpointStatus::Online) + .flat_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map_or_else(Vec::new, |snapshot| { + render::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .filter_map(|entry| { + snapshot.workspaces.get(entry.index).map(|workspace| { + ( + endpoint.endpoint_id.clone(), + workspace.workspace_id.clone(), + ) + }) + }) + .collect() + }) + }) + .collect::<Vec<_>>(); + if workspaces.is_empty() { + return true; + } + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()); + let current = workspaces.iter().position(|(endpoint_id, workspace_id)| { + endpoint_id == &self.active_endpoint_id && Some(workspace_id.as_str()) == focused + }); + let next = match (current, action) { + (Some(index), KeybindAction::PreviousWorkspace) => { + (index + workspaces.len() - 1) % workspaces.len() + } + (Some(index), KeybindAction::NextWorkspace) => (index + 1) % workspaces.len(), + (None, KeybindAction::PreviousWorkspace) => workspaces.len() - 1, + (None, KeybindAction::NextWorkspace) => 0, + _ => unreachable!("endpoint workspace navigation"), + }; + let (endpoint_id, workspace_id) = workspaces[next].clone(); + self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ); + return true; + } + if matches!( + action, + KeybindAction::PreviousAgent | KeybindAction::NextAgent | KeybindAction::FocusAgent(_) + ) { + let agents = super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return true; + } + let next = match action { + KeybindAction::FocusAgent(index) => { + if index >= agents.len() { + return true; + } + index + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.as_deref()); + let current = agents.iter().position(|target| { + target.endpoint_id == self.active_endpoint_id + && Some(target.pane_id.as_str()) == focused + }); + match (current, action) { + (Some(index), KeybindAction::PreviousAgent) => { + (index + agents.len() - 1) % agents.len() + } + (Some(index), KeybindAction::NextAgent) => (index + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + _ => 0, + } + } + _ => unreachable!("endpoint agent navigation"), + }; + let target = &agents[next]; + self.focus_or_activate( + target.endpoint_id.clone(), + ClientEndpointFocusTarget::Pane(target.pane_id.clone()), + outcome, + ); + return true; + } + false + } + + pub(super) fn activate_endpoint( + &mut self, + endpoint_id: ClientEndpointId, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id != self.active_endpoint_id { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } + true + } + + pub(super) fn focus_or_activate( + &mut self, + endpoint_id: ClientEndpointId, + target: ClientEndpointFocusTarget, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id == self.active_endpoint_id { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + }; + self.push_endpoint_method(method, outcome); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(target), + }); + } + true + } +} diff --git a/src/client/shell/endpoint_notices.rs b/src/client/shell/endpoint_notices.rs new file mode 100644 index 00000000..e796d2c5 --- /dev/null +++ b/src/client/shell/endpoint_notices.rs @@ -0,0 +1,80 @@ +use super::*; +use ratatui::widgets::{Clear, Widget}; + +pub(super) fn render_mobile_banner( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + super::notifications::render_mobile_notice_banner( + buffer, + area, + ¬ice.title, + Some(¬ice.body), + palette.red, + offset_for_warning, + palette, + ) +} + +pub(super) fn render_lifecycle_banner( + buffer: &mut Buffer, + area: Rect, + label: &str, + status: ClientEndpointStatus, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() || status == ClientEndpointStatus::Online { + return Rect::default(); + } + let (symbol, state, color) = endpoint_status_presentation(status, palette); + let text = format!("{symbol} {label} · {state}"); + let width = u16::try_from(unicode_width::UnicodeWidthStr::width(text.as_str()) + 2) + .unwrap_or(u16::MAX) + .min(area.width); + let y = area + .y + .saturating_add(top_offset) + .min(area.bottom().saturating_sub(1)); + let rect = Rect::new(area.right().saturating_sub(width), y, width, 1); + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(palette.surface0)); + super::render::put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(2), + &text, + Style::default().fg(color).bg(palette.surface0), + ); + rect +} + +pub(super) fn render_notice( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + top_offset: u16, + palette: &Palette, +) -> Rect { + super::notifications::render_notification_card( + buffer, + area, + ¬ice.title, + ¬ice.body, + crate::config::ToastHerdrPosition::TopRight, + top_offset, + match notice.key.kind { + ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { + palette.red + } + ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { + palette.yellow + } + }, + palette, + ) +} diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs new file mode 100644 index 00000000..51c940cd --- /dev/null +++ b/src/client/shell/endpoint_sidebar.rs @@ -0,0 +1,452 @@ +use super::render::{display_width, put_right_text, put_text, ShellRenderState}; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area); + let mut y = workspace_area.y; + for (index, endpoint) in state.endpoints.iter().enumerate() { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let active = &endpoint.endpoint_id == state.active_endpoint_id; + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + if active && collapsed { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let label = if endpoint.endpoint_id.is_local() { + "L".to_owned() + } else { + (index + 1).to_string() + }; + let marker = if collapsed { "▸" } else { "▾" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(1), + &format!("{marker}{label}"), + Style::default().fg(if endpoint.status == ClientEndpointStatus::Online { + palette.text + } else { + palette.overlay0 + }), + ); + if !endpoint.endpoint_id.is_local() { + let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette); + put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color)); + } + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + if collapsed { + continue; + } + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + for workspace in &snapshot.workspaces { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let focused = active && workspace.focused; + if focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let stale = endpoint.status != ClientEndpointStatus::Online; + let number = format!(" {}", workspace.number); + let number_width = super::render::display_width(&number).min(rect.width); + let dim = if stale { + Modifier::DIM + } else { + Modifier::empty() + }; + put_text( + buffer, + rect.x, + rect.y, + number_width, + &number, + Style::default() + .fg(if focused && !stale { + palette.text + } else { + palette.overlay0 + }) + .add_modifier(dim), + ); + put_text( + buffer, + rect.x.saturating_add(number_width), + rect.y, + rect.width.saturating_sub(number_width), + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(if stale { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }) + .add_modifier(dim), + ); + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + y = y.saturating_add(1); + } + } + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + super::endpoint_agents::render_collapsed( + buffer, + detail_area, + state.endpoints, + state.active_endpoint_id, + config, + hits, + ); + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + Style::default().fg(palette.overlay0), + ); +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + active_snapshot: Option<&ClientShellSnapshot>, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " machines", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + enum Row { + Endpoint(usize), + Workspace { + endpoint: usize, + entry: WorkspaceEntry, + }, + } + let mut rows = Vec::new(); + for (endpoint_index, endpoint) in state.endpoints.iter().enumerate() { + rows.push(Row::Endpoint(endpoint_index)); + if state.collapsed_endpoints.contains(&endpoint.endpoint_id) { + continue; + } + if let Some(snapshot) = endpoint.snapshot.as_deref() { + rows.extend( + super::sidebar::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .map(|entry| Row::Workspace { + endpoint: endpoint_index, + entry, + }), + ); + } + } + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = rows + .iter() + .map(|row| match row { + Row::Endpoint(_) => 1, + Row::Workspace { endpoint, entry } => state.endpoints[*endpoint] + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.workspaces.get(entry.index)) + .map(|workspace| { + super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1), + }) + .collect::<Vec<_>>(); + let gaps = vec![0; rows.len()]; + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for row in rows.iter().skip(*state.workspace_scroll) { + match row { + Row::Endpoint(index) => { + if y >= body.bottom() { + break; + } + let endpoint = &state.endpoints[*index]; + let rect = Rect::new(body.x, y, content_width, 1); + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + let marker = if collapsed { "▸" } else { "▾" }; + render_endpoint_row( + buffer, + rect, + marker, + endpoint, + collapsed && &endpoint.endpoint_id == state.active_endpoint_id, + palette, + ); + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + } + Row::Workspace { endpoint, entry } => { + let endpoint = &state.endpoints[*endpoint]; + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let tokens = super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ); + let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + let nested = Rect::new( + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + rect.height, + ); + let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id; + super::sidebar::render_workspace_rows( + buffer, + nested, + workspace, + workspace.agent_status, + config.status_indicators, + entry, + tokens, + endpoint_active, + false, + false, + palette, + ); + if endpoint.status != ClientEndpointStatus::Online { + buffer.set_style( + rect, + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle: None, + }); + y = y.saturating_add(height); + } + } + } + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + let label = format!(" new · {}", active_endpoint_label(state)); + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + display_width(&label).min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + &label, + Style::default().fg(palette.overlay0), + ); + let attention = active_snapshot.is_some_and(super::global_menu::global_menu_attention); + let width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(width), + footer_y, + width, + 1, + ); + put_right_text( + buffer, + workspace_area, + footer_y, + if attention { "● menu" } else { "menu" }, + Style::default().fg(if attention { + palette.accent + } else { + palette.overlay0 + }), + ); + } + super::endpoint_agents::render_expanded( + buffer, + detail_area, + active_snapshot.and_then(|snapshot| snapshot.agent_view_label.as_deref()), + state.endpoints, + state.active_endpoint_id, + config, + state.agent_scroll, + hits, + ); + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +fn active_endpoint_label<'a>(state: &'a ShellRenderState<'_>) -> &'a str { + state + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == state.active_endpoint_id) + .map_or("Local", |endpoint| endpoint.label.as_str()) +} + +fn render_endpoint_row( + buffer: &mut Buffer, + rect: Rect, + marker: &str, + endpoint: &ClientShellEndpoint, + highlighted: bool, + palette: &Palette, +) { + if highlighted { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let (glyph, state, color) = endpoint_status_presentation(endpoint.status, palette); + let state = if endpoint.status == ClientEndpointStatus::Online { + "" + } else { + state + }; + let signal = if endpoint.endpoint_id.is_local() { + String::new() + } else if state.is_empty() { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_width = display_width(&signal).min(rect.width); + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(signal_width.saturating_add(1)), + &format!(" {marker} {}", endpoint.label), + Style::default() + .fg( + if matches!(endpoint.status, ClientEndpointStatus::Disabled) { + palette.overlay0 + } else { + palette.text + }, + ) + .add_modifier(Modifier::BOLD), + ); + put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color)); +} diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs new file mode 100644 index 00000000..2f5a8e46 --- /dev/null +++ b/src/client/shell/endpoints.rs @@ -0,0 +1,506 @@ +use super::*; + +#[derive(Clone, Debug)] +pub(crate) struct ClientShellEndpoint { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) label: String, + pub(crate) status: ClientEndpointStatus, + pub(crate) snapshot: Option<Box<ClientShellSnapshot>>, + /// Connection generation that produced `snapshot`. `None` is reserved for local tests. + pub(crate) snapshot_generation: Option<u64>, + pub(crate) agent_recency: HashMap<String, u64>, + pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) methods: Option<HashSet<String>>, +} + +pub(super) struct MachineHit { + pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointFocusTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +impl ClientShellState { + pub(crate) fn set_endpoint_catalog(&mut self, profiles: &[SavedSshEndpoint]) { + let mut next = Vec::with_capacity(profiles.len().saturating_add(1)); + let local = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .cloned() + .unwrap_or_else(local_endpoint); + next.push(local); + for profile in profiles { + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + let previous = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .filter(|endpoint| { + profile.enabled && endpoint.status != ClientEndpointStatus::Disabled + }); + next.push(ClientShellEndpoint { + endpoint_id, + label: profile.label.clone(), + status: previous.map_or( + if profile.enabled { + ClientEndpointStatus::Connecting + } else { + ClientEndpointStatus::Disabled + }, + |endpoint| endpoint.status, + ), + snapshot: previous.and_then(|endpoint| endpoint.snapshot.clone()), + snapshot_generation: previous.and_then(|endpoint| endpoint.snapshot_generation), + agent_recency: previous + .map(|endpoint| endpoint.agent_recency.clone()) + .unwrap_or_default(), + agent_presentation: previous + .map(|endpoint| endpoint.agent_presentation.clone()) + .unwrap_or_default(), + methods: previous.and_then(|endpoint| endpoint.methods.clone()), + }); + } + + if !next + .iter() + .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + { + self.select_unavailable_local(); + } + self.collapsed_endpoints.retain(|endpoint_id| { + next.iter() + .any(|endpoint| &endpoint.endpoint_id == endpoint_id) + }); + self.endpoints = next; + } + + pub(crate) fn select_unavailable_local(&mut self) { + self.reset_endpoint_projection(); + self.active_endpoint_id = ClientEndpointId::Local; + self.mode = ClientShellMode::Terminal; + self.snapshot = None; + self.graphics.set_scope("local:unavailable"); + self.reconcile_input_source(); + } + + pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + self.retire_endpoint_notifications(endpoint_id); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = ClientEndpointStatus::Disabled; + endpoint.snapshot = None; + endpoint.snapshot_generation = None; + endpoint.methods = None; + endpoint.agent_recency.clear(); + endpoint.agent_presentation = Default::default(); + } + } + + pub(crate) fn set_endpoint_status( + &mut self, + endpoint_id: &ClientEndpointId, + status: ClientEndpointStatus, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = status; + } + } + + pub(crate) fn mark_endpoint_disconnected(&mut self, endpoint_id: &ClientEndpointId) { + self.set_endpoint_status(endpoint_id, ClientEndpointStatus::Reconnecting); + if endpoint_id == &self.active_endpoint_id { + let pending = self.pending_requests.keys().cloned().collect::<Vec<_>>(); + for request_id in pending { + self.cancel_endpoint_request(&request_id); + } + self.pending_integration_installs = 0; + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + } + } + + pub(crate) fn set_endpoint_methods_for( + &mut self, + endpoint_id: &ClientEndpointId, + methods: Option<Vec<String>>, + ) { + let methods = methods.map(|methods| methods.into_iter().collect::<HashSet<_>>()); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.methods = methods; + } + } + + pub(crate) fn endpoint_projection_available(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints.iter().any(|endpoint| { + &endpoint.endpoint_id == endpoint_id + && endpoint.status == ClientEndpointStatus::Online + && endpoint.snapshot.is_some() + }) + } + + pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool { + let Some(endpoint) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return false; + }; + if endpoint.status != ClientEndpointStatus::Online { + return false; + } + let Some(snapshot) = endpoint.snapshot.clone() else { + return false; + }; + if endpoint_id != &self.active_endpoint_id { + self.active_endpoint_id = endpoint_id.clone(); + self.pane_surface = None; + self.pending_pane_surface = None; + } + self.apply_active_snapshot(snapshot); + true + } + + pub(crate) fn endpoint_status( + &self, + endpoint_id: &ClientEndpointId, + ) -> Option<ClientEndpointStatus> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status) + } + + pub(crate) fn endpoint_has_snapshot(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| endpoint.snapshot.is_some()) + } + + pub(crate) fn endpoint_is_online(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoint_status(endpoint_id) == Some(ClientEndpointStatus::Online) + && self.endpoint_has_snapshot(endpoint_id) + } + + pub(crate) fn endpoint_boot_id(&self, endpoint_id: &ClientEndpointId) -> Option<&str> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)? + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + } + + pub(crate) fn endpoint_snapshot_matches( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + revision: u64, + ) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| { + endpoint + .snapshot_generation + .is_none_or(|snapshot_generation| snapshot_generation == generation) + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.boot_id == boot_id && snapshot.revision == revision + }) + }) + } + + pub(crate) fn endpoint_snapshot_identity( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + ) -> Option<(&str, u64)> { + let endpoint = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?; + if endpoint + .snapshot_generation + .is_some_and(|snapshot_generation| snapshot_generation != generation) + { + return None; + } + endpoint + .snapshot + .as_deref() + .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) + } + + /// A terminal normally starts focused. `None` means this host cannot report focus events, + /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline. + pub(crate) fn host_focus_baseline(&self) -> bool { + self.outer_focused.unwrap_or(true) + } + + pub(crate) fn endpoint_label(&self, endpoint_id: &ClientEndpointId) -> &str { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map_or("Unknown endpoint", |endpoint| endpoint.label.as_str()) + } + + pub(crate) fn active_endpoint_label(&self) -> &str { + self.endpoint_label(&self.active_endpoint_id) + } + + pub(crate) fn endpoint_is_active(&self, endpoint_id: &ClientEndpointId) -> bool { + &self.active_endpoint_id == endpoint_id + } + + pub(crate) fn multi_endpoint_active(&self) -> bool { + self.endpoints.len() > 1 + } + + #[cfg(test)] + pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_snapshot(&endpoint_id, snapshot); + } + + #[cfg(test)] + pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_methods_for(&endpoint_id, methods); + } + + pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { + self.endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) + } + + pub(super) fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + #[cfg(test)] + pub(crate) fn cache_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, None, snapshot, true); + } + + pub(crate) fn cache_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, true); + } + + /// Metadata delivered while an endpoint surface is inactive must never advance this + /// aggregate client's viewed watermark, even if the frozen source frame still exists. + pub(crate) fn cache_endpoint_snapshot_inactive_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, false); + } + + fn cache_endpoint_snapshot_with_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: Option<u64>, + mut snapshot: Box<ClientShellSnapshot>, + acknowledge_surface: bool, + ) { + snapshot + .commands + .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return; + }; + if self.endpoints[index].snapshot_generation == generation + && self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| { + previous.boot_id == snapshot.boot_id && previous.revision > snapshot.revision + }) + { + return; + } + let boot_changed = self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| previous.boot_id != snapshot.boot_id); + if boot_changed { + self.retire_endpoint_notifications(endpoint_id); + } + self.endpoints[index] + .agent_presentation + .project_snapshot(&mut snapshot); + let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id { + self.pane_surface.as_ref() + } else { + None + }; + if let Some(surface) = presented_surface { + self.endpoints[index] + .agent_presentation + .acknowledge_surface(&mut snapshot, surface, self.outer_focused); + } + let previous = self.endpoints[index].snapshot.as_deref(); + let mut next_recency = self + .endpoints + .iter() + .flat_map(|endpoint| endpoint.agent_recency.values()) + .copied() + .max() + .unwrap_or_default(); + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + agents.sort_by_key(|agent| agent.state_change_seq); + let mut recency = self.endpoints[index].agent_recency.clone(); + for agent in agents { + let changed = previous + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|previous| previous.pane_id == agent.pane_id) + }) + .is_none_or(|previous| previous.state_change_seq != agent.state_change_seq); + if changed { + next_recency = next_recency.saturating_add(1); + recency.insert(agent.pane_id.clone(), next_recency); + } + } + recency.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + let endpoint = &mut self.endpoints[index]; + endpoint.agent_recency = recency; + endpoint.snapshot_generation = generation; + endpoint.snapshot = Some(snapshot); + } + + pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool { + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + else { + return false; + }; + let changed = { + let endpoint = &mut self.endpoints[index]; + let Some(snapshot) = endpoint.snapshot.as_deref_mut() else { + return false; + }; + endpoint + .agent_presentation + .acknowledge_surface(snapshot, surface, self.outer_focused) + }; + if changed { + self.snapshot = self.endpoints[index].snapshot.clone(); + } + changed + } + + #[cfg(test)] + pub(crate) fn set_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot(endpoint_id, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + pub(crate) fn set_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + fn apply_cached_endpoint_snapshot(&mut self, endpoint_id: &ClientEndpointId) { + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.clone()) + else { + return; + }; + if endpoint_id == &self.active_endpoint_id { + self.apply_active_snapshot(snapshot); + } + } +} + +pub(super) fn endpoint_status_presentation( + status: ClientEndpointStatus, + palette: &Palette, +) -> (&'static str, &'static str, ratatui::style::Color) { + match status { + ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow), + ClientEndpointStatus::Online => ("●", "online", palette.green), + ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow), + ClientEndpointStatus::Attention => ("!", "attention", palette.red), + ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0), + } +} + +pub(super) fn local_endpoint() -> ClientShellEndpoint { + ClientShellEndpoint { + endpoint_id: ClientEndpointId::Local, + label: "Local".into(), + status: ClientEndpointStatus::Online, + snapshot: None, + snapshot_generation: None, + agent_recency: HashMap::new(), + agent_presentation: Default::default(), + methods: None, + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index 8a512d3a..31061c9b 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -3,10 +3,7 @@ use super::*; impl ClientShellState { #[cfg(unix)] pub(crate) fn graphics_scope(&self) -> &str { - self.snapshot - .as_deref() - .map(|snapshot| snapshot.boot_id.as_str()) - .unwrap_or_default() + self.graphics.scope() } #[cfg(unix)] diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index d2ca3a82..f8826136 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -236,6 +236,9 @@ impl ClientShellState { self.outer_focused = Some(true); outcome.query_host_appearance = true; outcome.repaint |= self.config.redraw_on_focus_gained; + if let Some(surface) = self.pane_surface.clone() { + outcome.repaint |= self.acknowledge_active_surface_agents(&surface); + } outcome .requests .push(ClientMessage::ClientShellFocus { focused: true }); @@ -798,7 +801,36 @@ impl ClientShellState { ) { use crate::input::{KeybindAction, KeybindMatch}; - let indexed_target_exists = match &binding { + if !self.indexed_navigation_target_exists(&binding) { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + pub(super) fn indexed_navigation_target_exists( + &self, + binding: &crate::input::KeybindMatch, + ) -> bool { + use crate::input::{KeybindAction, KeybindMatch}; + + match binding { KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { self.snapshot.as_deref().is_some_and(|snapshot| { self.navigation_workspace_entries(snapshot) @@ -819,38 +851,15 @@ impl ClientShellState { }) .is_some(), KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { - self.snapshot.as_deref().is_some_and(|snapshot| { - super::agent_sidebar::ordered_agent_pane_ids( - snapshot, - self.config.agent_panel_sort, - ) - .get(*index) - .is_some() - }) + super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() } _ => true, - }; - if !indexed_target_exists { - return; } - - if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { - self.cycle_pane(false, outcome); - } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { - self.cycle_pane(true, outcome); - } else { - if !preserve_navigate { - self.mode = ClientShellMode::Terminal; - } - self.record_binding(binding, outcome); - } - if !preserve_navigate { - if self.mode == ClientShellMode::Navigate { - self.mode = self.copy_or_terminal_mode(); - } - self.navigate_workspace_id = None; - } - outcome.repaint = true; } fn move_navigate_workspace(&mut self, delta: isize) { diff --git a/src/client/shell/input_source.rs b/src/client/shell/input_source.rs new file mode 100644 index 00000000..7997c72f --- /dev/null +++ b/src/client/shell/input_source.rs @@ -0,0 +1,41 @@ +use super::*; + +impl ClientShellState { + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 014f51a0..abe50590 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -202,6 +202,16 @@ fn render_header_button( } } +fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str { + match status { + ClientEndpointStatus::Connecting => "connecting", + ClientEndpointStatus::Online => "online", + ClientEndpointStatus::Reconnecting => "reconnecting", + ClientEndpointStatus::Attention => "attention", + ClientEndpointStatus::Disabled => "disabled", + } +} + fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { let tabs = snapshot .tabs @@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher( buffer: &mut Buffer, area: Rect, snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, scroll: &mut usize, @@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher( let items = mobile_items( snapshot, + endpoints, + active_endpoint_id, config, selected_workspace_id, viewport.width.saturating_sub(1), @@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher( let end = start.saturating_add(item.lines.len()); if matches!( item.target.as_ref(), - Some(ClientMobileTarget::Workspace(workspace_id)) - if workspace_id == selected_workspace_id + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id ) { if start < *scroll { *scroll = start; @@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { fn mobile_items( snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, content_width: u16, ) -> Vec<MobileItem> { let palette = &config.palette; let mut items = Vec::new(); - let ordered_agents = - super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); - if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { - let title = snapshot - .agent_view_label - .as_deref() + if endpoints.len() > 1 { + items.push(MobileItem::section("machines", palette)); + for endpoint in endpoints { + let background = palette.panel_bg; + let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette); + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled(symbol, Style::default().fg(color).bg(background)), + Span::styled( + format!(" {}", endpoint.label), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + format!(" {state}"), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())), + }); + } + } + let agents = + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agent_view_label = snapshot.agent_view_label.as_deref(); + if !agents.is_empty() || agent_view_label.is_some() { + let title = agent_view_label .map(|label| format!("agents · {label}")) .unwrap_or_else(|| "agents".to_owned()); items.push(MobileItem::section(title, palette)); - if ordered_agents.is_empty() { + if agents.is_empty() { items.push(MobileItem { lines: vec![Line::from(Span::styled( " no matching agents", @@ -586,19 +631,19 @@ fn mobile_items( target: None, }); } - for pane_id in ordered_agents { - let Some(agent) = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - else { - continue; - }; - let workspace = snapshot + for row in agents { + let endpoint = row.endpoint; + let agent = row.agent; + let workspace = endpoint + .snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == agent.workspace_id); - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let tab = endpoint + .snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == agent.tab_id); let agent_label = agent .display_agent .as_deref() @@ -609,7 +654,8 @@ fn mobile_items( .map(|workspace| workspace.label.as_str()) .unwrap_or(agent_label); let mut detail = Vec::new(); - let workspace_tab_count = snapshot + let workspace_tab_count = endpoint + .snapshot .tabs .iter() .filter(|candidate| candidate.workspace_id == agent.workspace_id) @@ -633,11 +679,24 @@ fn mobile_items( }), ); detail.push(agent_label.to_owned()); - let background = if agent.focused { + if endpoint.stale() { + detail.push(mobile_endpoint_state(endpoint.status).to_owned()); + } + let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused { palette.surface_dim } else { palette.panel_bg }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; items.push(MobileItem { lines: vec![ Line::from(vec![ @@ -645,19 +704,24 @@ fn mobile_items( Span::styled( status_icon(agent.agent_status, config.status_indicators), Style::default() - .fg(status_color(agent.agent_status, palette)) - .bg(background), + .fg(if endpoint.stale() { + palette.overlay0 + } else { + status_color(agent.agent_status, palette) + }) + .bg(background) + .add_modifier(dim), ), Span::styled(" ", Style::default().bg(background)), Span::styled( crate::ui::truncate_end( - primary, + &format!("{} · {primary}", endpoint.label), usize::from(content_width.saturating_sub(5)), ), Style::default() - .fg(palette.text) + .fg(foreground) .bg(background) - .add_modifier(Modifier::BOLD), + .add_modifier(Modifier::BOLD | dim), ), ]), Line::from(Span::styled( @@ -665,11 +729,17 @@ fn mobile_items( &format!(" {}", detail.join(" · ")), usize::from(content_width), ), - Style::default().fg(palette.overlay0).bg(background), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), )), ], background, - target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + target: Some(ClientMobileTarget::Agent { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: agent.pane_id.clone(), + }), }); } } @@ -680,91 +750,119 @@ fn mobile_items( ClientMobileTarget::NewWorkspace, palette, )); - for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { - let Some(workspace) = snapshot.workspaces.get(entry.index) else { - continue; - }; - let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let background = if selected { - palette.surface0 - } else if workspace.focused { - palette.surface_dim - } else { - palette.panel_bg - }; - let connector = if entry.indented { - if entry.last_child { - "└─ " + for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) { + for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) { + let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = endpoint.endpoint_id == active_endpoint_id + && selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { + palette.surface_dim } else { - "├─ " - } - } else { - "" - }; - let name = if entry.indented && !workspace.custom_label { - workspace - .branch - .as_deref() - .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) - .unwrap_or(&workspace.label) - } else { - &workspace.label - }; - let branch = workspace.branch.as_deref().unwrap_or("shell"); - let detail_prefix = if entry.indented { - if entry.last_child { - " " + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } } else { - " │ " - } - } else { - " " - }; - items.push(MobileItem { - lines: vec![ - Line::from(vec![ - Span::styled( - format!(" {connector}"), - Style::default().fg(palette.overlay0).bg(background), - ), - Span::styled( - status_icon(workspace.agent_status, config.status_indicators), - Style::default() - .fg(status_color(workspace.agent_status, palette)) - .bg(background), - ), - Span::styled(" ", Style::default().bg(background)), - Span::styled( + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let status = if endpoint.stale() { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }; + let stale_detail = if endpoint.stale() { + format!(" · {}", mobile_endpoint_state(endpoint.status)) + } else { + String::new() + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default().fg(status).bg(background).add_modifier(dim), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + &format!("{} · {name}", endpoint.label), + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(foreground) + .bg(background) + .add_modifier(Modifier::BOLD | dim), + ), + ]), + Line::from(Span::styled( crate::ui::truncate_end( - name, - usize::from(content_width.saturating_sub(if entry.indented { - 8 - } else { - 5 - })), + &format!( + "{detail_prefix}{branch} · {}{stale_detail}", + compact_tab_status(endpoint.snapshot, workspace) + ), + usize::from(content_width), ), Style::default() - .fg(palette.text) + .fg(palette.overlay0) .bg(background) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(Span::styled( - crate::ui::truncate_end( - &format!( - "{detail_prefix}{branch} · {}", - compact_tab_status(snapshot, workspace) - ), - usize::from(content_width), - ), - Style::default().fg(palette.overlay0).bg(background), - )), - ], - background, - target: Some(ClientMobileTarget::Workspace( - workspace.workspace_id.clone(), - )), - }); + .add_modifier(dim), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }), + }); + } } if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { @@ -803,7 +901,10 @@ fn mobile_items( .add_modifier(Modifier::BOLD), ))], background, - target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + target: Some(ClientMobileTarget::Tab { + endpoint_id: active_endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }), }); } } @@ -890,6 +991,22 @@ impl ClientShellState { .find(|(rect, _)| super::contains(*rect, point)) .map(|(_, target)| target.clone()); match target { + Some(ClientMobileTarget::Machine(endpoint_id)) => { + if endpoint_id == self.active_endpoint_id { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } else if self.endpoint_is_online(&endpoint_id) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + } + } Some(ClientMobileTarget::NewWorkspace) => { self.mobile_switcher_suspended = true; self.record_binding( @@ -897,34 +1014,44 @@ impl ClientShellState { outcome, ); } - Some( - target @ (ClientMobileTarget::Workspace(_) - | ClientMobileTarget::Tab(_) - | ClientMobileTarget::Agent(_)), - ) => { - let method = match target { - ClientMobileTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { workspace_id }, - ) - } - ClientMobileTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { - tab_id, - }) - } - ClientMobileTarget::Agent(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { - pane_id, - }) - } - ClientMobileTarget::NewWorkspace - | ClientMobileTarget::NewTab - | ClientMobileTarget::Menu(_) => return true, - }; - self.mode = ClientShellMode::Terminal; - self.navigate_workspace_id = None; - self.push_endpoint_method(method, outcome); + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Agent { + endpoint_id, + pane_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Tab { + endpoint_id, + tab_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Tab(tab_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } } Some(ClientMobileTarget::NewTab) => { self.mobile_switcher_suspended = true; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 612319e2..48646f28 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -466,6 +466,9 @@ impl ClientShellState { if self.hits.workspace_body.height == 0 || point.1 < self.hits.workspace_body.y.saturating_sub(1) || point.1 >= self.hits.new_workspace.y + || self.hits.workspaces.iter().any(|hit| { + hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point) + }) { return None; } @@ -473,12 +476,17 @@ impl ClientShellState { .hits .workspaces .iter() - .filter(|hit| !hit.indented) + .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented) .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) .collect::<Vec<_>>(); let snapshot = self.snapshot.as_deref()?; let entries = render::workspace_entries(snapshot, &self.collapsed_groups); - let last_hit = self.hits.workspaces.last()?; + let last_hit = self + .hits + .workspaces + .iter() + .rev() + .find(|hit| hit.endpoint_id == self.active_endpoint_id)?; let last_position = entries.iter().position(|entry| { snapshot .workspaces @@ -1109,21 +1117,7 @@ impl ClientShellState { .max(mouse.row.abs_diff(press.start_row)); if delta >= 1 { let source_workspace_id = press.workspace_id.clone(); - let draggable = self - .snapshot - .as_deref() - .and_then(|snapshot| { - snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == source_workspace_id) - }) - .is_some_and(|workspace| { - !workspace - .worktree - .as_ref() - .is_some_and(|worktree| worktree.is_linked_worktree) - }); + let draggable = self.endpoint_workspace_is_draggable(press); if draggable { if let Some(target) = self.workspace_drop_target_at(point) { self.chrome_drag = Some(ClientChromeDrag::Workspace { @@ -1268,14 +1262,7 @@ impl ClientShellState { return; } if let Some(press) = self.workspace_press.take() { - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { - workspace_id: press.workspace_id, - }, - ), - outcome, - ); + self.finish_endpoint_workspace_press(press, outcome); return; } if let Some(press) = self.tab_press.take() { @@ -1552,14 +1539,14 @@ impl ClientShellState { .navigator_rows .iter() .find(|(rect, _)| super::contains(*rect, point)) - .copied(); + .cloned(); match mouse.kind { MouseEventKind::Moved => { - if let Some((_, index)) = row_hit { + if let Some((_, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target); } outcome.repaint = true; } @@ -1573,30 +1560,13 @@ impl ClientShellState { navigator.filter = None; } outcome.repaint = true; - } else if let Some((rect, index)) = row_hit { + } else if let Some((rect, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target.clone()); } - let workspace = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - render::client_navigator_rows(snapshot, navigator) - .get(index) - .map(|row| { - matches!( - row.target, - ClientNavigatorTarget::Workspace(_) - ) - }) - } - _ => None, - }) - .unwrap_or(false); + let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. }); if workspace && mouse.column <= rect.x.saturating_add(3) { self.toggle_selected_navigator_workspace(); outcome.repaint = true; @@ -1749,13 +1719,7 @@ impl ClientShellState { return; } let workspace_id = (!self.sidebar_collapsed) - .then(|| { - self.hits - .workspaces - .iter() - .find(|hit| super::contains(hit.rect, point)) - .map(|hit| hit.workspace_id.clone()) - }) + .then(|| self.active_endpoint_workspace_at(point)) .flatten(); if let Some(workspace_id) = workspace_id { self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); @@ -1949,6 +1913,9 @@ impl ClientShellState { outcome.repaint = true; return; } + if self.handle_endpoint_machine_click(point, outcome) { + return; + } if super::contains(self.hits.global_launcher, point) { self.toggle_global_menu(); outcome.repaint = true; @@ -2023,6 +1990,7 @@ impl ClientShellState { .iter() .find(|hit| super::contains(hit.rect, point)) .map(|hit| ClientWorkspacePress { + endpoint_id: hit.endpoint_id.clone(), workspace_id: hit.workspace_id.clone(), start_column: mouse.column, start_row: mouse.row, @@ -2059,6 +2027,9 @@ impl ClientShellState { self.tab_press = Some(tab_press); return; } + if self.handle_endpoint_agent_click(point, outcome) { + return; + } let agent_pane_id = self .hits .agents diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs new file mode 100644 index 00000000..f3eeeb3c --- /dev/null +++ b/src/client/shell/notification_policy.rs @@ -0,0 +1,319 @@ +use super::*; + +const MAX_QUEUED_NOTIFICATIONS: usize = 8; +const COMPLETION_EVIDENCE_GRACE: std::time::Duration = std::time::Duration::from_secs(1); +pub(super) const COMPLETION_RECHECK_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(50); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotificationValidation { + Current, + AwaitingSnapshot, + Stale, +} + +fn notification_duration(kind: SemanticNotificationKind) -> std::time::Duration { + std::time::Duration::from_secs(match kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }) +} + +impl ClientShellState { + pub(super) fn retire_endpoint_notifications(&mut self, endpoint_id: &ClientEndpointId) { + self.pending_notifications + .retain(|pending| &pending.endpoint_id != endpoint_id); + self.queued_notifications + .retain(|queued| &queued.endpoint_id != endpoint_id); + if self + .visible_notification + .as_ref() + .is_some_and(|visible| &visible.endpoint_id == endpoint_id) + { + self.visible_notification = None; + self.promote_queued_notification(std::time::Instant::now()); + } + } + + fn queue_visible_notification( + &mut self, + mut notification: ClientVisibleNotification, + now: std::time::Instant, + ) { + if self.visible_notification.is_none() { + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + return; + } + if self.queued_notifications.len() == MAX_QUEUED_NOTIFICATIONS { + self.queued_notifications.pop_front(); + } + self.queued_notifications.push_back(notification); + } + + fn promote_queued_notification(&mut self, now: std::time::Instant) -> bool { + let Some(mut notification) = self.queued_notifications.pop_front() else { + return false; + }; + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + true + } + + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.as_ref() else { + return; + }; + if notification.event.pane_id.is_some() + && !self.endpoint_is_online(¬ification.endpoint_id) + { + let label = self.endpoint_label(¬ification.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is unavailable")); + outcome.repaint = true; + return; + } + let notification = self + .visible_notification + .take() + .expect("checked visible notification"); + self.promote_queued_notification(std::time::Instant::now()); + outcome.repaint = true; + let Some(pane_id) = notification.event.pane_id else { + return; + }; + if notification.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else if self.endpoint_is_online(¬ification.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: notification.endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + } + + pub(crate) fn receive_notification( + &mut self, + endpoint_id: &ClientEndpointId, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification.as_ref().is_some_and(|visible| { + visible.endpoint_id == *endpoint_id + && visible.event.pane_id.as_deref() == Some(pane_id) + }) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications.retain(|pending| { + pending.endpoint_id != *endpoint_id + || pending.event.pane_id.as_deref() != Some(pane_id) + }); + self.queued_notifications.retain(|queued| { + queued.endpoint_id != *endpoint_id + || queued.event.pane_id.as_deref() != Some(pane_id) + }); + if cleared_visible { + self.visible_notification = None; + self.promote_queued_notification(now); + } + } + // Completion evidence is advisory. A Finished effect is valid only while the + // client-projected pane remains Done, even when delivery is immediate. + let validate_state = delay > 0 || event.kind == SemanticNotificationKind::Finished; + self.pending_notifications.push(ClientPendingNotification { + endpoint_id: endpoint_id.clone(), + event, + deadline, + expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now), + validate_state, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + self.promote_queued_notification(now); + repaint = true; + } + if self + .visible_endpoint_notice + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_endpoint_notice = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state { + match self.notification_validation(&pending.endpoint_id, &pending.event) { + NotificationValidation::Current => {} + NotificationValidation::AwaitingSnapshot if now < pending.expires_at => { + let mut pending = pending; + pending.deadline = now + .checked_add(COMPLETION_RECHECK_INTERVAL) + .unwrap_or(now) + .min(pending.expires_at); + self.pending_notifications.push(pending); + continue; + } + NotificationValidation::AwaitingSnapshot | NotificationValidation::Stale => { + continue; + } + } + } + let target_active = + self.notification_target_is_active(&pending.endpoint_id, &pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + self.queue_visible_notification( + ClientVisibleNotification { + endpoint_id: pending.endpoint_id, + event: pending.event, + deadline: now, + }, + now, + ); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> bool { + if endpoint_id != &self.active_endpoint_id { + return false; + } + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_validation( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> NotificationValidation { + let Some(pane_id) = event.pane_id.as_deref() else { + // Finished notifications carry no independently trustworthy completion state. Without + // a projected pane to verify as Done, do not emit completion chrome or sound. + return if event.kind == SemanticNotificationKind::Finished { + NotificationValidation::Stale + } else { + NotificationValidation::Current + }; + }; + let Some(agent) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) + else { + return NotificationValidation::AwaitingSnapshot; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention + if agent.agent_status == crate::api::schema::AgentStatus::Blocked => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Done => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Working => + { + NotificationValidation::AwaitingSnapshot + } + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + NotificationValidation::Current + } + SemanticNotificationKind::NeedsAttention | SemanticNotificationKind::Finished => { + NotificationValidation::Stale + } + } + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs index 43fe566a..b8437ea7 100644 --- a/src/client/shell/notifications.rs +++ b/src/client/shell/notifications.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use super::notification_policy::COMPLETION_RECHECK_INTERVAL; use super::*; use ratatui::{ style::Color, @@ -5,7 +7,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -fn render_mobile_notice_banner( +pub(super) fn render_mobile_notice_banner( buffer: &mut Buffer, area: Rect, title: &str, @@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner( ) } -pub(super) fn render_mobile_endpoint_notice_banner( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - offset_for_warning: bool, - palette: &Palette, -) -> Rect { - render_mobile_notice_banner( - buffer, - area, - ¬ice.title, - Some(¬ice.body), - palette.red, - offset_for_warning, - palette, - ) -} - -fn render_notification_card( +pub(super) fn render_notification_card( buffer: &mut Buffer, area: Rect, title: &str, @@ -221,205 +205,13 @@ pub(super) fn render_visible_notification( ) } -pub(super) fn render_endpoint_notice( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - top_offset: u16, - palette: &Palette, -) -> Rect { - render_notification_card( - buffer, - area, - ¬ice.title, - ¬ice.body, - crate::config::ToastHerdrPosition::TopRight, - top_offset, - match notice.key.kind { - ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { - palette.red - } - ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { - palette.yellow - } - }, - palette, - ) -} - -impl ClientShellState { - pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { - let Some(notification) = self.visible_notification.take() else { - return; - }; - outcome.repaint = true; - if let Some(pane_id) = notification.event.pane_id { - self.push_endpoint_method( - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), - outcome, - ); - } - } - - pub(crate) fn receive_notification( - &mut self, - event: SemanticNotification, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let delay = if event.kind == SemanticNotificationKind::Custom { - 0 - } else { - self.config.toast_delay_seconds - }; - let deadline = now - .checked_add(std::time::Duration::from_secs(delay)) - .unwrap_or(now); - let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { - self.visible_notification - .as_ref() - .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) - }); - if let Some(pane_id) = event.pane_id.as_deref() { - self.pending_notifications - .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); - if cleared_visible { - self.visible_notification = None; - } - } - self.pending_notifications.push(ClientPendingNotification { - event, - deadline, - validate_state: delay > 0, - }); - let (effects, repaint) = self.tick_notifications(now); - (effects, repaint || cleared_visible) - } - - pub(crate) fn tick_notifications( - &mut self, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let mut repaint = false; - if self - .visible_notification - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_notification = None; - repaint = true; - } - if self - .visible_endpoint_notice - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_endpoint_notice = None; - repaint = true; - } - - let pending = std::mem::take(&mut self.pending_notifications); - let mut effects = Vec::new(); - for pending in pending { - if pending.deadline > now { - self.pending_notifications.push(pending); - continue; - } - if pending.validate_state && !self.notification_still_current(&pending.event) { - continue; - } - let target_active = self.notification_target_is_active(&pending.event); - let suppress_external = target_active && self.outer_focused != Some(false); - if let Some(sound) = pending.event.sound { - let suppress_sound = - pending.event.kind == SemanticNotificationKind::Finished && suppress_external; - if !suppress_sound { - effects.push(ClientShellNotificationEffect::Sound { - sound: match sound { - SemanticNotificationSound::Done => crate::sound::Sound::Done, - SemanticNotificationSound::Request => crate::sound::Sound::Request, - }, - agent: pending.event.agent.clone(), - }); - } - } - - match self.config.toast_delivery { - crate::config::ToastDelivery::Off => {} - crate::config::ToastDelivery::Herdr if !target_active => { - let duration = match pending.event.kind { - SemanticNotificationKind::NeedsAttention => 8, - SemanticNotificationKind::Finished => 5, - SemanticNotificationKind::UpdateInstalled => 3, - SemanticNotificationKind::Custom => 5, - }; - self.visible_notification = Some(ClientVisibleNotification { - event: pending.event, - deadline: now + std::time::Duration::from_secs(duration), - }); - repaint = true; - } - crate::config::ToastDelivery::Herdr => {} - crate::config::ToastDelivery::Terminal if !suppress_external => { - effects.push(ClientShellNotificationEffect::Terminal { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::System if !suppress_external => { - effects.push(ClientShellNotificationEffect::System { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} - } - } - (effects, repaint) - } - - fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { - let Some(snapshot) = self.snapshot.as_deref() else { - return false; - }; - if let Some(tab_id) = event.tab_id.as_deref() { - return snapshot.focused_tab_id.as_deref() == Some(tab_id); - } - event.workspace_id.as_deref().is_some_and(|workspace_id| { - snapshot.focused_workspace_id.as_deref() == Some(workspace_id) - }) - } - - fn notification_still_current(&self, event: &SemanticNotification) -> bool { - let Some(pane_id) = event.pane_id.as_deref() else { - return true; - }; - let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { - snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - }) else { - return false; - }; - match event.kind { - SemanticNotificationKind::NeedsAttention => { - agent.agent_status == crate::api::schema::AgentStatus::Blocked - } - SemanticNotificationKind::Finished => matches!( - agent.agent_status, - crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done - ), - SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, - } - } -} - #[cfg(test)] mod tests { use super::*; fn notification() -> ClientVisibleNotification { ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notice".into(), @@ -463,6 +255,205 @@ mod tests { } #[test] + fn unavailable_notification_target_stays_visible_and_reports_the_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, + event: SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "agent needs attention".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("workspace".into()), + tab_id: Some("tab".into()), + pane_id: Some("pane".into()), + position: None, + }, + deadline: std::time::Instant::now() + std::time::Duration::from_secs(5), + }); + let mut outcome = ClientShellInput::default(); + + state.focus_visible_notification(&mut outcome); + + assert!(state.visible_notification.is_some()); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.body.contains("Local is unavailable"))); + assert!(outcome.repaint); + } + + #[test] + fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() { + 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 snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + state.set_snapshot(Box::new(snapshot)); + + let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: Some(SemanticNotificationSound::Done), + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("tab_1".into()), + pane_id: Some("pane_1".into()), + position: None, + }, + std::time::Instant::now(), + ); + + assert!(effects.is_empty()); + assert!(!repaint); + assert!(state.visible_notification.is_none()); + } + + #[test] + fn finished_hint_waits_for_the_projected_completion_snapshot() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Terminal; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(snapshot.clone())); + let now = std::time::Instant::now(); + let event = SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("background-tab".into()), + pane_id: Some("pane_1".into()), + position: None, + }; + + let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now); + assert!(effects.is_empty()); + assert_eq!(state.pending_notifications.len(), 1); + + snapshot.revision = snapshot.revision.saturating_add(1); + snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle; + snapshot.agents[0].state_change_seq = 2; + state.set_snapshot(Box::new(snapshot)); + let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished" + )); + assert!(state.pending_notifications.is_empty()); + } + + #[test] + fn retiring_one_endpoint_preserves_other_endpoint_notifications() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut local = notification(); + local.event.title = "local".into(); + let remote_id = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut remote = notification(); + remote.endpoint_id = remote_id.clone(); + remote.event.title = "remote".into(); + state.visible_notification = Some(local); + state.queued_notifications.push_back(remote); + + state.retire_endpoint_notifications(&ClientEndpointId::Local); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| (¬ification.endpoint_id, notification.event.title.as_str())), + Some((&remote_id, "remote")) + ); + } + + #[test] + fn concurrent_endpoint_notifications_are_queued_in_arrival_order() { + let mut config = Config::default(); + config.ui.toast.delivery = crate::config::ToastDelivery::Herdr; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let now = std::time::Instant::now(); + for title in ["first", "second"] { + state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: title.into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: Some(title.into()), + position: None, + }, + now, + ); + } + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("first") + ); + assert_eq!(state.queued_notifications.len(), 1); + + state.tick_notifications(now + std::time::Duration::from_secs(5)); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("second") + ); + assert!(state.queued_notifications.is_empty()); + } + + #[test] fn notification_rect_stays_inside_short_nonzero_area() { let palette = crate::app::client_palette_from_config(&Config::default()); for height in [1, 2] { diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 154b00b6..db089f4e 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -191,98 +191,123 @@ impl ClientShellState { } pub(super) fn open_navigator_overlay(&mut self) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let expanded_workspaces = snapshot - .workspaces - .iter() - .map(|workspace| workspace.workspace_id.clone()) - .collect(); + let expanded_workspaces = + super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints) + .flat_map(|endpoint| { + endpoint.snapshot.workspaces.iter().map(move |workspace| { + (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()) + }) + }) + .collect(); let mut navigator = ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces, }; - let rows = render::client_navigator_rows(snapshot, &navigator); - navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator); + navigator.selected = rows + .iter() + .find(|row| row.current) + .map(|row| row.target.clone()); self.overlay = Some(ClientShellOverlay::Navigator(navigator)); } pub(super) fn move_navigator_selection(&mut self, delta: isize) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { return; }; - let rows = render::client_navigator_rows(snapshot, navigator); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); if rows.is_empty() { - navigator.selected = 0; + navigator.selected = None; return; } - navigator.selected = (navigator.selected as isize + delta) - .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + let selected = + super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0); + let next = + (selected as isize + delta).clamp(0, rows.len().saturating_sub(1) as isize) as usize; + navigator.selected = Some(rows[next].target.clone()); } pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { - let target = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .map(|row| row.target.clone()) - }; + let target = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator) + } + _ => None, + }); let Some(target) = target else { return; }; - self.overlay = None; - let method = match target { - ClientNavigatorTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { - workspace_id, - }) - } - ClientNavigatorTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + let activated = match target { + ClientNavigatorTarget::Machine { endpoint_id } => { + self.activate_endpoint(endpoint_id, outcome) } - ClientNavigatorTarget::Pane(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ), + ClientNavigatorTarget::Tab { + endpoint_id, + tab_id, + } => { + self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome) } + ClientNavigatorTarget::Pane { + endpoint_id, + pane_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ), }; - self.push_endpoint_method(method, outcome); + if activated { + self.overlay = None; + } outcome.repaint = true; } pub(super) fn toggle_selected_navigator_workspace(&mut self) { - let workspace_id = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .and_then(|row| match &row.target { - ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), - _ => None, - }) - }; - if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = - (workspace_id, self.overlay.as_mut()) + let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then( + |target| match target { + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => Some((endpoint_id, workspace_id)), + _ => None, + }, + ) + } + _ => None, + }); + if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_key, self.overlay.as_mut()) { - if !navigator.expanded_workspaces.remove(&workspace_id) { - navigator.expanded_workspaces.insert(workspace_id); + if !navigator.expanded_workspaces.remove(&workspace_key) { + navigator.expanded_workspaces.insert(workspace_key); } - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } } @@ -433,7 +458,7 @@ impl ClientShellState { Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { navigator.query.push_str(text); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; true } _ => false, @@ -641,11 +666,11 @@ impl ClientShellState { if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if code == KeyCode::Backspace { navigator.query.pop(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if let KeyCode::Char(character) = code { if modifiers.difference(KeyModifiers::SHIFT).is_empty() { navigator.filter = None; @@ -654,7 +679,7 @@ impl ClientShellState { } else { navigator.query.push(character); } - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -664,7 +689,7 @@ impl ClientShellState { if code == KeyCode::Backspace && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { if navigator.filter.take().is_some() { - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -672,25 +697,23 @@ impl ClientShellState { } if code == KeyCode::Home && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } outcome.repaint = true; return; } if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { - let last = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - Some(render::client_navigator_rows(snapshot, navigator).len()) - } - _ => None, - }) - .unwrap_or(0) - .saturating_sub(1); + let last = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ) + .last() + .map(|row| row.target.clone()), + _ => None, + }); if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.selected = last; } @@ -735,7 +758,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = Some(filter); - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; @@ -744,7 +767,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 30ac78b9..faf252a8 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -10,7 +10,7 @@ pub(crate) struct OverlayRender { pub(crate) cancel: Rect, pub(crate) navigator_popup: Rect, pub(crate) navigator_search: Rect, - pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(crate) worktree_search: Rect, pub(crate) worktree_rows: Vec<(Rect, usize)>, pub(crate) help_popup: Rect, @@ -33,6 +33,8 @@ pub(crate) fn render_client_overlay( b: &mut Buffer, o: &ClientShellOverlay, s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, k: &LiveKeybindConfig, p: &Palette, ) -> Option<OverlayRender> { @@ -58,7 +60,9 @@ pub(crate) fn render_client_overlay( ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), - ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Navigator(v) => { + render_navigator_overlay(b, v, endpoints, active_endpoint_id, p) + } ClientShellOverlay::Settings(v) => { settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) } @@ -669,91 +673,11 @@ fn render_rename_overlay( }) } -pub(crate) fn client_navigator_rows( - s: &ClientShellSnapshot, - n: &ClientNavigatorOverlay, -) -> Vec<ClientNavigatorRow> { - let q = n.query.trim().to_lowercase(); - let filter = |st| match n.filter { - Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, - Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, - Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, - Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, - None => true, - }; - let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); - let filtering = n.filter.is_some() || !q.is_empty(); - let mut out = Vec::new(); - for w in &s.workspaces { - let mut children = Vec::new(); - for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { - let mut panes = Vec::new(); - for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { - let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); - let st = a - .map(|a| a.agent_status) - .unwrap_or(crate::api::schema::AgentStatus::Unknown); - let label = p - .label - .clone() - .or_else(|| a.and_then(|a| a.name.clone())) - .or_else(|| a.and_then(|a| a.display_agent.clone())) - .or_else(|| a.and_then(|a| a.title.clone())) - .unwrap_or_else(|| format!("pane {}", ix + 1)); - let meta = p - .foreground_cwd - .clone() - .or_else(|| p.cwd.clone()) - .unwrap_or_default(); - if !filtering || filter(st) && (text(&label) || text(&meta)) { - panes.push(ClientNavigatorRow { - depth: 2, - label, - meta, - status: st, - current: s.focused_pane_id.as_deref() == Some(&p.pane_id), - target: ClientNavigatorTarget::Pane(p.pane_id.clone()), - }) - } - } - if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { - children.push(ClientNavigatorRow { - depth: 1, - label: t.label.clone(), - meta: format!( - "{} panes", - s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() - ), - status: t.agent_status, - current: s.focused_tab_id.as_deref() == Some(&t.tab_id), - target: ClientNavigatorTarget::Tab(t.tab_id.clone()), - }); - children.extend(panes) - } - } - let wm = - filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); - if !filtering || wm || !children.is_empty() { - out.push(ClientNavigatorRow { - depth: 0, - label: w.label.clone(), - meta: w.branch.clone().unwrap_or_default(), - status: w.agent_status, - current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), - target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), - }); - if n.expanded_workspaces.contains(&w.workspace_id) || filtering { - out.extend(children) - } - } - } - out -} - fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, - s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, p: &Palette, ) -> Option<OverlayRender> { let a = b.area; @@ -766,7 +690,7 @@ fn render_navigator_overlay( a.height.saturating_sub(my * 2).max(4), ); let i = panel(b, q, p.accent, p.panel_bg)?; - let rows = client_navigator_rows(s, n); + let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { format!(" / {}", n.query) } else if let Some(f) = n.filter { @@ -798,7 +722,12 @@ fn render_navigator_overlay( b, i, i.y, - &format!("{} panes", s.panes.len()), + &format!( + "{} panes", + rows.iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count() + ), Style::default().fg(p.overlay0).bg(p.panel_bg), ); put_text( @@ -810,14 +739,12 @@ fn render_navigator_overlay( Style::default().fg(p.surface1).bg(p.panel_bg), ); let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0); let max = rows.len().saturating_sub(body.height as usize); let scroll = n .scroll - .max( - n.selected - .saturating_sub(body.height.saturating_sub(1) as usize), - ) - .min(n.selected) + .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) + .min(selected) .min(max); let mut row_hits = Vec::new(); for (vis, (ix, r)) in rows @@ -828,8 +755,17 @@ fn render_navigator_overlay( .enumerate() { let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); - row_hits.push((rect, ix)); - let st = if ix == n.selected { + row_hits.push((rect, r.target.clone())); + let st = if r.stale { + Style::default() + .fg(p.overlay0) + .bg(if ix == selected { + p.surface0 + } else { + p.panel_bg + }) + .add_modifier(Modifier::DIM) + } else if ix == selected { Style::default() .fg(contrast(p)) .bg(p.accent) @@ -840,23 +776,92 @@ fn render_navigator_overlay( .bg(p.panel_bg) }; b.set_style(rect, st); - let tree = match r.depth { - 0 => match &r.target { - ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", - _ => "▸", - }, - 1 => "└──", - _ => " └──", + let tree = match &r.target { + ClientNavigatorTarget::Machine { .. } => "▾", + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } if n + .expanded_workspaces + .contains(&(endpoint_id.clone(), workspace_id.clone())) => + { + if r.depth == 0 { + "▾" + } else { + " ▾" + } + } + ClientNavigatorTarget::Workspace { .. } => { + if r.depth == 0 { + "▸" + } else { + " ▸" + } + } + ClientNavigatorTarget::Tab { .. } => { + if r.depth == 1 { + "└──" + } else { + " └──" + } + } + ClientNavigatorTarget::Pane { .. } => { + if r.depth == 2 { + " └──" + } else { + " └──" + } + } }; - let label = format!( - " {} {} {} {}", - if r.current { "◆" } else { " " }, - tree, - status_dot(r.status), - r.label - ); + let current = if r.current { "◆ " } else { "" }; + let status = r.status.map(status_dot).unwrap_or_default(); + let status_separator = if status.is_empty() { "" } else { " " }; + let label = format!(" {tree} {current}{status}{status_separator}{}", r.label); put_text(b, rect.x, rect.y, rect.width, &label, st); - if !r.meta.is_empty() { + if let Some(status) = r.status { + let prefix = format!(" {tree} {current}"); + let status_style = if r.stale || ix == selected { + st + } else { + Style::default().fg(status_color(status, p)).bg(p.panel_bg) + }; + put_text( + b, + rect.x.saturating_add(display_width(&prefix)), + rect.y, + display_width(status_dot(status)), + status_dot(status), + status_style, + ); + } + let machine_status = match &r.target { + ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status), + _ => None, + }; + if let Some(status) = machine_status { + let (glyph, state, color) = endpoint_status_presentation(status, p); + let signal = if status == ClientEndpointStatus::Online { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_style = if ix == selected { + st + } else { + Style::default() + .fg(color) + .bg(p.panel_bg) + .add_modifier(if r.stale { + Modifier::DIM + } else { + Modifier::empty() + }) + }; + put_right_text(b, rect, rect.y, &signal, signal_style); + } else if !r.meta.is_empty() { let label_width = display_width(&label).min(rect.width); let meta = Rect::new( rect.x.saturating_add(label_width).saturating_add(1), @@ -868,7 +873,7 @@ fn render_navigator_overlay( } } let dy = i.bottom() - 2; - if let Some(r) = rows.get(n.selected) { + if let Some(r) = rows.get(selected) { put_text( b, i.x, diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 387695ec..ad9ac8b1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -3,16 +3,31 @@ use super::*; #[path = "../shell/overlays.rs"] mod overlays; #[path = "../shell/sidebar.rs"] -mod sidebar; +pub(in crate::client::shell) mod sidebar; #[path = "../shell/tabs.rs"] mod tabs; pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; -pub(super) use overlays::{ - client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, -}; +pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows; +pub(super) use overlays::{render_client_overlay, render_context_menu, render_global_menu}; pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; + +pub(in crate::client::shell) fn render_sidebar_background( + buffer: &mut Buffer, + area: Rect, + palette: &Palette, +) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + pub(super) fn render_mode_bar( buffer: &mut Buffer, pane_area: Rect, @@ -196,6 +211,9 @@ pub(super) fn render_mode_bar( } pub(super) struct ShellRenderState<'a> { + pub(super) endpoints: &'a [ClientShellEndpoint], + pub(super) active_endpoint_id: &'a ClientEndpointId, + pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>, pub(super) collapsed_groups: &'a HashSet<String>, pub(super) workspace_scroll: &'a mut usize, pub(super) agent_scroll: &'a mut usize, @@ -228,7 +246,26 @@ pub(super) fn render_shell( ); } if layout.sidebar.width > 0 { - if state.sidebar_collapsed { + if state.endpoints.len() > 1 { + if state.sidebar_collapsed { + super::endpoint_sidebar::render_collapsed( + buffer, + layout.sidebar, + config, + &mut state, + &mut hits, + ); + } else { + super::endpoint_sidebar::render_expanded( + buffer, + layout.sidebar, + Some(snapshot), + config, + &mut state, + &mut hits, + ); + } + } else if state.sidebar_collapsed { render_collapsed_sidebar( buffer, layout.sidebar, @@ -267,8 +304,10 @@ pub(super) fn render_shell( hits.agent_scrollbar = Rect::default(); hits.agent_sort_toggle = Rect::default(); hits.new_workspace = Rect::default(); + hits.machines.clear(); hits.workspaces.clear(); hits.agents.clear(); + hits.endpoint_agents.clear(); hits.tab_scroll_left = Rect::default(); hits.tab_scroll_right = Rect::default(); hits.new_tab = Rect::default(); @@ -277,7 +316,7 @@ pub(super) fn render_shell( hits } -fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { +pub(super) fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { let width = display_width(text).min(area.width); put_text( buffer, diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs index d90d371f..200201c5 100644 --- a/src/client/shell/settings.rs +++ b/src/client/shell/settings.rs @@ -313,6 +313,9 @@ impl ClientShellState { (true, Vec::new()) } PendingEndpointKind::IntegrationInstall => { + let cancelled = result + .as_ref() + .is_err_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); self.pending_integration_installs = self.pending_integration_installs.saturating_sub(1); if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { @@ -328,7 +331,8 @@ impl ClientShellState { } settings.installing_integrations = self.pending_integration_installs > 0; } - let actions = if self.pending_integration_installs == 0 + let actions = if !cancelled + && self.pending_integration_installs == 0 && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { let mut deferred = ClientShellInput::default(); diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 2a71e297..0aaa0d51 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -4,7 +4,9 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; -fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { +pub(in crate::client::shell) fn collapsed_sidebar_sections( + area: Rect, +) -> (Rect, Option<u16>, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); if content.is_empty() { return (Rect::default(), None, Rect::default()); @@ -85,6 +87,7 @@ pub(crate) fn render_collapsed_sidebar( ); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: false, group_toggle: None, @@ -308,6 +311,7 @@ pub(crate) fn render_sidebar( config.status_indicators, entry, rows, + true, selected, dragged, palette, @@ -330,6 +334,7 @@ pub(crate) fn render_sidebar( }); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: entry.indented, group_toggle, @@ -440,17 +445,6 @@ pub(crate) fn render_sidebar( ); } -pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { - buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); - let separator_x = area.right().saturating_sub(1); - for y in area.y..area.bottom() { - if let Some(cell) = buffer.cell_mut((separator_x, y)) { - cell.set_symbol("│"); - cell.set_style(Style::default().fg(palette.surface_dim)); - } - } -} - pub(crate) fn workspace_entries( snapshot: &ClientShellSnapshot, collapsed_groups: &HashSet<String>, @@ -540,7 +534,7 @@ pub(crate) fn workspace_entries( entries } -fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { +pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { let workspace = snapshot.workspaces.get(index)?; let worktree = workspace.worktree.as_ref()?; if worktree.is_linked_worktree { @@ -560,7 +554,7 @@ fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<Stri .then(|| worktree.key.clone()) } -fn displayed_workspace_status( +pub(super) fn displayed_workspace_status( snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace, collapsed_groups: &HashSet<String>, @@ -589,7 +583,7 @@ fn displayed_workspace_status( .unwrap_or(workspace.agent_status) } -fn workspace_rows( +pub(in crate::client::shell) fn workspace_rows( workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, indented: bool, @@ -618,7 +612,7 @@ fn workspace_rows( ) } -fn render_workspace_rows( +pub(in crate::client::shell) fn render_workspace_rows( buffer: &mut Buffer, area: Rect, workspace: &ClientShellWorkspace, @@ -626,6 +620,7 @@ fn render_workspace_rows( indicators: crate::config::StatusIndicatorStyle, entry: &WorkspaceEntry, rows: Vec<Vec<crate::ui::ResolvedToken>>, + endpoint_active: bool, selected: bool, dragged: bool, palette: &Palette, @@ -661,7 +656,7 @@ fn render_workspace_rows( } else { x = x.saturating_add(3); } - let highlighted = workspace.focused || dragged; + let highlighted = endpoint_active && workspace.focused || dragged; let workspace_style = Style::default() .fg(if highlighted { palette.text @@ -673,7 +668,7 @@ fn render_workspace_rows( } else { Modifier::empty() }); - let secondary_style = Style::default().fg(if workspace.focused { + let secondary_style = Style::default().fg(if endpoint_active && workspace.focused { palette.mauve } else { palette.overlay0 @@ -703,7 +698,7 @@ fn render_workspace_rows( Some(palette.selection_bg) } else if dragged { Some(palette.surface1) - } else if workspace.focused { + } else if endpoint_active && workspace.focused { Some(palette.active_row_bg) } else { None diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 60307f66..67bdff91 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum ClientMobileTarget { + Machine(ClientEndpointId), NewWorkspace, - Workspace(String), + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, NewTab, - Tab(String), - Agent(String), + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Agent { + endpoint_id: ClientEndpointId, + pane_id: String, + }, Menu(usize), } #[derive(Default)] pub(super) struct ShellHitMap { + pub(super) machines: Vec<MachineHit>, pub(super) workspaces: Vec<WorkspaceHit>, pub(super) workspace_body: Rect, pub(super) workspace_scrollbar: Rect, @@ -139,6 +150,7 @@ pub(super) struct ShellHitMap { pub(super) popup: Option<PaneHit>, pub(super) pane_splits: Vec<PaneSplitHit>, pub(super) agents: Vec<(Rect, String)>, + pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>, pub(super) agent_body: Rect, pub(super) agent_scrollbar: Rect, pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>, @@ -164,7 +176,7 @@ pub(super) struct ShellHitMap { pub(super) overlay_cancel: Rect, pub(super) navigator_popup: Rect, pub(super) navigator_search: Rect, - pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(super) worktree_search: Rect, pub(super) worktree_rows: Vec<(Rect, usize)>, pub(super) help_popup: Rect, @@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture { } pub(super) struct ClientWorkspacePress { + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) start_column: u16, pub(super) start_row: u16, @@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag { pub(super) struct WorkspaceHit { pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) indented: bool, pub(super) group_toggle: Option<(Rect, String)>, @@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit { #[derive(Debug)] pub(crate) enum ClientShellAction { Endpoint { + endpoint_id: ClientEndpointId, boot_id: String, request: Box<crate::api::schema::Request>, }, ClipboardWrite(Vec<u8>), Request(ClientMessage), OpenSafeWebUrl(String), + ActivateEndpoint { + endpoint_id: ClientEndpointId, + target: Option<ClientEndpointFocusTarget>, + }, ReplayMouse(Vec<crossterm::event::MouseEvent>), Keybind(crate::input::KeybindAction), } @@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter { Done, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum ClientNavigatorTarget { - Workspace(String), - Tab(String), - Pane(String), + Machine { + endpoint_id: ClientEndpointId, + }, + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Pane { + endpoint_id: ClientEndpointId, + pane_id: String, + }, } #[derive(Clone, Debug)] @@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow { pub(super) depth: u8, pub(super) label: String, pub(super) meta: String, - pub(super) status: crate::api::schema::AgentStatus, + pub(super) status: Option<crate::api::schema::AgentStatus>, + pub(super) stale: bool, pub(super) current: bool, pub(super) target: ClientNavigatorTarget, } @@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow { pub(super) struct ClientNavigatorOverlay { pub(super) query: String, pub(super) search_focused: bool, - pub(super) selected: usize, + pub(super) selected: Option<ClientNavigatorTarget>, pub(super) scroll: usize, pub(super) filter: Option<ClientNavigatorFilter>, - pub(super) expanded_workspaces: HashSet<String>, + pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>, } #[derive(Debug)] @@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect { } pub(super) struct ClientPendingNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, + pub(super) expires_at: std::time::Instant, pub(super) validate_state: bool, } pub(super) struct ClientVisibleNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, } @@ -861,6 +896,9 @@ pub(crate) struct ClientShellState { pub(super) config: ClientShellConfig, pub(super) snapshot: Option<Box<ClientShellSnapshot>>, pub(super) pane_surface: Option<PaneSurfaceFrame>, + /// A future projection surface waits here until its matching snapshot arrives. The visible + /// pane surface always remains an exact snapshot pair. + pub(super) pending_pane_surface: Option<PaneSurfaceFrame>, pub(super) graphics: crate::kitty_graphics::surface::ClientState, pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, pub(super) popup_terminal_id: Option<String>, @@ -887,6 +925,9 @@ pub(crate) struct ClientShellState { pub(super) last_tab_bar_width: Option<u16>, pub(super) last_composed_size: Option<(u16, u16)>, pub(super) hits: ShellHitMap, + pub(super) endpoints: Vec<ClientShellEndpoint>, + pub(super) active_endpoint_id: ClientEndpointId, + pub(super) collapsed_endpoints: HashSet<ClientEndpointId>, pub(super) mode: ClientShellMode, pub(super) navigate_workspace_id: Option<String>, pub(super) overlay: Option<ClientShellOverlay>, @@ -917,14 +958,11 @@ pub(crate) struct ClientShellState { pub(super) popup_pending: bool, pub(super) popup_pending_deadline: Option<std::time::Instant>, pub(super) next_request_id: u64, - /// Methods advertised by this endpoint. `None` is used only by local tests - /// and legacy construction paths; negotiated endpoint connections always - /// install an explicit set. - pub(super) endpoint_methods: Option<HashSet<String>>, pub(super) pending_requests: HashMap<String, PendingEndpointRequest>, pub(super) pending_integration_installs: usize, pub(super) pending_notifications: Vec<ClientPendingNotification>, pub(super) visible_notification: Option<ClientVisibleNotification>, + pub(super) queued_notifications: VecDeque<ClientVisibleNotification>, pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>, pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>, pub(super) outer_focused: Option<bool>, @@ -1000,6 +1038,7 @@ impl ClientShellState { config, snapshot: None, pane_surface: None, + pending_pane_surface: None, graphics: crate::kitty_graphics::surface::ClientState::default(), graphics_cell_size: crate::kitty_graphics::HostCellSize { width_px: 1, @@ -1029,6 +1068,9 @@ impl ClientShellState { last_tab_bar_width: None, last_composed_size: None, hits: ShellHitMap::default(), + endpoints: vec![local_endpoint()], + active_endpoint_id: ClientEndpointId::Local, + collapsed_endpoints: HashSet::new(), mode: ClientShellMode::Terminal, navigate_workspace_id: None, overlay, @@ -1059,11 +1101,11 @@ impl ClientShellState { popup_pending: false, popup_pending_deadline: None, next_request_id: 1, - endpoint_methods: None, pending_requests: HashMap::new(), pending_integration_installs: 0, pending_notifications: Vec::new(), visible_notification: None, + queued_notifications: VecDeque::new(), endpoint_notice_seen: HashSet::new(), visible_endpoint_notice: None, outer_focused: None, @@ -1078,29 +1120,6 @@ impl ClientShellState { } } - pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { - self.endpoint_methods = methods.map(|methods| methods.into_iter().collect()); - } - - pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { - self.endpoint_methods - .as_ref() - .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) - } - - fn focused_tab_count(&self) -> usize { - let Some(snapshot) = self.snapshot.as_deref() else { - return 0; - }; - snapshot - .tabs - .iter() - .filter(|tab| { - Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() - }) - .count() - } - pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { if !self.mobile_switcher_suspended || self.overlay.is_some() { return false; @@ -1173,16 +1192,80 @@ impl ClientShellState { } } - pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) { + pub(super) fn reset_endpoint_projection(&mut self) { + self.hits = ShellHitMap::default(); + self.pane_surface = None; + self.pending_pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_focused_workspace = true; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.endpoint_notice_seen.clear(); + self.visible_endpoint_notice = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } + + pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) { snapshot .commands .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); - if self.snapshot.as_ref().is_some_and(|current| { - current.boot_id == snapshot.boot_id && snapshot.revision < current.revision - }) { + let graphics_scope = format!( + "{}:{}", + self.active_endpoint_id.storage_key(), + snapshot.boot_id + ); + let endpoint_boot_changed = + self.snapshot.is_some() && self.graphics.scope() != graphics_scope; + if !endpoint_boot_changed + && self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) + { return; } - self.graphics.set_scope(&snapshot.boot_id); + self.graphics.set_scope(&graphics_scope); let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { current.commands.len() != snapshot.commands.len() || current @@ -1221,10 +1304,11 @@ impl ClientShellState { self.local_config_diagnostic.as_deref(), snapshot.config_diagnostic.as_deref(), ); - let boot_changed = self - .snapshot - .as_ref() - .is_some_and(|current| current.boot_id != snapshot.boot_id); + let boot_changed = endpoint_boot_changed + || self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); if boot_changed || self .pane_surface @@ -1234,55 +1318,7 @@ impl ClientShellState { self.hits = ShellHitMap::default(); } if boot_changed { - self.pane_surface = None; - self.input_leases = ClientInputLeases::default(); - self.popup_terminal_id = None; - self.chrome_drag = None; - self.workspace_press = None; - self.tab_press = None; - self.workspace_scroll = 0; - self.agent_scroll = 0; - self.tab_scroll = 0; - self.mobile_switcher_scroll = 0; - self.reveal_focused_workspace = true; - self.reveal_mobile_workspace = false; - self.mobile_switcher_suspended = false; - self.reveal_focused_tab = true; - self.last_tab_bar_width = None; - self.last_composed_size = None; - self.pending_requests.clear(); - self.pane_scroll_in_flight.clear(); - self.pane_scroll_queued.clear(); - self.pane_scroll_targets.clear(); - self.popup_pending = false; - self.popup_pending_deadline = None; - self.pending_integration_installs = 0; - self.pending_notifications.clear(); - self.visible_notification = None; - self.endpoint_notice_seen.clear(); - self.visible_endpoint_notice = None; - self.endpoint_error = None; - self.navigate_workspace_id = None; - self.overlay = self - .config - .startup_onboarding - .then_some(ClientShellOverlay::Onboarding); - self.previous_pane_id = None; - self.pane_mouse_gesture = None; - self.url_click_consumes_until_up = false; - self.replaying_url_click = false; - self.selection = None; - self.last_pane_click = None; - self.selection_autoscroll = None; - self.selection_autoscroll_deadline = None; - self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; - self.copy_mode = None; - self.reset_copy_pipeline(); - self.copy_feedback = None; - self.copy_feedback_deadline = None; - self.host_mouse_pixels = None; - self.dismissed_product_announcement = None; + self.reset_endpoint_projection(); } else if let Some(previous) = self .snapshot .as_deref() @@ -1474,16 +1510,64 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + let pending_surface = self.pending_pane_surface.take(); + if let Some(surface) = pending_surface { + let matching = self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }); + if matching { + self.install_pane_surface(surface, false); + } else if self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision > snapshot.revision + }) { + self.pending_pane_surface = Some(surface); + } + } self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } - pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + pub(crate) fn has_presented_surface(&self) -> bool { + self.pane_surface.is_some() + } + + pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision { + return; + } + if self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) { + return; + } + if surface.projection_revision == snapshot.revision.saturating_add(1) { + // The next expected surface waits separately for its exact snapshot. Keeping the + // current pair avoids treating this speculative successor as presentation evidence. + self.pending_pane_surface = Some(surface); + self.hits = ShellHitMap::default(); + return; + } + // A surface that skips one or more revisions supersedes any retained pair, but is still + // not rendered until its matching snapshot arrives. Retain it monotonically so delayed + // intermediate surfaces cannot replace it. + self.install_pane_surface(surface, true); + } + + fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) { let Some(snapshot) = self.snapshot.as_ref() else { return; }; if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision + || (!retain_future && surface.projection_revision != snapshot.revision) || self.pane_surface.as_ref().is_some_and(|current| { current.boot_id == surface.boot_id && (surface.projection_revision < current.projection_revision @@ -1493,13 +1577,12 @@ impl ClientShellState { { return; } - if self - .snapshot - .as_ref() - .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) - { + // A retained future surface is not presentable yet. Clear hit targets immediately; the + // exact-pair compose guard prevents it from replacing the visible frame. + if surface.projection_revision != snapshot.revision { self.hits = ShellHitMap::default(); } + self.acknowledge_active_surface_agents(&surface); let previous_popup = self.popup_terminal_id.clone(); let next_popup = surface .popup @@ -1696,45 +1779,8 @@ impl ClientShellState { pub(crate) fn invalidate_pane_surface(&mut self) { self.pane_surface = None; + self.pending_pane_surface = None; self.hits = ShellHitMap::default(); self.host_mouse_pixels = None; } - - fn wants_ascii_input(&self) -> bool { - if let Some(overlay) = self.overlay.as_ref() { - return matches!( - overlay, - ClientShellOverlay::ConfirmClose(_) - | ClientShellOverlay::Help(_) - | ClientShellOverlay::Navigator(_) - | ClientShellOverlay::WorktreeRemove(_) - | ClientShellOverlay::ContextMenu(_) - | ClientShellOverlay::GlobalMenu(_) - ); - } - matches!( - self.mode, - ClientShellMode::Prefix - | ClientShellMode::Navigate - | ClientShellMode::Resize - | ClientShellMode::Copy - ) - } - - pub(crate) fn reconcile_input_source(&mut self) { - // Keep the platform restore token while another window has focus. Restoring - // through a global key injection is only safe after this client regains focus. - if self.outer_focused == Some(false) { - return; - } - let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); - if desired != self.ascii_input_source_active { - self.ascii_input_source_active = desired; - self.pending_input_source_changes.push(desired); - } - } - - pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { - std::mem::take(&mut self.pending_input_source_changes) - } } diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 2ccfe77b..10f97118 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() { &state.overlay, Some(ClientShellOverlay::WorktreeCreate(create)) if create.checkout_path - == std::path::Path::new("/tmp/herdr-worktrees") - .join("repo") - .join("feature-client-shell").to_string_lossy() + == "/tmp/herdr-worktrees/repo/feature-client-shell" )); let submit = state.handle_input_bytes(b"\r"); let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { @@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { state.set_pane_surface(surface()); let now = std::time::Instant::now(); let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "first".into(), @@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_some()); state.config.toast_delay_seconds = 1; let (_, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "replacement".into(), diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index e8555db6..7d71a981 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { 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 pane_target = { let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") else { panic!("expected navigator"); }; - render::client_navigator_rows(snapshot, navigator) + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) .iter() - .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .map(|row| row.target.clone()) .expect("pane row") }; let pane_rect = state .hits .navigator_rows .iter() - .find(|(_, index)| *index == pane_index) + .find(|(_, target)| *target == pane_target) .map(|(rect, _)| *rect) .expect("visible pane row"); let select = diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs new file mode 100644 index 00000000..8149b530 --- /dev/null +++ b/src/client/shell/tests/endpoint_requests.rs @@ -0,0 +1,298 @@ +use super::*; +use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus}; + +fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) { + 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: "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(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + assert!(state.popup_pending); + (state, outcome.actions) +} + +fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected worktree preparation"); + }; + state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None))); + state.handle_input_bytes(b"feature/reconnect"); + let outcome = state.handle_input_bytes(b"\r"); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating + )); + (state, outcome.actions) +} + +fn request_id(actions: &[ClientShellAction]) -> &str { + let [ClientShellAction::Endpoint { request, .. }] = actions else { + panic!("expected one endpoint request"); + }; + &request.id +} + +#[test] +fn cancelling_popup_request_unblocks_input_and_ignores_late_success() { + let (mut state, actions) = pending_popup(); + let id = request_id(&actions); + assert!(state.cancel_endpoint_request(id)); + assert!(!state.popup_pending); + assert!(state.pending_requests.is_empty()); + assert!(state + .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {})) + .1 + .is_empty()); + assert!(!state.popup_pending); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +#[test] +fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() { + let (mut state, _) = pending_worktree(); + state.mark_endpoint_disconnected(&ClientEndpointId::Local); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.set_pane_surface(surface()); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))]); + assert!(state.overlay.is_none()); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +struct TestTransport { + fail: bool, +} + +impl crate::client::endpoint::EndpointTransport for TestTransport { + fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> { + if self.fail { + Err(std::io::ErrorKind::BrokenPipe.into()) + } else { + Ok(()) + } + } +} + +#[test] +fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + for fail_send in [false, true] { + let (mut state, actions) = pending_worktree(); + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: fail_send }, + 1, + EndpointNegotiation::default(), + ); + endpoints.set_surface_active(&ClientEndpointId::Local, fail_send); + let mut commands = EndpointCommands::default(); + let (tx, _rx) = tokio::sync::mpsc::channel(16); + let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions( + actions, + &mut commands, + &mut endpoints, + Some(&mut state), + &mut Vec::new(), + &tx, + ) + .unwrap(); + assert!(repaint); + assert!(replay.is_empty()); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| { notice.title == "Action interrupted" })); + assert!(commands.disconnect(&ClientEndpointId::Local).is_empty()); + } +} + +#[test] +fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + let (mut state, actions) = pending_popup(); + let stale_id = request_id(&actions).to_owned(); + let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into())); + let current_id = request_id(¤t).to_owned(); + let mut commands = EndpointCommands::default(); + for (generation, actions) in [(1, actions), (2, current)] { + for action in actions { + let ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } = action + else { + panic!("expected endpoint request"); + }; + commands.enqueue(endpoint_id, generation, boot_id, request); + } + } + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: false }, + 2, + EndpointNegotiation::default(), + ); + let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints); + assert_eq!(cancelled, vec![stale_id.clone()]); + state.cancel_endpoint_request(&stale_id); + assert!(!state.popup_pending); + assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id)); + assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", ¤t_id)); + assert!(state.pending_requests.contains_key(¤t_id)); +} + +#[test] +fn cancelled_integration_install_does_not_queue_a_refresh() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.open_settings_overlay(); + let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else { + panic!("settings overlay"); + }; + settings.installing_integrations = true; + state.pending_integration_installs = 1; + let mut outcome = ClientShellInput::default(); + assert!(state.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Pi, + } + ), + PendingEndpointKind::IntegrationInstall, + &mut outcome, + )); + assert!(state.cancel_endpoint_request(request_id(&outcome.actions))); + assert!(state.pending_requests.is_empty()); + assert_eq!(state.pending_integration_installs, 0); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::Settings(settings)) + if !settings.installing_integrations && !settings.loading_integrations + )); +} + +#[test] +fn cancelled_selection_does_not_replay_its_fallback_key() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.selection = Some(crate::selection::Selection::absolute_range( + "pane_1".into(), + (0, 0), + (0, 2), + )); + let mut outcome = ClientShellInput::default(); + state.request_selection_copy_with_fallback( + &mut outcome, + Some(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )), + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.pending_requests.is_empty()); +} + +#[test] +fn cancelled_link_activation_does_not_replay_mouse_input() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + let pane_id = state.hits.panes[0].pane_id.clone(); + let inner_rect = state.hits.panes[0].inner_rect; + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events: vec![MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: inner_rect.x, + row: inner_rect.y, + modifiers: KeyModifiers::NONE, + }], + }, + &mut outcome, + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn another_machine_disconnect_does_not_cancel_active_popup() { + let (mut state, _) = pending_popup(); + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + state.mark_endpoint_disconnected(&remote); + assert!(state.popup_pending); + assert_eq!(state.pending_requests.len(), 1); +} diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs new file mode 100644 index 00000000..52c71923 --- /dev/null +++ b/src/client/shell/tests/endpoints.rs @@ -0,0 +1,1205 @@ +use super::*; +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 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) +} + +#[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 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 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_endpoint_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, + 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 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_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:")); +} + +#[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::Tab { .. } => row.depth == 2 && row.status.is_none(), + ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + })); + assert_eq!(rows.iter().filter(|row| row.current).count(), 1); + + 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::Tab { .. } => row.depth == 1, + ClientNavigatorTarget::Pane { .. } => row.depth == 2, + 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::Tab { + 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_foreign_tab_selection_keeps_the_tab_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::Tab { + endpoint_id: target_endpoint, + tab_id, + .. + } if target_endpoint == &endpoint_id && tab_id == "tab_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote tab 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: Some(ClientEndpointFocusTarget::Tab(tab_id)), + }] if activated == &endpoint_id && tab_id == "tab_1" + )); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index d20485b3..b8f17e57 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() { 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 { + let [ClientShellAction::Endpoint { + boot_id, request, .. + }] = &create.actions[..] + else { panic!("expected one endpoint action: {:?}", create.actions); }; assert_eq!(boot_id, "boot-1"); diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs index 094b141f..b7cc2a47 100644 --- a/src/client/shell/tests/mobile.rs +++ b/src/client/shell/tests/mobile.rs @@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() { state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "bottom notification".into(), @@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() { } #[test] +fn mobile_switcher_can_activate_an_online_saved_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = crate::client::endpoint::SavedSshEndpoint { + id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + }; + 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(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + state.mode = ClientShellMode::Navigate; + + let frame = state.compose(44, 30).expect("mobile machine switcher"); + let text = frame + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("machines")); + assert!(text.contains("Build")); + let machine = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect) + }) + .expect("saved machine target"); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: machine.x, + row: machine.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + 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.mark_endpoint_disconnected(&endpoint_id); + state.receive_endpoint_unavailable("network lost".into()); + state.mode = ClientShellMode::Navigate; + let stale = state.compose(44, 30).expect("stale mobile switcher"); + let text = stale + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("reconnecting"), "frame: {text}"); + assert!(text.contains("network lost"), "frame: {text}"); +} + +#[test] fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { let mut config = ClientShellConfig::from_config(&Config::default()); config.mouse_capture = false; @@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1" + ) + .then_some(*rect) }) .expect("workspace hit"); let focused = state.handle_raw_events(vec![click(workspace_hit)]); @@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1" + ) + .then_some(*rect) }) .expect("agent hit"); let focused = state.handle_raw_events(vec![click(agent_hit)]); @@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Tab { + endpoint_id, + tab_id, + } if endpoint_id.is_local() && tab_id == "tab_1" + ) + .then_some(*rect) }) .expect("tab hit"); let focused = state.handle_raw_events(vec![click(tab_hit)]); @@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); assert!(state.mobile_switcher_scroll > 2); assert!(state.hits.mobile_targets.iter().any(|(_, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8" + ) })); let close = state.hits.mobile_close; state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 4cf97152..497ce2c6 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -6,7 +6,7 @@ use crate::protocol::{ }; use crossterm::event::MouseEvent; -fn snapshot() -> ClientShellSnapshot { +pub(super) fn snapshot() -> ClientShellSnapshot { ClientShellSnapshot { boot_id: "boot-1".into(), revision: 1, @@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; mod copy; +mod endpoint_requests; +mod endpoints; #[path = "input.rs"] mod input_domain; mod keybindings_settings; diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 6ccdcf6e..044ef145 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces: HashSet::new(), diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 2e2901e8..7107aa71 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { state.set_snapshot(Box::new(endpoint_snapshot)); state.set_pane_surface(surface()); state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notification".into(), diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index bcadf1c8..ea2e8f24 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -1,10 +1,24 @@ use super::*; +fn checkout_path_preview(root: &str, repo: &str, branch: &str) -> String { + // This is an endpoint path, not a path on the machine drawing the dialog. + let separator = if !root.starts_with('/') && root.contains('\\') { + '\\' + } else { + '/' + }; + format!( + "{}{separator}{repo}{separator}{}", + root.trim_end_matches(separator), + crate::worktree::branch_to_path_slug(branch) + ) +} + impl ClientShellState { - fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> { + fn endpoint_worktree_directory(&self) -> Option<String> { self.snapshot .as_deref() - .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + .map(|snapshot| snapshot.worktree_directory.clone()) } pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { @@ -241,13 +255,8 @@ impl ClientShellState { let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { return; }; - create.checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &create.repo_name, - &create.branch, - ) - .display() - .to_string(); + create.checkout_path = + checkout_path_preview(&worktree_directory, &create.repo_name, &create.branch); create.error = None; } @@ -270,9 +279,7 @@ impl ClientShellState { create.branch = branch.clone(); create.replace_on_type = false; create.checkout_path = - crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) - .display() - .to_string(); + checkout_path_preview(&worktree_directory, &create.repo_name, &branch); create.creating = true; create.error = None; let workspace_id = create.source_workspace_id.clone(); @@ -399,13 +406,8 @@ impl ClientShellState { let Some(worktree_directory) = self.endpoint_worktree_directory() else { return false; }; - let checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &source.repo_name, - &branch, - ) - .display() - .to_string(); + let checkout_path = + checkout_path_preview(&worktree_directory, &source.repo_name, &branch); self.overlay = Some(ClientShellOverlay::WorktreeCreate( ClientWorktreeCreateOverlay { source_workspace_id: workspace_id, @@ -549,3 +551,24 @@ impl ClientShellState { } } } + +#[cfg(test)] +mod path_tests { + use super::checkout_path_preview; + + #[test] + fn endpoint_path_style_is_independent_of_the_client_os() { + assert_eq!( + checkout_path_preview("/worktrees/", "repo", "feature/a"), + "/worktrees/repo/feature-a" + ); + assert_eq!( + checkout_path_preview(r"C:\worktrees\", "repo", "feature/a"), + r"C:\worktrees\repo\feature-a" + ); + assert_eq!( + checkout_path_preview(r"\\server\share", "repo", "feature/a"), + r"\\server\share\repo\feature-a" + ); + } +} diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs new file mode 100644 index 00000000..8a4af0b7 --- /dev/null +++ b/src/client/shell_runtime.rs @@ -0,0 +1,739 @@ +use super::*; + +pub(super) fn dispatch_client_shell_actions( + actions: Vec<shell::ClientShellAction>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + endpoints: &mut endpoint::EndpointRegistry, + mut shell: Option<&mut shell::ClientShellState>, + detached_process_children: &mut Vec<std::process::Child>, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(Vec<crossterm::event::MouseEvent>, bool), ClientError> { + let mut replay_mouse = Vec::new(); + let mut repaint = false; + for action in actions { + match action { + shell::ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } => { + if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| { + endpoints.active_id() == &endpoint_id && endpoints.active_surface_available() + }) { + endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request); + } else if let Some(shell) = shell.as_deref_mut() { + repaint |= shell.cancel_endpoint_request(&request.id); + } + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + if endpoints.active_surface_available() { + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + } + shell::ClientShellAction::ActivateEndpoint { + endpoint_id, + target, + } => { + let _ = event_tx.try_send(ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force: false, + }); + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + // A source-off-first handoff leaves the registry's committed identity pointing at a + // deliberately surface-inactive source. Do not drain its retained queue into a server that + // must reject it; completion below resumes the committed owner's lane. + if endpoints.active_surface_available() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = shell { + for request_id in cancelled { + repaint |= shell.cancel_endpoint_request(&request_id); + } + } + } + Ok((replay_mouse, repaint)) +} + +pub(super) fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +pub(super) fn sync_client_shell_keyboard_report_all( + state: &mut ClientState, +) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +pub(super) fn clear_endpoint_host_effects( + state: &mut ClientState, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, +) { + state.endpoint_mouse_capture_requested = false; + state.endpoint_sgr_pixels_requested = false; + let enabled = if state.shell.is_some() { + state.shell_mouse_capture_preference + } else { + state.direct_mouse_capture_preference + }; + let sgr_pixels = super::effective_sgr_pixel_mouse(enabled, false, state.pixel_geometry_exact); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(std::sync::atomic::Ordering::Acquire) + { + let _ = super::set_mouse_capture(enabled, sgr_pixels); + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, std::sync::atomic::Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, std::sync::atomic::Ordering::Release); + + state.pane_keyboard_report_all = false; + let _ = sync_client_shell_keyboard_report_all(state); + let _ = crate::terminal_effects::write_window_title(&mut std::io::stdout(), None); +} + +pub(super) fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn install_pending_activation( + state: &mut ClientState, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + activation: endpoint::PendingEndpointActivation, +) { + let retired = activation + .source_command_lane() + .map(|source| endpoint_commands.retire_lane(source)) + .unwrap_or_default(); + if let Some(shell) = state.shell.as_mut() { + for request_id in retired { + shell.cancel_endpoint_request(&request_id); + } + } + *next_surface_serial = next_surface_serial.saturating_add(1); + state.freeze_presentation(); + *pending = Some(activation); +} + +pub(super) fn begin_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + force: bool, + now: std::time::Instant, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(), ClientError> { + if let Some(activation) = pending.as_mut() { + if activation.can_retarget(&endpoint_id) { + let retarget_error = activation.retarget(target, endpoints).err(); + if let Some(error) = retarget_error { + rollback_endpoint_activation(state, endpoints, pending, error, false); + } + } else { + // Once rollback starts, even a request for the original target is a new intent. It + // replaces the retained successor instead of mutating the transaction being retired. + let outcome = activation.supersede(endpoint_id, target, endpoints); + if let endpoint::ActivationRollback::Unavailable(message) = outcome { + *pending = None; + present_handoff_unavailable(state, message); + } + } + return Ok(()); + } + let already_active = !force + && endpoints.active_id() == &endpoint_id + && endpoints + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + if already_active { + if let (Some(shell), Some(target)) = (state.shell.as_mut(), target) { + let actions = shell.focus_endpoint_target(target); + let (_, repaint) = dispatch_client_shell_actions( + actions, + endpoint_commands, + endpoints, + Some(shell), + &mut state.detached_process_children, + event_tx, + )?; + if repaint { + if let Some(frame) = shell.compose(state.reported_size.0, state.reported_size.1) { + state.present_frame(frame); + } + } + } + return Ok(()); + } + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + match endpoint::PendingEndpointActivation::begin( + shell, + endpoints, + endpoint_id.clone(), + target, + resize, + *next_surface_serial, + now, + ) { + Ok(activation) => install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + activation, + ), + Err(endpoint::ActivationBeginError::Preflight(error)) => { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "{}: {error}", + shell.endpoint_label(&endpoint_id) + )); + } + } + Err(endpoint::ActivationBeginError::Partial { activation, error }) => { + // A send error is not evidence that its peer did not observe the write. Freeze and + // retain the lifecycle object before rollback so no source or target output can be + // projected until one ownership path has been proved again. + install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + *activation, + ); + rollback_endpoint_activation( + state, + endpoints, + pending, + format!( + "{}: {error}", + state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| format!("{endpoint_id:?}")) + ), + false, + ); + } + } + Ok(()) +} + +pub(super) fn complete_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, +) -> Result<Option<ClientLoopEvent>, ClientError> { + let sync_endpoint = pending + .as_ref() + .and_then(endpoint::PendingEndpointActivation::presentation_sync_endpoint) + .cloned(); + if let Some(endpoint_id) = sync_endpoint.as_ref() { + state.replay_host_theme(endpoints, endpoint_id); + } + let completion = { + let Some(activation) = pending.as_mut() else { + return Ok(None); + }; + let Some(shell) = state.shell.as_mut() else { + return Ok(None); + }; + match activation.complete(shell, endpoints) { + Ok(completion) => completion, + Err(error) => { + shell.receive_endpoint_unavailable(error); + return Ok(None); + } + } + }; + + if matches!( + completion, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + ) { + #[cfg(unix)] + if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } = + &completion + { + if previous != endpoint { + state.retire_endpoint_graphics(previous); + } + } + // The coherent target frame can replace the frozen source now, but the registry keeps + // pane input disabled until a second projection epoch has replayed host modes/effects. + state.unfreeze_presentation(); + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + return Ok(None); + } + if completion == endpoint::ActivationCompletion::AwaitingPresentationEffects { + return Ok(None); + } + + let _ = pending.take(); + endpoints.unfreeze_input(); + let successor = match completion { + endpoint::ActivationCompletion::RestoredSource { + error, + successor: next, + .. + } => { + if next.is_none() { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(error); + } + } + next + } + endpoint::ActivationCompletion::Activated => None, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + | endpoint::ActivationCompletion::AwaitingPresentationEffects => unreachable!(), + }; + state.unfreeze_presentation(); + if successor.is_none() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + } + } + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + if let Some(intent) = successor { + return Ok(Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: intent.endpoint_id, + target: intent.target, + force: true, + })); + } + Ok(None) +} + +pub(super) fn present_handoff_unavailable(state: &mut ClientState, message: String) { + // An unavailable committed endpoint has no presentation lease. Keep all pane input and late + // source output blocked, while allowing this client-owned chrome frame through the freeze. + state.freeze_presentation(); + let frame = state.shell.as_mut().and_then(|shell| { + shell.receive_endpoint_unavailable(message); + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = frame { + state.present_frozen_chrome(frame); + } +} + +pub(super) fn rollback_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + error: String, + source_release_rejected: bool, +) { + let Some(activation) = pending.as_mut() else { + return; + }; + match activation.rollback(endpoints, error.clone(), source_release_rejected) { + endpoint::ActivationRollback::Pending => state.freeze_presentation(), + endpoint::ActivationRollback::Unavailable(message) => { + *pending = None; + // No endpoint has been proven safe to present. Keep pane input frozen, but render + // the client-owned unavailable chrome rather than silently swallowing the error. + present_handoff_unavailable(state, message); + } + } +} + +pub(super) fn handle_endpoint_disconnect( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + notice: &str, +) -> bool { + supervisors.disconnected(endpoint_id, generation, now); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + format!("endpoint connection was lost while activating {notice}"), + ); + match outcome { + endpoint::ActivationRollback::Pending => {} + endpoint::ActivationRollback::Unavailable(error) => { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.mark_endpoint_disconnected(endpoint_id); + endpoint_was_active.then(|| format!("{} {notice}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn handle_endpoint_attention( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + message: String, +) -> bool { + endpoints.disconnect(endpoint_id); + supervisors.record_status( + endpoint_id, + generation, + endpoint::ClientEndpointStatus::Attention, + now, + ); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + "endpoint reported attention while activating".into(), + ); + if let endpoint::ActivationRollback::Unavailable(error) = outcome { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Attention); + endpoint_was_active.then(|| format!("{}: {message}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn install_client_shell_snapshot( + state: &mut ClientState, + endpoint_id: &endpoint::ClientEndpointId, + snapshot: Box<crate::protocol::ClientShellSnapshot>, + projection_pending: bool, + endpoints: &mut endpoint::EndpointRegistry, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let Some(connection) = endpoints.connection(endpoint_id) else { + return Ok(()); + }; + let generation = connection.generation; + let project_snapshot = + !projection_pending && endpoints.active_id() == endpoint_id && connection.surface_active; + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { + let waits_for_selected_surface = projection_pending + || (endpoints.active_id() == endpoint_id + && !project_snapshot + && shell.has_presented_surface()); + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + if !waits_for_selected_surface { + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Online); + } + if project_snapshot { + shell.set_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + } else { + shell.cache_endpoint_snapshot_inactive_for_generation( + endpoint_id, + generation, + snapshot, + ); + } + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }), + graphics_cleanup, + ) + } else { + (None, None, Vec::new()) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + endpoints.send_to(endpoint_id, &resize); + } + if let Some(frame) = composed { + if projection_pending { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(()) +} + +pub(super) fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option<FrameData>, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<bool, ClientError> { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(endpoints, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + let _ = write_to_server(endpoints, &resize); + } + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let (replay, dispatch_repaint) = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + endpoints, + state.shell.as_mut(), + &mut state.detached_process_children, + event_tx, + )?; + let frame = if dispatch_repaint { + state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + } else { + frame + }; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + let active_endpoint_online = state + .shell + .as_ref() + .is_none_or(|shell| shell.endpoint_is_online(endpoints.active_id())) + && endpoints.active_surface_available(); + for request in outcome.requests { + if let ClientMessage::ClientShellHostTheme { update } = &request { + state.record_host_theme_update(update); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_theme(update.clone(), endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + } + // Host focus belongs to a pending target even when the source has gone offline or has + // already had its surface revoked. Route it before the ordinary source-online gate. + if let ClientMessage::ClientShellFocus { focused } = request { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_focus(focused, endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + if active_endpoint_online { + write_to_server(endpoints, &ClientMessage::ClientShellFocus { focused }) + .map_err(ClientError::ConnectionLost)?; + } + continue; + } + if !active_endpoint_online { + continue; + } + if pending_activation.is_some() { + // Pane input and non-focus host effects do not cross the frozen handoff boundary. + continue; + } + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + if pending_activation.is_some() { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(false) +} diff --git a/src/client/startup.rs b/src/client/startup.rs new file mode 100644 index 00000000..40ee2cc8 --- /dev/null +++ b/src/client/startup.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Runs the thin client and enters the main event loop. +pub fn run_client() -> io::Result<()> { + run_client_with_mode(None, None, "connecting to server") +} + +#[cfg(unix)] +pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { + run_client_with_mode( + Some((terminal_id, takeover)), + Some(AttachEscapeState::default()), + "attaching to terminal", + ) +} + +#[cfg(windows)] +pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { + debug_assert!(!crate::platform::capabilities().direct_terminal_attach); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "direct terminal attach is not supported on Windows yet", + )) +} diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 00000000..c26ca165 --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,231 @@ +use super::*; + +/// State tracking for the thin client. +pub(super) struct ClientState { + /// Stateful semantic-frame encoder used when the server sends FrameData. + pub(super) blit_encoder: render_ansi::BlitEncoder, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_mouse_capture_requested: bool, + pub(super) endpoint_sgr_pixels_requested: bool, + /// Latest physical host theme observations, retained so an endpoint selected after the + /// observation receives the same client-owned baseline. + pub(super) host_theme_updates: Vec<crate::protocol::ClientHostThemeUpdate>, + pub(super) direct_mouse_capture_preference: bool, + pub(super) shell_mouse_capture_preference: bool, + pub(super) direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + pub(super) pane_keyboard_report_all: bool, + pub(super) keyboard_report_all_active: bool, + pub(super) reported_size: (u16, u16), + pub(super) reported_cell_size: (u32, u32), + pub(super) sound_config: crate::config::SoundConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_exact: bool, + #[cfg(unix)] + pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, + #[cfg(unix)] + pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>, + #[cfg(unix)] + pub(super) pending_surface_graphics: + HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>, + pub(super) attach_escape: Option<AttachEscapeState>, + #[cfg(unix)] + pub(super) mouse_scroll_lines: usize, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) redraw_on_focus_gained: bool, + pub(super) repaint_pending: bool, + /// During a source-off-first handoff the currently blitted frame remains authoritative until + /// an acknowledged target snapshot/surface pair commits. + pub(super) presentation_frozen: bool, + pub(super) draw_host_cursor: bool, + pub(super) detached_process_children: Vec<std::process::Child>, + pub(super) shell: Option<shell::ClientShellState>, +} + +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); + } + } +} + +impl ClientState { + pub(super) fn request_repaint(&mut self) { + self.repaint_pending = true; + } + + pub(super) fn freeze_presentation(&mut self) { + self.presentation_frozen = true; + } + + pub(super) fn record_host_theme_update( + &mut self, + update: &crate::protocol::ClientHostThemeUpdate, + ) { + use crate::protocol::ClientHostThemeUpdate; + + match update { + ClientHostThemeUpdate::DefaultColor { kind, .. } => { + self.host_theme_updates.retain(|current| { + !matches!( + current, + ClientHostThemeUpdate::DefaultColor { + kind: current_kind, + .. + } if current_kind == kind + ) + }); + } + ClientHostThemeUpdate::PaletteColors(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::PaletteColors(_))), + ClientHostThemeUpdate::Appearance(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::Appearance(_))), + } + self.host_theme_updates.push(update.clone()); + } + + /// Replay the retained physical-host baseline only after an endpoint owns the committed + /// presentation. The endpoint transport preserves this order ahead of the resync control. + pub(super) fn replay_host_theme( + &self, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_id: &endpoint::ClientEndpointId, + ) { + for update in &self.host_theme_updates { + let _ = endpoints.send_to( + endpoint_id, + &crate::protocol::ClientMessage::ClientShellHostTheme { + update: update.clone(), + }, + ); + } + } + + pub(super) fn unfreeze_presentation(&mut self) { + self.presentation_frozen = false; + // A resize or metadata event may have happened while frozen. Force a full frame rather + // than attempting to patch the old source frame. + self.request_repaint(); + } + + /// Present a composed error/chrome frame while retaining the handoff input freeze. The pane + /// cells are still the last coherent surface; only client chrome (including the error) moves. + pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) { + let frozen = self.presentation_frozen; + self.presentation_frozen = false; + self.present_frame(frame_data); + self.presentation_frozen = frozen; + } + + pub(super) fn present_graphics(&mut self, graphics: &[u8]) { + if self.presentation_frozen || graphics.is_empty() || !self.kitty_graphics_enabled { + return; + } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); + } + + pub(super) fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result<bool> { + if self.presentation_frozen || self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); + } + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows + } else { + patch.rows + }; + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); + }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) + } + + #[cfg(unix)] + pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) { + let transfer_ids = self + .pending_surface_graphics + .keys() + .filter(|(owner, _, _)| owner == endpoint_id) + .map(|(_, transfer_id, _)| *transfer_id) + .collect::<Vec<_>>(); + self.pending_surface_graphics + .retain(|(owner, _, _), _| owner != endpoint_id); + if self + .retired_direct_graphics + .as_ref() + .is_some_and(|(owner, _, _)| owner == endpoint_id) + { + self.retired_direct_graphics = None; + } + if let Ok(mut matcher) = self.direct_graphics_response.lock() { + for transfer_id in transfer_ids { + matcher.retire(transfer_id); + } + } + } + + pub(super) fn present_frame(&mut self, frame_data: FrameData) { + if self.presentation_frozen { + return; + } + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; + } +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index a46af2b1..c7328efd 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -89,7 +89,18 @@ fn connect_terminal_session_stream( } }; - match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + match do_handshake( + &mut stream, + cols, + rows, + 0, + 0, + false, + None, + false, + false, + true, + ) { Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {} Ok(handshake) => { eprintln!( diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 5d789444..cca73232 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -156,6 +156,26 @@ fn host_cursor_policy_native_and_drawn_override_auto_detection() { )); } +#[test] +fn image_bridge_follows_the_selected_remote_endpoint() { + let remote = crate::client::endpoint::ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + + assert!(endpoint_accepts_local_images(false, &remote, true)); + assert!(endpoint_accepts_local_images( + true, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images( + false, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images(false, &remote, false)); +} + #[cfg(unix)] #[test] fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { @@ -181,6 +201,11 @@ fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { Some(ctrl_v) )); assert!(!should_bridge_clipboard_image_paste( + &[0x16], + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( b"\x1b[200~text\x1b[201~", true, Some(ctrl_v) diff --git a/src/client/transport.rs b/src/client/transport.rs new file mode 100644 index 00000000..4807c31d --- /dev/null +++ b/src/client/transport.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(super) fn start_endpoint_transport( + stream: LocalStream, + lifetime: impl Send + 'static, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + max_frame_size: usize, +) -> Result<endpoint::NativeEndpointTransport, ClientError> { + let reader = stream.try_clone().map_err(ClientError::ConnectionFailed)?; + let transport = endpoint::NativeEndpointTransport::with_lifetime(stream, lifetime) + .map_err(ClientError::ConnectionFailed)?; + let stopped = transport.stop_handle(); + let event_tx = event_tx.clone(); + std::thread::Builder::new() + .name("endpoint-reader".into()) + .spawn(move || { + server_reader_thread( + reader, + event_tx, + &stopped, + max_frame_size, + endpoint_id, + generation, + ); + }) + .map_err(ClientError::ConnectionFailed)?; + Ok(transport) +} + +/// Reads complete frames while retaining partial-read progress across nonblocking polls. +pub(super) fn server_reader_thread( + mut stream: LocalStream, + event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + should_quit: &Arc<AtomicBool>, + max_frame_size: usize, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, +) { + if stream.set_nonblocking(true).is_err() { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + }); + return; + } + + let mut stream = EndpointReader { + stream: &mut stream, + stopped: should_quit, + }; + loop { + if should_quit.load(Ordering::Acquire) { + break; + } + + match protocol::read_message(&mut stream, max_frame_size) { + Ok(msg) => { + if event_tx + .blocking_send(ClientLoopEvent::ServerMessage { + endpoint_id: endpoint_id.clone(), + generation, + message: Box::new(msg), + }) + .is_err() + { + break; + } + } + Err(protocol::FramingError::UnexpectedEof) => { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(err) => { + warn!(err = %err, "server read error"); + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + } + } +} + +struct EndpointReader<'a> { + stream: &'a mut LocalStream, + stopped: &'a AtomicBool, +} + +impl io::Read for EndpointReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> { + loop { + if self.stopped.load(Ordering::Acquire) { + return Ok(0); + } + match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? { + crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count), + crate::ipc::LocalStreamReadCount::Closed => return Ok(0), + crate::ipc::LocalStreamReadCount::Pending => { + crate::platform::wait_client_stream_readable(self.stream)?; + } + } + } + } +} + +pub(in crate::client) fn write_to_local_server( + stream: &mut LocalStream, + msg: &ClientMessage, +) -> io::Result<()> { + protocol::write_message(stream, msg).map_err(|error| io::Error::other(error.to_string())) +} + +pub(super) trait ClientMessageSink { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()>; +} + +impl ClientMessageSink for LocalStream { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + write_to_local_server(self, message) + } +} + +impl ClientMessageSink for endpoint::EndpointRegistry { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + // The lifecycle loop consumes failures for every endpoint, including Local. A send + // failure must not bypass that transition or tear down unrelated connections. + self.send(message); + Ok(()) + } +} + +pub(super) fn write_to_server( + stream: &mut impl ClientMessageSink, + msg: &ClientMessage, +) -> io::Result<()> { + stream.send_client_message(msg) +} |
