feat(plugins): redesign plugin manager
This commit is contained in:
parent
d80637cda7
commit
4e25e88c31
11 changed files with 741 additions and 74 deletions
36
Cargo.lock
generated
36
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"))]
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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("?");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
.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)
|
.width(Fill)
|
||||||
.style(container::bordered_box)
|
.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 {
|
||||||
|
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 col = column![text("Available plugins").size(13).style(primary_style)].spacing(6);
|
||||||
|
let mut visible = 0usize;
|
||||||
|
|
||||||
// Curated registry entries (from the OpenCADStudio repo).
|
// Curated registry entries (from the OpenCADStudio repo).
|
||||||
for e in m.registry {
|
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 tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
|
||||||
let selected = m.selected_tag.get(&e.repo).cloned();
|
let selected = m.selected_tag.get(&e.repo).cloned();
|
||||||
let header = row![
|
let mut info = column![text(e.name.clone()).size(14)].spacing(4);
|
||||||
text(e.name.clone()).size(14),
|
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),
|
Space::new().width(Fill),
|
||||||
install_controls(&e.repo, tags, selected, false),
|
install_controls(&e.repo, tags, selected, false),
|
||||||
]
|
]
|
||||||
.align_y(iced::Center);
|
.align_y(iced::Center);
|
||||||
let mut body = column![header, text(e.repo.clone()).size(11).style(primary_style)];
|
col = col.push(market_card(
|
||||||
if !e.description.is_empty() {
|
column![info, actions].spacing(8),
|
||||||
body = body.push(text(e.description.clone()).size(12).style(muted_style));
|
m.selected_repo == Some(e.repo.as_str()),
|
||||||
}
|
));
|
||||||
col = col.push(market_card(body));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual: link any repo by owner/repo.
|
// User-linked repositories join the same catalog, but curated and installed
|
||||||
col = col.push(Space::new().height(6));
|
// repositories are suppressed so every plugin appears only once.
|
||||||
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),
|
|
||||||
);
|
|
||||||
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue