From 866aaf740e10fcfce169aaf81380ea0257fbe272 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 21 May 2026 20:19:54 +0300 Subject: refactor: separate server client boundaries --- .github/workflows/release.yml | 2 +- AGENTS.md | 2 +- scripts/changelog.py | 2 +- src/api/client.rs | 245 +++++ src/api/event_hub.rs | 39 + src/api/mod.rs | 1356 +------------------------- src/api/server.rs | 769 +++++++++++++++ src/api/status.rs | 54 ++ src/api/subscriptions.rs | 368 ++++++++ src/api/wait.rs | 124 +++ src/app/actions.rs | 72 +- src/app/agents.rs | 7 +- src/app/api.rs | 1339 ++------------------------ src/app/api/agents.rs | 127 +++ src/app/api/integrations.rs | 46 + src/app/api/panes.rs | 353 +++++++ src/app/api/responses.rs | 19 + src/app/api/tabs.rs | 233 +++++ src/app/api/workspaces.rs | 168 ++++ src/app/creation.rs | 25 +- src/app/input/mod.rs | 33 +- src/app/input/modal.rs | 19 +- src/app/input/mouse.rs | 194 +++- src/app/input/navigate.rs | 68 +- src/app/input/selection.rs | 72 +- src/app/input/terminal.rs | 84 +- src/app/mod.rs | 61 +- src/app/runtime.rs | 33 +- src/app/session.rs | 2 +- src/app/state.rs | 61 +- src/app/terminal_targets.rs | 6 +- src/app/theme_sync.rs | 2 +- src/cli.rs | 1938 ++++---------------------------------- src/cli/agent.rs | 429 +++++++++ src/cli/integration.rs | 142 +++ src/cli/pane.rs | 410 ++++++++ src/cli/server.rs | 48 + src/cli/status.rs | 299 ++++++ src/cli/tab.rs | 194 ++++ src/cli/workspace.rs | 163 ++++ src/client/blit.rs | 1430 ---------------------------- src/client/mod.rs | 12 +- src/input/encode.rs | 4 +- src/kitty_graphics.rs | 18 +- src/main.rs | 1 + src/persist/snapshot.rs | 19 +- src/platform/linux.rs | 2 +- src/platform/macos.rs | 2 +- src/protocol/mod.rs | 6 + src/protocol/render_ansi.rs | 1430 ++++++++++++++++++++++++++++ src/protocol/wire.rs | 1513 +++++++++++++++++++++++++++++ src/remote.rs | 114 ++- src/server/autodetect.rs | 6 +- src/server/client_accept.rs | 50 + src/server/client_transport.rs | 2 +- src/server/clients.rs | 208 ++++ src/server/headless.rs | 573 ++--------- src/server/keybindings.rs | 17 + src/server/mod.rs | 7 +- src/server/notifications.rs | 60 ++ src/server/protocol.rs | 1513 ----------------------------- src/server/render_stream.rs | 39 +- src/server/socket_paths.rs | 180 ++++ src/server/terminal_attach.rs | 14 + src/terminal/mod.rs | 11 +- src/terminal/runtime.rs | 312 ++++++ src/terminal/runtime_registry.rs | 54 ++ src/ui.rs | 70 +- src/ui/mobile.rs | 10 +- src/ui/panes.rs | 35 +- src/ui/sidebar.rs | 7 +- src/update.rs | 2 +- src/workspace.rs | 8 +- src/workspace/tab.rs | 4 +- tests/cli_wrapper.rs | 36 + 75 files changed, 9191 insertions(+), 8186 deletions(-) create mode 100644 src/api/client.rs create mode 100644 src/api/event_hub.rs create mode 100644 src/api/server.rs create mode 100644 src/api/status.rs create mode 100644 src/api/subscriptions.rs create mode 100644 src/api/wait.rs create mode 100644 src/app/api/agents.rs create mode 100644 src/app/api/integrations.rs create mode 100644 src/app/api/panes.rs create mode 100644 src/app/api/responses.rs create mode 100644 src/app/api/tabs.rs create mode 100644 src/app/api/workspaces.rs create mode 100644 src/cli/agent.rs create mode 100644 src/cli/integration.rs create mode 100644 src/cli/pane.rs create mode 100644 src/cli/server.rs create mode 100644 src/cli/status.rs create mode 100644 src/cli/tab.rs create mode 100644 src/cli/workspace.rs delete mode 100644 src/client/blit.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/render_ansi.rs create mode 100644 src/protocol/wire.rs create mode 100644 src/server/client_accept.rs create mode 100644 src/server/clients.rs create mode 100644 src/server/keybindings.rs create mode 100644 src/server/notifications.rs delete mode 100644 src/server/protocol.rs create mode 100644 src/server/socket_paths.rs create mode 100644 src/server/terminal_attach.rs create mode 100644 src/terminal/runtime.rs create mode 100644 src/terminal/runtime_registry.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6de5624..9b77cc59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -298,7 +298,7 @@ jobs: git show "${GITHUB_REF_NAME}:docs/next/product-announcement.json" > "$ANNOUNCEMENT_PATH" cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH" python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH" - RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/server/protocol.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))') + RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/protocol/wire.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))') CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("website/latest.json")).get("version", ""))') if [ "$CURRENT_VERSION" = "$VERSION" ]; then diff --git a/AGENTS.md b/AGENTS.md index 9da6753a..d30b138b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ The app update check and the in-app **What's New** flow both depend on that exac Do not edit `website/latest.json` during normal feature, fix, or test work. It describes the latest published release binaries, not the current unreleased source tree. The release workflow updates it after release assets are published. -When changing the server/client wire protocol, compare `src/server/protocol.rs::PROTOCOL_VERSION` against the latest released tag. Bump it only if the current source protocol is not already greater than the latest released protocol. Multiple unreleased wire changes in the same release cycle must share the same single protocol bump; Herdr supports tagged releases, not arbitrary `master` client/server compatibility. When a bump is required, update all hardcoded protocol expectations and manual protocol fixtures in tests. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. +When changing the server/client wire protocol, compare `src/protocol/wire.rs::PROTOCOL_VERSION` against the latest released tag. Bump it only if the current source protocol is not already greater than the latest released protocol. Multiple unreleased wire changes in the same release cycle must share the same single protocol bump; Herdr supports tagged releases, not arbitrary `master` client/server compatibility. When a bump is required, update all hardcoded protocol expectations and manual protocol fixtures in tests. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. ## External contributor guardrail diff --git a/scripts/changelog.py b/scripts/changelog.py index 91c93f6e..1e7d2738 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -18,7 +18,7 @@ VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") -PROTOCOL_SOURCE_PATH = Path("src/server/protocol.rs") +PROTOCOL_SOURCE_PATH = Path("src/protocol/wire.rs") ASSET_TARGETS = ( "linux-x86_64", "linux-aarch64", diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 00000000..7929f937 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,245 @@ +use std::fmt; +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::api::schema::{ + ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult, + SubscriptionEventEnvelope, SuccessResponse, +}; + +/// API connection target resolved by clients at the process edge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionTarget { + LocalSession(Option), + SocketPath(PathBuf), +} + +impl ConnectionTarget { + fn socket_path(&self) -> PathBuf { + match self { + Self::LocalSession(None) => crate::api::socket_path(), + Self::LocalSession(Some(name)) => crate::session::api_socket_path_for(Some(name)), + Self::SocketPath(path) => path.clone(), + } + } +} + +/// Reusable client for Herdr's newline-delimited JSON API. +#[derive(Debug, Clone)] +pub struct ApiClient { + target: ConnectionTarget, +} + +impl ApiClient { + pub fn local() -> Self { + Self::for_target(ConnectionTarget::LocalSession(None)) + } + + pub fn for_target(target: ConnectionTarget) -> Self { + Self { target } + } + + pub fn socket_path(&self) -> PathBuf { + self.target.socket_path() + } + + pub fn request(&self, request: Request) -> Result { + let value = self.request_value(&request)?; + parse_response_value(value) + } + + pub fn request_value(&self, request: &Request) -> Result { + let mut stream = self.connect()?; + write_request(&mut stream, request)?; + + let mut reader = BufReader::new(stream); + read_json_line(&mut reader) + } + + pub fn request_value_with_timeout( + &self, + request: &Request, + timeout: Duration, + ) -> Result { + let mut stream = self.connect()?; + stream.set_write_timeout(Some(timeout))?; + stream.set_read_timeout(Some(timeout))?; + write_request(&mut stream, request)?; + + let mut reader = BufReader::new(stream); + read_json_line(&mut reader) + } + + #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors. + pub fn subscribe( + &self, + id: impl Into, + params: EventsSubscribeParams, + read_timeout: Option, + ) -> Result<(SuccessResponse, EventStream), ApiClientError> { + let request = Request { + id: id.into(), + method: Method::EventsSubscribe(params), + }; + let (ack, stream) = self.subscribe_value(&request, read_timeout)?; + Ok((parse_response_value(ack)?, stream)) + } + + pub fn subscribe_value( + &self, + request: &Request, + read_timeout: Option, + ) -> Result<(serde_json::Value, EventStream), ApiClientError> { + let mut stream = self.connect()?; + write_request(&mut stream, request)?; + if let Some(timeout) = read_timeout { + stream.set_read_timeout(Some(timeout))?; + } + + let mut reader = BufReader::new(stream); + let ack = read_json_line(&mut reader)?; + Ok((ack, EventStream { reader })) + } + + pub fn status(&self) -> Result { + let response = self.request(Request { + id: "api-client:status".into(), + method: Method::Ping(PingParams::default()), + })?; + match response.result { + ResponseResult::Pong { version, protocol } => Ok(crate::api::RuntimeStatus { + version: Some(version), + protocol: Some(protocol), + }), + result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))), + } + } + + fn connect(&self) -> io::Result { + UnixStream::connect(self.socket_path()) + } +} + +pub struct EventStream { + reader: BufReader, +} + +impl EventStream { + pub fn next_value(&mut self) -> Result, ApiClientError> { + read_optional_json_line(&mut self.reader) + } + + pub fn next_event(&mut self) -> Result, ApiClientError> { + self.next_value()? + .map(serde_json::from_value) + .transpose() + .map_err(ApiClientError::Json) + } +} + +#[derive(Debug)] +pub enum ApiClientError { + Io(io::Error), + Json(serde_json::Error), + ErrorResponse(ErrorResponse), + EmptyResponse, + UnexpectedResult(String), +} + +impl fmt::Display for ApiClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::Json(err) => write!(f, "{err}"), + Self::ErrorResponse(response) => write!(f, "{}", response.error.message), + Self::EmptyResponse => write!(f, "empty api response"), + Self::UnexpectedResult(result) => write!(f, "unexpected api result: {result}"), + } + } +} + +impl std::error::Error for ApiClientError {} + +impl From for ApiClientError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +impl From for ApiClientError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiClientError> { + stream.write_all(serde_json::to_string(request)?.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + Ok(()) +} + +fn read_json_line( + reader: &mut BufReader, +) -> Result { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 || line.trim().is_empty() { + return Err(ApiClientError::EmptyResponse); + } + serde_json::from_str(&line).map_err(ApiClientError::Json) +} + +fn read_optional_json_line( + reader: &mut BufReader, +) -> Result, ApiClientError> { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if line.trim().is_empty() { + return Err(ApiClientError::EmptyResponse); + } + serde_json::from_str(&line) + .map(Some) + .map_err(ApiClientError::Json) +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum WireResponse { + Success(Box), + Error(ErrorResponse), +} + +pub(crate) fn parse_response_value( + value: serde_json::Value, +) -> Result { + match serde_json::from_value(value)? { + WireResponse::Success(response) => Ok(*response), + WireResponse::Error(response) => Err(ApiClientError::ErrorResponse(response)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_session_target_resolves_named_session_socket() { + let client = ApiClient::for_target(ConnectionTarget::LocalSession(Some("work".into()))); + assert!(client.socket_path().ends_with("sessions/work/herdr.sock")); + } + + #[test] + fn socket_path_target_uses_explicit_path() { + let path = PathBuf::from("/tmp/herdr-test.sock"); + let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone())); + assert_eq!(client.socket_path(), path); + } +} diff --git a/src/api/event_hub.rs b/src/api/event_hub.rs new file mode 100644 index 00000000..b66a2157 --- /dev/null +++ b/src/api/event_hub.rs @@ -0,0 +1,39 @@ +#[derive(Clone, Default)] +pub struct EventHub { + inner: std::sync::Arc>, +} + +#[derive(Default)] +struct EventHubState { + next_sequence: u64, + events: Vec<(u64, crate::api::schema::EventEnvelope)>, +} + +impl EventHub { + const MAX_EVENTS: usize = 512; + + pub fn push(&self, event: crate::api::schema::EventEnvelope) { + let Ok(mut state) = self.inner.lock() else { + return; + }; + state.next_sequence += 1; + let sequence = state.next_sequence; + state.events.push((sequence, event)); + let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS); + if overflow > 0 { + state.events.drain(0..overflow); + } + } + + pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> { + let Ok(state) = self.inner.lock() else { + return Vec::new(); + }; + state + .events + .iter() + .filter(|(event_sequence, _)| *event_sequence > sequence) + .cloned() + .collect() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 4893c295..34b37138 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,32 +1,23 @@ +pub mod client; +mod event_hub; pub mod schema; +mod server; +mod status; +mod subscriptions; +mod wait; -use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; +pub use event_hub::EventHub; +pub use server::start_server; +pub use status::{read_runtime_status_at, RuntimeStatus}; -use tracing::{debug, error, info, warn}; +use std::path::PathBuf; -use regex::Regex; use tokio::sync::mpsc; -use crate::api::schema::{ - ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request, - ResponseResult, Subscription, SubscriptionEventData, SubscriptionEventEnvelope, - SubscriptionEventKind, SuccessResponse, -}; +use crate::api::schema::{Method, Request}; pub const SOCKET_PATH_ENV_VAR: &str = "HERDR_SOCKET_PATH"; -const SOCKET_PERMISSION_MODE: u32 = 0o600; -const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); -const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); -const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); - pub(crate) fn request_changes_ui(request: &Request) -> bool { matches!( &request.method, @@ -58,1331 +49,6 @@ pub struct ApiRequestMessage { pub type ApiRequestSender = mpsc::UnboundedSender; -#[derive(Clone, Default)] -pub struct EventHub { - inner: std::sync::Arc>, -} - -#[derive(Default)] -struct EventHubState { - next_sequence: u64, - events: Vec<(u64, crate::api::schema::EventEnvelope)>, -} - -impl EventHub { - const MAX_EVENTS: usize = 512; - - pub fn push(&self, event: crate::api::schema::EventEnvelope) { - let Ok(mut state) = self.inner.lock() else { - return; - }; - state.next_sequence += 1; - let sequence = state.next_sequence; - state.events.push((sequence, event)); - let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS); - if overflow > 0 { - state.events.drain(0..overflow); - } - } - - pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> { - let Ok(state) = self.inner.lock() else { - return Vec::new(); - }; - state - .events - .iter() - .filter(|(event_sequence, _)| *event_sequence > sequence) - .cloned() - .collect() - } -} - pub fn socket_path() -> PathBuf { crate::session::active_api_socket_path() } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeStatus { - pub version: Option, - pub protocol: Option, -} - -pub fn read_runtime_status_at( - socket_path: &Path, - timeout: Duration, -) -> io::Result> { - if !socket_path.exists() { - return Ok(None); - } - - let mut stream = match UnixStream::connect(socket_path) { - Ok(stream) => stream, - Err(err) - if matches!( - err.kind(), - io::ErrorKind::ConnectionRefused - | io::ErrorKind::NotFound - | io::ErrorKind::TimedOut - ) => - { - return Ok(None); - } - Err(err) => return Err(err), - }; - - stream.set_write_timeout(Some(timeout))?; - stream.set_read_timeout(Some(timeout))?; - - let request = Request { - id: "runtime:status".into(), - method: Method::Ping(crate::api::schema::PingParams::default()), - }; - stream.write_all(serde_json::to_string(&request)?.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - let read = reader.read_line(&mut line)?; - if read == 0 || line.trim().is_empty() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "empty server status response", - )); - } - - let response: serde_json::Value = serde_json::from_str(&line).map_err(io::Error::other)?; - if response.get("error").is_some() { - return Err(io::Error::other(format!( - "server status request failed: {response}" - ))); - } - - let result = &response["result"]; - Ok(Some(RuntimeStatus { - version: result - .get("version") - .and_then(|value| value.as_str()) - .map(str::to_owned), - protocol: result - .get("protocol") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()), - })) -} - -pub struct ServerHandle { - _thread: std::thread::JoinHandle<()>, - path: PathBuf, - running: Arc, -} - -impl Drop for ServerHandle { - fn drop(&mut self) { - self.running.store(false, Ordering::Relaxed); - - if let Err(err) = fs::remove_file(&self.path) { - if err.kind() != std::io::ErrorKind::NotFound { - warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); - } - } - } -} - -pub fn start_server( - api_tx: ApiRequestSender, - event_hub: EventHub, -) -> std::io::Result { - let path = socket_path(); - prepare_socket_path(&path)?; - - let listener = UnixListener::bind(&path)?; - restrict_socket_permissions(&path)?; - info!(path = %path.display(), "api server listening"); - - let running = Arc::new(AtomicBool::new(true)); - let listener_running = Arc::clone(&running); - let thread = std::thread::spawn(move || { - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let api_tx = api_tx.clone(); - let event_hub = event_hub.clone(); - let connection_running = Arc::clone(&listener_running); - std::thread::spawn(move || { - if let Err(err) = - handle_connection(stream, &api_tx, &event_hub, &connection_running) - { - warn!(err = %err, "api connection failed"); - } - }); - } - Err(err) => { - error!(err = %err, "api listener accept failed"); - break; - } - } - } - debug!("api server thread exiting"); - }); - - Ok(ServerHandle { - _thread: thread, - path, - running, - }) -} - -fn prepare_socket_path(path: &Path) -> std::io::Result<()> { - crate::ipc::prepare_socket_path(path, |path| { - format!( - "herdr is already running (socket busy at {})", - path.display() - ) - }) -} - -fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> { - crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) -} - -fn handle_connection( - mut stream: UnixStream, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - running: &Arc, -) -> std::io::Result<()> { - stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?; - stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?; - - let mut line = String::new(); - { - let mut reader = BufReader::new(&stream); - let read = reader.read_line(&mut line)?; - if read == 0 { - return Ok(()); - } - } - - stream.set_read_timeout(None)?; - - let line = line.trim(); - if line.is_empty() { - return Ok(()); - } - - let request = match serde_json::from_str::(line) { - Ok(request) => request, - Err(err) => { - write_json_line_allow_disconnect( - &mut stream, - &ErrorResponse { - id: String::new(), - error: ErrorBody { - code: "invalid_request".into(), - message: format!("invalid request: {err}"), - }, - }, - )?; - return Ok(()); - } - }; - - let request_id = request.id.clone(); - let method = api_method_name(&request.method); - let changes_ui = request_changes_ui(&request); - crate::logging::api_request_started(&request_id, method, changes_ui); - - match request.method { - Method::EventsSubscribe(params) => { - let result = stream_subscriptions( - stream, - request_id.clone(), - params, - api_tx, - event_hub, - running, - ); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - "stream_closed", - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - Method::PaneWaitForOutput(params) => { - let Some(response) = - wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - method_body => { - let response = handle_request( - Request { - id: request_id.clone(), - method: method_body, - }, - api_tx, - ); - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - } -} - -fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { - match request.method { - Method::Ping(_) => serde_json::to_string(&SuccessResponse { - id: request.id, - result: ResponseResult::Pong { - version: env!("CARGO_PKG_VERSION").into(), - protocol: crate::server::protocol::PROTOCOL_VERSION, - }, - }) - .unwrap_or_else(|_| { - r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# - .to_string() - }), - _ => dispatch_to_app(request, api_tx), - } -} - -fn api_method_name(method: &Method) -> &'static str { - match method { - Method::Ping(_) => "ping", - Method::ServerStop(_) => "server.stop", - Method::ServerReloadConfig(_) => "server.reload_config", - Method::WorkspaceCreate(_) => "workspace.create", - Method::WorkspaceList(_) => "workspace.list", - Method::WorkspaceGet(_) => "workspace.get", - Method::WorkspaceFocus(_) => "workspace.focus", - Method::WorkspaceRename(_) => "workspace.rename", - Method::WorkspaceClose(_) => "workspace.close", - Method::TabCreate(_) => "tab.create", - Method::TabList(_) => "tab.list", - Method::TabGet(_) => "tab.get", - Method::TabFocus(_) => "tab.focus", - Method::TabRename(_) => "tab.rename", - Method::TabClose(_) => "tab.close", - Method::AgentList(_) => "agent.list", - Method::AgentGet(_) => "agent.get", - Method::AgentRead(_) => "agent.read", - Method::AgentSend(_) => "agent.send", - Method::AgentRename(_) => "agent.rename", - Method::AgentFocus(_) => "agent.focus", - Method::AgentStart(_) => "agent.start", - Method::PaneSplit(_) => "pane.split", - Method::PaneList(_) => "pane.list", - Method::PaneGet(_) => "pane.get", - Method::PaneRename(_) => "pane.rename", - Method::PaneSendText(_) => "pane.send_text", - Method::PaneSendKeys(_) => "pane.send_keys", - Method::PaneSendInput(_) => "pane.send_input", - Method::PaneRead(_) => "pane.read", - Method::PaneReportAgent(_) => "pane.report_agent", - Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", - Method::PaneReleaseAgent(_) => "pane.release_agent", - Method::PaneClose(_) => "pane.close", - Method::EventsSubscribe(_) => "events.subscribe", - Method::EventsWait(_) => "events.wait", - Method::PaneWaitForOutput(_) => "pane.wait_for_output", - Method::IntegrationInstall(_) => "integration.install", - Method::IntegrationUninstall(_) => "integration.uninstall", - } -} - -fn api_response_outcome(response: &str) -> &'static str { - let Ok(value) = serde_json::from_str::(response) else { - return "error"; - }; - - match value - .get("error") - .and_then(|error| error.get("code")) - .and_then(|code| code.as_str()) - { - Some("timeout") => "timeout", - Some(_) => "error", - None => "ok", - } -} - -fn output_match_read_source( - source: &crate::api::schema::ReadSource, -) -> crate::api::schema::ReadSource { - match source { - crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped, - other => *other, - } -} - -fn wait_for_output( - request_id: String, - params: crate::api::schema::PaneWaitForOutputParams, - stream: &mut UnixStream, - api_tx: &ApiRequestSender, - running: &Arc, -) -> std::io::Result> { - crate::logging::api_wait_started(&request_id, ¶ms.pane_id, params.timeout_ms); - let deadline = params - .timeout_ms - .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); - - let regex = match ¶ms.r#match { - crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { - Ok(regex) => Some(regex), - Err(err) => { - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "invalid_regex".into(), - message: err.to_string(), - }, - }) - .unwrap(), - )); - } - }, - crate::api::schema::OutputMatch::Substring { .. } => None, - }; - - loop { - if should_stop_connection(stream, running)? { - crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "client_disconnected"); - return Ok(None); - } - - let read_request = Request { - id: format!("{request_id}:read"), - method: Method::PaneRead(crate::api::schema::PaneReadParams { - pane_id: params.pane_id.clone(), - source: output_match_read_source(¶ms.source), - lines: params.lines, - format: crate::api::schema::ReadFormat::Text, - strip_ansi: params.strip_ansi, - }), - }; - let response = - dispatch_to_app_with_timeout(read_request, api_tx, Some(APP_RESPONSE_TIMEOUT)); - let Ok(value) = serde_json::from_str::(&response) else { - return Ok(Some(response)); - }; - if value.get("error").is_some() { - let mut value = value; - value["id"] = serde_json::Value::String(request_id.clone()); - return Ok(Some(serde_json::to_string(&value).unwrap())); - } - - let read_value = value["result"]["read"].clone(); - let Ok(read) = serde_json::from_value::(read_value) - else { - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read result".into(), - }, - }) - .unwrap(), - )); - }; - - let matched_line = match_output(&read.text, ¶ms.r#match, regex.as_ref()); - if matched_line.is_some() { - let revision = read.revision; - crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "matched"); - return Ok(Some( - serde_json::to_string(&SuccessResponse { - id: request_id, - result: ResponseResult::OutputMatched { - pane_id: params.pane_id, - revision, - matched_line, - read, - }, - }) - .unwrap(), - )); - } - - if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - crate::logging::api_wait_timed_out(&request_id, ¶ms.pane_id); - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "timeout".into(), - message: "timed out waiting for output match".into(), - }, - }) - .unwrap(), - )); - } - - std::thread::sleep(CONNECTION_POLL_INTERVAL); - } -} - -fn stream_subscriptions( - mut stream: UnixStream, - request_id: String, - params: crate::api::schema::EventsSubscribeParams, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - running: &Arc, -) -> std::io::Result<()> { - let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); - for (index, subscription) in params.subscriptions.into_iter().enumerate() { - let active = - match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) { - Ok(active) => active, - Err(response) => { - if let Err(err) = write_json_line(&mut stream, &response) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - return Ok(()); - } - }; - subscriptions.push(active); - } - - if let Err(err) = write_json_line( - &mut stream, - &SuccessResponse { - id: request_id, - result: ResponseResult::SubscriptionStarted {}, - }, - ) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - - loop { - if should_stop_connection(&mut stream, running)? { - return Ok(()); - } - - for subscription in &mut subscriptions { - if let Some(event) = subscription.poll(api_tx, event_hub) { - if let Err(err) = write_json_line(&mut stream, &event) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - } - } - std::thread::sleep(CONNECTION_POLL_INTERVAL); - } -} - -fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { - stream.write_all(value.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush() -} - -fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { - match write_text_line(stream, value) { - Err(err) if is_connection_closed_error(&err) => Ok(()), - result => result, - } -} - -fn write_json_line(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { - let encoded = serde_json::to_string(value) - .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; - write_text_line(stream, &encoded) -} - -fn write_json_line_allow_disconnect( - stream: &mut UnixStream, - value: &T, -) -> std::io::Result<()> { - let encoded = serde_json::to_string(value) - .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; - write_text_line_allow_disconnect(stream, &encoded) -} - -fn should_stop_connection( - stream: &mut UnixStream, - running: &Arc, -) -> std::io::Result { - if !running.load(Ordering::Relaxed) { - return Ok(true); - } - - probe_stream_closed(stream) -} - -fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result { - stream.set_nonblocking(true)?; - let mut probe = [0u8; 1]; - let status = match stream.read(&mut probe) { - Ok(0) => Ok(true), - Ok(_) => Ok(true), - Err(err) - if matches!( - err.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted - ) => - { - Ok(false) - } - Err(err) if is_connection_closed_error(&err) => Ok(true), - Err(err) => Err(err), - }; - stream.set_nonblocking(false)?; - status -} - -fn is_connection_closed_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::NotConnected - | std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::WriteZero - ) -} - -fn match_output( - text: &str, - matcher: &crate::api::schema::OutputMatch, - regex: Option<&Regex>, -) -> Option { - match matcher { - crate::api::schema::OutputMatch::Substring { value } => text - .lines() - .find(|line| line.contains(value)) - .map(|line| line.to_string()), - crate::api::schema::OutputMatch::Regex { .. } => regex.and_then(|re| { - text.lines() - .find(|line| re.is_match(line)) - .map(|line| line.to_string()) - }), - } -} - -struct ActiveOutputMatchedSubscription { - pane_id: String, - source: crate::api::schema::ReadSource, - lines: Option, - matcher: crate::api::schema::OutputMatch, - regex: Option, - strip_ansi: bool, - currently_matching: bool, - request_prefix: String, -} - -struct ActiveAgentStatusChangedSubscription { - pane_id: String, - status_filter: Option, - last_status: Option, - request_prefix: String, -} - -struct ActiveEventSubscription { - event_kind: crate::api::schema::EventKind, - last_sequence: u64, -} - -enum ActiveSubscription { - Event(ActiveEventSubscription), - OutputMatched(ActiveOutputMatchedSubscription), - AgentStatusChanged(ActiveAgentStatusChangedSubscription), -} - -impl ActiveSubscription { - fn new( - subscription: Subscription, - request_id: &str, - index: usize, - api_tx: &ApiRequestSender, - _event_hub: &EventHub, - ) -> Result { - match subscription { - Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceCreated, - last_sequence: 0, - })), - Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceClosed, - last_sequence: 0, - })), - Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceFocused, - last_sequence: 0, - })), - Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabCreated, - last_sequence: 0, - })), - Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabClosed, - last_sequence: 0, - })), - Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabFocused, - last_sequence: 0, - })), - Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabRenamed, - last_sequence: 0, - })), - Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneCreated, - last_sequence: 0, - })), - Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneClosed, - last_sequence: 0, - })), - Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneFocused, - last_sequence: 0, - })), - Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneExited, - last_sequence: 0, - })), - Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneAgentDetected, - last_sequence: 0, - })), - Subscription::PaneOutputMatched { - pane_id, - source, - lines, - r#match, - strip_ansi, - } => { - let regex = match &r#match { - crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { - Ok(regex) => Some(regex), - Err(err) => { - return Err(ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_regex".into(), - message: err.to_string(), - }, - }); - } - }, - crate::api::schema::OutputMatch::Substring { .. } => None, - }; - - let probe = pane_read( - format!("{request_id}:sub:{index}:probe"), - &pane_id, - source, - lines, - strip_ansi, - api_tx, - ); - probe?; - - Ok(Self::OutputMatched(ActiveOutputMatchedSubscription { - pane_id, - source, - lines, - matcher: r#match, - regex, - strip_ansi, - currently_matching: false, - request_prefix: format!("{request_id}:sub:{index}"), - })) - } - Subscription::PaneAgentStatusChanged { - pane_id, - agent_status, - } => { - let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?; - - Ok(Self::AgentStatusChanged( - ActiveAgentStatusChangedSubscription { - pane_id, - status_filter: agent_status, - last_status: Some(probe.agent_status), - request_prefix: format!("{request_id}:sub:{index}"), - }, - )) - } - } - } - - fn poll( - &mut self, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - ) -> Option { - match self { - Self::Event(subscription) => subscription.poll(event_hub), - Self::OutputMatched(subscription) => { - serde_json::to_value(subscription.poll(api_tx)?).ok() - } - Self::AgentStatusChanged(subscription) => { - serde_json::to_value(subscription.poll(api_tx)?).ok() - } - } - } -} - -impl ActiveEventSubscription { - fn poll(&mut self, event_hub: &EventHub) -> Option { - for (sequence, event) in event_hub.events_after(self.last_sequence) { - self.last_sequence = sequence; - if event.event == self.event_kind { - return serde_json::to_value(event).ok(); - } - } - None - } -} - -impl ActiveOutputMatchedSubscription { - fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { - let read = pane_read( - format!("{}:read", self.request_prefix), - &self.pane_id, - output_match_read_source(&self.source), - self.lines, - self.strip_ansi, - api_tx, - ) - .ok()?; - - let matched_line = match_output(&read.text, &self.matcher, self.regex.as_ref()); - match matched_line { - Some(matched_line) => { - if self.currently_matching { - return None; - } - self.currently_matching = true; - Some(SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneOutputMatched, - data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { - pane_id: self.pane_id.clone(), - matched_line, - read, - }), - }) - } - None => { - self.currently_matching = false; - None - } - } - } -} - -impl ActiveAgentStatusChangedSubscription { - fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { - let pane = pane_get( - format!("{}:pane", self.request_prefix), - &self.pane_id, - api_tx, - ) - .ok()?; - let current_status = pane.agent_status; - let previous_status = self.last_status.replace(current_status); - if previous_status.is_none() || previous_status == Some(current_status) { - return None; - } - if self - .status_filter - .is_some_and(|wanted| wanted != current_status) - { - return None; - } - - Some(SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneAgentStatusChanged, - data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { - pane_id: pane.pane_id, - workspace_id: pane.workspace_id, - agent_status: current_status, - agent: pane.agent, - custom_status: pane.custom_status, - }), - }) - } -} - -fn pane_read( - request_id: String, - pane_id: &str, - source: crate::api::schema::ReadSource, - lines: Option, - strip_ansi: bool, - api_tx: &ApiRequestSender, -) -> Result { - let response = dispatch_to_app_with_timeout( - Request { - id: request_id.clone(), - method: Method::PaneRead(crate::api::schema::PaneReadParams { - pane_id: pane_id.to_string(), - source, - lines, - format: crate::api::schema::ReadFormat::Text, - strip_ansi, - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); - let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { - id: request_id.clone(), - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read response".into(), - }, - })?; - if value.get("error").is_some() { - return serde_json::from_value(value).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read error".into(), - }, - }); - } - serde_json::from_value(value["result"]["read"].clone()).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read result".into(), - }, - }) -} - -fn pane_get( - request_id: String, - pane_id: &str, - api_tx: &ApiRequestSender, -) -> Result { - let response = dispatch_to_app_with_timeout( - Request { - id: request_id.clone(), - method: Method::PaneGet(crate::api::schema::PaneTarget { - pane_id: pane_id.to_string(), - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); - let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { - id: request_id.clone(), - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get response".into(), - }, - })?; - if value.get("error").is_some() { - return serde_json::from_value(value).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get error".into(), - }, - }); - } - serde_json::from_value(value["result"]["pane"].clone()).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get result".into(), - }, - }) -} - -fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { - dispatch_to_app_with_timeout(request, api_tx, None) -} - -fn dispatch_to_app_with_timeout( - request: Request, - api_tx: &ApiRequestSender, - timeout: Option, -) -> String { - let request_id = request.id.clone(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if let Err(err) = api_tx.send(ApiRequestMessage { - request, - respond_to, - }) { - return error_response_json( - request_id, - "server_unavailable", - format!("failed to dispatch request: {err}"), - ); - } - - let response = match timeout { - Some(timeout) => response_rx.recv_timeout(timeout).map_err(|err| match err { - std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!( - "timed out waiting for app response after {} ms", - timeout.as_millis() - ), - ), - std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "app response channel closed", - ), - }), - None => response_rx - .recv() - .map_err(|err| std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)), - }; - - match response { - Ok(response) => response, - Err(err) => error_response_json( - request_id, - "server_unavailable", - format!("request handling failed: {err}"), - ), - } -} - -fn error_response_json(id: String, code: &str, message: String) -> String { - serde_json::to_string(&ErrorResponse { - id, - error: ErrorBody { - code: code.into(), - message, - }, - }) - .unwrap_or_else(|_| { - r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"# - .to_string() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::fs::PermissionsExt; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - fn unique_test_path(name: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) - } - - fn read_line(stream: &mut UnixStream) -> String { - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - line - } - - #[test] - fn socket_path_prefers_explicit_env_override() { - let _guard = env_lock().lock().unwrap(); - let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var(SOCKET_PATH_ENV_VAR, &unique); - assert_eq!(socket_path(), PathBuf::from(&unique)); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - } - - #[test] - fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() { - let _guard = env_lock().lock().unwrap(); - let config_home = unique_test_path("socket-default-config-home"); - let runtime_dir = unique_test_path("socket-default-runtime"); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var("XDG_CONFIG_HOME", &config_home); - std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); - - let expected = config_home - .join(crate::config::app_dir_name()) - .join("herdr.sock"); - assert_eq!(socket_path(), expected); - - std::env::remove_var("XDG_CONFIG_HOME"); - std::env::remove_var("XDG_RUNTIME_DIR"); - } - - #[test] - fn socket_path_uses_named_session_dir() { - let _guard = env_lock().lock().unwrap(); - let config_home = unique_test_path("socket-named-config-home"); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); - std::env::set_var("XDG_CONFIG_HOME", &config_home); - - let expected = config_home - .join(crate::config::app_dir_name()) - .join("sessions") - .join("work") - .join("herdr.sock"); - assert_eq!(socket_path(), expected); - - std::env::remove_var(crate::session::SESSION_ENV_VAR); - std::env::remove_var("XDG_CONFIG_HOME"); - } - - #[test] - fn restrict_socket_permissions_sets_user_only_mode() { - let dir = unique_test_path("socket-perms"); - fs::create_dir_all(&dir).unwrap(); - let path = dir.join("api.sock"); - let _listener = UnixListener::bind(&path).unwrap(); - - restrict_socket_permissions(&path).unwrap(); - - let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, SOCKET_PERMISSION_MODE); - - drop(_listener); - let _ = fs::remove_file(&path); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn api_response_outcome_uses_top_level_error_shape() { - let ok_with_error_text = r#"{"id":"req","result":{"read":{"text":"user said \"error\": \"timeout\"","revision":1}}}"#; - assert_eq!(api_response_outcome(ok_with_error_text), "ok"); - - let timeout = r#"{"id":"req","error":{"code":"timeout","message":"timed out waiting for output match"}}"#; - assert_eq!(api_response_outcome(timeout), "timeout"); - - let generic_error = - r#"{"id":"req","error":{"code":"server_unavailable","message":"boom"}}"#; - assert_eq!(api_response_outcome(generic_error), "error"); - } - - #[test] - fn ping_request_returns_pong() { - let (tx, _rx) = mpsc::unbounded_channel(); - let response = handle_request( - Request { - id: "req_1".into(), - method: Method::Ping(crate::api::schema::PingParams::default()), - }, - &tx, - ); - - let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.id, "req_1"); - assert!(matches!(parsed.result, ResponseResult::Pong { .. })); - } - - #[test] - fn request_dispatches_to_app_channel() { - let (tx, mut rx) = mpsc::unbounded_channel(); - let request = Request { - id: "req_2".into(), - method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), - }; - - let request_for_thread = request.clone(); - let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); - - let msg = rx.blocking_recv().unwrap(); - assert_eq!(msg.request.id, "req_2"); - msg.respond_to - .send( - serde_json::to_string(&SuccessResponse { - id: "req_2".into(), - result: ResponseResult::Ok {}, - }) - .unwrap(), - ) - .unwrap(); - - let response = thread.join().unwrap(); - let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.id, "req_2"); - } - - #[test] - fn wait_for_output_stops_when_client_disconnects() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let (first_read_tx, first_read_rx) = std::sync::mpsc::channel(); - let responder = std::thread::spawn(move || { - let mut notified = false; - while let Some(msg) = api_rx.blocking_recv() { - assert!(matches!(msg.request.method, Method::PaneRead(_))); - if !notified { - first_read_tx.send(()).unwrap(); - notified = true; - } - msg.respond_to - .send( - serde_json::to_string(&SuccessResponse { - id: msg.request.id, - result: ResponseResult::PaneRead { - read: crate::api::schema::PaneReadResult { - pane_id: "pane_1".into(), - workspace_id: "ws_1".into(), - tab_id: "tab_1".into(), - source: crate::api::schema::ReadSource::RecentUnwrapped, - format: crate::api::schema::ReadFormat::Text, - text: String::new(), - revision: 0, - truncated: false, - }, - }, - }) - .unwrap(), - ) - .unwrap(); - } - }); - - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - first_read_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - drop(client); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - - server_thread.join().unwrap(); - drop(running); - responder.join().unwrap(); - } - - #[test] - fn subscriptions_stop_when_client_disconnects() { - let (api_tx, _api_rx) = mpsc::unbounded_channel::(); - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all( - br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, - ) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - let ack = read_line(&mut client); - let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); - assert_eq!(ack["result"]["type"], "subscription_started"); - - drop(client); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - server_thread.join().unwrap(); - } - - #[test] - fn subscriptions_stop_when_server_shuts_down() { - let (api_tx, _api_rx) = mpsc::unbounded_channel::(); - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all( - br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, - ) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - let ack = read_line(&mut client); - let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); - assert_eq!(ack["result"]["type"], "subscription_started"); - - running.store(false, Ordering::Relaxed); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - server_thread.join().unwrap(); - } -} diff --git a/src/api/server.rs b/src/api/server.rs new file mode 100644 index 00000000..2f9a423e --- /dev/null +++ b/src/api/server.rs @@ -0,0 +1,769 @@ +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tracing::{debug, error, info, warn}; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse, +}; +use crate::api::subscriptions::ActiveSubscription; +use crate::api::wait::wait_for_output; +use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; + +const SOCKET_PERMISSION_MODE: u32 = 0o600; +pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); +pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); +const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct ServerHandle { + _thread: std::thread::JoinHandle<()>, + path: PathBuf, + running: Arc, +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != std::io::ErrorKind::NotFound { + warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); + } + } + } +} + +pub fn start_server( + api_tx: ApiRequestSender, + event_hub: EventHub, +) -> std::io::Result { + let path = socket_path(); + prepare_socket_path(&path)?; + + let listener = UnixListener::bind(&path)?; + restrict_socket_permissions(&path)?; + info!(path = %path.display(), "api server listening"); + + let running = Arc::new(AtomicBool::new(true)); + let listener_running = Arc::clone(&running); + let thread = std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let api_tx = api_tx.clone(); + let event_hub = event_hub.clone(); + let connection_running = Arc::clone(&listener_running); + std::thread::spawn(move || { + if let Err(err) = + handle_connection(stream, &api_tx, &event_hub, &connection_running) + { + warn!(err = %err, "api connection failed"); + } + }); + } + Err(err) => { + error!(err = %err, "api listener accept failed"); + break; + } + } + } + debug!("api server thread exiting"); + }); + + Ok(ServerHandle { + _thread: thread, + path, + running, + }) +} + +fn prepare_socket_path(path: &Path) -> std::io::Result<()> { + crate::ipc::prepare_socket_path(path, |path| { + format!( + "herdr is already running (socket busy at {})", + path.display() + ) + }) +} + +fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> { + crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) +} + +fn handle_connection( + mut stream: UnixStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc, +) -> std::io::Result<()> { + stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?; + stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?; + + let mut line = String::new(); + { + let mut reader = BufReader::new(&stream); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(()); + } + } + + stream.set_read_timeout(None)?; + + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let request = match serde_json::from_str::(line) { + Ok(request) => request, + Err(err) => { + write_json_line_allow_disconnect( + &mut stream, + &ErrorResponse { + id: String::new(), + error: ErrorBody { + code: "invalid_request".into(), + message: format!("invalid request: {err}"), + }, + }, + )?; + return Ok(()); + } + }; + + let request_id = request.id.clone(); + let method = api_method_name(&request.method); + let changes_ui = request_changes_ui(&request); + crate::logging::api_request_started(&request_id, method, changes_ui); + + match request.method { + Method::EventsSubscribe(params) => { + let result = stream_subscriptions( + stream, + request_id.clone(), + params, + api_tx, + event_hub, + running, + ); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + "stream_closed", + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + Method::PaneWaitForOutput(params) => { + let Some(response) = + wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? + else { + crate::logging::api_request_completed( + &request_id, + method, + "client_disconnected", + changes_ui, + ); + return Ok(()); + }; + let result = write_text_line_allow_disconnect(&mut stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + method_body => { + let response = handle_request( + Request { + id: request_id.clone(), + method: method_body, + }, + api_tx, + ); + let result = write_text_line_allow_disconnect(&mut stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + } +} + +fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { + match request.method { + Method::Ping(_) => serde_json::to_string(&SuccessResponse { + id: request.id, + result: ResponseResult::Pong { + version: env!("CARGO_PKG_VERSION").into(), + protocol: crate::protocol::PROTOCOL_VERSION, + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# + .to_string() + }), + _ => dispatch_to_app(request, api_tx), + } +} + +fn api_method_name(method: &Method) -> &'static str { + match method { + Method::Ping(_) => "ping", + Method::ServerStop(_) => "server.stop", + Method::ServerReloadConfig(_) => "server.reload_config", + Method::WorkspaceCreate(_) => "workspace.create", + Method::WorkspaceList(_) => "workspace.list", + Method::WorkspaceGet(_) => "workspace.get", + Method::WorkspaceFocus(_) => "workspace.focus", + Method::WorkspaceRename(_) => "workspace.rename", + Method::WorkspaceClose(_) => "workspace.close", + Method::TabCreate(_) => "tab.create", + Method::TabList(_) => "tab.list", + Method::TabGet(_) => "tab.get", + Method::TabFocus(_) => "tab.focus", + Method::TabRename(_) => "tab.rename", + Method::TabClose(_) => "tab.close", + Method::AgentList(_) => "agent.list", + Method::AgentGet(_) => "agent.get", + Method::AgentRead(_) => "agent.read", + Method::AgentSend(_) => "agent.send", + Method::AgentRename(_) => "agent.rename", + Method::AgentFocus(_) => "agent.focus", + Method::AgentStart(_) => "agent.start", + Method::PaneSplit(_) => "pane.split", + Method::PaneList(_) => "pane.list", + Method::PaneGet(_) => "pane.get", + Method::PaneRename(_) => "pane.rename", + Method::PaneSendText(_) => "pane.send_text", + Method::PaneSendKeys(_) => "pane.send_keys", + Method::PaneSendInput(_) => "pane.send_input", + Method::PaneRead(_) => "pane.read", + Method::PaneReportAgent(_) => "pane.report_agent", + Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", + Method::PaneReleaseAgent(_) => "pane.release_agent", + Method::PaneClose(_) => "pane.close", + Method::EventsSubscribe(_) => "events.subscribe", + Method::EventsWait(_) => "events.wait", + Method::PaneWaitForOutput(_) => "pane.wait_for_output", + Method::IntegrationInstall(_) => "integration.install", + Method::IntegrationUninstall(_) => "integration.uninstall", + } +} + +fn api_response_outcome(response: &str) -> &'static str { + let Ok(value) = serde_json::from_str::(response) else { + return "error"; + }; + + match value + .get("error") + .and_then(|error| error.get("code")) + .and_then(|code| code.as_str()) + { + Some("timeout") => "timeout", + Some(_) => "error", + None => "ok", + } +} + +fn stream_subscriptions( + mut stream: UnixStream, + request_id: String, + params: crate::api::schema::EventsSubscribeParams, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc, +) -> std::io::Result<()> { + let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); + for (index, subscription) in params.subscriptions.into_iter().enumerate() { + let active = + match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) { + Ok(active) => active, + Err(response) => { + if let Err(err) = write_json_line(&mut stream, &response) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + return Ok(()); + } + }; + subscriptions.push(active); + } + + if let Err(err) = write_json_line( + &mut stream, + &SuccessResponse { + id: request_id, + result: ResponseResult::SubscriptionStarted {}, + }, + ) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + + loop { + if should_stop_connection(&mut stream, running)? { + return Ok(()); + } + + for subscription in &mut subscriptions { + if let Some(event) = subscription.poll(api_tx, event_hub) { + if let Err(err) = write_json_line(&mut stream, &event) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + } + } + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} + +fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { + stream.write_all(value.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush() +} + +fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { + match write_text_line(stream, value) { + Err(err) if is_connection_closed_error(&err) => Ok(()), + result => result, + } +} + +fn write_json_line(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { + let encoded = serde_json::to_string(value) + .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; + write_text_line(stream, &encoded) +} + +fn write_json_line_allow_disconnect( + stream: &mut UnixStream, + value: &T, +) -> std::io::Result<()> { + let encoded = serde_json::to_string(value) + .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; + write_text_line_allow_disconnect(stream, &encoded) +} + +pub(super) fn should_stop_connection( + stream: &mut UnixStream, + running: &Arc, +) -> std::io::Result { + if !running.load(Ordering::Relaxed) { + return Ok(true); + } + + probe_stream_closed(stream) +} + +fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result { + stream.set_nonblocking(true)?; + let mut probe = [0u8; 1]; + let status = match stream.read(&mut probe) { + Ok(0) => Ok(true), + Ok(_) => Ok(true), + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => + { + Ok(false) + } + Err(err) if is_connection_closed_error(&err) => Ok(true), + Err(err) => Err(err), + }; + stream.set_nonblocking(false)?; + status +} + +fn is_connection_closed_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::WriteZero + ) +} + +fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { + dispatch_to_app_with_timeout(request, api_tx, None) +} + +pub(super) fn dispatch_to_app_with_timeout( + request: Request, + api_tx: &ApiRequestSender, + timeout: Option, +) -> String { + let request_id = request.id.clone(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = api_tx.send(ApiRequestMessage { + request, + respond_to, + }) { + return error_response_json( + request_id, + "server_unavailable", + format!("failed to dispatch request: {err}"), + ); + } + + let response = match timeout { + Some(timeout) => response_rx.recv_timeout(timeout).map_err(|err| match err { + std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "timed out waiting for app response after {} ms", + timeout.as_millis() + ), + ), + std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "app response channel closed", + ), + }), + None => response_rx + .recv() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)), + }; + + match response { + Ok(response) => response, + Err(err) => error_response_json( + request_id, + "server_unavailable", + format!("request handling failed: {err}"), + ), + } +} + +fn error_response_json(id: String, code: &str, message: String) -> String { + serde_json::to_string(&ErrorResponse { + id, + error: ErrorBody { + code: code.into(), + message, + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"# + .to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::sync::{Mutex, OnceLock}; + use tokio::sync::mpsc; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn unique_test_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn read_line(stream: &mut UnixStream) -> String { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + #[test] + fn socket_path_prefers_explicit_env_override() { + let _guard = env_lock().lock().unwrap(); + let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &unique); + assert_eq!(socket_path(), PathBuf::from(&unique)); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() { + let _guard = env_lock().lock().unwrap(); + let config_home = unique_test_path("socket-default-config-home"); + let runtime_dir = unique_test_path("socket-default-runtime"); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); + + let expected = config_home + .join(crate::config::app_dir_name()) + .join("herdr.sock"); + assert_eq!(socket_path(), expected); + + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + } + + #[test] + fn socket_path_uses_named_session_dir() { + let _guard = env_lock().lock().unwrap(); + let config_home = unique_test_path("socket-named-config-home"); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + + let expected = config_home + .join(crate::config::app_dir_name()) + .join("sessions") + .join("work") + .join("herdr.sock"); + assert_eq!(socket_path(), expected); + + std::env::remove_var(crate::session::SESSION_ENV_VAR); + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn restrict_socket_permissions_sets_user_only_mode() { + let dir = unique_test_path("socket-perms"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("api.sock"); + let _listener = UnixListener::bind(&path).unwrap(); + + restrict_socket_permissions(&path).unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, SOCKET_PERMISSION_MODE); + + drop(_listener); + let _ = fs::remove_file(&path); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn api_response_outcome_uses_top_level_error_shape() { + let ok_with_error_text = r#"{"id":"req","result":{"read":{"text":"user said \"error\": \"timeout\"","revision":1}}}"#; + assert_eq!(api_response_outcome(ok_with_error_text), "ok"); + + let timeout = r#"{"id":"req","error":{"code":"timeout","message":"timed out waiting for output match"}}"#; + assert_eq!(api_response_outcome(timeout), "timeout"); + + let generic_error = + r#"{"id":"req","error":{"code":"server_unavailable","message":"boom"}}"#; + assert_eq!(api_response_outcome(generic_error), "error"); + } + + #[test] + fn ping_request_returns_pong() { + let (tx, _rx) = mpsc::unbounded_channel(); + let response = handle_request( + Request { + id: "req_1".into(), + method: Method::Ping(crate::api::schema::PingParams::default()), + }, + &tx, + ); + + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_1"); + assert!(matches!(parsed.result, ResponseResult::Pong { .. })); + } + + #[test] + fn request_dispatches_to_app_channel() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let request = Request { + id: "req_2".into(), + method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), + }; + + let request_for_thread = request.clone(); + let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); + + let msg = rx.blocking_recv().unwrap(); + assert_eq!(msg.request.id, "req_2"); + msg.respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: "req_2".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(), + ) + .unwrap(); + + let response = thread.join().unwrap(); + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_2"); + } + + #[test] + fn wait_for_output_stops_when_client_disconnects() { + let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); + let (first_read_tx, first_read_rx) = std::sync::mpsc::channel(); + let responder = std::thread::spawn(move || { + let mut notified = false; + while let Some(msg) = api_rx.blocking_recv() { + assert!(matches!(msg.request.method, Method::PaneRead(_))); + if !notified { + first_read_tx.send(()).unwrap(); + notified = true; + } + msg.respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: msg.request.id, + result: ResponseResult::PaneRead { + read: crate::api::schema::PaneReadResult { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + source: crate::api::schema::ReadSource::RecentUnwrapped, + format: crate::api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + }, + }) + .unwrap(), + ) + .unwrap(); + } + }); + + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + first_read_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + drop(client); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + + server_thread.join().unwrap(); + drop(running); + responder.join().unwrap(); + } + + #[test] + fn subscriptions_stop_when_client_disconnects() { + let (api_tx, _api_rx) = mpsc::unbounded_channel::(); + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all( + br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, + ) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + let ack = read_line(&mut client); + let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); + assert_eq!(ack["result"]["type"], "subscription_started"); + + drop(client); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + server_thread.join().unwrap(); + } + + #[test] + fn subscriptions_stop_when_server_shuts_down() { + let (api_tx, _api_rx) = mpsc::unbounded_channel::(); + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all( + br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, + ) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + let ack = read_line(&mut client); + let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); + assert_eq!(ack["result"]["type"], "subscription_started"); + + running.store(false, Ordering::Relaxed); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + server_thread.join().unwrap(); + } +} diff --git a/src/api/status.rs b/src/api/status.rs new file mode 100644 index 00000000..f58e69c0 --- /dev/null +++ b/src/api/status.rs @@ -0,0 +1,54 @@ +use std::io; +use std::path::Path; +use std::time::Duration; + +use crate::api::schema::{Method, Request, ResponseResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeStatus { + pub version: Option, + pub protocol: Option, +} + +pub fn read_runtime_status_at( + socket_path: &Path, + timeout: Duration, +) -> io::Result> { + if !socket_path.exists() { + return Ok(None); + } + + let client = crate::api::client::ApiClient::for_target( + crate::api::client::ConnectionTarget::SocketPath(socket_path.to_path_buf()), + ); + let request = Request { + id: "runtime:status".into(), + method: Method::Ping(crate::api::schema::PingParams::default()), + }; + let response = client + .request_value_with_timeout(&request, timeout) + .and_then(crate::api::client::parse_response_value); + let response = match response { + Ok(response) => response, + Err(crate::api::client::ApiClientError::Io(err)) + if matches!( + err.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotFound + | io::ErrorKind::TimedOut + ) => + { + return Ok(None); + } + Err(err) => return Err(io::Error::other(err)), + }; + match response.result { + ResponseResult::Pong { version, protocol } => Ok(Some(RuntimeStatus { + version: Some(version), + protocol: Some(protocol), + })), + result => Err(io::Error::other(format!( + "server status request returned unexpected result: {result:?}" + ))), + } +} diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs new file mode 100644 index 00000000..891b743a --- /dev/null +++ b/src/api/subscriptions.rs @@ -0,0 +1,368 @@ +use regex::Regex; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request, + Subscription, SubscriptionEventData, SubscriptionEventEnvelope, SubscriptionEventKind, +}; +use crate::api::server::{dispatch_to_app_with_timeout, APP_RESPONSE_TIMEOUT}; +use crate::api::{ApiRequestSender, EventHub}; + +pub(super) fn output_match_read_source( + source: &crate::api::schema::ReadSource, +) -> crate::api::schema::ReadSource { + match source { + crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped, + other => *other, + } +} + +pub(super) fn match_output( + text: &str, + matcher: &crate::api::schema::OutputMatch, + regex: Option<&Regex>, +) -> Option { + match matcher { + crate::api::schema::OutputMatch::Substring { value } => text + .lines() + .find(|line| line.contains(value)) + .map(|line| line.to_string()), + crate::api::schema::OutputMatch::Regex { .. } => regex.and_then(|re| { + text.lines() + .find(|line| re.is_match(line)) + .map(|line| line.to_string()) + }), + } +} + +pub(super) struct ActiveOutputMatchedSubscription { + pane_id: String, + source: crate::api::schema::ReadSource, + lines: Option, + matcher: crate::api::schema::OutputMatch, + regex: Option, + strip_ansi: bool, + currently_matching: bool, + request_prefix: String, +} + +pub(super) struct ActiveAgentStatusChangedSubscription { + pane_id: String, + status_filter: Option, + last_status: Option, + request_prefix: String, +} + +pub(super) struct ActiveEventSubscription { + event_kind: crate::api::schema::EventKind, + last_sequence: u64, +} + +pub(super) enum ActiveSubscription { + Event(ActiveEventSubscription), + OutputMatched(ActiveOutputMatchedSubscription), + AgentStatusChanged(ActiveAgentStatusChangedSubscription), +} + +impl ActiveSubscription { + pub(super) fn new( + subscription: Subscription, + request_id: &str, + index: usize, + api_tx: &ApiRequestSender, + _event_hub: &EventHub, + ) -> Result { + match subscription { + Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceCreated, + last_sequence: 0, + })), + Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceClosed, + last_sequence: 0, + })), + Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceFocused, + last_sequence: 0, + })), + Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabCreated, + last_sequence: 0, + })), + Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabClosed, + last_sequence: 0, + })), + Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabFocused, + last_sequence: 0, + })), + Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabRenamed, + last_sequence: 0, + })), + Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneCreated, + last_sequence: 0, + })), + Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneClosed, + last_sequence: 0, + })), + Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneFocused, + last_sequence: 0, + })), + Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneExited, + last_sequence: 0, + })), + Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneAgentDetected, + last_sequence: 0, + })), + Subscription::PaneOutputMatched { + pane_id, + source, + lines, + r#match, + strip_ansi, + } => { + let regex = match &r#match { + crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { + Ok(regex) => Some(regex), + Err(err) => { + return Err(ErrorResponse { + id: request_id.to_string(), + error: ErrorBody { + code: "invalid_regex".into(), + message: err.to_string(), + }, + }); + } + }, + crate::api::schema::OutputMatch::Substring { .. } => None, + }; + + let probe = pane_read( + format!("{request_id}:sub:{index}:probe"), + &pane_id, + source, + lines, + strip_ansi, + api_tx, + ); + probe?; + + Ok(Self::OutputMatched(ActiveOutputMatchedSubscription { + pane_id, + source, + lines, + matcher: r#match, + regex, + strip_ansi, + currently_matching: false, + request_prefix: format!("{request_id}:sub:{index}"), + })) + } + Subscription::PaneAgentStatusChanged { + pane_id, + agent_status, + } => { + let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?; + + Ok(Self::AgentStatusChanged( + ActiveAgentStatusChangedSubscription { + pane_id, + status_filter: agent_status, + last_status: Some(probe.agent_status), + request_prefix: format!("{request_id}:sub:{index}"), + }, + )) + } + } + } + + pub(super) fn poll( + &mut self, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + ) -> Option { + match self { + Self::Event(subscription) => subscription.poll(event_hub), + Self::OutputMatched(subscription) => { + serde_json::to_value(subscription.poll(api_tx)?).ok() + } + Self::AgentStatusChanged(subscription) => { + serde_json::to_value(subscription.poll(api_tx)?).ok() + } + } + } +} + +impl ActiveEventSubscription { + fn poll(&mut self, event_hub: &EventHub) -> Option { + for (sequence, event) in event_hub.events_after(self.last_sequence) { + self.last_sequence = sequence; + if event.event == self.event_kind { + return serde_json::to_value(event).ok(); + } + } + None + } +} + +impl ActiveOutputMatchedSubscription { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { + let read = pane_read( + format!("{}:read", self.request_prefix), + &self.pane_id, + output_match_read_source(&self.source), + self.lines, + self.strip_ansi, + api_tx, + ) + .ok()?; + + let matched_line = match_output(&read.text, &self.matcher, self.regex.as_ref()); + match matched_line { + Some(matched_line) => { + if self.currently_matching { + return None; + } + self.currently_matching = true; + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneOutputMatched, + data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { + pane_id: self.pane_id.clone(), + matched_line, + read, + }), + }) + } + None => { + self.currently_matching = false; + None + } + } + } +} + +impl ActiveAgentStatusChangedSubscription { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { + let pane = pane_get( + format!("{}:pane", self.request_prefix), + &self.pane_id, + api_tx, + ) + .ok()?; + let current_status = pane.agent_status; + let previous_status = self.last_status.replace(current_status); + if previous_status.is_none() || previous_status == Some(current_status) { + return None; + } + if self + .status_filter + .is_some_and(|wanted| wanted != current_status) + { + return None; + } + + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneAgentStatusChanged, + data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + agent_status: current_status, + agent: pane.agent, + custom_status: pane.custom_status, + }), + }) + } +} + +fn pane_read( + request_id: String, + pane_id: &str, + source: crate::api::schema::ReadSource, + lines: Option, + strip_ansi: bool, + api_tx: &ApiRequestSender, +) -> Result { + let response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::PaneRead(crate::api::schema::PaneReadParams { + pane_id: pane_id.to_string(), + source, + lines, + format: crate::api::schema::ReadFormat::Text, + strip_ansi, + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { + id: request_id.clone(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read response".into(), + }, + })?; + if value.get("error").is_some() { + return serde_json::from_value(value).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read error".into(), + }, + }); + } + serde_json::from_value(value["result"]["read"].clone()).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read result".into(), + }, + }) +} + +fn pane_get( + request_id: String, + pane_id: &str, + api_tx: &ApiRequestSender, +) -> Result { + let response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::PaneGet(crate::api::schema::PaneTarget { + pane_id: pane_id.to_string(), + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { + id: request_id.clone(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get response".into(), + }, + })?; + if value.get("error").is_some() { + return serde_json::from_value(value).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get error".into(), + }, + }); + } + serde_json::from_value(value["result"]["pane"].clone()).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get result".into(), + }, + }) +} diff --git a/src/api/wait.rs b/src/api/wait.rs new file mode 100644 index 00000000..4bce14f2 --- /dev/null +++ b/src/api/wait.rs @@ -0,0 +1,124 @@ +use std::os::unix::net::UnixStream; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use regex::Regex; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse, +}; +use crate::api::server::{ + dispatch_to_app_with_timeout, should_stop_connection, APP_RESPONSE_TIMEOUT, + CONNECTION_POLL_INTERVAL, +}; +use crate::api::subscriptions::{match_output, output_match_read_source}; +use crate::api::ApiRequestSender; + +pub(super) fn wait_for_output( + request_id: String, + params: crate::api::schema::PaneWaitForOutputParams, + stream: &mut UnixStream, + api_tx: &ApiRequestSender, + running: &Arc, +) -> std::io::Result> { + crate::logging::api_wait_started(&request_id, ¶ms.pane_id, params.timeout_ms); + let deadline = params + .timeout_ms + .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); + + let regex = match ¶ms.r#match { + crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { + Ok(regex) => Some(regex), + Err(err) => { + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "invalid_regex".into(), + message: err.to_string(), + }, + }) + .unwrap(), + )); + } + }, + crate::api::schema::OutputMatch::Substring { .. } => None, + }; + + loop { + if should_stop_connection(stream, running)? { + crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "client_disconnected"); + return Ok(None); + } + + let read_request = Request { + id: format!("{request_id}:read"), + method: Method::PaneRead(crate::api::schema::PaneReadParams { + pane_id: params.pane_id.clone(), + source: output_match_read_source(¶ms.source), + lines: params.lines, + format: crate::api::schema::ReadFormat::Text, + strip_ansi: params.strip_ansi, + }), + }; + let response = + dispatch_to_app_with_timeout(read_request, api_tx, Some(APP_RESPONSE_TIMEOUT)); + let Ok(value) = serde_json::from_str::(&response) else { + return Ok(Some(response)); + }; + if value.get("error").is_some() { + let mut value = value; + value["id"] = serde_json::Value::String(request_id.clone()); + return Ok(Some(serde_json::to_string(&value).unwrap())); + } + + let read_value = value["result"]["read"].clone(); + let Ok(read) = serde_json::from_value::(read_value) + else { + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read result".into(), + }, + }) + .unwrap(), + )); + }; + + let matched_line = match_output(&read.text, ¶ms.r#match, regex.as_ref()); + if matched_line.is_some() { + let revision = read.revision; + crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "matched"); + return Ok(Some( + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::OutputMatched { + pane_id: params.pane_id, + revision, + matched_line, + read, + }, + }) + .unwrap(), + )); + } + + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + crate::logging::api_wait_timed_out(&request_id, ¶ms.pane_id); + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "timeout".into(), + message: "timed out waiting for output match".into(), + }, + }) + .unwrap(), + )); + } + + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index 139f7cd4..0c722125 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -461,11 +461,11 @@ impl AppState { .any(|pane| pane.attached_terminal_id == terminal_id) }) }); - if !still_attached { - self.terminals.remove(&terminal_id); - if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { - runtime.shutdown(); - } + if !still_attached + && self.terminals.remove(&terminal_id).is_some() + && !self.terminal_runtime_shutdowns.contains(&terminal_id) + { + self.terminal_runtime_shutdowns.push(terminal_id); } } } @@ -682,7 +682,7 @@ impl AppState { self.selection_autoscroll = None; } - pub fn copy_selection(&mut self) { + pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { let mut sel = match self.selection.take() { Some(sel) => sel, None => return, @@ -697,7 +697,7 @@ impl AppState { }; let text = self - .runtime_for_pane_in_workspace(ws_idx, sel.pane_id) + .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id) .and_then(|rt| rt.extract_selection(&sel)); if let Some(text) = text { @@ -717,7 +717,11 @@ impl AppState { // --------------------------------------------------------------------------- impl AppState { - pub fn apply_workspace_git_statuses(&mut self, results: Vec) -> bool { + pub fn apply_workspace_git_statuses( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + results: Vec, + ) -> bool { let mut changed = false; for result in results { let Some(ws_idx) = self @@ -729,7 +733,7 @@ impl AppState { }; if self.workspaces[ws_idx] - .resolved_identity_cwd_from(&self.terminals, &self.terminal_runtimes) + .resolved_identity_cwd_from(&self.terminals, terminal_runtimes) .as_ref() != Some(&result.resolved_identity_cwd) { @@ -833,7 +837,7 @@ impl AppState { // dispatch; never touches AppState. AppEvent::ClipboardWrite { .. } => Vec::new(), AppEvent::GitStatusRefreshed { results } => { - self.apply_workspace_git_statuses(results); + let _ = results; Vec::new() } } @@ -1019,12 +1023,16 @@ mod tests { let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); let second_id = state.workspaces[1].id.clone(); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id: first_id, - resolved_identity_cwd: first_cwd, - branch: Some("main".into()), - ahead_behind: Some((2, 1)), - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id: first_id, + resolved_identity_cwd: first_cwd, + branch: Some("main".into()), + ahead_behind: Some((2, 1)), + }], + ); assert!(changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("main")); @@ -1040,12 +1048,16 @@ mod tests { state.workspaces[0].cached_git_branch = Some("old".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 0)); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), - branch: Some("main".into()), - ahead_behind: Some((0, 1)), - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), + branch: Some("main".into()), + ahead_behind: Some((0, 1)), + }], + ); assert!(!changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("old")); @@ -1060,12 +1072,16 @@ mod tests { state.workspaces[0].cached_git_branch = Some("main".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 2)); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd, - branch: None, - ahead_behind: None, - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd, + branch: None, + ahead_behind: None, + }], + ); assert!(changed); assert_eq!(state.workspaces[0].branch(), None); diff --git a/src/app/agents.rs b/src/app/agents.rs index 15b93674..f36ba1ca 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -336,9 +336,7 @@ impl App { self.render_dirty.clone(), ) .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let ws_idx = self.state.workspaces.len() - 1; @@ -386,8 +384,7 @@ impl App { target: target_pane.raw().to_string(), })? .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.state - .terminal_runtimes + self.terminal_runtimes .insert(result.1.terminal.id.clone(), result.1.runtime); self.state .terminals diff --git a/src/app/api.rs b/src/app/api.rs index 6eb95f03..c8ae530a 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1,13 +1,14 @@ use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; -use super::{ - api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, pane_agent_status, - }, - App, Mode, OverlayPaneState, ToastKind, -}; +mod agents; +mod integrations; +mod panes; +mod responses; +mod tabs; +mod workspaces; + +use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; impl App { @@ -20,7 +21,10 @@ impl App { if let AppEvent::GitStatusRefreshed { results } = ev { self.git_refresh_in_flight = false; self.last_git_remote_status_refresh = Instant::now(); - if self.state.apply_workspace_git_statuses(results) { + if self + .state + .apply_workspace_git_statuses(&self.terminal_runtimes, results) + { self.render_dirty.store(true, Ordering::Release); self.render_notify.notify_one(); } @@ -75,8 +79,11 @@ impl App { if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) - { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace( + &self.terminal_runtimes, + ws_idx, + pane_id, + ) { runtime.begin_graceful_release(agent); } } @@ -156,6 +163,7 @@ impl App { } self.sync_toast_deadline(previous_toast); + self.shutdown_detached_terminal_runtimes(); } fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { @@ -293,12 +301,10 @@ impl App { pane_id: crate::layout::PaneId, event: crate::ghostty::FocusEvent, ) { - let Some(runtime) = self - .state - .workspaces - .get(ws_idx) - .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)) - else { + let Some(runtime) = self.state.workspaces.get(ws_idx).and_then(|_| { + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + }) else { return; }; runtime.try_send_focus_event(event); @@ -306,13 +312,7 @@ impl App { pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_internal_events(); - use bytes::Bytes; - - use crate::api::schema::{ - ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationUninstallResult, Method, - PaneListParams, PaneReadResult, ReadFormat, ReadSource, ResponseResult, - SuccessResponse, TabListParams, - }; + use crate::api::schema::{Method, ResponseResult, SuccessResponse}; let response = match request.method { Method::ServerStop(_) => { @@ -332,1280 +332,65 @@ impl App { }, } } - Method::WorkspaceList(_) => SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceList { - workspaces: self - .state - .workspaces - .iter() - .enumerate() - .map(|(idx, _)| self.workspace_info(idx)) - .collect(), - }, - }, - Method::WorkspaceGet(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - let Some(_) = self.state.workspaces.get(index) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } - } + Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), + Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { - let cwd = params - .cwd - .map(std::path::PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - match self.create_workspace_with_options(cwd, params.focus) { - Ok(index) => { - if let Some(label) = params.label { - if let Some(workspace) = self.state.workspaces.get_mut(index) { - workspace.set_custom_name(label); - crate::logging::workspace_renamed(&workspace.id); - } - } - let workspace = self.workspace_info(index); - let tab = self - .tab_info(index, 0) - .expect("new workspace should have an initial tab"); - let root_pane = self - .root_pane_info(index, 0) - .expect("new workspace should have an initial root pane"); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceCreated, - data: crate::api::schema::EventData::WorkspaceCreated { - workspace: workspace.clone(), - }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabCreated, - data: crate::api::schema::EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); - SuccessResponse { - id: request.id, - result: self - .workspace_created_result(index) - .expect("new workspace should produce a complete create response"), - } - } - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_create_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } + return self.handle_workspace_create(request.id, params); } Method::WorkspaceFocus(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - if self.state.workspaces.get(index).is_none() { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - } - self.state.switch_workspace(index); - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } + return self.handle_workspace_focus(request.id, target) } Method::WorkspaceRename(params) => { - let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", params.workspace_id), - }, - }) - .unwrap(); - }; - let Some(ws) = self.state.workspaces.get_mut(index) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", params.workspace_id), - }, - }) - .unwrap(); - }; - ws.set_custom_name(params.label.clone()); - crate::logging::workspace_renamed(&ws.id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceRenamed, - data: crate::api::schema::EventData::WorkspaceRenamed { - workspace_id: self.public_workspace_id(index), - label: params.label, - }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } + return self.handle_workspace_rename(request.id, params); } Method::WorkspaceClose(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - if self.state.workspaces.get(index).is_none() { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - } - self.state.selected = index; - self.state.close_selected_workspace(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { - workspace_id: target.workspace_id, - }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::TabList(TabListParams { workspace_id }) => { - let tabs = if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - (0..ws.tabs.len()) - .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) - .collect() - } else { - let mut tabs = Vec::new(); - for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { - for tab_idx in 0..ws.tabs.len() { - if let Some(tab) = self.tab_info(ws_idx, tab_idx) { - tabs.push(tab); - } - } - } - tabs - }; - SuccessResponse { - id: request.id, - result: ResponseResult::TabList { tabs }, - } - } - Method::TabGet(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - let Some(tab) = self.tab_info(ws_idx, tab_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabCreate(params) => { - let crate::api::schema::TabCreateParams { - workspace_id, - cwd, - focus, - label, - } = params; - let ws_idx = if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - ws_idx - } else if let Some(active) = self.state.active { - active - } else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: "no active workspace".into(), - }, - }) - .unwrap(); - }; - let cwd = cwd - .map(std::path::PathBuf::from) - .or_else(|| { - self.state - .focused_runtime_in_workspace(ws_idx) - .and_then(|rt| rt.cwd()) - }) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - let (rows, cols) = self.state.estimate_pane_size(); - let default_shell = self.state.default_shell.clone(); - let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; - let host_terminal_theme = self.state.host_terminal_theme; - let result = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("workspace disappeared")) - .and_then(|ws| { - ws.create_tab( - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - &default_shell, - ) - }); - match result { - Ok((tab_idx, terminal, runtime)) => { - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - if let Some(label) = label { - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, tab_idx) - .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); - if let Some(tab) = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.set_custom_name(label); - crate::logging::tab_renamed(&workspace_id, &tab_id); - } - } - if focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - let root_pane = self - .root_pane_info(ws_idx, tab_idx) - .expect("new tab should have a root pane"); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabCreated, - data: crate::api::schema::EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); - SuccessResponse { - id: request.id, - result: self - .tab_created_result(ws_idx, tab_idx) - .expect("new tab should produce a complete create response"), - } - } - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_create_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - } - Method::TabFocus(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabRename(params) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", params.tab_id), - }, - }) - .unwrap(); - }; - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, tab_idx) - .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); - let Some(tab) = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", params.tab_id), - }, - }) - .unwrap(); - }; - tab.set_custom_name(params.label.clone()); - crate::logging::tab_renamed(&workspace_id, &tab_id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabRenamed, - data: crate::api::schema::EventData::TabRenamed { - tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), - workspace_id: self.public_workspace_id(ws_idx), - label: params.label, - }, - }); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabClose(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - if ws.tabs.len() <= 1 { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_close_failed".into(), - message: "cannot close the last tab in a workspace".into(), - }, - }) - .unwrap(); - } - if !ws.close_tab(tab_idx) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_close_failed".into(), - message: format!("tab {} could not be closed", target.tab_id), - }, - }) - .unwrap(); - } - self.state.remove_unattached_terminal_ids(terminal_ids); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabClosed, - data: crate::api::schema::EventData::TabClosed { - tab_id: target.tab_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::AgentList(_) => SuccessResponse { - id: request.id, - result: ResponseResult::AgentList { - agents: self.collect_agent_infos(), - }, - }, - Method::AgentGet(target) => { - let agent = match self.agent_info_for_target(&target.target) { - Ok(agent) => agent, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_target_error_body(err), - }) - .unwrap(); - } - }; - SuccessResponse { - id: request.id, - result: ResponseResult::AgentInfo { agent }, - } - } - Method::AgentFocus(target) => { - let agent = match self.focus_agent_target(&target.target) { - Ok(agent) => agent, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_target_error_body(err), - }) - .unwrap(); - } - }; - SuccessResponse { - id: request.id, - result: ResponseResult::AgentInfo { agent }, - } - } - Method::AgentRename(params) => { - let agent = match self.rename_agent_target(¶ms.target, params.name) { - Ok(agent) => agent, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_rename_error_body(err), - }) - .unwrap(); - } - }; - SuccessResponse { - id: request.id, - result: ResponseResult::AgentInfo { agent }, - } - } - Method::AgentStart(params) => { - let (agent, argv) = match self.start_agent(params) { - Ok(started) => started, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_start_error_body(err), - }) - .unwrap(); - } - }; - SuccessResponse { - id: request.id, - result: ResponseResult::AgentStarted { agent, argv }, - } - } - Method::AgentRead(params) => { - let resolved = match self.resolve_terminal_target(¶ms.target) { - Ok(resolved) => resolved, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_target_error_body(err), - }) - .unwrap(); - } - }; - let Some((pane, workspace_id)) = - self.lookup_runtime(resolved.ws_idx, resolved.pane_id) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "agent_not_found".into(), - message: format!("agent target {} not found", params.target), - }, - }) - .unwrap(); - }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - }, - }; - SuccessResponse { - id: request.id, - result: ResponseResult::PaneRead { - read: PaneReadResult { - pane_id: self - .public_pane_id(resolved.ws_idx, resolved.pane_id) - .unwrap_or_else(|| params.target.clone()), - workspace_id, - tab_id: self - .public_tab_id(resolved.ws_idx, resolved.tab_idx) - .unwrap(), - source: params.source, - format: params.format, - text, - revision: 0, - truncated: false, - }, - }, - } - } - Method::AgentSend(params) => { - let resolved = match self.resolve_terminal_target(¶ms.target) { - Ok(resolved) => resolved, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_target_error_body(err), - }) - .unwrap(); - } - }; - let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "agent_not_found".into(), - message: format!("agent target {} not found", params.target), - }, - }) - .unwrap(); - }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "agent_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneSplit(params) => { - let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { - self.state.workspaces.get(ws_idx).and_then(|ws| { - let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; - ws.tabs.get(tab_idx)?.cwd_for_pane( - target_pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) - }) - }); - let default_shell = self.state.default_shell.clone(); - let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; - let host_terminal_theme = self.state.host_terminal_theme; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - }; - let direction = match params.direction { - crate::api::schema::SplitDirection::Right => { - ratatui::layout::Direction::Horizontal - } - crate::api::schema::SplitDirection::Down => { - ratatui::layout::Direction::Vertical - } - }; - let (target_tab_idx, new_pane) = match ws.split_pane( - target_pane_id, - direction, - rows, - cols, - split_cwd, - scrollback_limit_bytes, - host_terminal_theme, - &default_shell, - params.focus, - ) { - Some(Ok(result)) => result, - Some(Err(err)) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_split_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - None => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - } - }; - if params.focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(target_tab_idx); - self.state.mode = Mode::Terminal; - } - self.state - .terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - self.schedule_session_save(); - let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneList(PaneListParams { workspace_id }) => { - match self.collect_panes_for_workspace(workspace_id.as_deref()) { - Ok(panes) => SuccessResponse { - id: request.id, - result: ResponseResult::PaneList { panes }, - }, - Err((code, message)) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { code, message }, - }) - .unwrap(); - } - } - } - Method::PaneGet(target) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - let Some(pane) = self.pane_info(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneRename(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(terminal_id) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.terminal_id(pane_id)) - .cloned() - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - match params.label.map(|label| label.trim().to_string()) { - Some(label) if !label.is_empty() => terminal.set_manual_label(label), - _ => terminal.clear_manual_label(), - } - self.state.mark_session_dirty(); - let pane = self.pane_info(ws_idx, pane_id).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneRead(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(tab_idx) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - }, - }; - SuccessResponse { - id: request.id, - result: ResponseResult::PaneRead { - read: PaneReadResult { - pane_id: params.pane_id, - workspace_id, - tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), - source: params.source, - format: params.format, - text, - revision: 0, - truncated: false, - }, - }, - } - } + return self.handle_workspace_close(request.id, target) + } + Method::TabList(params) => return self.handle_tab_list(request.id, params), + Method::TabGet(target) => return self.handle_tab_get(request.id, target), + Method::TabCreate(params) => return self.handle_tab_create(request.id, params), + Method::TabFocus(target) => return self.handle_tab_focus(request.id, target), + Method::TabRename(params) => return self.handle_tab_rename(request.id, params), + Method::TabClose(target) => return self.handle_tab_close(request.id, target), + Method::AgentList(_) => return self.handle_agent_list(request.id), + Method::AgentGet(target) => return self.handle_agent_get(request.id, target), + Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), + Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), + Method::AgentStart(params) => return self.handle_agent_start(request.id, params), + Method::AgentRead(params) => return self.handle_agent_read(request.id, params), + Method::AgentSend(params) => return self.handle_agent_send(request.id, params), + Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), + Method::PaneList(params) => return self.handle_pane_list(request.id, params), + Method::PaneGet(target) => return self.handle_pane_get(request.id, target), + Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), + Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneReportAgent(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_agent".into(), - message: "agent label must not be empty".into(), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookStateReported { - pane_id, - source: params.source, - agent_label, - state: detect_state_from_api(params.state), - message: params.message, - custom_status: normalize_custom_status(params.custom_status), - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_report_agent(request.id, params); } Method::PaneClearAgentAuthority(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { - pane_id, - source: params.source, - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_clear_agent_authority(request.id, params); } Method::PaneReleaseAgent(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_agent".into(), - message: "agent label must not be empty".into(), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { - pane_id, - source: params.source, - known_agent: crate::detect::parse_agent_label(&agent_label), - agent_label, - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneSendText(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_release_agent(request.id, params); } + Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params), Method::PaneSendInput(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, - Err(key) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_key".into(), - message: format!("unsupported key {key}"), - }, - }) - .unwrap(); - } - }; - if !params.text.is_empty() { - let text_bytes = encode_api_text(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneClose(target) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); - let should_close_workspace = { - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - ws.close_pane(pane_id) - }; - if should_close_workspace { - self.state.selected = ws_idx; - self.state.close_selected_workspace(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneClosed, - data: crate::api::schema::EventData::PaneClosed { - pane_id: target.pane_id.clone(), - workspace_id: workspace_id.clone(), - }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { workspace_id }, - }); - } else { - self.state.remove_unattached_terminal_ids(terminal_id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneClosed, - data: crate::api::schema::EventData::PaneClosed { - pane_id: target.pane_id, - workspace_id, - }, - }); - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneSendKeys(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, - Err(key) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_key".into(), - message: format!("unsupported key {key}"), - }, - }) - .unwrap(); - } - }; - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_send_input(request.id, params) } + Method::PaneClose(target) => return self.handle_pane_close(request.id, target), + Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), Method::IntegrationInstall(params) => { - let target = params.target; - let messages = match crate::integration::install_target(target) { - Ok(messages) => messages, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "integration_install_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - }; - - SuccessResponse { - id: request.id, - result: ResponseResult::IntegrationInstall { - target, - details: IntegrationInstallResult { messages }, - }, - } + return self.handle_integration_install(request.id, params); } Method::IntegrationUninstall(params) => { - let target = params.target; - let messages = match crate::integration::uninstall_target(target) { - Ok(messages) => messages, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "integration_uninstall_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - }; - - SuccessResponse { - id: request.id, - result: ResponseResult::IntegrationUninstall { - target, - details: IntegrationUninstallResult { messages }, - }, - } + return self.handle_integration_uninstall(request.id, params); } _ => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "not_implemented".into(), - message: "method not implemented yet".into(), - }, - }) - .unwrap(); + return responses::encode_error( + request.id, + "not_implemented", + "method not implemented yet", + ); } }; diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs new file mode 100644 index 00000000..4077b38c --- /dev/null +++ b/src/app/api/agents.rs @@ -0,0 +1,127 @@ +use bytes::Bytes; + +use crate::api::schema::{ + AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, + ReadSource, ResponseResult, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_error_body, encode_success}; + +impl App { + pub(super) fn handle_agent_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::AgentList { + agents: self.collect_agent_infos(), + }, + ) + } + + pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.agent_info_for_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_focus(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.focus_agent_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_rename(&mut self, id: String, params: AgentRenameParams) -> String { + let agent = match self.rename_agent_target(¶ms.target, params.name) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_rename_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { + let (agent, argv) = match self.start_agent(params) { + Ok(started) => started, + Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentStarted { agent, argv }) + } + + pub(super) fn handle_agent_read( + &mut self, + id: String, + params: crate::api::schema::AgentReadParams, + ) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some((pane, workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id) + else { + return agent_not_found(id, ¶ms.target); + }; + let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; + let text = match params.format { + ReadFormat::Text => match params.source { + ReadSource::Visible => pane.visible_text(), + ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), + }, + ReadFormat::Ansi => match params.source { + ReadSource::Visible => pane.visible_ansi(), + ReadSource::Recent => pane.recent_ansi(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), + }, + }; + + encode_success( + id, + ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: self + .public_pane_id(resolved.ws_idx, resolved.pane_id) + .unwrap_or_else(|| params.target.clone()), + workspace_id, + tab_id: self + .public_tab_id(resolved.ws_idx, resolved.tab_idx) + .unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + ) + } + + pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "agent_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn agent_not_found(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_found", + format!("agent target {target} not found"), + ) +} diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs new file mode 100644 index 00000000..0cca8cba --- /dev/null +++ b/src/app/api/integrations.rs @@ -0,0 +1,46 @@ +use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_integration_install( + &mut self, + id: String, + params: crate::api::schema::IntegrationInstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::install_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationInstall { + target, + details: IntegrationInstallResult { messages }, + }, + ) + } + + pub(super) fn handle_integration_uninstall( + &mut self, + id: String, + params: crate::api::schema::IntegrationUninstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::uninstall_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationUninstall { + target, + details: IntegrationUninstallResult { messages }, + }, + ) + } +} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs new file mode 100644 index 00000000..b09ecb2a --- /dev/null +++ b/src/app/api/panes.rs @@ -0,0 +1,353 @@ +use bytes::Bytes; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams, + PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, + PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, + PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult, +}; +use crate::app::{App, Mode}; + +use super::super::api_helpers::{ + detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, + normalize_reported_agent_label, +}; +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { + let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let direction = match params.direction { + crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, + crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, + }; + let (target_tab_idx, new_pane) = match ws.split_pane( + target_pane_id, + direction, + rows, + cols, + split_cwd, + scrollback_limit_bytes, + host_terminal_theme, + &default_shell, + params.focus, + ) { + Some(Ok(result)) => result, + Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()), + None => return pane_not_found(id, ¶ms.target_pane_id), + }; + if params.focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(target_tab_idx); + self.state.mode = Mode::Terminal; + } + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + self.schedule_session_save(); + let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane: pane.clone() }, + }); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_list(&mut self, id: String, params: PaneListParams) -> String { + match self.collect_panes_for_workspace(params.workspace_id.as_deref()) { + Ok(panes) => encode_success(id, ResponseResult::PaneList { panes }), + Err((code, message)) => encode_error(id, &code, message), + } + } + + pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + match params.label.map(|label| label.trim().to_string()) { + Some(label) if !label.is_empty() => terminal.set_manual_label(label), + _ => terminal.clear_manual_label(), + } + self.state.mark_session_dirty(); + let pane = self.pane_info(ws_idx, pane_id).unwrap(); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_read(&mut self, id: String, params: PaneReadParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; + let text = match params.format { + ReadFormat::Text => match params.source { + ReadSource::Visible => pane.visible_text(), + ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), + }, + ReadFormat::Ansi => match params.source { + ReadSource::Visible => pane.visible_ansi(), + ReadSource::Recent => pane.recent_ansi(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), + }, + }; + + encode_success( + id, + ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: params.pane_id, + workspace_id, + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + ) + } + + pub(super) fn handle_pane_report_agent( + &mut self, + id: String, + params: PaneReportAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookStateReported { + pane_id, + source: params.source, + agent_label, + state: detect_state_from_api(params.state), + message: params.message, + custom_status: normalize_custom_status(params.custom_status), + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_clear_agent_authority( + &mut self, + id: String, + params: PaneClearAgentAuthorityParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { + pane_id, + source: params.source, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_release_agent( + &mut self, + id: String, + params: PaneReleaseAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { + pane_id, + source: params.source, + known_agent: crate::detect::parse_agent_label(&agent_label), + agent_label, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_text( + &mut self, + id: String, + params: PaneSendTextParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_input( + &mut self, + id: String, + params: PaneSendInputParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + if !params.text.is_empty() { + let text_bytes = encode_api_text(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); + let should_close_workspace = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, &target.pane_id); + }; + ws.close_pane(pane_id) + }; + if should_close_workspace { + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id.clone(), + workspace_id: workspace_id.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { workspace_id }, + }); + } else { + self.state.remove_unattached_terminal_ids(terminal_id); + self.shutdown_detached_terminal_runtimes(); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id, + workspace_id, + }, + }); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_keys( + &mut self, + id: String, + params: PaneSendKeysParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn pane_not_found(id: String, pane_id: &str) -> String { + encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) +} + +fn invalid_agent(id: String) -> String { + encode_error(id, "invalid_agent", "agent label must not be empty") +} diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs new file mode 100644 index 00000000..7c871c37 --- /dev/null +++ b/src/app/api/responses.rs @@ -0,0 +1,19 @@ +use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; + +pub(super) fn encode_success(id: String, result: ResponseResult) -> String { + serde_json::to_string(&SuccessResponse { id, result }).unwrap() +} + +pub(super) fn encode_error(id: String, code: &str, message: impl Into) -> String { + encode_error_body( + id, + ErrorBody { + code: code.into(), + message: message.into(), + }, + ) +} + +pub(super) fn encode_error_body(id: String, error: ErrorBody) -> String { + serde_json::to_string(&ErrorResponse { id, error }).unwrap() +} diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs new file mode 100644 index 00000000..7978a17f --- /dev/null +++ b/src/app/api/tabs.rs @@ -0,0 +1,233 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams, + TabRenameParams, TabTarget, +}; +use crate::app::{App, Mode}; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_tab_list(&mut self, id: String, params: TabListParams) -> String { + let tabs = if let Some(workspace_id) = params.workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return workspace_not_found(id, &workspace_id); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return workspace_not_found(id, &workspace_id); + }; + (0..ws.tabs.len()) + .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) + .collect() + } else { + let mut tabs = Vec::new(); + for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { + for tab_idx in 0..ws.tabs.len() { + if let Some(tab) = self.tab_info(ws_idx, tab_idx) { + tabs.push(tab); + } + } + } + tabs + }; + + encode_success(id, ResponseResult::TabList { tabs }) + } + + pub(super) fn handle_tab_get(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + let Some(tab) = self.tab_info(ws_idx, tab_idx) else { + return tab_not_found(id, &target.tab_id); + }; + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_create(&mut self, id: String, params: TabCreateParams) -> String { + let TabCreateParams { + workspace_id, + cwd, + focus, + label, + } = params; + let ws_idx = if let Some(workspace_id) = workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return workspace_not_found(id, &workspace_id); + }; + ws_idx + } else if let Some(active) = self.state.active { + active + } else { + return encode_error(id, "workspace_not_found", "no active workspace"); + }; + let cwd = cwd + .map(PathBuf::from) + .or_else(|| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|rt| rt.cwd()) + }) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let result = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("workspace disappeared")) + .and_then(|ws| { + ws.create_tab( + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + &default_shell, + ) + }); + match result { + Ok((tab_idx, terminal, runtime)) => { + self.terminal_runtimes.insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); + if let Some(label) = label { + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let tab_id = self + .public_tab_id(ws_idx, tab_idx) + .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); + if let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.set_custom_name(label); + crate::logging::tab_renamed(&workspace_id, &tab_id); + } + } + if focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(tab_idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + let root_pane = self + .root_pane_info(ws_idx, tab_idx) + .expect("new tab should have a root pane"); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + encode_success( + id, + self.tab_created_result(ws_idx, tab_idx) + .expect("new tab should produce a complete create response"), + ) + } + Err(err) => encode_error(id, "tab_create_failed", err.to_string()), + } + } + + pub(super) fn handle_tab_focus(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + self.state.switch_workspace(ws_idx); + self.state.switch_tab(tab_idx); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_rename(&mut self, id: String, params: TabRenameParams) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { + return tab_not_found(id, ¶ms.tab_id); + }; + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let tab_id = self + .public_tab_id(ws_idx, tab_idx) + .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); + let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + else { + return tab_not_found(id, ¶ms.tab_id); + }; + tab.set_custom_name(params.label.clone()); + crate::logging::tab_renamed(&workspace_id, &tab_id); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::TabRenamed, + data: EventData::TabRenamed { + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + workspace_id: self.public_workspace_id(ws_idx), + label: params.label, + }, + }); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return tab_not_found(id, &target.tab_id); + }; + if ws.tabs.len() <= 1 { + return encode_error( + id, + "tab_close_failed", + "cannot close the last tab in a workspace", + ); + } + if !ws.close_tab(tab_idx) { + return encode_error( + id, + "tab_close_failed", + format!("tab {} could not be closed", target.tab_id), + ); + } + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::TabClosed, + data: EventData::TabClosed { + tab_id: target.tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn workspace_not_found(id: String, workspace_id: &str) -> String { + encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) +} + +fn tab_not_found(id: String, tab_id: &str) -> String { + encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs new file mode 100644 index 00000000..2f8c1a28 --- /dev/null +++ b/src/app/api/workspaces.rs @@ -0,0 +1,168 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, + WorkspaceRenameParams, WorkspaceTarget, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_workspace_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| self.workspace_info(idx)) + .collect(), + }, + ) + } + + pub(super) fn handle_workspace_get(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + let Some(_) = self.state.workspaces.get(index) else { + return workspace_not_found(id, &target.workspace_id); + }; + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_create( + &mut self, + id: String, + params: WorkspaceCreateParams, + ) -> String { + let cwd = params + .cwd + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + match self.create_workspace_with_options(cwd, params.focus) { + Ok(index) => { + if let Some(label) = params.label { + if let Some(workspace) = self.state.workspaces.get_mut(index) { + workspace.set_custom_name(label); + crate::logging::workspace_renamed(&workspace.id); + } + } + let workspace = self.workspace_info(index); + let tab = self + .tab_info(index, 0) + .expect("new workspace should have an initial tab"); + let root_pane = self + .root_pane_info(index, 0) + .expect("new workspace should have an initial root pane"); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceCreated, + data: EventData::WorkspaceCreated { + workspace: workspace.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + encode_success( + id, + self.workspace_created_result(index) + .expect("new workspace should produce a complete create response"), + ) + } + Err(err) => encode_error(id, "workspace_create_failed", err.to_string()), + } + } + + pub(super) fn handle_workspace_focus(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.switch_workspace(index); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_rename( + &mut self, + id: String, + params: WorkspaceRenameParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let Some(ws) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + ws.set_custom_name(params.label.clone()); + crate::logging::workspace_renamed(&ws.id); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceRenamed, + data: EventData::WorkspaceRenamed { + workspace_id: self.public_workspace_id(index), + label: params.label, + }, + }); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.selected = index; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: target.workspace_id, + }, + }); + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn workspace_not_found(id: String, workspace_id: &str) -> String { + encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) +} diff --git a/src/app/creation.rs b/src/app/creation.rs index f3d8644e..de1ad128 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -11,7 +11,7 @@ impl App { self.state .workspaces .get(ws_idx)? - .resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes) + .resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) } pub(super) fn workspace_creation_source(&self) -> Option { @@ -89,9 +89,7 @@ impl App { self.state.host_terminal_theme, &self.state.default_shell, )?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); if focus { ws.switch_tab(idx); @@ -124,9 +122,7 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), )?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; @@ -262,11 +258,7 @@ impl App { tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, cwd: ws.tabs[tab_idx] - .cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), @@ -281,7 +273,9 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option<(&crate::terminal::TerminalRuntime, String)> { - let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let runtime = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -290,7 +284,8 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option<&crate::terminal::TerminalRuntime> { - self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) } pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { @@ -299,7 +294,7 @@ impl App { crate::api::schema::WorkspaceInfo { workspace_id: self.public_workspace_id(index), number: index + 1, - label: ws.display_name_from(&self.state.terminals, &self.state.terminal_runtimes), + label: ws.display_name_from(&self.state.terminals, &self.terminal_runtimes), focused: self.state.active == Some(index), pane_count: ws.public_pane_numbers.len(), tab_count: ws.tabs.len(), diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 245f935b..7c2bbe03 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -70,7 +70,13 @@ impl App { } Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), - Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event), + Mode::ContextMenu => { + handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); + } Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), @@ -85,7 +91,10 @@ impl App { return; } if let Some(ws_idx) = self.state.active { - if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { let _ = rt.send_paste(text).await; } } @@ -183,7 +192,7 @@ impl App { let previous_agent_panel_scope = self.state.agent_panel_scope; let previous_settings_section = self.state.settings.section; - if let Some(action) = self.state.handle_mouse(mouse) { + if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), @@ -232,7 +241,11 @@ impl App { // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App impl AppState { - pub(crate) fn split_pane(&mut self, direction: Direction) { + pub(crate) fn split_pane( + &mut self, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + direction: Direction, + ) { // Actual PTY spawning happens in Workspace::split_focused // which needs events channel — this is called from navigate_key // where we don't have async context, so the workspace handles it @@ -245,11 +258,7 @@ impl AppState { .and_then(|i| self.workspaces.get(i)) .and_then(|ws| { let tab = ws.active_tab()?; - tab.cwd_for_pane( - tab.layout.focused(), - &self.terminals, - &self.terminal_runtimes, - ) + tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes) }); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { @@ -263,8 +272,7 @@ impl AppState { &self.default_shell, ) { let new_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); + terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.layout.focus_pane(new_id); @@ -332,10 +340,11 @@ fn numbered_lines_bytes(count: usize) -> Vec { #[cfg(test)] fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); crate::persist::capture( &state.workspaces, &state.terminals, - &state.terminal_runtimes, + &terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 084f44a9..868ee3d3 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -498,7 +498,12 @@ pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { } } -pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { +pub(super) fn apply_context_menu_action( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + menu: ContextMenuState, + idx: usize, +) { let item = menu.items().get(idx).copied(); match (menu.kind, item) { (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { @@ -554,11 +559,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split vertical")) => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Zoom")) => { @@ -577,7 +582,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS } } -pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { +pub(crate) fn handle_context_menu_key( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + key: KeyEvent, +) { match key.code { KeyCode::Esc => { state.context_menu = None; @@ -596,7 +605,7 @@ pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { KeyCode::Enter => { if let Some(menu) = state.context_menu.take() { let idx = menu.list.highlighted; - apply_context_menu_action(state, menu, idx); + apply_context_menu_action(state, terminal_runtimes, menu, idx); } } _ => {} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e3f13d77..4d49a917 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -10,6 +10,7 @@ use crate::{ }, layout::{PaneInfo, SplitBorder}, selection::Selection, + terminal::TerminalRuntimeRegistry, }; #[cfg(test)] @@ -25,7 +26,11 @@ use super::{ }; impl AppState { - pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) { + pub(crate) fn handle_pane_mouse_only( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) { if self.mode != Mode::Terminal { return; } @@ -38,16 +43,20 @@ impl AppState { | MouseEventKind::ScrollDown | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(&info, mouse); + self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); } MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(&info, mouse); + self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } MouseEventKind::Moved => {} } } - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option { + pub(super) fn handle_mouse( + &mut self, + terminal_runtimes: &mut TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> Option { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; @@ -187,7 +196,7 @@ impl AppState { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { if let Some(idx) = item_idx { - apply_context_menu_action(self, menu, idx); + apply_context_menu_action(self, terminal_runtimes, menu, idx); } else { leave_modal(self); } @@ -224,7 +233,7 @@ impl AppState { } if let Some((pane_id, target)) = - self.scrollbar_target_at(mouse.column, mouse.row) + self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) { self.focus_pane(pane_id); match target { @@ -237,7 +246,11 @@ impl AppState { }); } ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset(pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + pane_id, + offset_from_bottom, + ); } } if self.mode != Mode::Terminal { @@ -370,7 +383,7 @@ impl AppState { self.mode = Mode::Terminal; } - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -384,7 +397,7 @@ impl AppState { info.id, row, col, - self.pane_scroll_metrics(info.id), + self.pane_scroll_metrics(terminal_runtimes, info.id), )); } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x @@ -402,13 +415,13 @@ impl AppState { MouseEventKind::Drag(MouseButton::Left) => { if self.selection.is_some() { - self.update_selection_drag(mouse.column, mouse.row); + self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -504,11 +517,16 @@ impl AppState { grab_row_offset, } => { if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( + terminal_runtimes, *pane_id, mouse.row, *grab_row_offset, ) { - self.set_pane_scroll_offset(*pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + *pane_id, + offset_from_bottom, + ); } } DragTarget::SidebarDivider => { @@ -535,14 +553,14 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; @@ -597,7 +615,7 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } } } @@ -607,7 +625,7 @@ impl AppState { if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(&info, mouse); + let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } } @@ -622,12 +640,12 @@ impl AppState { } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {} + if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; self.selection_autoscroll = None; - self.handle_terminal_wheel(mouse); + self.handle_terminal_wheel(terminal_runtimes, mouse); } MouseEventKind::ScrollUp if in_sidebar => { @@ -1070,17 +1088,29 @@ impl AppState { self.mode = Mode::Terminal; } - pub(crate) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_up( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_up(lines); } } } - pub(crate) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_down( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_down(lines); } } @@ -1088,24 +1118,33 @@ impl AppState { pub(crate) fn pane_scroll_metrics( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, ) -> Option { self.active - .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id)) + .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } - pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { + pub(super) fn handle_terminal_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) { const LINES_PER_NOTCH: usize = 3; if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); - if self.forward_pane_wheel(&info, mouse) { + if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { return; } match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; @@ -1114,15 +1153,19 @@ impl AppState { if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; } if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), @@ -1132,11 +1175,17 @@ impl AppState { } } - pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_mouse_button( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); @@ -1151,11 +1200,17 @@ impl AppState { true } - fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + fn forward_pane_reported_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; if !rt @@ -1177,11 +1232,17 @@ impl AppState { true } - pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; match rt.wheel_routing() { @@ -1215,11 +1276,13 @@ impl AppState { pub(super) fn set_pane_scroll_offset( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } @@ -1227,6 +1290,7 @@ impl AppState { pub(super) fn scrollbar_target_at( &self, + terminal_runtimes: &TerminalRuntimeRegistry, col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { @@ -1239,7 +1303,7 @@ impl AppState { && row < track.y + track.height }) })?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1259,6 +1323,7 @@ impl AppState { pub(super) fn scrollbar_offset_for_pane_row( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, row: u16, grab_row_offset: u16, @@ -1270,7 +1335,7 @@ impl AppState { .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1435,6 +1500,7 @@ mod tests { app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, + &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::ConfirmClose); @@ -1458,6 +1524,56 @@ mod tests { assert_eq!(app.state.workspaces[0].display_name(), "a"); } + #[tokio::test] + async fn keyboard_context_menu_split_keeps_new_runtime() { + let mut app = app_for_mouse_test(); + app.state.default_shell = "/usr/bin/true".into(); + let (workspace, terminal, runtime) = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + &app.state.default_shell, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.terminal_runtimes.insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let runtime_count = app.terminal_runtimes.len(); + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane { + pane_id, + has_manual_label: false, + }, + x: 2, + y: 2, + list: MenuListState::new(1), + }); + app.state.mode = Mode::ContextMenu; + + handle_context_menu_key( + &mut app.state, + &mut app.terminal_runtimes, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } + } + #[test] fn dragging_pane_split_updates_captured_layout_ratio() { let mut app = app_for_mouse_test(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 17180fed..9256bbbb 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -16,6 +16,7 @@ use crate::{ }, input::TerminalKey, layout::NavDirection, + terminal::TerminalRuntimeRegistry, }; pub(crate) fn terminal_direct_navigation_action( @@ -55,7 +56,12 @@ impl App { self.launch_focused_scrollback_editor(); finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); } else { - execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix); + execute_navigate_action_in_context( + &mut self.state, + &mut self.terminal_runtimes, + action, + ActionContext::Prefix, + ); } self.selection_autoscroll_deadline = None; return; @@ -88,6 +94,7 @@ impl App { } else { execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, ActionContext::Navigate, ); @@ -105,7 +112,10 @@ impl App { let Some(ws_idx) = self.state.active else { return false; }; - let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else { + let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + else { return false; }; @@ -174,11 +184,7 @@ impl App { env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); } if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) }) { env.push(( "HERDR_ACTIVE_PANE_CWD".to_string(), @@ -245,7 +251,7 @@ impl App { .ok_or_else(|| std::io::Error::other("no focused pane"))?; let scrollback = self .state - .runtime_for_pane_in_workspace(ws_idx, pane_id) + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? .recent_text(usize::MAX); @@ -298,7 +304,7 @@ impl App { tab.cwd_for_pane( previous_focus, &self.state.terminals, - &self.state.terminal_runtimes, + &self.terminal_runtimes, ) }); let new_pane = ws.split_focused_command( @@ -312,8 +318,7 @@ impl App { self.state.host_terminal_theme, )?; let new_pane_id = new_pane.pane_id; - self.state - .terminal_runtimes + self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); self.state .terminals @@ -428,6 +433,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.update_dismissed = true; let terminal_key = TerminalKey::from(key); @@ -441,7 +447,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { } if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } } @@ -587,11 +598,18 @@ fn action_for_key( #[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } pub(super) fn execute_navigate_action_in_context( state: &mut AppState, + terminal_runtimes: &mut TerminalRuntimeRegistry, action: NavigateAction, context: ActionContext, ) { @@ -694,11 +712,11 @@ pub(super) fn execute_navigate_action_in_context( NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), NavigateAction::SplitVertical => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); leave_navigate_mode(state); } NavigateAction::SplitHorizontal => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); leave_navigate_mode(state); } NavigateAction::ClosePane => { @@ -1192,10 +1210,12 @@ mod tests { #[test] fn empty_state_new_tab_is_no_op() { let mut state = crate::app::state::AppState::test_new(); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.mode = Mode::Prefix; execute_navigate_action_in_context( &mut state, + &mut terminal_runtimes, NavigateAction::NewTab, ActionContext::Prefix, ); @@ -1277,9 +1297,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -1303,7 +1321,7 @@ mod tests { .await; assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.state.terminal_runtimes.len(), 2); + assert_eq!(app.terminal_runtimes.len(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); let _ = wait_for_file(&output_path); @@ -1321,6 +1339,11 @@ mod tests { assert!(!app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); let _ = std::fs::remove_file(output_path); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -1337,7 +1360,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index ad1dd33f..06de30b5 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -1,10 +1,14 @@ use crossterm::event::{MouseEvent, MouseEventKind}; -use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}; +use crate::{ + app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, + terminal::TerminalRuntimeRegistry, +}; impl AppState { pub(crate) fn update_selection_cursor( &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, screen_col: u16, screen_row: u16, @@ -12,7 +16,7 @@ impl AppState { let Some(info) = self.pane_info_by_id(pane_id).cloned() else { return; }; - let metrics = self.pane_scroll_metrics(pane_id); + let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); if let Some(selection) = self.selection.as_mut() { selection.drag(screen_col, screen_row, info.inner_rect, metrics); } @@ -22,7 +26,12 @@ impl AppState { usize::from(distance).saturating_mul(3).clamp(3, 15) } - pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + pub(super) fn update_selection_drag( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + screen_col: u16, + screen_row: u16, + ) { let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { return; }; @@ -44,14 +53,16 @@ impl AppState { // Anchor is stored in absolute row; for a simple screen // comparison, check whether the mouse is on a different // cell than the anchor's screen position. - let (ar, ac) = - s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id)); + let (ar, ac) = s.anchor_screen_pos( + info.inner_rect, + self.pane_scroll_metrics(terminal_runtimes, s.pane_id), + ); ar != screen_row || ac != screen_col }); let is_dragging = was_dragging || anchor_differs_from_mouse; // Advance the selection cursor. - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); // If the mouse is on a different cell than the anchor but drag() // didn't transition (cursor clamped to edge == anchor), force @@ -67,9 +78,13 @@ impl AppState { if screen_row < top { // Cursor above pane — immediate scroll + set autoscroll state if is_dragging { - self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row)); + self.scroll_pane_up( + terminal_runtimes, + pane_id, + Self::selection_edge_scroll_lines(top - screen_row), + ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Up, last_mouse_screen_col: screen_col, @@ -81,11 +96,12 @@ impl AppState { // Cursor below pane — immediate scroll + set autoscroll state if is_dragging { self.scroll_pane_down( + terminal_runtimes, pane_id, Self::selection_edge_scroll_lines(screen_row - bottom), ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Down, last_mouse_screen_col: screen_col, @@ -123,7 +139,11 @@ impl AppState { } } - pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + pub(super) fn scroll_selection_with_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> bool { const LINES_PER_NOTCH: usize = 3; let Some(selection) = self.selection.as_ref() else { @@ -135,11 +155,15 @@ impl AppState { let pane_id = selection.pane_id; self.focus_pane(pane_id); match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } _ => return false, } - self.update_selection_cursor(pane_id, mouse.column, mouse.row); + self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); true } } @@ -148,6 +172,7 @@ impl AppState { mod autoscroll_tests { use super::*; use crate::layout::PaneInfo; + use crate::terminal::TerminalRuntimeRegistry; use crate::workspace::Workspace; use ratatui::layout::Rect; @@ -188,7 +213,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(5, 4); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 4); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -200,7 +226,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -211,7 +238,8 @@ mod autoscroll_tests { let (mut state, pane_id) = make_state_with_pane(); state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); // Same-cell drag on top edge row — still anchored - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); assert!(state.selection_autoscroll.is_none()); } @@ -222,7 +250,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -234,7 +263,8 @@ mod autoscroll_tests { // Anchor at bottom edge row state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); // Same-cell drag — still anchored - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); assert!(state.selection_autoscroll.is_none()); } @@ -246,7 +276,8 @@ mod autoscroll_tests { sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); // Drag cursor one row below the pane bottom - state.update_selection_drag(0, 24); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 24); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -266,7 +297,8 @@ mod autoscroll_tests { inner_rect: Rect::new(0, 0, 80, 24), }); // Move cursor into safe zone (middle of pane, not on edge rows) - state.update_selection_drag(5, 12); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 12); assert!(state.selection_autoscroll.is_none()); } } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 41a62e82..eee5b863 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -47,6 +47,7 @@ impl App { } else { super::navigate::execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, super::navigate::ActionContext::Direct, ); @@ -88,7 +89,9 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; // Intercept plain PageUp/PageDown presses for pane scrollback when the // focused pane doesn't handle its own scrolling (e.g., a plain shell @@ -116,9 +119,11 @@ impl App { .unwrap_or(10) .max(1); if key_event.code == KeyCode::PageUp { - self.state.scroll_pane_up(pane_id, lines); + self.state + .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); } else { - self.state.scroll_pane_down(pane_id, lines); + self.state + .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); } debug!( code = ?key_event.code, @@ -223,7 +228,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let start_row = info.inner_rect.y; @@ -242,7 +247,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after drag"); assert_eq!( @@ -329,7 +334,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let top_row = info.inner_rect.y; @@ -340,7 +345,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!( @@ -523,7 +528,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -602,6 +612,7 @@ mod tests { api_rx, crate::api::EventHub::default(), ); + app.state.default_shell = "/usr/bin/true".into(); let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, @@ -615,9 +626,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -639,6 +648,11 @@ mod tests { assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -648,7 +662,7 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::pane::PaneRuntime::test_with_channel( + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( info.inner_rect.width, info.inner_rect.height, ); @@ -677,7 +691,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -693,8 +707,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -702,8 +716,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert_eq!( end_metrics.offset_from_bottom, @@ -720,7 +734,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -737,8 +751,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_up = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert!(after_up.offset_from_bottom > 0); @@ -748,8 +762,8 @@ mod tests { )); let after_down = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageDown"); assert_eq!(after_down.offset_from_bottom, 0); } @@ -763,7 +777,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -780,8 +794,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_press = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp press"); assert_eq!( after_press.offset_from_bottom, @@ -795,8 +809,8 @@ mod tests { let after_release = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp release"); assert_eq!( after_release.offset_from_bottom, @@ -813,7 +827,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -831,8 +845,8 @@ mod tests { let metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after modified PageUp"); assert_eq!(metrics.offset_from_bottom, 0); } @@ -848,7 +862,7 @@ mod tests { bytes.extend_from_slice(&numbered_lines_bytes(64)); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -864,8 +878,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -873,8 +887,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. assert_eq!(end_metrics.offset_from_bottom, 0); diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c4845a1..6a94f116 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -62,6 +62,7 @@ pub(crate) struct OverlayPaneState { pub struct App { pub state: AppState, + pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry, pub event_tx: mpsc::Sender, pub(crate) event_rx: mpsc::Receiver, pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver, @@ -224,7 +225,7 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); - let mut restored_terminal_runtimes = std::collections::HashMap::new(); + let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let ( workspaces, active, @@ -255,7 +256,7 @@ impl App { render_dirty.clone(), ); restored_terminals = terminals; - restored_terminal_runtimes = terminal_runtimes; + restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); ( @@ -346,7 +347,6 @@ impl App { let mut state = AppState { terminals: std::collections::HashMap::new(), - terminal_runtimes: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), workspaces, active, @@ -457,14 +457,14 @@ impl App { global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), session_dirty: false, + terminal_runtime_shutdowns: Vec::new(), }; state.terminals = restored_terminals; - state.terminal_runtimes = restored_terminal_runtimes; for ws_idx in 0..state.workspaces.len() { let cwd = state.workspaces[ws_idx] - .resolved_identity_cwd_from(&state.terminals, &state.terminal_runtimes); + .resolved_identity_cwd_from(&state.terminals, &restored_terminal_runtimes); state.workspaces[ws_idx].cached_git_branch = cwd.as_deref().and_then(crate::workspace::git_branch); } @@ -488,6 +488,7 @@ impl App { config_diagnostic_deadline: None, toast_deadline: None, state, + terminal_runtimes: restored_terminal_runtimes, event_tx, event_rx, last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, @@ -593,14 +594,31 @@ impl App { let area = frame.area(); if kitty_graphics_enabled { cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area); - crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); + crate::ui::compute_view_with_cell_size( + &mut self.state, + &self.terminal_runtimes, + area, + cell_size, + ); } else { - crate::ui::compute_view(&mut self.state, area); + crate::ui::compute_view_with_runtime_registry( + &mut self.state, + &self.terminal_runtimes, + area, + ); } - crate::ui::render(&self.state, frame); + crate::ui::render_with_runtime_registry( + &self.state, + &self.terminal_runtimes, + frame, + ); })?; if kitty_graphics_enabled { - crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; + crate::kitty_graphics::paint_local_pane_graphics( + &self.state, + &self.terminal_runtimes, + cell_size, + )?; } self.last_render_at = Some(now); needs_render = false; @@ -664,7 +682,9 @@ impl App { } fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { - let desired = self.state.should_capture_host_mouse(); + let desired = self + .state + .should_capture_host_mouse_from(&self.terminal_runtimes); if desired == *active { return Ok(()); } @@ -1024,7 +1044,8 @@ impl App { if self.state.mouse_capture { self.handle_mouse_event_headless(mouse); } else { - self.state.handle_pane_mouse_only(mouse); + self.state + .handle_pane_mouse_only(&self.terminal_runtimes, mouse); } } crate::raw_input::RawInputEvent::Paste(text) => { @@ -1032,9 +1053,11 @@ impl App { if let Some(ws_idx) = self.state.active { if let Some(ws) = self.state.workspaces.get(ws_idx) { if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = - self.state.runtime_for_pane_in_workspace(ws_idx, focused) - { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace( + &self.terminal_runtimes, + ws_idx, + focused, + ) { let _ = runtime.try_send_bytes(bytes::Bytes::from( if runtime .input_state() @@ -1087,7 +1110,11 @@ impl App { input::handle_confirm_close_key(&mut self.state, key_event); } Mode::ContextMenu => { - input::handle_context_menu_key(&mut self.state, key_event); + input::handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); } Mode::KeybindHelp => { input::handle_keybind_help_key(&mut self.state, key_event); @@ -2221,7 +2248,7 @@ mod tests { 3 ); - let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } @@ -2268,7 +2295,7 @@ mod tests { assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, background_tab); - let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 5671bf38..7f7306cc 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -11,10 +11,20 @@ use crate::events::AppEvent; use crate::workspace::{Workspace, WorkspaceGitStatus}; impl App { + pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) { + let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns); + for terminal_id in terminal_ids { + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + runtime.shutdown(); + } + } + } + pub(crate) fn drain_api_requests(&mut self) -> bool { let mut changed = false; while let Ok(msg) = self.api_rx.try_recv() { changed |= self.handle_api_request_message(msg); + self.shutdown_detached_terminal_runtimes(); } changed } @@ -53,7 +63,7 @@ impl App { &mut self, event: crate::raw_input::RawInputEvent, ) -> bool { - match event { + let changed = match event { crate::raw_input::RawInputEvent::Key(key) => { let key_id = repeat_key_identity(&key); match key.kind { @@ -90,7 +100,8 @@ impl App { if self.state.mouse_capture { self.handle_mouse(mouse); } else { - self.state.handle_pane_mouse_only(mouse); + self.state + .handle_pane_mouse_only(&self.terminal_runtimes, mouse); } true } @@ -108,7 +119,9 @@ impl App { self.update_host_terminal_theme(kind, color) } crate::raw_input::RawInputEvent::Unsupported => false, - } + }; + self.shutdown_detached_terminal_runtimes(); + changed } fn handle_resize_poll(&mut self) -> bool { @@ -248,7 +261,10 @@ impl App { } // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable - let Some(metrics) = self.state.pane_scroll_metrics(pane_id) else { + let Some(metrics) = self + .state + .pane_scroll_metrics(&self.terminal_runtimes, pane_id) + else { self.stop_selection_autoscroll(); return; }; @@ -259,7 +275,8 @@ impl App { self.stop_selection_autoscroll(); return; } - self.state.scroll_pane_up(pane_id, 1); + self.state + .scroll_pane_up(&self.terminal_runtimes, pane_id, 1); } crate::app::state::SelectionAutoscrollDirection::Down => { let at_bottom = metrics.offset_from_bottom == 0; @@ -267,12 +284,14 @@ impl App { self.stop_selection_autoscroll(); return; } - self.state.scroll_pane_down(pane_id, 1); + self.state + .scroll_pane_down(&self.terminal_runtimes, pane_id, 1); } } // Extend selection cursor to last known mouse position self.state.update_selection_cursor( + &self.terminal_runtimes, pane_id, autoscroll.last_mouse_screen_col, autoscroll.last_mouse_screen_row, @@ -328,7 +347,7 @@ impl App { .workspaces .iter() .filter_map(|ws| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes) + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) .map(|cwd| (ws.id.clone(), cwd)) }) .collect(); diff --git a/src/app/session.rs b/src/app/session.rs index 85bdd1bb..976e320e 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -28,7 +28,7 @@ impl App { let snap = crate::persist::capture( &self.state.workspaces, &self.state.terminals, - &self.state.terminal_runtimes, + &self.terminal_runtimes, self.state.active, self.state.selected, self.state.agent_panel_scope, diff --git a/src/app/state.rs b/src/app/state.rs index 6924dc65..13958d03 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -891,8 +891,6 @@ pub enum SidebarWidthSource { pub struct AppState { pub terminals: std::collections::HashMap, - pub terminal_runtimes: - std::collections::HashMap, /// Terminal ids whose size is currently owned by a direct attach client. pub direct_attach_resize_locks: std::collections::HashSet, pub workspaces: Vec, @@ -1001,6 +999,9 @@ pub struct AppState { pub host_terminal_theme: TerminalTheme, /// Set when a persisted session snapshot would change. pub session_dirty: bool, + /// Terminal runtimes that should be shut down by the app/runtime layer + /// after state has detached their terminal metadata. + pub(crate) terminal_runtime_shutdowns: Vec, } impl AppState { @@ -1039,17 +1040,23 @@ impl AppState { section == SettingsSection::Integrations && self.integration_updates_available() } - pub fn focused_pane_requests_mouse_capture(&self) -> bool { + pub(crate) fn focused_pane_requests_mouse_capture_from( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) -> bool { self.mode == Mode::Terminal && self .active - .and_then(|idx| self.focused_runtime_in_workspace(idx)) + .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) .and_then(crate::terminal::TerminalRuntime::input_state) .is_some_and(crate::pane::InputState::mouse_reporting_enabled) } - pub fn should_capture_host_mouse(&self) -> bool { - self.mouse_capture || self.focused_pane_requests_mouse_capture() + pub(crate) fn should_capture_host_mouse_from( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) -> bool { + self.mouse_capture || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) } pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { @@ -1066,11 +1073,12 @@ impl AppState { /// Returns true when the given (workspace, tab, pane) refers to the /// currently focused pane in the active workspace's active tab. - pub(crate) fn runtime_for_pane_in_workspace( - &self, + pub(crate) fn runtime_for_pane_in_workspace<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { #[cfg(test)] if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) { return Some(runtime); @@ -1086,14 +1094,15 @@ impl AppState { return Some(runtime); } let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?; - self.terminal_runtimes.get(terminal_id) + terminal_runtimes.get(terminal_id) } #[cfg(test)] - pub(crate) fn runtime_for_pane( - &self, + pub(crate) fn runtime_for_pane<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { self.workspaces.iter().find_map(|ws| { #[cfg(test)] if let Some(runtime) = ws.test_runtimes.get(&pane_id) { @@ -1104,17 +1113,18 @@ impl AppState { return Some(runtime); } let terminal_id = ws.terminal_id(pane_id)?; - self.terminal_runtimes.get(terminal_id) + terminal_runtimes.get(terminal_id) }) } - pub(crate) fn focused_runtime_in_workspace( - &self, + pub(crate) fn focused_runtime_in_workspace<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, ws_idx: usize, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { let ws = self.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - self.runtime_for_pane_in_workspace(ws_idx, pane_id) + self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) } pub fn is_active_pane( @@ -1161,7 +1171,6 @@ impl AppState { pub fn test_new() -> Self { Self { terminals: std::collections::HashMap::new(), - terminal_runtimes: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), workspaces: Vec::new(), active: None, @@ -1262,6 +1271,7 @@ impl AppState { global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), session_dirty: false, + terminal_runtime_shutdowns: Vec::new(), } } @@ -1289,14 +1299,13 @@ impl AppState { pane_id: crate::layout::PaneId, runtime: crate::terminal::TerminalRuntime, ) { - let Some(terminal_id) = self + if let Some(ws) = self .workspaces - .iter() - .find_map(|ws| ws.terminal_id(pane_id).cloned()) - else { - return; - }; - self.terminal_runtimes.insert(terminal_id, runtime); + .iter_mut() + .find(|ws| ws.terminal_id(pane_id).is_some()) + { + ws.insert_test_runtime(pane_id, runtime); + } } } diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index 71ca83bb..d38d115c 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -162,11 +162,7 @@ impl App { workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, cwd: ws.tabs[tab_idx] - .cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), agent_status: pane_agent_status(terminal.state, pane.seen), }) diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index 802725f0..a45818df 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -37,7 +37,7 @@ impl App { return; } - for runtime in self.state.terminal_runtimes.values() { + for runtime in self.terminal_runtimes.values() { runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } diff --git a/src/cli.rs b/src/cli.rs index e9fe6e81..fed93fba 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,20 +1,21 @@ -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; -use crate::api; +use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ - AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, - AgentTarget, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState, - PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, - PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, - PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, - TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, - WorkspaceTarget, + AgentStatus, Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, + ReadSource, Request, SplitDirection, Subscription, }; +mod agent; +mod integration; +mod pane; +mod server; +mod status; +mod tab; +mod workspace; + pub enum CommandOutcome { Handled(i32), NotCli, @@ -27,20 +28,20 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { let exit_code = match command { "server" => { - let Some(exit_code) = run_server_command(&args[2..])? else { + let Some(exit_code) = server::run_server_command(&args[2..])? else { return Ok(CommandOutcome::NotCli); }; exit_code } - "status" => run_status_command(&args[2..])?, + "status" => status::run_status_command(&args[2..])?, "config" => run_config_command(&args[2..])?, - "workspace" => run_workspace_command(&args[2..])?, - "tab" => run_tab_command(&args[2..])?, - "agent" => run_agent_command(&args[2..])?, + "workspace" => workspace::run_workspace_command(&args[2..])?, + "tab" => tab::run_tab_command(&args[2..])?, + "agent" => agent::run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, - "pane" => run_pane_command(&args[2..])?, + "pane" => pane::run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, - "integration" => run_integration_command(&args[2..])?, + "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; @@ -48,54 +49,6 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { Ok(CommandOutcome::Handled(exit_code)) } -fn run_server_command(args: &[String]) -> std::io::Result> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - return Ok(None); - }; - - match subcommand { - "stop" => server_stop(&args[1..]).map(Some), - "reload-config" => server_reload_config(&args[1..]).map(Some), - "help" | "--help" | "-h" => { - print_server_help(); - Ok(Some(0)) - } - _ => { - print_server_help(); - Ok(Some(2)) - } - } -} - -fn run_status_command(args: &[String]) -> std::io::Result { - match args.first().map(|arg| arg.as_str()) { - None => print_full_status(), - Some("server") => { - if args.len() > 1 { - eprintln!("usage: herdr status server"); - return Ok(2); - } - print_server_status() - } - Some("client") => { - if args.len() > 1 { - eprintln!("usage: herdr status client"); - return Ok(2); - } - print_client_status(); - Ok(0) - } - Some("help" | "--help" | "-h") => { - print_status_help(); - Ok(0) - } - Some(_) => { - print_status_help(); - Ok(2) - } - } -} - fn run_config_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_config_help(); @@ -201,1563 +154,190 @@ fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf { .file_name() .and_then(|name| name.to_str()) .unwrap_or("config.toml"); - path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}")) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ServerRuntimeStatus { - Running { - version: Option, - protocol: Option, - }, - NotRunning, -} - -fn print_full_status() -> std::io::Result { - let server = read_server_runtime_status()?; - - println!("client:"); - println!(" version: {}", env!("CARGO_PKG_VERSION")); - println!(" protocol: {}", crate::server::protocol::PROTOCOL_VERSION); - println!(); - println!("server:"); - print_server_status_body(&server, " "); - println!(); - println!("update:"); - println!(" restart_needed: {}", restart_needed_label(&server)); - - Ok(0) -} - -fn print_server_status() -> std::io::Result { - let server = read_server_runtime_status()?; - print_server_status_body(&server, ""); - Ok(0) -} - -fn print_client_status() { - println!("version: {}", env!("CARGO_PKG_VERSION")); - println!("protocol: {}", crate::server::protocol::PROTOCOL_VERSION); - println!("binary: {}", current_exe_label()); -} - -fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { - match server { - ServerRuntimeStatus::Running { version, protocol } => { - println!("{indent}status: running"); - println!("{indent}version: {}", option_label(version.as_deref())); - println!("{indent}protocol: {}", protocol_label(*protocol)); - println!("{indent}compatible: {}", compatibility_label(*protocol)); - println!("{indent}socket: {}", api::socket_path().display()); - } - ServerRuntimeStatus::NotRunning => { - println!("{indent}status: not running"); - println!("{indent}socket: {}", api::socket_path().display()); - } - } -} - -fn read_server_runtime_status() -> std::io::Result { - match send_request(&Request { - id: "cli:status:server".into(), - method: Method::Ping(PingParams::default()), - }) { - Ok(response) => { - if response.get("error").is_some() { - return Err(std::io::Error::other(format!( - "server status request failed: {}", - response - ))); - } - - let result = &response["result"]; - Ok(ServerRuntimeStatus::Running { - version: result - .get("version") - .and_then(|value| value.as_str()) - .map(str::to_owned), - protocol: result - .get("protocol") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()), - }) - } - Err(err) if server_not_running_error(&err) => Ok(ServerRuntimeStatus::NotRunning), - Err(err) => Err(err), - } -} - -fn server_not_running_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused - ) -} - -fn option_label(value: Option<&str>) -> &str { - value.unwrap_or("unknown") -} - -fn protocol_label(protocol: Option) -> String { - protocol - .map(|value| value.to_string()) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn compatibility_label(protocol: Option) -> &'static str { - match protocol { - Some(protocol) if protocol == crate::server::protocol::PROTOCOL_VERSION => "yes", - Some(_) => "no", - None => "unknown", - } -} - -fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str { - match server { - ServerRuntimeStatus::Running { version, .. } => match version.as_deref() { - Some(env!("CARGO_PKG_VERSION")) => "no", - Some(_) => "yes", - None => "unknown", - }, - ServerRuntimeStatus::NotRunning => "no", - } -} - -fn current_exe_label() -> String { - std::env::current_exe() - .map(|path| path.display().to_string()) - .unwrap_or_else(|err| format!("unknown ({err})")) -} - -fn run_workspace_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_workspace_help(); - return Ok(2); - }; - - match subcommand { - "list" => workspace_list(&args[1..]), - "create" => workspace_create(&args[1..]), - "get" => workspace_get(&args[1..]), - "focus" => workspace_focus(&args[1..]), - "rename" => workspace_rename(&args[1..]), - "close" => workspace_close(&args[1..]), - "help" | "--help" | "-h" => { - print_workspace_help(); - Ok(0) - } - _ => { - print_workspace_help(); - Ok(2) - } - } -} - -fn run_tab_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_tab_help(); - return Ok(2); - }; - - match subcommand { - "list" => tab_list(&args[1..]), - "create" => tab_create(&args[1..]), - "get" => tab_get(&args[1..]), - "focus" => tab_focus(&args[1..]), - "rename" => tab_rename(&args[1..]), - "close" => tab_close(&args[1..]), - "help" | "--help" | "-h" => { - print_tab_help(); - Ok(0) - } - _ => { - print_tab_help(); - Ok(2) - } - } -} - -fn run_agent_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_agent_help(); - return Ok(2); - }; - - match subcommand { - "list" => agent_list(&args[1..]), - "get" => agent_get(&args[1..]), - "read" => agent_read(&args[1..]), - "send" => agent_send(&args[1..]), - "rename" => agent_rename(&args[1..]), - "focus" => agent_focus(&args[1..]), - "wait" => agent_wait(&args[1..]), - "attach" => agent_attach(&args[1..]), - "start" => agent_start(&args[1..]), - "help" | "--help" | "-h" => { - print_agent_help(); - Ok(0) - } - _ => { - print_agent_help(); - Ok(2) - } - } -} - -fn run_terminal_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_terminal_help(); - return Ok(2); - }; - - match subcommand { - "attach" => terminal_attach(&args[1..]), - "help" | "--help" | "-h" => { - print_terminal_help(); - Ok(0) - } - _ => { - print_terminal_help(); - Ok(2) - } - } -} - -fn run_pane_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_pane_help(); - return Ok(2); - }; - - match subcommand { - "list" => pane_list(&args[1..]), - "get" => pane_get(&args[1..]), - "read" => pane_read(&args[1..]), - "rename" => pane_rename(&args[1..]), - "split" => pane_split(&args[1..]), - "close" => pane_close(&args[1..]), - "send-text" => pane_send_text(&args[1..]), - "send-keys" => pane_send_keys(&args[1..]), - "report-agent" => pane_report_agent(&args[1..]), - "run" => pane_run(&args[1..]), - "help" | "--help" | "-h" => { - print_pane_help(); - Ok(0) - } - _ => { - print_pane_help(); - Ok(2) - } - } -} - -fn run_wait_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_wait_help(); - return Ok(2); - }; - - match subcommand { - "output" => wait_output(&args[1..]), - "agent-status" => wait_agent_status(&args[1..]), - "help" | "--help" | "-h" => { - print_wait_help(); - Ok(0) - } - _ => { - print_wait_help(); - Ok(2) - } - } -} - -fn run_integration_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_integration_help(); - return Ok(2); - }; - - match subcommand { - "install" => integration_install(&args[1..]), - "uninstall" => integration_uninstall(&args[1..]), - "status" => integration_status(&args[1..]), - "help" | "--help" | "-h" => { - print_integration_help(); - Ok(0) - } - _ => { - print_integration_help(); - Ok(2) - } - } -} - -fn run_session_command(args: &[String]) -> std::io::Result { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_session_help(); - return Ok(2); - }; - - match subcommand { - "list" => session_list(&args[1..]), - "attach" => session_attach_help(&args[1..]), - "stop" => session_stop(&args[1..]), - "delete" => session_delete(&args[1..]), - "help" | "--help" | "-h" => { - print_session_help(); - Ok(0) - } - _ => { - print_session_help(); - Ok(2) - } - } -} - -fn server_stop(args: &[String]) -> std::io::Result { - if !args.is_empty() { - eprintln!("usage: herdr server stop"); - return Ok(2); - } - - send_ok_request(Method::ServerStop(EmptyParams::default())) -} - -fn server_reload_config(args: &[String]) -> std::io::Result { - if !args.is_empty() { - eprintln!("usage: herdr server reload-config"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:server:reload-config".into(), - method: Method::ServerReloadConfig(EmptyParams::default()), - })?) -} - -fn session_attach_help(args: &[String]) -> std::io::Result { - if matches!( - args.first().map(String::as_str), - Some("help" | "--help" | "-h") - ) { - eprintln!("usage: herdr session attach "); - return Ok(0); - } - eprintln!("usage: herdr session attach "); - Ok(2) -} - -fn session_list(args: &[String]) -> std::io::Result { - let json = match parse_session_json_only(args, "usage: herdr session list [--json]") { - Ok(json) => json, - Err(code) => return Ok(code), - }; - - let sessions = crate::session::list_sessions()?; - if json { - _print_json(&serde_json::json!({ - "sessions": sessions, - })); - } else { - print_session_table(&sessions); - } - Ok(0) -} - -fn session_stop(args: &[String]) -> std::io::Result { - let (name, json) = - match parse_session_name_and_json(args, "usage: herdr session stop [--json]") { - Ok(parsed) => parsed, - Err(code) => return Ok(code), - }; - - let target = match crate::session::parse_target_name(&name) { - Ok(target) => target, - Err(message) => { - print_session_error("invalid_session_name", &message); - return Ok(1); - } - }; - match crate::session::stop_session(target.as_deref()) { - Ok(session) => { - if json { - _print_json(&serde_json::json!({ - "stopped": true, - "session": session, - })); - } else { - println!("stopped session {}", session.name); - } - Ok(0) - } - Err(message) => { - print_session_error("session_stop_failed", &message); - Ok(1) - } - } -} - -fn session_delete(args: &[String]) -> std::io::Result { - let (name, json) = - match parse_session_name_and_json(args, "usage: herdr session delete [--json]") { - Ok(parsed) => parsed, - Err(code) => return Ok(code), - }; - - match crate::session::delete_session(&name) { - Ok(session) => { - if json { - _print_json(&serde_json::json!({ - "deleted": true, - "session": session, - })); - } else { - println!("deleted session {}", session.name); - } - Ok(0) - } - Err(message) => { - print_session_error("session_delete_failed", &message); - Ok(1) - } - } -} - -fn workspace_list(args: &[String]) -> std::io::Result { - if !args.is_empty() { - eprintln!("usage: herdr workspace list"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:list".into(), - method: Method::WorkspaceList(EmptyParams::default()), - })?) -} - -fn workspace_create(args: &[String]) -> std::io::Result { - let mut cwd = None; - let mut focus = false; - let mut label = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--cwd" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--label" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --label"); - return Ok(2); - }; - label = Some(value.clone()); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:workspace:create".into(), - method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }), - })?) -} - -fn workspace_get(args: &[String]) -> std::io::Result { - let Some(raw_workspace_id) = args.first() else { - eprintln!("usage: herdr workspace get "); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr workspace get "); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:get".into(), - method: Method::WorkspaceGet(WorkspaceTarget { - workspace_id: normalize_workspace_id(raw_workspace_id), - }), - })?) -} - -fn workspace_focus(args: &[String]) -> std::io::Result { - let Some(raw_workspace_id) = args.first() else { - eprintln!("usage: herdr workspace focus "); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr workspace focus "); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:focus".into(), - method: Method::WorkspaceFocus(WorkspaceTarget { - workspace_id: normalize_workspace_id(raw_workspace_id), - }), - })?) -} - -fn workspace_rename(args: &[String]) -> std::io::Result { - if args.len() < 2 { - eprintln!("usage: herdr workspace rename