diff options
Diffstat (limited to 'src/app/api/panes.rs')
| -rw-r--r-- | src/app/api/panes.rs | 386 |
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"); + } + } } |
