aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/panes.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api/panes.rs')
-rw-r--r--src/app/api/panes.rs386
1 files changed, 351 insertions, 35 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 7ef4ed3c..2d994dca 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1,16 +1,17 @@
use bytes::Bytes;
use crate::api::schema::{
- EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection,
- PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason,
- PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect,
- PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, PaneMoveParams,
- PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
- PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
- PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
- PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams,
- PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
+ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams,
+ PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams,
+ PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams,
+ PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination,
+ PaneMoveParams, PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult,
+ PaneProcessInfo, PaneProcessInfoParams, PaneProcessInfoProcess, PaneReadParams, PaneReadResult,
+ PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult,
+ PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams,
+ PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason,
+ PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
};
use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason};
use crate::app::{App, Mode};
@@ -22,6 +23,10 @@ use super::super::api_helpers::{
};
use super::responses::{encode_error, encode_success};
+const METADATA_SOURCE_MAX_CHARS: usize = 80;
+const METADATA_TTL_MIN_MS: u64 = 1;
+const METADATA_TTL_MAX_MS: u64 = 86_400_000;
+
impl App {
pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
@@ -40,6 +45,10 @@ impl App {
let Some((ws_idx, target_pane_id)) = target else {
return encode_error(id, "pane_not_found", "pane not found");
};
+ let extra_env = match super::env::normalize_launch_env(params.env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
let (rows, cols) = self.state.estimate_pane_size();
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| {
@@ -75,6 +84,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
None => ws.split_pane(
@@ -86,6 +96,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
};
@@ -124,6 +135,21 @@ impl App {
}
}
+ pub(super) fn handle_pane_current(&mut self, id: String, params: PaneCurrentParams) -> String {
+ let target = match params.caller_pane_id.as_deref() {
+ Some(caller_pane_id) => self.parse_pane_id(caller_pane_id),
+ None => self.resolve_optional_pane(None),
+ };
+ let Some((ws_idx, pane_id)) = target else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+
+ encode_success(id, ResponseResult::PaneCurrent { pane })
+ }
+
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);
@@ -152,6 +178,54 @@ impl App {
encode_success(id, ResponseResult::PaneLayout { layout })
}
+ pub(super) fn handle_pane_process_info(
+ &mut self,
+ id: String,
+ params: PaneProcessInfoParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let shell_pid = runtime.child_pid();
+ let foreground_job = shell_pid.and_then(crate::detect::foreground_job);
+ let foreground_process_group_id = foreground_job.as_ref().map(|job| job.process_group_id);
+ let foreground_processes = foreground_job
+ .map(|job| {
+ job.processes
+ .into_iter()
+ .map(|process| PaneProcessInfoProcess {
+ pid: process.pid,
+ name: process.name,
+ argv0: process.argv0,
+ argv: process.argv,
+ cmdline: process.cmdline,
+ cwd: crate::platform::process_cwd(process.pid)
+ .map(|cwd| cwd.display().to_string()),
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ encode_success(
+ id,
+ ResponseResult::PaneProcessInfo {
+ process_info: PaneProcessInfo {
+ pane_id: public_pane_id,
+ shell_pid,
+ foreground_process_group_id,
+ tty: None,
+ foreground_processes,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_neighbor(
&mut self,
id: String,
@@ -910,6 +984,7 @@ impl App {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: closed_workspace_id.clone(),
+ workspace: None,
},
});
}
@@ -1184,29 +1259,26 @@ impl App {
},
None => None,
};
- let Some(source) = normalize_optional_text(Some(params.source)) else {
- return encode_error(id, "invalid_metadata_request", "missing metadata source");
+ let source = match normalize_metadata_source(params.source) {
+ Ok(source) => source,
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
};
let raw_title_set = params.title.is_some();
let raw_display_agent_set = params.display_agent.is_some();
let raw_custom_status_set = params.custom_status.is_some();
let raw_state_labels_set = !params.state_labels.is_empty();
- let ttl = params.ttl_ms.map(std::time::Duration::from_millis);
+ let ttl = match normalize_metadata_ttl(params.ttl_ms) {
+ Ok(ttl) => ttl,
+ Err(message) => return encode_error(id, "invalid_metadata_ttl", message),
+ };
let title = normalize_presentation_text(params.title);
let display_agent = normalize_presentation_text(params.display_agent);
let custom_status = normalize_custom_status(params.custom_status);
let applies_to_source = match params.applies_to_source {
- Some(applies_to_source) => {
- let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source))
- else {
- return encode_error(
- id,
- "invalid_metadata_request",
- "missing metadata authority source",
- );
- };
- Some(applies_to_source)
- }
+ Some(applies_to_source) => match normalize_metadata_source(applies_to_source) {
+ Ok(applies_to_source) => Some(applies_to_source),
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
+ },
None => None,
};
let state_labels = match normalize_state_labels(params.state_labels) {
@@ -1353,29 +1425,39 @@ impl App {
}
pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String {
+ match self.close_pane(id.clone(), &target) {
+ Ok(()) => encode_success(id, ResponseResult::Ok {}),
+ Err(response) => response,
+ }
+ }
+
+ /// Close a pane; `Err` carries the encoded error response.
+ pub(super) fn close_pane(&mut self, id: String, target: &PaneTarget) -> Result<(), String> {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let workspace_id = self.public_workspace_id(ws_idx);
if self.state.close_pane_would_close_workspace(ws_idx, pane_id)
&& self.state.confirm_implicit_worktree_group_close(ws_idx)
{
- return encode_error(
+ return Err(encode_error(
id,
"confirmation_required",
"closing this pane would close a worktree group",
- );
+ ));
}
+ let workspace_snapshot = self.workspace_info(ws_idx);
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);
+ return Err(pane_not_found(id, &target.pane_id));
};
ws.close_pane(pane_id)
};
+ self.state.plugin_panes.remove(&pane_id);
if should_close_workspace {
self.state.selected = ws_idx;
self.state.close_selected_workspace();
@@ -1389,7 +1471,10 @@ impl App {
});
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed { workspace_id },
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace_snapshot),
+ },
});
} else {
self.state.remove_unattached_terminal_ids(terminal_id);
@@ -1404,7 +1489,7 @@ impl App {
});
}
- encode_success(id, ResponseResult::Ok {})
+ Ok(())
}
pub(super) fn handle_pane_send_keys(
@@ -1432,9 +1517,39 @@ impl App {
}
}
-fn normalize_optional_text(value: Option<String>) -> Option<String> {
- let value = value?.trim().to_string();
- (!value.is_empty()).then_some(value)
+fn normalize_metadata_source(value: String) -> Result<String, &'static str> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err("metadata source must not be empty");
+ }
+ if value.chars().count() > METADATA_SOURCE_MAX_CHARS {
+ return Err("metadata source must be 80 characters or fewer");
+ }
+ if !value.chars().all(metadata_source_char_is_valid) {
+ return Err(
+ "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen",
+ );
+ }
+ Ok(value.to_string())
+}
+
+fn metadata_source_char_is_valid(ch: char) -> bool {
+ ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')
+}
+
+fn normalize_metadata_ttl(
+ ttl_ms: Option<u64>,
+) -> Result<Option<std::time::Duration>, &'static str> {
+ let Some(ttl_ms) = ttl_ms else {
+ return Ok(None);
+ };
+ if ttl_ms < METADATA_TTL_MIN_MS {
+ return Err("metadata ttl_ms must be at least 1");
+ }
+ if ttl_ms > METADATA_TTL_MAX_MS {
+ return Err("metadata ttl_ms must be 86400000 or less");
+ }
+ Ok(Some(std::time::Duration::from_millis(ttl_ms)))
}
fn normalize_presentation_text(value: Option<String>) -> Option<String> {
@@ -1659,11 +1774,51 @@ fn invalid_agent(id: String) -> String {
mod tests {
use super::*;
use crate::{
- api::schema::{SplitDirection, SuccessResponse},
+ api::schema::{ErrorResponse, SplitDirection, SuccessResponse},
config::Config,
workspace::Workspace,
};
+ fn app_with_test_workspace() -> (App, String) {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("metadata")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+ (app, public_pane_id)
+ }
+
+ fn metadata_params(pane_id: String) -> PaneReportMetadataParams {
+ PaneReportMetadataParams {
+ pane_id,
+ source: "user:metadata.test-1".into(),
+ agent: None,
+ applies_to_source: None,
+ title: None,
+ display_agent: None,
+ custom_status: Some("activity".into()),
+ state_labels: std::collections::HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ seq: None,
+ ttl_ms: None,
+ }
+ }
+
+ fn metadata_error_code(response: &str) -> String {
+ let response: ErrorResponse = serde_json::from_str(response).unwrap();
+ response.error.code
+ }
+
fn app_with_linked_worktree() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1674,6 +1829,7 @@ mod tests {
crate::api::EventHub::default(),
);
app.state.workspaces = vec![Workspace::test_new("issue")];
+ app.state.ensure_test_terminals();
app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
label: "herdr".into(),
@@ -1722,6 +1878,106 @@ mod tests {
}
#[test]
+ fn api_pane_current_prefers_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.ensure_test_terminals();
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some(right_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, right_public);
+ assert!(!pane.focused);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_ne!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_falls_back_to_focused_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ assert!(pane.focused);
+ }
+
+ #[test]
+ fn api_pane_current_dispatches_through_socket_request() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::PaneCurrent(
+ crate::api::schema::PaneCurrentParams::default(),
+ ),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_reports_invalid_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some("missing".into()),
+ },
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
+ fn api_pane_current_reports_no_active_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = None;
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() {
let mut app = app_with_linked_worktree();
let source = app.state.workspaces[0].tabs[0].root_pane;
@@ -2825,4 +3081,64 @@ mod tests {
assert_eq!(focus.focused_pane_id, Some(root_public));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
}
+
+ #[test]
+ fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.ttl_ms = Some(METADATA_TTL_MAX_MS);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_source_shape() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for source in ["", "user metadata", "user/metadata", "user:\u{7f}metadata"] {
+ let mut params = metadata_params(pane_id.clone());
+ params.source = source.into();
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_long_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.source = "a".repeat(METADATA_SOURCE_MAX_CHARS + 1);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_applies_to_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.applies_to_source = Some("herdr source".into());
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_ttl_outside_supported_range() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for ttl_ms in [0, METADATA_TTL_MAX_MS + 1] {
+ let mut params = metadata_params(pane_id.clone());
+ params.ttl_ms = Some(ttl_ms);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_ttl");
+ }
+ }
}