GUI plugins #9
3 changed files with 287 additions and 0 deletions
pinakes-ui: add plugin page registry
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie83791e82c68f757173e5dc53a646b356a6a6964
commit
be4305f46e
|
|
@ -4,6 +4,7 @@ use tracing_subscriber::EnvFilter;
|
||||||
mod app;
|
mod app;
|
||||||
mod client;
|
mod client;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod plugin_ui;
|
||||||
mod state;
|
mod state;
|
||||||
mod styles;
|
mod styles;
|
||||||
|
|
||||||
|
|
|
||||||
38
crates/pinakes-ui/src/plugin_ui/mod.rs
Normal file
38
crates/pinakes-ui/src/plugin_ui/mod.rs
Normal file
|
|
@ -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;
|
||||||
248
crates/pinakes-ui/src/plugin_ui/registry.rs
Normal file
248
crates/pinakes-ui/src/plugin_ui/registry.rs
Normal file
|
|
@ -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<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<chrono::DateTime<chrono::Utc>> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue