diff --git a/crates/pinakes-ui/src/main.rs b/crates/pinakes-ui/src/main.rs index 024584d..51b4068 100644 --- a/crates/pinakes-ui/src/main.rs +++ b/crates/pinakes-ui/src/main.rs @@ -4,6 +4,7 @@ use tracing_subscriber::EnvFilter; mod app; mod client; mod components; +mod plugin_ui; mod state; mod styles; diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs new file mode 100644 index 0000000..fc40360 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -0,0 +1,38 @@ +//! Plugin UI system for Pinakes Desktop/Web UI +//! +//! This module provides a declarative UI plugin system that allows plugins +//! to define custom pages without needing to compile against the UI crate. +//! +//! # Architecture +//! +//! - [`registry`] - Plugin page registry and context provider +//! - [`data`] - Data fetching and caching for plugin data sources +//! - [`actions`] - Action execution system for plugin interactions +//! - [`renderer`] - Schema-to-Dioxus rendering components +//! +//! # Usage +//! +//! Plugins define their UI as JSON schemas in the plugin manifest: +//! +//! ```toml +//! [[ui.pages]] +//! id = "my-plugin-page" +//! title = "My Plugin" +//! route = "/plugins/my-plugin" +//! icon = "cog" +//! +//! [ui.pages.layout] +//! type = "container" +//! # ... more layout definition +//! ``` +//! +//! The UI schema is fetched from the server and rendered using the +//! components in this module. + +pub mod actions; +pub mod data; +pub mod registry; +pub mod renderer; + +pub use registry::{PluginPage, PluginRegistry}; +pub use renderer::PluginViewRenderer; diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs new file mode 100644 index 0000000..473509d --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -0,0 +1,248 @@ +//! Plugin UI Registry +//! +//! Manages plugin-provided UI pages and provides hooks for accessing +//! page definitions at runtime. +//! +//! ## Usage +//! +//! ```rust,ignore +//! // Initialize registry with API client +//! let registry = PluginRegistry::new(api_client); +//! registry.refresh().await?; +//! +//! // Access pages +//! if let Some(page) = registry.get_page("my-plugin", "demo") { +//! println!("Page: {}", page.page.title); +//! } +//! ``` + +use std::collections::HashMap; + +use dioxus::prelude::*; +use pinakes_plugin_api::UiPage; + +use crate::client::ApiClient; + +/// Information about a plugin-provided UI page +#[derive(Debug, Clone)] +pub struct PluginPage { + /// Plugin ID that provides this page + pub plugin_id: String, + /// Page definition from schema + pub page: UiPage, +} + +impl PluginPage { + /// Full route including plugin prefix + pub fn full_route(&self) -> String { + format!("/plugins/{}/{}", self.plugin_id, self.page.id) + } +} + +/// Registry of all plugin-provided UI pages +/// +/// This is typically stored as a context value in the Dioxus tree. +#[derive(Debug, Clone)] +pub struct PluginRegistry { + /// API client for fetching pages from server + client: ApiClient, + /// Cached pages: (plugin_id, page_id) -> PluginPage + pages: HashMap<(String, String), PluginPage>, + /// Last refresh timestamp + last_refresh: Option>, +} + +impl PluginRegistry { + /// Create a new empty registry + pub fn new(client: ApiClient) -> Self { + Self { + client, + pages: HashMap::new(), + last_refresh: None, + } + } + + /// Create a new registry with pre-loaded pages + pub fn with_pages( + client: ApiClient, + pages: Vec<(String, String, UiPage)>, + ) -> Self { + let mut registry = Self::new(client); + for (plugin_id, _page_id, page) in pages { + registry.register_page(plugin_id, page); + } + registry + } + + /// Register a page from a plugin + pub fn register_page(&mut self, plugin_id: String, page: UiPage) { + let page_id = page.id.clone(); + self + .pages + .insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page }); + } + + /// Get a specific page by plugin ID and page ID + pub fn get_page( + &self, + plugin_id: &str, + page_id: &str, + ) -> Option<&PluginPage> { + self + .pages + .get(&(plugin_id.to_string(), page_id.to_string())) + } + + /// Get all pages + pub fn all_pages(&self) -> Vec<&PluginPage> { + self.pages.values().collect() + } + + /// Get all page routes for navigation + pub fn routes(&self) -> Vec<(String, String, String)> { + self + .pages + .values() + .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) + .collect() + } + + /// Check if any pages are registered + pub fn is_empty(&self) -> bool { + self.pages.is_empty() + } + + /// Number of registered pages + pub fn len(&self) -> usize { + self.pages.len() + } + + /// Refresh pages from server + pub async fn refresh(&mut self) -> Result<(), String> { + match self.client.get_plugin_ui_pages().await { + Ok(pages) => { + self.pages.clear(); + for (plugin_id, page) in pages { + self.register_page(plugin_id, page); + } + self.last_refresh = Some(chrono::Utc::now()); + Ok(()) + }, + Err(e) => Err(format!("Failed to refresh plugin pages: {e}")), + } + } + + /// Get last refresh time + pub const fn last_refresh(&self) -> Option> { + self.last_refresh + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new(ApiClient::default()) + } +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::UiElement; + + use super::*; + + fn create_test_page(id: &str, title: &str) -> UiPage { + UiPage { + id: id.to_string(), + title: title.to_string(), + route: format!("/plugins/test/{id}"), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 16, + padding: None, + }, + data_sources: HashMap::new(), + } + } + + #[test] + fn test_registry_empty() { + let client = ApiClient::default(); + let registry = PluginRegistry::new(client); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + } + + #[test] + fn test_register_and_get_page() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + let page = create_test_page("demo", "Demo Page"); + + registry.register_page("my-plugin".to_string(), page.clone()); + + assert!(!registry.is_empty()); + assert_eq!(registry.len(), 1); + + let retrieved = registry.get_page("my-plugin", "demo"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().page.id, "demo"); + assert_eq!(retrieved.unwrap().page.title, "Demo Page"); + } + + #[test] + fn test_get_page_not_found() { + let client = ApiClient::default(); + let registry = PluginRegistry::new(client); + + let result = registry.get_page("nonexistent", "page"); + assert!(result.is_none()); + } + + #[test] + fn test_page_full_route() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + let page = create_test_page("demo", "Demo Page"); + + registry.register_page("my-plugin".to_string(), page.clone()); + + let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); + assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo"); + } + + #[test] + fn test_all_pages() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin1".to_string(), + create_test_page("page1", "Page 1"), + ); + registry.register_page( + "plugin2".to_string(), + create_test_page("page2", "Page 2"), + ); + + let all = registry.all_pages(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_routes() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin1".to_string(), + create_test_page("page1", "Page 1"), + ); + + let routes = registry.routes(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].0, "plugin1"); + assert_eq!(routes[0].1, "page1"); + assert_eq!(routes[0].2, "/plugins/plugin1/page1"); + } +}