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", "rayon",
"rfd", "rfd",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"truck-meshalgo", "truck-meshalgo",
@ -1867,6 +1868,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -2376,6 +2386,7 @@ dependencies = [
"iced_renderer", "iced_renderer",
"log", "log",
"num-traits", "num-traits",
"pulldown-cmark",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-segmentation", "unicode-segmentation",
@ -4136,6 +4147,25 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "pulp" name = "pulp"
version = "0.22.3" version = "0.22.3"
@ -5695,6 +5725,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" 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. # and the consolidated user config (settings.json) via serde-derived structs.
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
semver = "1"
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
# Plugin authors target this crate's semver, not OpenCADStudio internals. # Plugin authors target this crate's semver, not OpenCADStudio internals.
# The `host` feature (out-of-process plugin runtime: interprocess, libloading, # 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 # Forcing the `static` feature builds liblzma from the bundled source, making
# the binary self-contained on every platform. # the binary self-contained on every platform.
lzma-sys = { version = "0.1", features = ["static"], optional = true } 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"] } iced_aw = { version = "0.14.1", features = ["menu"] }
bytemuck = { version = "1.25", features = ["derive"] } bytemuck = { version = "1.25", features = ["derive"] }
glam = { version = "0.33", features = ["bytemuck"] } glam = { version = "0.33", features = ["bytemuck"] }

View file

@ -5,6 +5,7 @@ version = "0.1.0"
description = "Short description of what this add-on does" description = "Short description of what this add-on does"
author = "Your Name" author = "Your Name"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad] [opencad]
api_version = 3 api_version = 3

View file

@ -528,10 +528,19 @@ pub(super) struct OpenCADStudio {
plugin_repos: Vec<String>, plugin_repos: Vec<String>,
/// Add-repository text field in the Plugin Manager. /// Add-repository text field in the Plugin Manager.
plugin_repo_input: String, 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). /// Installable release tags fetched per linked repo (for the dropdown).
repo_release_tags: rustc_hash::FxHashMap<String, Vec<String>>, repo_release_tags: rustc_hash::FxHashMap<String, Vec<String>>,
/// The release tag currently selected per linked repo. /// The release tag currently selected per linked repo.
repo_selected_tag: rustc_hash::FxHashMap<String, String>, 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. /// Last marketplace status / error line shown in the Plugin Manager.
marketplace_status: String, marketplace_status: String,
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog. /// 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) ───────── // ── Plugin marketplace (install from a linked repo's releases) ─────────
/// Edit the add-repository text field. /// Edit the add-repository text field.
PluginRepoInput(String), PluginRepoInput(String),
/// Filter installed and available plugin cards.
PluginSearchInput(String),
/// Link the repository currently in the text field. /// Link the repository currently in the text field.
PluginRepoAdd, PluginRepoAdd,
/// Unlink a repository. /// Unlink a repository.
@ -2079,8 +2090,14 @@ pub enum Message {
PluginReleasesFetched(String, Result<Vec<String>, String>), PluginReleasesFetched(String, Result<Vec<String>, String>),
/// Choose a release tag for a repo (`repo`, `tag`). /// Choose a release tag for a repo (`repo`, `tag`).
PluginReleaseSelect(String, String), 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`. /// Install the selected release of `owner/repo`.
PluginInstall(String), 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. /// Result of an install: the plugin id, or an error message.
PluginInstalled(Result<String, String>), PluginInstalled(Result<String, String>),
/// Delete an installed plugin's folder (effective next restart). /// Delete an installed plugin's folder (effective next restart).
@ -2594,8 +2611,12 @@ impl OpenCADStudio {
plugin_registry: Vec::new(), plugin_registry: Vec::new(),
plugin_repos: Vec::new(), plugin_repos: Vec::new(),
plugin_repo_input: String::new(), plugin_repo_input: String::new(),
plugin_search_input: String::new(),
repo_release_tags: rustc_hash::FxHashMap::default(), repo_release_tags: rustc_hash::FxHashMap::default(),
repo_selected_tag: 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(), marketplace_status: String::new(),
point_size_buf: String::new(), point_size_buf: String::new(),
point_size_relative: true, point_size_relative: true,

View file

@ -295,6 +295,21 @@ impl OpenCADStudio {
Task::none() 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`. /// Background task: download and install the `tag` release of `owner/repo`.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> { pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
@ -305,7 +320,7 @@ impl OpenCADStudio {
.into_iter() .into_iter()
.find(|r| r.tag == tag) .find(|r| r.tag == tag)
.ok_or_else(|| format!("release {tag} not found"))?; .ok_or_else(|| format!("release {tag} not found"))?;
crate::plugin::marketplace::install(&rel) crate::plugin::marketplace::install(&rel, &repo)
}, },
Message::PluginInstalled, Message::PluginInstalled,
) )

View file

@ -3951,17 +3951,42 @@ impl OpenCADStudio {
// Refresh the on-disk external-plugin list each time the manager // Refresh the on-disk external-plugin list each time the manager
// opens so newly dropped-in packages show up. // opens so newly dropped-in packages show up.
self.external_plugins = crate::plugin::external::discover(); self.external_plugins = crate::plugin::external::discover();
self.marketplace_status.clear();
self.active_modal = Some(super::ModalKind::PluginManager); self.active_modal = Some(super::ModalKind::PluginManager);
// Fetch the curated registry and release lists for linked repos. // Fetch the curated registry and release lists for linked repos.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let mut tasks = vec![self.fetch_registry_task()]; 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( tasks.extend(
self.plugin_repos release_repos
.clone()
.into_iter() .into_iter()
.map(|r| self.fetch_releases_task(r)), .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); return Task::batch(tasks);
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -3985,26 +4010,66 @@ impl OpenCADStudio {
self.plugin_repo_input = s; self.plugin_repo_input = s;
Task::none() Task::none()
} }
Message::PluginSearchInput(s) => {
self.plugin_search_input = s;
Task::none()
}
Message::PluginRepoAdd => { Message::PluginRepoAdd => {
let repo = self let Some(repo) =
.plugin_repo_input crate::plugin::external::normalize_repository(&self.plugin_repo_input)
.trim() else {
.trim_start_matches("https://github.com/") self.marketplace_status =
.trim_end_matches('/') "Enter a GitHub URL or repository in owner/repo format.".to_string();
.to_string(); return Task::none();
if repo.is_empty() || self.plugin_repos.contains(&repo) { };
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(); return Task::none();
} }
self.plugin_repos.push(repo.clone()); self.plugin_repos.push(repo.clone());
self.plugin_repo_input.clear(); self.plugin_repo_input.clear();
self.persist_settings_if_changed(); self.persist_settings_if_changed();
self.marketplace_status = format!("Fetching releases for {repo}…"); 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) => { Message::PluginRepoRemove(repo) => {
self.plugin_repos.retain(|r| r != &repo); self.plugin_repos.retain(|r| r != &repo);
self.repo_release_tags.remove(&repo); self.repo_release_tags.remove(&repo);
self.repo_selected_tag.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(); self.persist_settings_if_changed();
Task::none() Task::none()
} }
@ -4012,11 +4077,32 @@ impl OpenCADStudio {
// Fetch releases for every curated repo so the dropdowns fill in. // Fetch releases for every curated repo so the dropdowns fill in.
#[cfg(not(target_arch = "wasm32"))] #[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() .iter()
.map(|e| self.fetch_releases_task(e.repo.clone())) .map(|e| self.fetch_releases_task(e.repo.clone()))
.collect(); .collect();
self.plugin_registry = entries; 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); return Task::batch(tasks);
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -4073,7 +4159,10 @@ impl OpenCADStudio {
.entry(repo.clone()) .entry(repo.clone())
.or_insert_with(|| first.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); self.repo_release_tags.insert(repo, tags);
Task::none() Task::none()
} }
@ -4085,6 +4174,27 @@ impl OpenCADStudio {
self.repo_selected_tag.insert(repo, tag); self.repo_selected_tag.insert(repo, tag);
Task::none() 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) => { Message::PluginInstall(repo) => {
let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else { let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else {
return Task::none(); return Task::none();
@ -4092,6 +4202,10 @@ impl OpenCADStudio {
self.marketplace_status = format!("Installing {repo} {tag}…"); self.marketplace_status = format!("Installing {repo} {tag}…");
self.install_task(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)) => { Message::PluginInstalled(Ok(id)) => {
self.marketplace_status = format!("Installed '{id}'. Restart to load it."); self.marketplace_status = format!("Installed '{id}'. Restart to load it.");
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]

View file

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

View file

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

View file

@ -30,6 +30,10 @@ pub struct ExternalPlugin {
pub name: String, pub name: String,
pub version: String, pub version: String,
pub description: 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 api_version: u32,
pub ribbon_order: i32, pub ribbon_order: i32,
pub command_prefixes: Vec<String>, pub command_prefixes: Vec<String>,
@ -125,6 +129,11 @@ pub fn discover() -> Vec<ExternalPlugin> {
continue; continue;
}; };
if let Some(mut p) = parse_plugin_toml(&text) { 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.lib_present = lib_present_in(&dir);
p.dir = dir; p.dir = dir;
found.push(p); found.push(p);
@ -156,6 +165,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
let mut name = String::new(); let mut name = String::new();
let mut version = String::new(); let mut version = String::new();
let mut description = String::new(); let mut description = String::new();
let mut repository = None;
let mut api_version: u32 = 0; let mut api_version: u32 = 0;
let mut ribbon_order: i32 = 0; let mut ribbon_order: i32 = 0;
let mut command_prefixes: Vec<String> = Vec::new(); 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), "name" => name = unquote(value),
"version" => version = unquote(value), "version" => version = unquote(value),
"description" => description = unquote(value), "description" => description = unquote(value),
"repository" => repository = normalize_repository(&unquote(value)),
"api_version" => api_version = value.parse().unwrap_or(0), "api_version" => api_version = value.parse().unwrap_or(0),
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0), "ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
"command_prefixes" => command_prefixes = parse_string_array(value), "command_prefixes" => command_prefixes = parse_string_array(value),
@ -187,6 +198,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
name, name,
version, version,
description, description,
repository,
api_version, api_version,
ribbon_order, ribbon_order,
command_prefixes, 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. /// Strip surrounding single or double quotes from a TOML scalar.
fn unquote(s: &str) -> String { fn unquote(s: &str) -> String {
let s = s.trim(); let s = s.trim();
@ -321,6 +353,7 @@ id = "opencad.my_plugin"
name = "My Plugin" name = "My Plugin"
version = "0.1.0" version = "0.1.0"
description = "Template plugin" description = "Template plugin"
repository = "https://github.com/example/opencad-my-plugin.git"
[opencad] [opencad]
api_version = 2 api_version = 2
@ -330,6 +363,7 @@ xdata_apps = ["MYPLUGIN_RECORD"]
"#; "#;
let p = parse_plugin_toml(toml).expect("parsed"); let p = parse_plugin_toml(toml).expect("parsed");
assert_eq!(p.api_version, 2); 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.command_prefixes.contains(&"MP_".to_string()));
assert!(p.api_compatible(), "API v2 plugins must run on API v3 host"); 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) 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> { fn download_string(url: &str) -> Result<String, String> {
agent() agent()
.get(url) .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 /// 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 /// the API version from the package's `plugin.toml` first. Returns the plugin
/// id on success. /// 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 lib = release.lib_asset().ok_or("no library for this platform")?;
let toml = release.toml_asset().ok_or("release has no plugin.toml")?; 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)?; let bytes = download_bytes(&lib.url)?;
replace_library(&dir, &lib.name, external_lib_ext(), &bytes)?; 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("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) Ok(manifest.id)
} }

View file

@ -6,17 +6,27 @@
use crate::app::Message; use crate::app::Message;
use crate::plugin::external::{ExternalPlugin, RegistryEntry}; use crate::plugin::external::{ExternalPlugin, RegistryEntry};
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space}; use iced::widget::{
use iced::{Background, Border, Element, Fill, Theme}; 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}; 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. /// Marketplace state passed to the Plugin Manager view.
pub struct MarketView<'a> { pub struct MarketView<'a> {
pub registry: &'a [RegistryEntry], pub registry: &'a [RegistryEntry],
pub input: &'a str, pub input: &'a str,
pub search: &'a str,
pub repos: &'a [String], pub repos: &'a [String],
pub release_tags: &'a FxHashMap<String, Vec<String>>, pub release_tags: &'a FxHashMap<String, Vec<String>>,
pub selected_tag: &'a FxHashMap<String, 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, pub status: &'a str,
} }
@ -104,7 +114,96 @@ fn status_badge<'a>(label: &str, kind: StatusKind) -> Element<'a, Message> {
.into() .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 { let (status, kind) = if loaded && disabled {
("Disabled", StatusKind::Muted) ("Disabled", StatusKind::Muted)
} else if loaded { } else if loaded {
@ -116,7 +215,7 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
} else { } else {
("Restart to load", StatusKind::Warning) ("Restart to load", StatusKind::Warning)
}; };
let mut header = row![ let header = row![
text(p.name.clone()).size(15), text(p.name.clone()).size(15),
Space::new().width(8), Space::new().width(8),
badge(format!("v{}", p.version)), badge(format!("v{}", p.version)),
@ -126,33 +225,55 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
status_badge(status, kind), status_badge(status, kind),
] ]
.align_y(iced::Center); .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 id_line = text(p.id.clone()).size(11).style(muted_style);
let mut body = column![header, id_line].spacing(5); let mut info_body = column![header, id_line].spacing(5);
if !p.description.is_empty() { 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() { if !p.command_prefixes.is_empty() {
body = body.push( info_body = info_body.push(
text(format!("Commands: {}", p.command_prefixes.join(", "))) text(format!("Commands: {}", p.command_prefixes.join(", ")))
.size(11) .size(11)
.style(muted_style), .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) .width(Fill)
.style(container::bordered_box) .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(move |theme: &Theme| card_style(theme, selected))
.into() .into()
} }
@ -221,74 +342,306 @@ fn install_controls<'a>(
controls.into() 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])) container(body.spacing(4).padding([10, 12]))
.width(Fill) .width(Fill)
.style(container::bordered_box) .style(move |theme: &Theme| card_style(theme, selected))
.into() .into()
} }
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { fn repository_display_name(repository: &str) -> String {
let mut col = column![text("Available plugins").size(13).style(primary_style)].spacing(6); repository
.rsplit_once('/')
// Curated registry entries (from the OpenCADStudio repo). .map(|(_, name)| name)
for e in m.registry { .unwrap_or(repository)
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default(); .split(['-', '_'])
let selected = m.selected_tag.get(&e.repo).cloned(); .filter(|part| !part.is_empty())
let header = row![ .map(|part| {
text(e.name.clone()).size(14), if part.eq_ignore_ascii_case("opencad") {
Space::new().width(Fill), "OpenCAD".to_string()
install_controls(&e.repo, tags, selected, false), } else {
] let mut chars = part.chars();
.align_y(iced::Center); chars
let mut body = column![header, text(e.repo.clone()).size(11).style(primary_style)]; .next()
if !e.description.is_empty() { .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
body = body.push(text(e.description.clone()).size(12).style(muted_style)); .unwrap_or_default()
} }
col = col.push(market_card(body)); })
.collect::<Vec<_>>()
.join(" ")
} }
// Manual: link any repo by owner/repo. fn add_repository_card<'a>(m: &MarketView) -> Element<'a, Message> {
col = col.push(Space::new().height(6)); let form = row![
col = col.push(text("Add a repository").size(12).style(muted_style)); text_input("GitHub URL or owner/repository", m.input)
col = col.push(
row![
text_input("owner/repo", m.input)
.on_input(Message::PluginRepoInput) .on_input(Message::PluginRepoInput)
.on_submit(Message::PluginRepoAdd) .on_submit(Message::PluginRepoAdd)
.size(13) .size(13)
.width(Fill), .width(Fill),
Space::new().width(8), Space::new().width(8),
pill_button("Add", Message::PluginRepoAdd, button::primary), pill_button(
"Add repository",
Message::PluginRepoAdd,
button::primary,
),
] ]
.align_y(iced::Center), .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 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);
col = col.push(market_card(
column![info, actions].spacing(8),
m.selected_repo == Some(e.repo.as_str()),
));
}
// 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 { 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 tags = m.release_tags.get(repo).cloned().unwrap_or_default();
let selected = m.selected_tag.get(repo).cloned(); let selected = m.selected_tag.get(repo).cloned();
let header = row![ let info = button(
text(repo.clone()).size(13), 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), Space::new().width(Fill),
install_controls(repo, tags, selected, true), install_controls(repo, tags, selected, true),
] ]
.align_y(iced::Center); .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() { if !m.status.is_empty() {
col = col.push(text(m.status.to_string()).size(11).style(muted_style)); col = col.push(text(m.status.to_string()).size(11).style(muted_style));
} }
col.into() 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>( pub fn view_window<'a>(
disabled: &FxHashSet<String>, disabled: &FxHashSet<String>,
externals: &[ExternalPlugin], externals: &[ExternalPlugin],
loaded: &FxHashSet<String>, loaded: &FxHashSet<String>,
market: MarketView, market: MarketView<'a>,
theme: &'a Theme,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let title = text("Plugins").size(20); 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) .size(12)
.style(muted_style); .style(muted_style);
@ -297,24 +650,71 @@ pub fn view_window<'a>(
if externals.is_empty() { if externals.is_empty() {
list = list.push(text("No plugins installed yet.").size(13).style(muted_style)); list = list.push(text("No plugins installed yet.").size(13).style(muted_style));
} else { } else {
list = list.push(text("Installed").size(13).style(primary_style)); let mut visible_installed = 0usize;
for p in externals { 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( list = list.push(external_card(
p, p,
repository,
update_tag,
loaded.contains(&p.id), loaded.contains(&p.id),
disabled.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. // Marketplace: install from a linked repository's releases.
list = list.push(Space::new().height(14)); list = list.push(Space::new().height(14));
list = list.push(marketplace_section(&market)); list = list.push(marketplace_section(&market, externals));
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into(); 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( container(
column![title, subtitle, Space::new().height(12), body] column![title, subtitle, Space::new().height(12), body]
.spacing(4) .spacing(4)
.padding(20) .padding(18)
.width(Fill) .width(Fill)
.height(Fill), .height(Fill),
) )
@ -328,3 +728,27 @@ pub fn view_window<'a>(
.height(Fill) .height(Fill)
.into() .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",
);
}
}