forked from 1b-agent/opencadstudio-upstream
feat(plugin): marketplace — install plugins from a curated registry
Add a Plugin Manager marketplace that installs external add-ons from a GitHub repo's Releases. A curated registry (plugins/registry.json in this repo, extended by PR) lists discoverable plugins; the host fetches it, shows each entry with a release dropdown, and on install downloads the asset matching the user's platform plus plugin.toml into the plugins folder (API-version checked first). Users can also link arbitrary owner/repo manually. Linked repos persist in settings. Desktop only (ureq + serde_json); the install path reuses the phase-2 loader. Part of the #100 extensibility epic (phase 2). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
29c71be101
commit
020a3a4c84
12 files changed
+599
-5
No files matched your search
+136
-3
@@ -5,11 +5,21 @@
|
||||
//! `docs/plugin-architecture.md`.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::plugin::external::ExternalPlugin;
|
||||
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
|
||||
use crate::plugin::manifest::PluginManifest;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
use rustc_hash::FxHashSet;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
/// Marketplace state passed to the Plugin Manager view.
|
||||
pub struct MarketView<'a> {
|
||||
pub registry: &'a [RegistryEntry],
|
||||
pub input: &'a str,
|
||||
pub repos: &'a [String],
|
||||
pub release_tags: &'a FxHashMap<String, Vec<String>>,
|
||||
pub selected_tag: &'a FxHashMap<String, String>,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
// Register the command names for autocomplete.
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
@@ -199,11 +209,131 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn pill_button<'a>(label: &str, msg: Message, bg: Color) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12).color(WHITE))
|
||||
.padding([4, 12])
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| {
|
||||
let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 }
|
||||
} else {
|
||||
bg
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(c)),
|
||||
text_color: WHITE,
|
||||
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
const GREEN: Color = Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 };
|
||||
const RED: Color = Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 };
|
||||
|
||||
/// Release dropdown + Install (+ optional unlink) for one repo.
|
||||
fn install_controls<'a>(
|
||||
repo: &str,
|
||||
tags: Vec<String>,
|
||||
selected: Option<String>,
|
||||
removable: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let repo_s = repo.to_string();
|
||||
let picker: Element<'_, Message> = if tags.is_empty() {
|
||||
text("no releases").size(11).color(DIM).into()
|
||||
} else {
|
||||
let r = repo_s.clone();
|
||||
pick_list(tags, selected, move |tag| {
|
||||
Message::PluginReleaseSelect(r.clone(), tag)
|
||||
})
|
||||
.text_size(12)
|
||||
.into()
|
||||
};
|
||||
let mut controls = row![
|
||||
picker,
|
||||
Space::new().width(8),
|
||||
pill_button("Install", Message::PluginInstall(repo_s.clone()), GREEN),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
.spacing(4);
|
||||
if removable {
|
||||
controls = controls.push(Space::new().width(6));
|
||||
controls = controls.push(pill_button("✕", Message::PluginRepoRemove(repo_s), RED));
|
||||
}
|
||||
controls.into()
|
||||
}
|
||||
|
||||
fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
|
||||
container(body.spacing(4).padding([10, 12]))
|
||||
.width(Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(CARD)),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
|
||||
let mut col = column![text("Available plugins").size(13).color(ACCENT)].spacing(6);
|
||||
|
||||
// Curated registry entries (from the OpenCADStudio repo).
|
||||
for e in m.registry {
|
||||
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(&e.repo).cloned();
|
||||
let header = row![
|
||||
text(e.name.clone()).size(14).color(WHITE),
|
||||
Space::new().width(Fill),
|
||||
install_controls(&e.repo, tags, selected, false),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
let mut body = column![header, text(e.repo.clone()).size(11).color(ACCENT)];
|
||||
if !e.description.is_empty() {
|
||||
body = body.push(text(e.description.clone()).size(12).color(DIM));
|
||||
}
|
||||
col = col.push(market_card(body));
|
||||
}
|
||||
|
||||
// Manual: link any repo by owner/repo.
|
||||
col = col.push(Space::new().height(6));
|
||||
col = col.push(text("Add a repository").size(12).color(DIM));
|
||||
col = col.push(
|
||||
row![
|
||||
text_input("owner/repo", m.input)
|
||||
.on_input(Message::PluginRepoInput)
|
||||
.on_submit(Message::PluginRepoAdd)
|
||||
.size(13)
|
||||
.width(Fill),
|
||||
Space::new().width(8),
|
||||
pill_button("Add", Message::PluginRepoAdd, Color { r: 0.2, g: 0.4, b: 0.62, a: 1.0 }),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
for repo in m.repos {
|
||||
let tags = m.release_tags.get(repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(repo).cloned();
|
||||
let header = row![
|
||||
text(repo.clone()).size(13).color(WHITE),
|
||||
Space::new().width(Fill),
|
||||
install_controls(repo, tags, selected, true),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
col = col.push(market_card(column![header]));
|
||||
}
|
||||
|
||||
if !m.status.is_empty() {
|
||||
col = col.push(text(m.status.to_string()).size(11).color(DIM));
|
||||
}
|
||||
col.into()
|
||||
}
|
||||
|
||||
pub fn view_window<'a>(
|
||||
plugins: &[&'static PluginManifest],
|
||||
disabled: &FxHashSet<String>,
|
||||
externals: &[ExternalPlugin],
|
||||
loaded: &FxHashSet<String>,
|
||||
market: MarketView,
|
||||
) -> Element<'a, Message> {
|
||||
let title = text("Installed Plugins").size(20).color(WHITE);
|
||||
let subtitle = text(format!(
|
||||
@@ -235,6 +365,9 @@ pub fn view_window<'a>(
|
||||
list = list.push(external_card(p, loaded.contains(&p.id)));
|
||||
}
|
||||
}
|
||||
// Marketplace: install from a linked repository's releases.
|
||||
list = list.push(Space::new().height(14));
|
||||
list = list.push(marketplace_section(&market));
|
||||
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
|
||||
|
||||
container(
|
||||
|
||||
Reference in new issue
Block a user