feat(plugins): redesign plugin manager

This commit is contained in:
Hakan Seven 2026-07-29 14:27:32 +03:00
commit 4e25e88c31
11 changed files with 741 additions and 74 deletions

36
Cargo.lock generated
View file

@ -43,6 +43,7 @@ dependencies = [
"rayon",
"rfd",
"rustc-hash 2.1.3",
"semver",
"serde",
"serde_json",
"truck-meshalgo",
@ -1867,6 +1868,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -2376,6 +2386,7 @@ dependencies = [
"iced_renderer",
"log",
"num-traits",
"pulldown-cmark",
"rustc-hash 2.1.3",
"thiserror 2.0.18",
"unicode-segmentation",
@ -4136,6 +4147,25 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags 2.13.1",
"getopts",
"memchr 2.8.3",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "pulp"
version = "0.22.3"
@ -5695,6 +5725,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"

View file

@ -35,6 +35,7 @@ solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"]
# and the consolidated user config (settings.json) via serde-derived structs.
serde_json = "1"
serde = { version = "1", features = ["derive"] }
semver = "1"
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
# Plugin authors target this crate's semver, not OpenCADStudio internals.
# The `host` feature (out-of-process plugin runtime: interprocess, libloading,
@ -47,7 +48,7 @@ serde = { version = "1", features = ["derive"] }
# Forcing the `static` feature builds liblzma from the bundled source, making
# the binary self-contained on every platform.
lzma-sys = { version = "0.1", features = ["static"], optional = true }
iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol"] }
iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] }
iced_aw = { version = "0.14.1", features = ["menu"] }
bytemuck = { version = "1.25", features = ["derive"] }
glam = { version = "0.33", features = ["bytemuck"] }

View file

@ -5,9 +5,10 @@ version = "0.1.0"
description = "Short description of what this add-on does"
author = "Your Name"
license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad]
api_version = 3
ribbon_order = 60
command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"]
xdata_apps = ["MYPLUGIN_RECORD"]

View file

@ -528,10 +528,19 @@ pub(super) struct OpenCADStudio {
plugin_repos: Vec<String>,
/// Add-repository text field in the Plugin Manager.
plugin_repo_input: String,
/// Live filter for installed and available plugin cards.
plugin_search_input: String,
/// Installable release tags fetched per linked repo (for the dropdown).
repo_release_tags: rustc_hash::FxHashMap<String, Vec<String>>,
/// The release tag currently selected per linked repo.
repo_selected_tag: rustc_hash::FxHashMap<String, String>,
/// Repository currently shown in the Plugin Manager detail pane.
selected_plugin_repo: Option<String>,
/// Parsed GitHub README content or the last fetch error, cached per repo.
plugin_readmes:
rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
/// README requests in flight, used to render a deterministic loading state.
plugin_readme_loading: rustc_hash::FxHashSet<String>,
/// Last marketplace status / error line shown in the Plugin Manager.
marketplace_status: String,
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog.
@ -2059,6 +2068,8 @@ pub enum Message {
// ── Plugin marketplace (install from a linked repo's releases) ─────────
/// Edit the add-repository text field.
PluginRepoInput(String),
/// Filter installed and available plugin cards.
PluginSearchInput(String),
/// Link the repository currently in the text field.
PluginRepoAdd,
/// Unlink a repository.
@ -2079,8 +2090,14 @@ pub enum Message {
PluginReleasesFetched(String, Result<Vec<String>, String>),
/// Choose a release tag for a repo (`repo`, `tag`).
PluginReleaseSelect(String, String),
/// Select a plugin source and show its GitHub README in the detail pane.
PluginReadmeSelect(String),
/// A GitHub README fetch finished (`repo`, Markdown or error).
PluginReadmeFetched(String, Result<String, String>),
/// Install the selected release of `owner/repo`.
PluginInstall(String),
/// Install a specific newer release from an installed plugin card.
PluginUpdate(String, String),
/// Result of an install: the plugin id, or an error message.
PluginInstalled(Result<String, String>),
/// Delete an installed plugin's folder (effective next restart).
@ -2594,8 +2611,12 @@ impl OpenCADStudio {
plugin_registry: Vec::new(),
plugin_repos: Vec::new(),
plugin_repo_input: String::new(),
plugin_search_input: String::new(),
repo_release_tags: rustc_hash::FxHashMap::default(),
repo_selected_tag: rustc_hash::FxHashMap::default(),
selected_plugin_repo: None,
plugin_readmes: rustc_hash::FxHashMap::default(),
plugin_readme_loading: rustc_hash::FxHashSet::default(),
marketplace_status: String::new(),
point_size_buf: String::new(),
point_size_relative: true,

View file

@ -295,6 +295,21 @@ impl OpenCADStudio {
Task::none()
}
/// Background task: fetch a repository README from its default branch.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move { crate::plugin::marketplace::fetch_readme(&repo) },
move |result| Message::PluginReadmeFetched(label, result),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_plugin_readme_task(&self, _repo: String) -> Task<Message> {
Task::none()
}
/// Background task: download and install the `tag` release of `owner/repo`.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
@ -305,7 +320,7 @@ impl OpenCADStudio {
.into_iter()
.find(|r| r.tag == tag)
.ok_or_else(|| format!("release {tag} not found"))?;
crate::plugin::marketplace::install(&rel)
crate::plugin::marketplace::install(&rel, &repo)
},
Message::PluginInstalled,
)

View file

@ -3951,17 +3951,42 @@ impl OpenCADStudio {
// Refresh the on-disk external-plugin list each time the manager
// opens so newly dropped-in packages show up.
self.external_plugins = crate::plugin::external::discover();
self.marketplace_status.clear();
self.active_modal = Some(super::ModalKind::PluginManager);
// Fetch the curated registry and release lists for linked repos.
#[cfg(not(target_arch = "wasm32"))]
{
let mut tasks = vec![self.fetch_registry_task()];
let release_repos: rustc_hash::FxHashSet<String> = self
.plugin_repos
.iter()
.cloned()
.chain(
self.external_plugins
.iter()
.filter_map(|plugin| plugin.repository.clone()),
)
.collect();
tasks.extend(
self.plugin_repos
.clone()
release_repos
.into_iter()
.map(|r| self.fetch_releases_task(r)),
);
if self.selected_plugin_repo.is_none() {
self.selected_plugin_repo = self
.external_plugins
.iter()
.find_map(|plugin| plugin.repository.clone())
.or_else(|| self.plugin_registry.first().map(|entry| entry.repo.clone()))
.or_else(|| self.plugin_repos.first().cloned());
}
if let Some(repo) = self.selected_plugin_repo.clone() {
if !self.plugin_readmes.contains_key(&repo)
&& self.plugin_readme_loading.insert(repo.clone())
{
tasks.push(self.fetch_plugin_readme_task(repo));
}
}
return Task::batch(tasks);
}
#[cfg(target_arch = "wasm32")]
@ -3985,26 +4010,66 @@ impl OpenCADStudio {
self.plugin_repo_input = s;
Task::none()
}
Message::PluginSearchInput(s) => {
self.plugin_search_input = s;
Task::none()
}
Message::PluginRepoAdd => {
let repo = self
.plugin_repo_input
.trim()
.trim_start_matches("https://github.com/")
.trim_end_matches('/')
.to_string();
if repo.is_empty() || self.plugin_repos.contains(&repo) {
let Some(repo) =
crate::plugin::external::normalize_repository(&self.plugin_repo_input)
else {
self.marketplace_status =
"Enter a GitHub URL or repository in owner/repo format.".to_string();
return Task::none();
};
if self.plugin_repos.contains(&repo)
|| self.plugin_registry.iter().any(|entry| entry.repo == repo)
{
self.marketplace_status = format!("{repo} is already in the catalog.");
self.selected_plugin_repo = Some(repo.clone());
if !self.plugin_readmes.contains_key(&repo)
&& self.plugin_readme_loading.insert(repo.clone())
{
return self.fetch_plugin_readme_task(repo);
}
return Task::none();
}
if self
.external_plugins
.iter()
.any(|plugin| plugin.repository.as_deref() == Some(repo.as_str()))
{
self.marketplace_status = format!("{repo} is already installed.");
self.selected_plugin_repo = Some(repo.clone());
if !self.plugin_readmes.contains_key(&repo)
&& self.plugin_readme_loading.insert(repo.clone())
{
return self.fetch_plugin_readme_task(repo);
}
return Task::none();
}
self.plugin_repos.push(repo.clone());
self.plugin_repo_input.clear();
self.persist_settings_if_changed();
self.marketplace_status = format!("Fetching releases for {repo}");
self.fetch_releases_task(repo)
self.selected_plugin_repo = Some(repo.clone());
self.plugin_readmes.remove(&repo);
self.plugin_readme_loading.insert(repo.clone());
Task::batch(vec![
self.fetch_releases_task(repo.clone()),
self.fetch_plugin_readme_task(repo),
])
}
Message::PluginRepoRemove(repo) => {
self.plugin_repos.retain(|r| r != &repo);
self.repo_release_tags.remove(&repo);
self.repo_selected_tag.remove(&repo);
if self.selected_plugin_repo.as_deref() == Some(repo.as_str())
&& !self.plugin_registry.iter().any(|entry| entry.repo == repo)
{
self.selected_plugin_repo =
self.plugin_registry.first().map(|entry| entry.repo.clone());
}
self.persist_settings_if_changed();
Task::none()
}
@ -4012,11 +4077,32 @@ impl OpenCADStudio {
// Fetch releases for every curated repo so the dropdowns fill in.
#[cfg(not(target_arch = "wasm32"))]
{
let tasks: Vec<_> = entries
if self.selected_plugin_repo.is_none() {
self.selected_plugin_repo = self
.external_plugins
.iter()
.find_map(|plugin| {
plugin.repository.clone().or_else(|| {
entries
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name))
.map(|entry| entry.repo.clone())
})
})
.or_else(|| entries.first().map(|entry| entry.repo.clone()));
}
let mut tasks: Vec<_> = entries
.iter()
.map(|e| self.fetch_releases_task(e.repo.clone()))
.collect();
self.plugin_registry = entries;
if let Some(repo) = self.selected_plugin_repo.clone() {
if !self.plugin_readmes.contains_key(&repo)
&& self.plugin_readme_loading.insert(repo.clone())
{
tasks.push(self.fetch_plugin_readme_task(repo));
}
}
return Task::batch(tasks);
}
#[cfg(target_arch = "wasm32")]
@ -4073,7 +4159,10 @@ impl OpenCADStudio {
.entry(repo.clone())
.or_insert_with(|| first.clone());
}
self.marketplace_status = format!("{repo}: {} installable release(s)", tags.len());
if self.marketplace_status == format!("Fetching releases for {repo}") {
self.marketplace_status =
format!("Repository added. {} installable release(s) found.", tags.len());
}
self.repo_release_tags.insert(repo, tags);
Task::none()
}
@ -4085,6 +4174,27 @@ impl OpenCADStudio {
self.repo_selected_tag.insert(repo, tag);
Task::none()
}
Message::PluginReadmeSelect(repo) => {
self.selected_plugin_repo = Some(repo.clone());
if self.plugin_readme_loading.contains(&repo) {
return Task::none();
}
if matches!(self.plugin_readmes.get(&repo), Some(Ok(_))) {
return Task::none();
}
// A second click on an error state acts as retry.
self.plugin_readmes.remove(&repo);
self.plugin_readme_loading.insert(repo.clone());
self.fetch_plugin_readme_task(repo)
}
Message::PluginReadmeFetched(repo, result) => {
self.plugin_readme_loading.remove(&repo);
self.plugin_readmes.insert(
repo,
result.map(|source| iced::widget::markdown::Content::parse(&source)),
);
Task::none()
}
Message::PluginInstall(repo) => {
let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else {
return Task::none();
@ -4092,6 +4202,10 @@ impl OpenCADStudio {
self.marketplace_status = format!("Installing {repo} {tag}");
self.install_task(repo, tag)
}
Message::PluginUpdate(repo, tag) => {
self.marketplace_status = format!("Updating {repo} to {tag}");
self.install_task(repo, tag)
}
Message::PluginInstalled(Ok(id)) => {
self.marketplace_status = format!("Installed '{id}'. Restart to load it.");
#[cfg(not(target_arch = "wasm32"))]

View file

@ -1537,7 +1537,7 @@ impl OpenCADStudio {
About => (440, 360),
Shortcuts => (720, 520),
Options => (480, 190),
PluginManager => (520, 460),
PluginManager => (940, 600),
UpdateNotice => (560, 460),
Layers => (900, 360),
Plot => (760, 540),

View file

@ -79,14 +79,19 @@ impl OpenCADStudio {
crate::ui::window::plugin_manager::MarketView {
registry: &self.plugin_registry,
input: &self.plugin_repo_input,
search: &self.plugin_search_input,
repos: &self.plugin_repos,
release_tags: &self.repo_release_tags,
selected_tag: &self.repo_selected_tag,
selected_repo: self.selected_plugin_repo.as_deref(),
readmes: &self.plugin_readmes,
readme_loading: &self.plugin_readme_loading,
status: &self.marketplace_status,
},
&self.active_theme,
),
520,
460,
940,
600,
),
super::super::ModalKind::UpdateNotice => {
let latest = self.update_notice_version.as_deref().unwrap_or("?");

View file

@ -30,6 +30,10 @@ pub struct ExternalPlugin {
pub name: String,
pub version: String,
pub description: String,
/// GitHub source in `owner/repo` form. New marketplace installs persist
/// this beside the manifest so their README remains discoverable even when
/// an older `plugin.toml` does not declare `repository`.
pub repository: Option<String>,
pub api_version: u32,
pub ribbon_order: i32,
pub command_prefixes: Vec<String>,
@ -125,6 +129,11 @@ pub fn discover() -> Vec<ExternalPlugin> {
continue;
};
if let Some(mut p) = parse_plugin_toml(&text) {
if p.repository.is_none() {
p.repository = std::fs::read_to_string(dir.join(".source_repo"))
.ok()
.and_then(|repo| normalize_repository(&repo));
}
p.lib_present = lib_present_in(&dir);
p.dir = dir;
found.push(p);
@ -156,6 +165,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
let mut name = String::new();
let mut version = String::new();
let mut description = String::new();
let mut repository = None;
let mut api_version: u32 = 0;
let mut ribbon_order: i32 = 0;
let mut command_prefixes: Vec<String> = Vec::new();
@ -175,6 +185,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
"name" => name = unquote(value),
"version" => version = unquote(value),
"description" => description = unquote(value),
"repository" => repository = normalize_repository(&unquote(value)),
"api_version" => api_version = value.parse().unwrap_or(0),
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
"command_prefixes" => command_prefixes = parse_string_array(value),
@ -187,6 +198,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
name,
version,
description,
repository,
api_version,
ribbon_order,
command_prefixes,
@ -195,6 +207,26 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
})
}
/// Convert a plugin source URL or shorthand to the marketplace's canonical
/// `owner/repo` form.
pub(crate) fn normalize_repository(value: &str) -> Option<String> {
let repo = value
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches('/')
.trim_end_matches(".git");
let mut parts = repo.split('/');
let (Some(owner), Some(name), None) = (parts.next(), parts.next(), parts.next()) else {
return None;
};
if owner.is_empty() || name.is_empty() {
None
} else {
Some(format!("{owner}/{name}"))
}
}
/// Strip surrounding single or double quotes from a TOML scalar.
fn unquote(s: &str) -> String {
let s = s.trim();
@ -321,6 +353,7 @@ id = "opencad.my_plugin"
name = "My Plugin"
version = "0.1.0"
description = "Template plugin"
repository = "https://github.com/example/opencad-my-plugin.git"
[opencad]
api_version = 2
@ -330,6 +363,7 @@ xdata_apps = ["MYPLUGIN_RECORD"]
"#;
let p = parse_plugin_toml(toml).expect("parsed");
assert_eq!(p.api_version, 2);
assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin"));
assert!(p.command_prefixes.contains(&"MP_".to_string()));
assert!(p.api_compatible(), "API v2 plugins must run on API v3 host");
}

View file

@ -148,6 +148,20 @@ pub fn fetch_releases(repo: &str) -> Result<Vec<Release>, String> {
Ok(out)
}
/// Fetch the repository README from its default branch as raw Markdown.
pub fn fetch_readme(repo: &str) -> Result<String, String> {
let url = format!("https://api.github.com/repos/{repo}/readme");
agent()
.get(&url)
.header("User-Agent", UA)
.header("Accept", "application/vnd.github.raw+json")
.call()
.map_err(|e| e.to_string())?
.body_mut()
.read_to_string()
.map_err(|e| e.to_string())
}
fn download_string(url: &str) -> Result<String, String> {
agent()
.get(url)
@ -176,7 +190,7 @@ fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
/// Download and install a release's package into the plugins folder. Verifies
/// the API version from the package's `plugin.toml` first. Returns the plugin
/// id on success.
pub fn install(release: &Release) -> Result<String, String> {
pub fn install(release: &Release, repository: &str) -> Result<String, String> {
let lib = release.lib_asset().ok_or("no library for this platform")?;
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
@ -199,6 +213,8 @@ pub fn install(release: &Release) -> Result<String, String> {
let bytes = download_bytes(&lib.url)?;
replace_library(&dir, &lib.name, external_lib_ext(), &bytes)?;
std::fs::write(dir.join("plugin.toml"), toml_text).map_err(|e| e.to_string())?;
std::fs::write(dir.join(".source_repo"), format!("{repository}\n"))
.map_err(|e| e.to_string())?;
Ok(manifest.id)
}

View file

@ -6,17 +6,27 @@
use crate::app::Message;
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Element, Fill, Theme};
use iced::widget::{
button, column, container, markdown, pick_list, row, rule, scrollable, text, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use rustc_hash::{FxHashMap, FxHashSet};
/// Empty lane kept at the right edge of scroll content so the vertical
/// scrollbar never covers card controls.
const SCROLLBAR_GUTTER: f32 = 16.0;
/// Marketplace state passed to the Plugin Manager view.
pub struct MarketView<'a> {
pub registry: &'a [RegistryEntry],
pub input: &'a str,
pub search: &'a str,
pub repos: &'a [String],
pub release_tags: &'a FxHashMap<String, Vec<String>>,
pub selected_tag: &'a FxHashMap<String, String>,
pub selected_repo: Option<&'a str>,
pub readmes: &'a FxHashMap<String, Result<markdown::Content, String>>,
pub readme_loading: &'a FxHashSet<String>,
pub status: &'a str,
}
@ -104,7 +114,96 @@ fn status_badge<'a>(label: &str, kind: StatusKind) -> Element<'a, Message> {
.into()
}
fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Element<'a, Message> {
fn card_style(theme: &Theme, selected: bool) -> container::Style {
let palette = theme.extended_palette();
container::Style {
background: selected
.then(|| Background::Color(palette.primary.weak.color.scale_alpha(0.18))),
border: Border {
color: if selected {
palette.primary.base.color
} else {
palette.background.strong.color
},
width: if selected { 1.5 } else { 1.0 },
radius: 6.0.into(),
},
..Default::default()
}
}
fn repository_for_external(
plugin: &ExternalPlugin,
registry: &[RegistryEntry],
) -> Option<String> {
plugin.repository.clone().or_else(|| {
registry
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name))
.map(|entry| entry.repo.clone())
})
}
fn matches_search(query: &str, values: &[&str]) -> bool {
let query = query.trim().to_lowercase();
query.is_empty()
|| values
.iter()
.any(|value| value.to_lowercase().contains(&query))
}
fn external_matches_search(plugin: &ExternalPlugin, repository: Option<&str>, query: &str) -> bool {
matches_search(
query,
&[
&plugin.name,
&plugin.id,
&plugin.description,
repository.unwrap_or_default(),
],
) || plugin
.command_prefixes
.iter()
.any(|command| matches_search(query, &[command]))
}
fn repository_is_installed(
repository: &str,
registry_name: Option<&str>,
externals: &[ExternalPlugin],
) -> bool {
externals.iter().any(|plugin| {
plugin.repository.as_deref() == Some(repository)
|| registry_name
.is_some_and(|name| plugin.name.eq_ignore_ascii_case(name))
})
}
fn trim_version_prefix(value: &str) -> &str {
value.trim_start_matches(|c| c == 'v' || c == 'V')
}
fn newest_update(installed: &str, tags: &[String]) -> Option<String> {
let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?;
tags.iter()
.filter_map(|tag| {
semver::Version::parse(trim_version_prefix(tag))
.ok()
.map(|version| (version, tag))
})
.filter(|(version, _)| version > &installed)
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, tag)| tag.clone())
}
fn external_card<'a>(
p: &ExternalPlugin,
repository: Option<String>,
update_tag: Option<String>,
loaded: bool,
disabled: bool,
selected: bool,
) -> Element<'a, Message> {
let (status, kind) = if loaded && disabled {
("Disabled", StatusKind::Muted)
} else if loaded {
@ -116,7 +215,7 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
} else {
("Restart to load", StatusKind::Warning)
};
let mut header = row![
let header = row![
text(p.name.clone()).size(15),
Space::new().width(8),
badge(format!("v{}", p.version)),
@ -126,33 +225,55 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
status_badge(status, kind),
]
.align_y(iced::Center);
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
if loaded {
header = header.push(Space::new().width(10));
header = header.push(toggle_button(&p.id, disabled));
}
header = header.push(Space::new().width(6));
header = header.push(pill_button(
"Uninstall",
Message::PluginUninstall(p.id.clone()),
button::danger,
));
let id_line = text(p.id.clone()).size(11).style(primary_style);
let mut body = column![header, id_line].spacing(5);
let id_line = text(p.id.clone()).size(11).style(muted_style);
let mut info_body = column![header, id_line].spacing(5);
if !p.description.is_empty() {
body = body.push(text(p.description.clone()).size(12).style(muted_style));
info_body = info_body.push(text(p.description.clone()).size(12).style(muted_style));
}
if !p.command_prefixes.is_empty() {
body = body.push(
info_body = info_body.push(
text(format!("Commands: {}", p.command_prefixes.join(", ")))
.size(11)
.style(muted_style),
);
}
container(body.padding([12, 14]))
let info: Element<'a, Message> = if let Some(repo) = repository.clone() {
button(info_body.width(Fill))
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(repo))
.into()
} else {
info_body.width(Fill).into()
};
let mut actions = row![Space::new().width(Fill)].align_y(iced::Center);
if let (Some(repo), Some(tag)) = (repository, update_tag) {
let label = format!("Update to {tag}");
actions = actions.push(pill_button(
&label,
Message::PluginUpdate(repo, tag),
button::primary,
));
actions = actions.push(Space::new().width(6));
}
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
if loaded {
actions = actions.push(toggle_button(&p.id, disabled));
actions = actions.push(Space::new().width(6));
}
actions = actions.push(pill_button(
"Uninstall",
Message::PluginUninstall(p.id.clone()),
button::danger,
));
container(column![info, actions].spacing(8).padding([10, 12]))
.width(Fill)
.style(container::bordered_box)
.style(move |theme: &Theme| card_style(theme, selected))
.into()
}
@ -221,74 +342,306 @@ fn install_controls<'a>(
controls.into()
}
fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
fn market_card<'a>(
body: iced::widget::Column<'a, Message>,
selected: bool,
) -> Element<'a, Message> {
container(body.spacing(4).padding([10, 12]))
.width(Fill)
.style(container::bordered_box)
.style(move |theme: &Theme| card_style(theme, selected))
.into()
}
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
fn repository_display_name(repository: &str) -> String {
repository
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(repository)
.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
if part.eq_ignore_ascii_case("opencad") {
"OpenCAD".to_string()
} else {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn add_repository_card<'a>(m: &MarketView) -> Element<'a, Message> {
let form = row![
text_input("GitHub URL or owner/repository", m.input)
.on_input(Message::PluginRepoInput)
.on_submit(Message::PluginRepoAdd)
.size(13)
.width(Fill),
Space::new().width(8),
pill_button(
"Add repository",
Message::PluginRepoAdd,
button::primary,
),
]
.align_y(iced::Center);
market_card(
column![
text("Add from GitHub").size(14),
text(
"Paste a public repository URL. Compatible releases and the README \
are detected automatically.",
)
.size(11)
.style(muted_style),
Space::new().height(3),
form,
]
.spacing(5),
false,
)
}
fn marketplace_section<'a>(
m: &MarketView,
externals: &[ExternalPlugin],
) -> Element<'a, Message> {
let mut col = column![text("Available plugins").size(13).style(primary_style)].spacing(6);
let mut visible = 0usize;
// Curated registry entries (from the OpenCADStudio repo).
for e in m.registry {
if repository_is_installed(&e.repo, Some(&e.name), externals)
|| !matches_search(m.search, &[&e.name, &e.description, &e.repo])
{
continue;
}
visible += 1;
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),
let mut info = column![text(e.name.clone()).size(14)].spacing(4);
if !e.description.is_empty() {
info = info.push(text(e.description.clone()).size(12).style(muted_style));
}
let info = button(info.width(Fill))
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(e.repo.clone()));
let actions = row![
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).style(primary_style)];
if !e.description.is_empty() {
body = body.push(text(e.description.clone()).size(12).style(muted_style));
}
col = col.push(market_card(body));
col = col.push(market_card(
column![info, actions].spacing(8),
m.selected_repo == Some(e.repo.as_str()),
));
}
// Manual: link any repo by owner/repo.
col = col.push(Space::new().height(6));
col = col.push(text("Add a repository").size(12).style(muted_style));
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, button::primary),
]
.align_y(iced::Center),
);
// User-linked repositories join the same catalog, but curated and installed
// repositories are suppressed so every plugin appears only once.
for repo in m.repos {
if m.registry.iter().any(|entry| entry.repo == *repo)
|| repository_is_installed(repo, None, externals)
{
continue;
}
let display_name = repository_display_name(repo);
if !matches_search(m.search, &[&display_name, repo]) {
continue;
}
visible += 1;
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),
let info = button(
column![
text(display_name).size(14),
text("Added from GitHub").size(11).style(muted_style),
]
.spacing(4)
.width(Fill),
)
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(repo.clone()));
let actions = row![
Space::new().width(Fill),
install_controls(repo, tags, selected, true),
]
.align_y(iced::Center);
col = col.push(market_card(column![header]));
col = col.push(market_card(
column![info, actions].spacing(8),
m.selected_repo == Some(repo.as_str()),
));
}
if visible == 0 {
let message = if m.search.trim().is_empty() {
"No additional plugins are available."
} else {
"No available plugins match your search."
};
col = col.push(text(message).size(12).style(muted_style));
}
col = col.push(Space::new().height(8));
col = col.push(add_repository_card(m));
if !m.status.is_empty() {
col = col.push(text(m.status.to_string()).size(11).style(muted_style));
}
col.into()
}
fn resolve_readme_link(repo: &str, uri: &str) -> String {
if uri.starts_with("https://")
|| uri.starts_with("http://")
|| uri.starts_with("mailto:")
{
uri.to_string()
} else if uri.starts_with('/') {
format!("https://github.com{uri}")
} else if uri.starts_with('#') {
format!("https://github.com/{repo}{uri}")
} else {
format!(
"https://github.com/{repo}/blob/HEAD/{}",
uri.trim_start_matches("./")
)
}
}
fn readme_panel<'a>(market: &MarketView<'a>, theme: &Theme) -> Element<'a, Message> {
let Some(repo) = market.selected_repo else {
return container(
column![
text("Plugin details").size(16),
text("Select a plugin to read its GitHub README.")
.size(12)
.style(muted_style),
]
.spacing(8),
)
.center(Fill)
.width(Fill)
.height(Fill)
.style(container::bordered_box)
.into();
};
let name = repo
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(repo)
.to_string();
let header = row![
column![
text(name).size(16),
text(repo.to_string()).size(11).style(primary_style),
]
.spacing(3)
.width(Fill),
pill_button(
"View on GitHub",
Message::OpenUrl(format!("https://github.com/{repo}")),
button::secondary,
),
]
.align_y(iced::Center);
let content: Element<'a, Message> = if market.readme_loading.contains(repo) {
container(
column![
text("Loading README…").size(13),
text("Fetching the default branch from GitHub.")
.size(11)
.style(muted_style),
]
.spacing(6),
)
.center(Fill)
.width(Fill)
.height(Fill)
.into()
} else {
match market.readmes.get(repo) {
Some(Ok(readme)) => {
let source_repo = repo.to_string();
markdown::view(
readme.items(),
markdown::Settings::with_text_size(13, theme),
)
.map(move |uri| {
Message::OpenUrl(resolve_readme_link(&source_repo, &uri))
})
}
Some(Err(error)) => container(
column![
text("README could not be loaded").size(14),
text(error.clone()).size(11).style(muted_style),
Space::new().height(4),
pill_button(
"Retry",
Message::PluginReadmeSelect(repo.to_string()),
button::secondary,
),
]
.spacing(6),
)
.center(Fill)
.width(Fill)
.height(Fill)
.into(),
None => container(
text("Select the plugin again to load its README.")
.size(12)
.style(muted_style),
)
.center(Fill)
.width(Fill)
.height(Fill)
.into(),
}
};
let gutter = iced::Padding {
top: 8.0,
right: SCROLLBAR_GUTTER,
bottom: 8.0,
left: 0.0,
};
let readme = scrollable(container(content).padding(gutter))
.height(Fill)
.width(Fill);
container(
column![header, rule::horizontal(1), readme]
.spacing(10)
.padding([12, 14])
.width(Fill)
.height(Fill),
)
.width(Fill)
.height(Fill)
.style(container::bordered_box)
.into()
}
pub fn view_window<'a>(
disabled: &FxHashSet<String>,
externals: &[ExternalPlugin],
loaded: &FxHashSet<String>,
market: MarketView,
market: MarketView<'a>,
theme: &'a Theme,
) -> Element<'a, Message> {
let title = text("Plugins").size(20);
let subtitle = text("Add-ons load from the plugins folder. Install from a repository below.")
let subtitle = text("Browse, install, and manage add-ons. Select one to view its README.")
.size(12)
.style(muted_style);
@ -297,24 +650,71 @@ pub fn view_window<'a>(
if externals.is_empty() {
list = list.push(text("No plugins installed yet.").size(13).style(muted_style));
} else {
list = list.push(text("Installed").size(13).style(primary_style));
let mut visible_installed = 0usize;
for p in externals {
let repository = repository_for_external(p, market.registry);
if !external_matches_search(p, repository.as_deref(), market.search) {
continue;
}
if visible_installed == 0 {
list = list.push(text("Installed").size(13).style(primary_style));
}
visible_installed += 1;
let selected = repository.as_deref() == market.selected_repo;
let update_tag = repository
.as_ref()
.and_then(|repo| market.release_tags.get(repo))
.and_then(|tags| newest_update(&p.version, tags));
list = list.push(external_card(
p,
repository,
update_tag,
loaded.contains(&p.id),
disabled.contains(&p.id),
selected,
));
}
if visible_installed == 0 && !market.search.trim().is_empty() {
list = list.push(
text("No installed plugins match your search.")
.size(12)
.style(muted_style),
);
}
}
// 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();
list = list.push(marketplace_section(&market, externals));
let gutter = iced::Padding {
top: 0.0,
right: SCROLLBAR_GUTTER,
bottom: 0.0,
left: 0.0,
};
let catalog = scrollable(container(list.width(Fill)).padding(gutter))
.height(Fill)
.width(Fill);
let search = text_input("Search plugins…", market.search)
.on_input(Message::PluginSearchInput)
.size(13)
.padding([7, 10])
.width(Fill);
let catalog_pane = column![search, catalog].spacing(10).width(Fill).height(Fill);
let details = readme_panel(&market, theme);
let body = row![
container(catalog_pane)
.width(Length::Fixed(410.0))
.height(Fill),
details,
]
.spacing(14)
.height(Fill)
.width(Fill);
container(
column![title, subtitle, Space::new().height(12), body]
.spacing(4)
.padding(20)
.padding(18)
.width(Fill)
.height(Fill),
)
@ -328,3 +728,27 @@ pub fn view_window<'a>(
.height(Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::{newest_update, repository_display_name};
#[test]
fn newest_update_uses_semver_not_release_order() {
let tags = vec![
"v1.4.0".to_string(),
"v2.0.0".to_string(),
"v1.9.9".to_string(),
];
assert_eq!(newest_update("1.3.0", &tags).as_deref(), Some("v2.0.0"));
assert_eq!(newest_update("2.0.0", &tags), None);
}
#[test]
fn repository_name_is_human_readable() {
assert_eq!(
repository_display_name("owner/opencad-storm_sewer-plugin"),
"OpenCAD Storm Sewer Plugin",
);
}
}