aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/agent_view.rs185
-rw-r--r--src/app/api/plugins/manifest.rs29
-rw-r--r--src/app/api/plugins/mod.rs96
-rw-r--r--src/app/api/plugins/runtime.rs35
-rw-r--r--src/app/api/worktrees.rs1
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![
diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs
index 0008def2..71004fe5 100644
--- a/src/app/api/plugins/runtime.rs
+++ b/src/app/api/plugins/runtime.rs
@@ -180,6 +180,41 @@ impl App {
Ok(log)
}
+ pub(crate) fn run_plugin_startup_hooks(&mut self) {
+ let mut context = self.current_plugin_context("plugin.startup");
+ context.invocation_source = Some("startup".to_string());
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin.enabled && plugin_manifest_available(plugin) && !plugin.startup.is_empty()
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
+ for plugin in plugins {
+ for startup in plugin.startup.clone() {
+ if ensure_platform_supported(
+ &effective_platforms(&startup.platforms, &plugin.platforms).clone(),
+ "startup",
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let _ = self.start_plugin_command(
+ &plugin,
+ None,
+ Some("startup".to_string()),
+ startup.command,
+ &context,
+ None,
+ );
+ }
+ }
+ }
+
pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) {
let event_name = event.event.dot_name();
if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) {
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index 2f143981..91a19673 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -828,6 +828,7 @@ mod tests {
enabled: true,
platforms: None,
build: Vec::new(),
+ startup: Vec::new(),
actions: Vec::new(),
events: vec![crate::api::schema::PluginManifestEventHook {
on: event.into(),