diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 02:43:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 03:47:07 +0300 |
| commit | fbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch) | |
| tree | d21daf93241786f8aa6fde23b41cdb9e15182b79 /src/app/api/panes.rs | |
| parent | d35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff) | |
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs.
Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
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"); + } + } } |
