pinakes-ui: add plugin page registry

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie83791e82c68f757173e5dc53a646b356a6a6964
This commit is contained in:
raf 2026-03-09 22:01:40 +03:00
commit be4305f46e
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 287 additions and 0 deletions

View file

@ -4,6 +4,7 @@ use tracing_subscriber::EnvFilter;
mod app;
mod client;
mod components;
mod plugin_ui;
mod state;
mod styles;

View 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;

View 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");
}
}