diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/agent_view.rs | 185 | ||||
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 29 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 96 | ||||
| -rw-r--r-- | src/app/api/plugins/runtime.rs | 35 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 1 |
5 files changed, 341 insertions, 5 deletions
diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs new file mode 100644 index 00000000..e14a933d --- /dev/null +++ b/src/app/api/agent_view.rs @@ -0,0 +1,185 @@ +use crate::api::schema::{AgentViewClearParams, AgentViewSetParams, ResponseResult}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_agent_view_set( + &mut self, + id: String, + mut params: AgentViewSetParams, + ) -> String { + if let Err(message) = crate::app::agent_view::validate_agent_view(&mut params) { + return encode_error(id, "invalid_agent_view", message); + } + if let Some(plugin_id) = params.source.strip_prefix("plugin:") { + let Some(plugin_id) = super::plugins::normalize_plugin_id(plugin_id) else { + return encode_error( + id, + "invalid_agent_view", + "plugin-owned agent view source has an invalid plugin id", + ); + }; + let Some(plugin) = self.state.installed_plugins.get(&plugin_id) else { + return encode_error(id, "plugin_not_found", "plugin not found"); + }; + if !plugin.enabled { + return encode_error(id, "plugin_disabled", "plugin is disabled"); + } + } + let source = params.source.clone(); + let label = params.label.clone(); + self.replace_agent_view_override(Some(params)); + encode_success( + id, + ResponseResult::AgentView { + active: true, + source: Some(source), + label, + }, + ) + } + + pub(super) fn handle_agent_view_clear( + &mut self, + id: String, + params: AgentViewClearParams, + ) -> String { + let source = match params.source { + Some(source) => match crate::app::agent_view::validate_agent_view_source(&source) { + Ok(source) => Some(source), + Err(message) => return encode_error(id, "invalid_agent_view", message), + }, + None => None, + }; + if source.as_deref().is_none_or(|source| { + self.state + .agent_view_override + .as_ref() + .is_some_and(|active| active.source == source) + }) { + self.replace_agent_view_override(None); + } + let active = self.state.agent_view_override.as_ref(); + encode_success( + id, + ResponseResult::AgentView { + active: active.is_some(), + source: active.map(|view| view.source.clone()), + label: active.and_then(|view| view.label.clone()), + }, + ) + } + + pub(crate) fn clear_agent_view_for_source(&mut self, source: &str) -> bool { + if self + .state + .agent_view_override + .as_ref() + .is_some_and(|active| active.source == source) + { + self.replace_agent_view_override(None); + true + } else { + false + } + } + + fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) { + self.state.agent_view_override = view; + self.state.agent_panel_scroll = 0; + self.state.mobile_switcher_scroll = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{ + AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue, + }; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + fn working_view(source: &str) -> AgentViewSetParams { + AgentViewSetParams { + source: source.to_string(), + label: Some("working".to_string()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("working".to_string()), + }), + sort: Vec::new(), + } + } + + #[test] + fn set_and_source_guarded_clear_replace_transient_view() { + let mut app = test_app(); + + let set = app.handle_agent_view_set("set".to_string(), working_view("example.views")); + let set: crate::api::schema::SuccessResponse = serde_json::from_str(&set).unwrap(); + assert_eq!( + set.result, + ResponseResult::AgentView { + active: true, + source: Some("example.views".to_string()), + label: Some("working".to_string()), + } + ); + assert_eq!( + app.state + .agent_view_override + .as_ref() + .map(|view| view.source.as_str()), + Some("example.views") + ); + + app.handle_agent_view_clear( + "wrong-source".to_string(), + AgentViewClearParams { + source: Some("other.views".to_string()), + }, + ); + assert!(app.state.agent_view_override.is_some()); + + app.handle_agent_view_clear( + "right-source".to_string(), + AgentViewClearParams { + source: Some("example.views".to_string()), + }, + ); + assert!(app.state.agent_view_override.is_none()); + } + + #[test] + fn invalid_view_does_not_replace_active_view() { + let mut app = test_app(); + app.handle_agent_view_set("set".to_string(), working_view("example.views")); + + let mut invalid = working_view("example.other"); + invalid.filter = Some(AgentViewFilter::Any { + filters: Vec::new(), + }); + let response = app.handle_agent_view_set("invalid".to_string(), invalid); + let response: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + + assert_eq!(response.error.code, "invalid_agent_view"); + assert_eq!( + app.state + .agent_view_override + .as_ref() + .map(|view| view.source.as_str()), + Some("example.views") + ); + } +} diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index 645748e0..f6464285 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -1,7 +1,7 @@ use crate::api::schema::{ InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook, - PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, - PluginSourceInfo, PluginSourceKind, + PluginManifestLinkHandler, PluginManifestPane, PluginManifestStartup, PluginPanePlacement, + PluginPlatform, PluginSourceInfo, PluginSourceKind, }; use crate::popup_size::PopupSize; @@ -22,6 +22,8 @@ struct RawPluginManifest { #[serde(default)] build: Vec<RawPluginManifestBuild>, #[serde(default)] + startup: Vec<RawPluginManifestStartup>, + #[serde(default)] actions: Vec<RawPluginManifestAction>, #[serde(default)] events: Vec<RawPluginManifestEventHook>, @@ -39,6 +41,13 @@ struct RawPluginManifestBuild { } #[derive(serde::Deserialize)] +struct RawPluginManifestStartup { + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] struct RawPluginManifestAction { id: String, title: String, @@ -151,6 +160,11 @@ pub(crate) fn load_plugin_manifest( .into_iter() .map(normalize_manifest_build) .collect::<Result<Vec<_>, _>>()?; + let startup = raw + .startup + .into_iter() + .map(normalize_manifest_startup) + .collect::<Result<Vec<_>, _>>()?; let mut actions = raw .actions .into_iter() @@ -202,6 +216,7 @@ pub(crate) fn load_plugin_manifest( enabled, platforms, build, + startup, actions, events, panes, @@ -250,6 +265,14 @@ fn normalize_manifest_build( Ok(PluginManifestBuild { platforms, command }) } +fn normalize_manifest_startup( + startup: RawPluginManifestStartup, +) -> Result<PluginManifestStartup, (&'static str, String)> { + let platforms = normalize_platforms(startup.platforms)?; + let command = normalize_command(startup.command)?; + Ok(PluginManifestStartup { platforms, command }) +} + pub(super) fn normalize_plugin_source( plugin: &InstalledPluginInfo, source: PluginSourceInfo, @@ -563,7 +586,7 @@ fn non_empty_trimmed( } } -pub(super) fn normalize_plugin_id(value: &str) -> Option<String> { +pub(crate) fn normalize_plugin_id(value: &str) -> Option<String> { normalize_identifier(value, PLUGIN_ID_MAX_CHARS) } diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 83a6065e..e48a1506 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -13,9 +13,9 @@ use crate::api::schema::{ ResponseResult, }; use crate::app::App; +pub(super) use manifest::normalize_plugin_id; use manifest::{ - effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id, - normalize_plugin_source, + effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_source, }; #[cfg(test)] @@ -110,6 +110,7 @@ impl App { } return encode_error(id, "plugin_registry_save_failed", err.to_string()); } + self.clear_agent_view_for_source(&format!("plugin:{plugin_id}")); } encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed }) } @@ -593,6 +594,9 @@ impl App { let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { return encode_error(id, "plugin_not_found", "plugin not found"); }; + if !enabled { + self.clear_agent_view_for_source(&format!("plugin:{plugin_id}")); + } if enabled { encode_success(id, ResponseResult::PluginEnabled { plugin }) } else { @@ -1212,6 +1216,38 @@ command = ["echo", "b"] } #[test] + fn startup_hook_manifest_loads() { + let root = unique_temp_path("plugin-startup-manifest"); + write_manifest_content( + &root, + r#" +id = "example.startup-manifest" +name = "Startup Manifest" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[startup]] +command = ["node", "restore.js"] +platforms = ["linux", "macos"] +"#, + ); + + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + + assert_eq!(plugin.startup.len(), 1); + assert_eq!(plugin.startup[0].command, ["node", "restore.js"]); + assert_eq!( + plugin.startup[0].platforms, + Some(vec![ + crate::api::schema::PluginPlatform::Linux, + crate::api::schema::PluginPlatform::Macos, + ]) + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] fn smoke_fixture_manifest_loads() { let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true) .expect("smoke fixture should load"); @@ -1235,6 +1271,12 @@ command = ["echo", "b"] let root = unique_temp_path("plugin-enable-disable"); write_manifest(&root); link_manifest(&mut app, &root); + app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { + source: "plugin:example.worktree-bootstrap".into(), + label: None, + filter: None, + sort: Vec::new(), + }); let disabled = app.handle_api_request(Request { id: "disable".into(), @@ -1246,6 +1288,19 @@ command = ["echo", "b"] panic!("expected disabled response: {disabled}"); }; assert!(!plugin.enabled); + assert!(app.state.agent_view_override.is_none()); + let delayed_restore = app.handle_api_request(Request { + id: "delayed-startup-restore".into(), + method: Method::AgentViewSet(crate::api::schema::AgentViewSetParams { + source: "plugin:example.worktree-bootstrap".into(), + label: None, + filter: None, + sort: Vec::new(), + }), + }); + let delayed_restore: crate::api::schema::ErrorResponse = + serde_json::from_str(&delayed_restore).unwrap(); + assert_eq!(delayed_restore.error.code, "plugin_disabled"); let enabled = app.handle_api_request(Request { id: "enable".into(), @@ -2266,6 +2321,43 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG #[cfg(unix)] #[test] + fn startup_hooks_run_once_with_plugin_environment() { + let mut app = test_app(); + let root = unique_temp_path("plugin-startup-hook"); + let capture = root.join("startup.txt"); + write_manifest_content( + &root, + &format!( + r#" +id = "example.startup" +name = "Startup" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos"] + +[[startup]] +command = ["sh", "-c", "printf '%s:%s' \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_EVENT\" > {}"] +"#, + capture.display() + ), + ); + link_manifest(&mut app, &root); + + app.run_plugin_startup_hooks(); + + assert_eq!( + read_capture_when_ready(&capture, || { + app.drain_all_internal_events(); + }), + "example.startup:startup" + ); + let plugin = app.state.installed_plugins.get("example.startup").unwrap(); + assert_eq!(plugin.startup.len(), 1); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] fn event_hooks_use_event_target_context() { let mut app = test_app(); app.state.workspaces = vec