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",
|
||||
"rfd",
|
||||
"rustc-hash 2.1.3",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"truck-meshalgo",
|
||||
|
|
@ -1867,6 +1868,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
|
|
@ -2376,6 +2386,7 @@ dependencies = [
|
|||
"iced_renderer",
|
||||
"log",
|
||||
"num-traits",
|
||||
"pulldown-cmark",
|
||||
"rustc-hash 2.1.3",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-segmentation",
|
||||
|
|
@ -4136,6 +4147,25 @@ dependencies = [
|
|||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"getopts",
|
||||
"memchr 2.8.3",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.22.3"
|
||||
|
|
@ -5695,6 +5725,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"]
|
|||
# and the consolidated user config (settings.json) via serde-derived structs.
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
semver = "1"
|
||||
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
|
||||
# Plugin authors target this crate's semver, not OpenCADStudio internals.
|
||||
# The `host` feature (out-of-process plugin runtime: interprocess, libloading,
|
||||
|
|
@ -47,7 +48,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
# Forcing the `static` feature builds liblzma from the bundled source, making
|
||||
# the binary self-contained on every platform.
|
||||
lzma-sys = { version = "0.1", features = ["static"], optional = true }
|
||||
iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol"] }
|
||||
iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] }
|
||||
iced_aw = { version = "0.14.1", features = ["menu"] }
|
||||
bytemuck = { version = "1.25", features = ["derive"] }
|
||||
glam = { version = "0.33", features = ["bytemuck"] }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ version = "0.1.0"
|
|||
description = "Short description of what this add-on does"
|
||||
author = "Your Name"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "your-account/your-plugin-repo"
|
||||
|
||||
[opencad]
|
||||
api_version = 3
|
||||
|
|
|
|||
|
|
@ -528,10 +528,19 @@ pub(super) struct OpenCADStudio {
|
|||
plugin_repos: Vec<String>,
|
||||
/// Add-repository text field in the Plugin Manager.
|
||||
plugin_repo_input: String,
|
||||
/// Live filter for installed and available plugin cards.
|
||||
plugin_search_input: String,
|
||||
/// Installable release tags fetched per linked repo (for the dropdown).
|
||||
repo_release_tags: rustc_hash::FxHashMap<String, Vec<String>>,
|
||||
/// The release tag currently selected per linked repo.
|
||||
repo_selected_tag: rustc_hash::FxHashMap<String, String>,
|
||||
/// Repository currently shown in the Plugin Manager detail pane.
|
||||
selected_plugin_repo: Option<String>,
|
||||
/// Parsed GitHub README content or the last fetch error, cached per repo.
|
||||
plugin_readmes:
|
||||
rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
|
||||
/// README requests in flight, used to render a deterministic loading state.
|
||||
plugin_readme_loading: rustc_hash::FxHashSet<String>,
|
||||
/// Last marketplace status / error line shown in the Plugin Manager.
|
||||
marketplace_status: String,
|
||||
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog.
|
||||
|
|
@ -2059,6 +2068,8 @@ pub enum Message {
|
|||
// ── Plugin marketplace (install from a linked repo's releases) ─────────
|
||||
/// Edit the add-repository text field.
|
||||
PluginRepoInput(String),
|
||||
/// Filter installed and available plugin cards.
|
||||
PluginSearchInput(String),
|
||||
/// Link the repository currently in the text field.
|
||||
PluginRepoAdd,
|
||||
/// Unlink a repository.
|
||||
|
|
@ -2079,8 +2090,14 @@ pub enum Message {
|
|||
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
||||
/// Choose a release tag for a repo (`repo`, `tag`).
|
||||
PluginReleaseSelect(String, String),
|
||||
/// Select a plugin source and show its GitHub README in the detail pane.
|
||||
PluginReadmeSelect(String),
|
||||
/// A GitHub README fetch finished (`repo`, Markdown or error).
|
||||
PluginReadmeFetched(String, Result<String, String>),
|
||||
/// Install the selected release of `owner/repo`.
|
||||
PluginInstall(String),
|
||||
/// Install a specific newer release from an installed plugin card.
|
||||
PluginUpdate(String, String),
|
||||
/// Result of an install: the plugin id, or an error message.
|
||||
PluginInstalled(Result<String, String>),
|
||||
/// Delete an installed plugin's folder (effective next restart).
|
||||
|
|
@ -2594,8 +2611,12 @@ impl OpenCADStudio {
|
|||
plugin_registry: Vec::new(),
|
||||
plugin_repos: Vec::new(),
|
||||
plugin_repo_input: String::new(),
|
||||
plugin_search_input: String::new(),
|
||||
repo_release_tags: rustc_hash::FxHashMap::default(),
|
||||
repo_selected_tag: rustc_hash::FxHashMap::default(),
|
||||
selected_plugin_repo: None,
|
||||
plugin_readmes: rustc_hash::FxHashMap::default(),
|
||||
plugin_readme_loading: rustc_hash::FxHashSet::default(),
|
||||
marketplace_status: String::new(),
|
||||
point_size_buf: String::new(),
|
||||
point_size_relative: true,
|
||||
|
|
|
|||
|
|
@ -295,6 +295,21 @@ impl OpenCADStudio {
|
|||
Task::none()
|
||||
}
|
||||
|
||||
/// Background task: fetch a repository README from its default branch.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
|
||||
let label = repo.clone();
|
||||
Task::perform(
|
||||
async move { crate::plugin::marketplace::fetch_readme(&repo) },
|
||||
move |result| Message::PluginReadmeFetched(label, result),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn fetch_plugin_readme_task(&self, _repo: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Background task: download and install the `tag` release of `owner/repo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
|
||||
|
|
@ -305,7 +320,7 @@ impl OpenCADStudio {
|
|||
.into_iter()
|
||||
.find(|r| r.tag == tag)
|
||||
.ok_or_else(|| format!("release {tag} not found"))?;
|
||||
crate::plugin::marketplace::install(&rel)
|
||||
crate::plugin::marketplace::install(&rel, &repo)
|
||||
},
|
||||
Message::PluginInstalled,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3951,17 +3951,42 @@ impl OpenCADStudio {
|
|||
// Refresh the on-disk external-plugin list each time the manager
|
||||
// opens so newly dropped-in packages show up.
|
||||
self.external_plugins = crate::plugin::external::discover();
|
||||
self.marketplace_status.clear();
|
||||
self.active_modal = Some(super::ModalKind::PluginManager);
|
||||
// Fetch the curated registry and release lists for linked repos.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut tasks = vec![self.fetch_registry_task()];
|
||||
let release_repos: rustc_hash::FxHashSet<String> = self
|
||||
.plugin_repos
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
self.external_plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| plugin.repository.clone()),
|
||||
)
|
||||
.collect();
|
||||
tasks.extend(
|
||||
self.plugin_repos
|
||||
.clone()
|
||||
release_repos
|
||||
.into_iter()
|
||||
.map(|r| self.fetch_releases_task(r)),
|
||||
);
|
||||
if self.selected_plugin_repo.is_none() {
|
||||
self.selected_plugin_repo = self
|
||||
.external_plugins
|
||||
.iter()
|
||||
.find_map(|plugin| plugin.repository.clone())
|
||||
.or_else(|| self.plugin_registry.first().map(|entry| entry.repo.clone()))
|
||||
.or_else(|| self.plugin_repos.first().cloned());
|
||||
}
|
||||
if let Some(repo) = self.selected_plugin_repo.clone() {
|
||||
if !self.plugin_readmes.contains_key(&repo)
|
||||
&& self.plugin_readme_loading.insert(repo.clone())
|
||||
{
|
||||
tasks.push(self.fetch_plugin_readme_task(repo));
|
||||
}
|
||||
}
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -3985,26 +4010,66 @@ impl OpenCADStudio {
|
|||
self.plugin_repo_input = s;
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginSearchInput(s) => {
|
||||
self.plugin_search_input = s;
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginRepoAdd => {
|
||||
let repo = self
|
||||
.plugin_repo_input
|
||||
.trim()
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if repo.is_empty() || self.plugin_repos.contains(&repo) {
|
||||
let Some(repo) =
|
||||
crate::plugin::external::normalize_repository(&self.plugin_repo_input)
|
||||
else {
|
||||
self.marketplace_status =
|
||||
"Enter a GitHub URL or repository in owner/repo format.".to_string();
|
||||
return Task::none();
|
||||
};
|
||||
if self.plugin_repos.contains(&repo)
|
||||
|| self.plugin_registry.iter().any(|entry| entry.repo == repo)
|
||||
{
|
||||
self.marketplace_status = format!("{repo} is already in the catalog.");
|
||||
self.selected_plugin_repo = Some(repo.clone());
|
||||
if !self.plugin_readmes.contains_key(&repo)
|
||||
&& self.plugin_readme_loading.insert(repo.clone())
|
||||
{
|
||||
return self.fetch_plugin_readme_task(repo);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.external_plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.repository.as_deref() == Some(repo.as_str()))
|
||||
{
|
||||
self.marketplace_status = format!("{repo} is already installed.");
|
||||
self.selected_plugin_repo = Some(repo.clone());
|
||||
if !self.plugin_readmes.contains_key(&repo)
|
||||
&& self.plugin_readme_loading.insert(repo.clone())
|
||||
{
|
||||
return self.fetch_plugin_readme_task(repo);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
self.plugin_repos.push(repo.clone());
|
||||
self.plugin_repo_input.clear();
|
||||
self.persist_settings_if_changed();
|
||||
self.marketplace_status = format!("Fetching releases for {repo}…");
|
||||
self.fetch_releases_task(repo)
|
||||
self.selected_plugin_repo = Some(repo.clone());
|
||||
self.plugin_readmes.remove(&repo);
|
||||
self.plugin_readme_loading.insert(repo.clone());
|
||||
Task::batch(vec![
|
||||
self.fetch_releases_task(repo.clone()),
|
||||
self.fetch_plugin_readme_task(repo),
|
||||
])
|
||||
}
|
||||
Message::PluginRepoRemove(repo) => {
|
||||
self.plugin_repos.retain(|r| r != &repo);
|
||||
self.repo_release_tags.remove(&repo);
|
||||
self.repo_selected_tag.remove(&repo);
|
||||
if self.selected_plugin_repo.as_deref() == Some(repo.as_str())
|
||||
&& !self.plugin_registry.iter().any(|entry| entry.repo == repo)
|
||||
{
|
||||
self.selected_plugin_repo =
|
||||
self.plugin_registry.first().map(|entry| entry.repo.clone());
|
||||
}
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
|
|
@ -4012,11 +4077,32 @@ impl OpenCADStudio {
|
|||
// Fetch releases for every curated repo so the dropdowns fill in.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tasks: Vec<_> = entries
|
||||
if self.selected_plugin_repo.is_none() {
|
||||
self.selected_plugin_repo = self
|
||||
.external_plugins
|
||||
.iter()
|
||||
.find_map(|plugin| {
|
||||
plugin.repository.clone().or_else(|| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name))
|
||||
.map(|entry| entry.repo.clone())
|
||||
})
|
||||
})
|
||||
.or_else(|| entries.first().map(|entry| entry.repo.clone()));
|
||||
}
|
||||
let mut tasks: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|e| self.fetch_releases_task(e.repo.clone()))
|
||||
.collect();
|
||||
self.plugin_registry = entries;
|
||||
if let Some(repo) = self.selected_plugin_repo.clone() {
|
||||
if !self.plugin_readmes.contains_key(&repo)
|
||||
&& self.plugin_readme_loading.insert(repo.clone())
|
||||
{
|
||||
tasks.push(self.fetch_plugin_readme_task(repo));
|
||||
}
|
||||
}
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -4073,7 +4159,10 @@ impl OpenCADStudio {
|
|||
.entry(repo.clone())
|
||||
.or_insert_with(|| first.clone());
|
||||
}
|
||||
self.marketplace_status = format!("{repo}: {} installable release(s)", tags.len());
|
||||
if self.marketplace_status == format!("Fetching releases for {repo}…") {
|
||||
self.marketplace_status =
|
||||
format!("Repository added. {} installable release(s) found.", tags.len());
|
||||
}
|
||||
self.repo_release_tags.insert(repo, tags);
|
||||
Task::none()
|
||||
}
|
||||
|
|
@ -4085,6 +4174,27 @@ impl OpenCADStudio {
|
|||
self.repo_selected_tag.insert(repo, tag);
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginReadmeSelect(repo) => {
|
||||
self.selected_plugin_repo = Some(repo.clone());
|
||||
if self.plugin_readme_loading.contains(&repo) {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(self.plugin_readmes.get(&repo), Some(Ok(_))) {
|
||||
return Task::none();
|
||||
}
|
||||
// A second click on an error state acts as retry.
|
||||
self.plugin_readmes.remove(&repo);
|
||||
self.plugin_readme_loading.insert(repo.clone());
|
||||
self.fetch_plugin_readme_task(repo)
|
||||
}
|
||||
Message::PluginReadmeFetched(repo, result) => {
|
||||
self.plugin_readme_loading.remove(&repo);
|
||||
self.plugin_readmes.insert(
|
||||
repo,
|
||||
result.map(|source| iced::widget::markdown::Content::parse(&source)),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginInstall(repo) => {
|
||||
let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else {
|
||||
return Task::none();
|
||||
|
|
@ -4092,6 +4202,10 @@ impl OpenCADStudio {
|
|||
self.marketplace_status = format!("Installing {repo} {tag}…");
|
||||
self.install_task(repo, tag)
|
||||
}
|
||||
Message::PluginUpdate(repo, tag) => {
|
||||
self.marketplace_status = format!("Updating {repo} to {tag}…");
|
||||
self.install_task(repo, tag)
|
||||
}
|
||||
Message::PluginInstalled(Ok(id)) => {
|
||||
self.marketplace_status = format!("Installed '{id}'. Restart to load it.");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
|
|
@ -1537,7 +1537,7 @@ impl OpenCADStudio {
|
|||
About => (440, 360),
|
||||
Shortcuts => (720, 520),
|
||||
Options => (480, 190),
|
||||
PluginManager => (520, 460),
|
||||
PluginManager => (940, 600),
|
||||
UpdateNotice => (560, 460),
|
||||
Layers => (900, 360),
|
||||
Plot => (760, 540),
|
||||
|
|
|
|||
|
|
@ -79,14 +79,19 @@ impl OpenCADStudio {
|
|||
crate::ui::window::plugin_manager::MarketView {
|
||||
registry: &self.plugin_registry,
|
||||
input: &self.plugin_repo_input,
|
||||
search: &self.plugin_search_input,
|
||||
repos: &self.plugin_repos,
|
||||
release_tags: &self.repo_release_tags,
|
||||
selected_tag: &self.repo_selected_tag,
|
||||
selected_repo: self.selected_plugin_repo.as_deref(),
|
||||
readmes: &self.plugin_readmes,
|
||||
readme_loading: &self.plugin_readme_loading,
|
||||
status: &self.marketplace_status,
|
||||
},
|
||||
&self.active_theme,
|
||||
),
|
||||
520,
|
||||
460,
|
||||
940,
|
||||
600,
|
||||
),
|
||||
super::super::ModalKind::UpdateNotice => {
|
||||
let latest = self.update_notice_version.as_deref().unwrap_or("?");
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ pub struct ExternalPlugin {
|
|||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
/// GitHub source in `owner/repo` form. New marketplace installs persist
|
||||
/// this beside the manifest so their README remains discoverable even when
|
||||
/// an older `plugin.toml` does not declare `repository`.
|
||||
pub repository: Option<String>,
|
||||
pub api_version: u32,
|
||||
pub ribbon_order: i32,
|
||||
pub command_prefixes: Vec<String>,
|
||||
|
|
@ -125,6 +129,11 @@ pub fn discover() -> Vec<ExternalPlugin> {
|
|||
continue;
|
||||
};
|
||||
if let Some(mut p) = parse_plugin_toml(&text) {
|
||||
if p.repository.is_none() {
|
||||
p.repository = std::fs::read_to_string(dir.join(".source_repo"))
|
||||
.ok()
|
||||
.and_then(|repo| normalize_repository(&repo));
|
||||
}
|
||||
p.lib_present = lib_present_in(&dir);
|
||||
p.dir = dir;
|
||||
found.push(p);
|
||||
|
|
@ -156,6 +165,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
|||
let mut name = String::new();
|
||||
let mut version = String::new();
|
||||
let mut description = String::new();
|
||||
let mut repository = None;
|
||||
let mut api_version: u32 = 0;
|
||||
let mut ribbon_order: i32 = 0;
|
||||
let mut command_prefixes: Vec<String> = Vec::new();
|
||||
|
|
@ -175,6 +185,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
|||
"name" => name = unquote(value),
|
||||
"version" => version = unquote(value),
|
||||
"description" => description = unquote(value),
|
||||
"repository" => repository = normalize_repository(&unquote(value)),
|
||||
"api_version" => api_version = value.parse().unwrap_or(0),
|
||||
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
|
||||
"command_prefixes" => command_prefixes = parse_string_array(value),
|
||||
|
|
@ -187,6 +198,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
|||
name,
|
||||
version,
|
||||
description,
|
||||
repository,
|
||||
api_version,
|
||||
ribbon_order,
|
||||
command_prefixes,
|
||||
|
|
@ -195,6 +207,26 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Convert a plugin source URL or shorthand to the marketplace's canonical
|
||||
/// `owner/repo` form.
|
||||
pub(crate) fn normalize_repository(value: &str) -> Option<String> {
|
||||
let repo = value
|
||||
.trim()
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_start_matches("http://github.com/")
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches(".git");
|
||||
let mut parts = repo.split('/');
|
||||
let (Some(owner), Some(name), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return None;
|
||||
};
|
||||
if owner.is_empty() || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{owner}/{name}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip surrounding single or double quotes from a TOML scalar.
|
||||
fn unquote(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
|
|
@ -321,6 +353,7 @@ id = "opencad.my_plugin"
|
|||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Template plugin"
|
||||
repository = "https://github.com/example/opencad-my-plugin.git"
|
||||
|
||||
[opencad]
|
||||
api_version = 2
|
||||
|
|
@ -330,6 +363,7 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
|||
"#;
|
||||
let p = parse_plugin_toml(toml).expect("parsed");
|
||||
assert_eq!(p.api_version, 2);
|
||||
assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin"));
|
||||
assert!(p.command_prefixes.contains(&"MP_".to_string()));
|
||||
assert!(p.api_compatible(), "API v2 plugins must run on API v3 host");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,20 @@ pub fn fetch_releases(repo: &str) -> Result<Vec<Release>, String> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fetch the repository README from its default branch as raw Markdown.
|
||||
pub fn fetch_readme(repo: &str) -> Result<String, String> {
|
||||
let url = format!("https://api.github.com/repos/{repo}/readme");
|
||||
agent()
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/vnd.github.raw+json")
|
||||
.call()
|
||||
.map_err(|e| e.to_string())?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn download_string(url: &str) -> Result<String, String> {
|
||||
agent()
|
||||
.get(url)
|
||||
|
|
@ -176,7 +190,7 @@ fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
|||
/// Download and install a release's package into the plugins folder. Verifies
|
||||
/// the API version from the package's `plugin.toml` first. Returns the plugin
|
||||
/// id on success.
|
||||
pub fn install(release: &Release) -> Result<String, String> {
|
||||
pub fn install(release: &Release, repository: &str) -> Result<String, String> {
|
||||
let lib = release.lib_asset().ok_or("no library for this platform")?;
|
||||
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
|
||||
|
||||
|
|
@ -199,6 +213,8 @@ pub fn install(release: &Release) -> Result<String, String> {
|
|||
let bytes = download_bytes(&lib.url)?;
|
||||
replace_library(&dir, &lib.name, external_lib_ext(), &bytes)?;
|
||||
std::fs::write(dir.join("plugin.toml"), toml_text).map_err(|e| e.to_string())?;
|
||||
std::fs::write(dir.join(".source_repo"), format!("{repository}\n"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(manifest.id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,27 @@
|
|||
|
||||
use crate::app::Message;
|
||||
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Element, Fill, Theme};
|
||||
use iced::widget::{
|
||||
button, column, container, markdown, pick_list, row, rule, scrollable, text, text_input, Space,
|
||||
};
|
||||
use iced::{Background, Border, Element, Fill, Length, Theme};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
/// Empty lane kept at the right edge of scroll content so the vertical
|
||||
/// scrollbar never covers card controls.
|
||||
const SCROLLBAR_GUTTER: f32 = 16.0;
|
||||
|
||||
/// Marketplace state passed to the Plugin Manager view.
|
||||
pub struct MarketView<'a> {
|
||||
pub registry: &'a [RegistryEntry],
|
||||
pub input: &'a str,
|
||||
pub search: &'a str,
|
||||
pub repos: &'a [String],
|
||||
pub release_tags: &'a FxHashMap<String, Vec<String>>,
|
||||
pub selected_tag: &'a FxHashMap<String, String>,
|
||||
pub selected_repo: Option<&'a str>,
|
||||
pub readmes: &'a FxHashMap<String, Result<markdown::Content, String>>,
|
||||
pub readme_loading: &'a FxHashSet<String>,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +114,96 @@ fn status_badge<'a>(label: &str, kind: StatusKind) -> Element<'a, Message> {
|
|||
.into()
|
||||
}
|
||||
|
||||
fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Element<'a, Message> {
|
||||
fn card_style(theme: &Theme, selected: bool) -> container::Style {
|
||||
let palette = theme.extended_palette();
|
||||
container::Style {
|
||||
background: selected
|
||||
.then(|| Background::Color(palette.primary.weak.color.scale_alpha(0.18))),
|
||||
border: Border {
|
||||
color: if selected {
|
||||
palette.primary.base.color
|
||||
} else {
|
||||
palette.background.strong.color
|
||||
},
|
||||
width: if selected { 1.5 } else { 1.0 },
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_for_external(
|
||||
plugin: &ExternalPlugin,
|
||||
registry: &[RegistryEntry],
|
||||
) -> Option<String> {
|
||||
plugin.repository.clone().or_else(|| {
|
||||
registry
|
||||
.iter()
|
||||
.find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name))
|
||||
.map(|entry| entry.repo.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_search(query: &str, values: &[&str]) -> bool {
|
||||
let query = query.trim().to_lowercase();
|
||||
query.is_empty()
|
||||
|| values
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase().contains(&query))
|
||||
}
|
||||
|
||||
fn external_matches_search(plugin: &ExternalPlugin, repository: Option<&str>, query: &str) -> bool {
|
||||
matches_search(
|
||||
query,
|
||||
&[
|
||||
&plugin.name,
|
||||
&plugin.id,
|
||||
&plugin.description,
|
||||
repository.unwrap_or_default(),
|
||||
],
|
||||
) || plugin
|
||||
.command_prefixes
|
||||
.iter()
|
||||
.any(|command| matches_search(query, &[command]))
|
||||
}
|
||||
|
||||
fn repository_is_installed(
|
||||
repository: &str,
|
||||
registry_name: Option<&str>,
|
||||
externals: &[ExternalPlugin],
|
||||
) -> bool {
|
||||
externals.iter().any(|plugin| {
|
||||
plugin.repository.as_deref() == Some(repository)
|
||||
|| registry_name
|
||||
.is_some_and(|name| plugin.name.eq_ignore_ascii_case(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn trim_version_prefix(value: &str) -> &str {
|
||||
value.trim_start_matches(|c| c == 'v' || c == 'V')
|
||||
}
|
||||
|
||||
fn newest_update(installed: &str, tags: &[String]) -> Option<String> {
|
||||
let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?;
|
||||
tags.iter()
|
||||
.filter_map(|tag| {
|
||||
semver::Version::parse(trim_version_prefix(tag))
|
||||
.ok()
|
||||
.map(|version| (version, tag))
|
||||
})
|
||||
.filter(|(version, _)| version > &installed)
|
||||
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||
.map(|(_, tag)| tag.clone())
|
||||
}
|
||||
|
||||
fn external_card<'a>(
|
||||
p: &ExternalPlugin,
|
||||
repository: Option<String>,
|
||||
update_tag: Option<String>,
|
||||
loaded: bool,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let (status, kind) = if loaded && disabled {
|
||||
("Disabled", StatusKind::Muted)
|
||||
} else if loaded {
|
||||
|
|
@ -116,7 +215,7 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
|
|||
} else {
|
||||
("Restart to load", StatusKind::Warning)
|
||||
};
|
||||
let mut header = row![
|
||||
let header = row![
|
||||
text(p.name.clone()).size(15),
|
||||
Space::new().width(8),
|
||||
badge(format!("v{}", p.version)),
|
||||
|
|
@ -126,33 +225,55 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen
|
|||
status_badge(status, kind),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
|
||||
if loaded {
|
||||
header = header.push(Space::new().width(10));
|
||||
header = header.push(toggle_button(&p.id, disabled));
|
||||
}
|
||||
header = header.push(Space::new().width(6));
|
||||
header = header.push(pill_button(
|
||||
"Uninstall",
|
||||
Message::PluginUninstall(p.id.clone()),
|
||||
button::danger,
|
||||
));
|
||||
|
||||
let id_line = text(p.id.clone()).size(11).style(primary_style);
|
||||
let mut body = column![header, id_line].spacing(5);
|
||||
let id_line = text(p.id.clone()).size(11).style(muted_style);
|
||||
let mut info_body = column![header, id_line].spacing(5);
|
||||
if !p.description.is_empty() {
|
||||
body = body.push(text(p.description.clone()).size(12).style(muted_style));
|
||||
info_body = info_body.push(text(p.description.clone()).size(12).style(muted_style));
|
||||
}
|
||||
if !p.command_prefixes.is_empty() {
|
||||
body = body.push(
|
||||
info_body = info_body.push(
|
||||
text(format!("Commands: {}", p.command_prefixes.join(", ")))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
);
|
||||
}
|
||||
container(body.padding([12, 14]))
|
||||
|
||||
let info: Element<'a, Message> = if let Some(repo) = repository.clone() {
|
||||
button(info_body.width(Fill))
|
||||
.width(Fill)
|
||||
.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()
|
||||
}
|
||||
|
||||
|
|
@ -221,74 +342,306 @@ fn install_controls<'a>(
|
|||
controls.into()
|
||||
}
|
||||
|
||||
fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
|
||||
fn market_card<'a>(
|
||||
body: iced::widget::Column<'a, Message>,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
container(body.spacing(4).padding([10, 12]))
|
||||
.width(Fill)
|
||||
.style(container::bordered_box)
|
||||
.style(move |theme: &Theme| card_style(theme, selected))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
|
||||
let mut col = column![text("Available plugins").size(13).style(primary_style)].spacing(6);
|
||||
|
||||
// Curated registry entries (from the OpenCADStudio repo).
|
||||
for e in m.registry {
|
||||
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(&e.repo).cloned();
|
||||
let header = row![
|
||||
text(e.name.clone()).size(14),
|
||||
Space::new().width(Fill),
|
||||
install_controls(&e.repo, tags, selected, false),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
let mut body = column![header, text(e.repo.clone()).size(11).style(primary_style)];
|
||||
if !e.description.is_empty() {
|
||||
body = body.push(text(e.description.clone()).size(12).style(muted_style));
|
||||
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()
|
||||
}
|
||||
col = col.push(market_card(body));
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// Manual: link any repo by owner/repo.
|
||||
col = col.push(Space::new().height(6));
|
||||
col = col.push(text("Add a repository").size(12).style(muted_style));
|
||||
col = col.push(
|
||||
row![
|
||||
text_input("owner/repo", m.input)
|
||||
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", 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 {
|
||||
if m.registry.iter().any(|entry| entry.repo == *repo)
|
||||
|| repository_is_installed(repo, None, externals)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let display_name = repository_display_name(repo);
|
||||
if !matches_search(m.search, &[&display_name, repo]) {
|
||||
continue;
|
||||
}
|
||||
visible += 1;
|
||||
let tags = m.release_tags.get(repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(repo).cloned();
|
||||
let header = row![
|
||||
text(repo.clone()).size(13),
|
||||
let info = button(
|
||||
column![
|
||||
text(display_name).size(14),
|
||||
text("Added from GitHub").size(11).style(muted_style),
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Fill),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(0)
|
||||
.style(button::text)
|
||||
.on_press(Message::PluginReadmeSelect(repo.clone()));
|
||||
let actions = row![
|
||||
Space::new().width(Fill),
|
||||
install_controls(repo, tags, selected, true),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
col = col.push(market_card(column![header]));
|
||||
col = col.push(market_card(
|
||||
column![info, actions].spacing(8),
|
||||
m.selected_repo == Some(repo.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
if visible == 0 {
|
||||
let message = if m.search.trim().is_empty() {
|
||||
"No additional plugins are available."
|
||||
} else {
|
||||
"No available plugins match your search."
|
||||
};
|
||||
col = col.push(text(message).size(12).style(muted_style));
|
||||
}
|
||||
|
||||
col = col.push(Space::new().height(8));
|
||||
col = col.push(add_repository_card(m));
|
||||
if !m.status.is_empty() {
|
||||
col = col.push(text(m.status.to_string()).size(11).style(muted_style));
|
||||
}
|
||||
col.into()
|
||||
}
|
||||
|
||||
fn resolve_readme_link(repo: &str, uri: &str) -> String {
|
||||
if uri.starts_with("https://")
|
||||
|| uri.starts_with("http://")
|
||||
|| uri.starts_with("mailto:")
|
||||
{
|
||||
uri.to_string()
|
||||
} else if uri.starts_with('/') {
|
||||
format!("https://github.com{uri}")
|
||||
} else if uri.starts_with('#') {
|
||||
format!("https://github.com/{repo}{uri}")
|
||||
} else {
|
||||
format!(
|
||||
"https://github.com/{repo}/blob/HEAD/{}",
|
||||
uri.trim_start_matches("./")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn readme_panel<'a>(market: &MarketView<'a>, theme: &Theme) -> Element<'a, Message> {
|
||||
let Some(repo) = market.selected_repo else {
|
||||
return container(
|
||||
column![
|
||||
text("Plugin details").size(16),
|
||||
text("Select a plugin to read its GitHub README.")
|
||||
.size(12)
|
||||
.style(muted_style),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.center(Fill)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(container::bordered_box)
|
||||
.into();
|
||||
};
|
||||
|
||||
let name = repo
|
||||
.rsplit_once('/')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or(repo)
|
||||
.to_string();
|
||||
let header = row![
|
||||
column![
|
||||
text(name).size(16),
|
||||
text(repo.to_string()).size(11).style(primary_style),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
pill_button(
|
||||
"View on GitHub",
|
||||
Message::OpenUrl(format!("https://github.com/{repo}")),
|
||||
button::secondary,
|
||||
),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
|
||||
let content: Element<'a, Message> = if market.readme_loading.contains(repo) {
|
||||
container(
|
||||
column![
|
||||
text("Loading README…").size(13),
|
||||
text("Fetching the default branch from GitHub.")
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.center(Fill)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
} else {
|
||||
match market.readmes.get(repo) {
|
||||
Some(Ok(readme)) => {
|
||||
let source_repo = repo.to_string();
|
||||
markdown::view(
|
||||
readme.items(),
|
||||
markdown::Settings::with_text_size(13, theme),
|
||||
)
|
||||
.map(move |uri| {
|
||||
Message::OpenUrl(resolve_readme_link(&source_repo, &uri))
|
||||
})
|
||||
}
|
||||
Some(Err(error)) => container(
|
||||
column![
|
||||
text("README could not be loaded").size(14),
|
||||
text(error.clone()).size(11).style(muted_style),
|
||||
Space::new().height(4),
|
||||
pill_button(
|
||||
"Retry",
|
||||
Message::PluginReadmeSelect(repo.to_string()),
|
||||
button::secondary,
|
||||
),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.center(Fill)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
None => container(
|
||||
text("Select the plugin again to load its README.")
|
||||
.size(12)
|
||||
.style(muted_style),
|
||||
)
|
||||
.center(Fill)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
}
|
||||
};
|
||||
|
||||
let gutter = iced::Padding {
|
||||
top: 8.0,
|
||||
right: SCROLLBAR_GUTTER,
|
||||
bottom: 8.0,
|
||||
left: 0.0,
|
||||
};
|
||||
let readme = scrollable(container(content).padding(gutter))
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
|
||||
container(
|
||||
column![header, rule::horizontal(1), readme]
|
||||
.spacing(10)
|
||||
.padding([12, 14])
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(container::bordered_box)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view_window<'a>(
|
||||
disabled: &FxHashSet<String>,
|
||||
externals: &[ExternalPlugin],
|
||||
loaded: &FxHashSet<String>,
|
||||
market: MarketView,
|
||||
market: MarketView<'a>,
|
||||
theme: &'a Theme,
|
||||
) -> Element<'a, Message> {
|
||||
let title = text("Plugins").size(20);
|
||||
let subtitle = text("Add-ons load from the plugins folder. Install from a repository below.")
|
||||
let subtitle = text("Browse, install, and manage add-ons. Select one to view its README.")
|
||||
.size(12)
|
||||
.style(muted_style);
|
||||
|
||||
|
|
@ -297,24 +650,71 @@ pub fn view_window<'a>(
|
|||
if externals.is_empty() {
|
||||
list = list.push(text("No plugins installed yet.").size(13).style(muted_style));
|
||||
} else {
|
||||
list = list.push(text("Installed").size(13).style(primary_style));
|
||||
let mut visible_installed = 0usize;
|
||||
for p in externals {
|
||||
let repository = repository_for_external(p, market.registry);
|
||||
if !external_matches_search(p, repository.as_deref(), market.search) {
|
||||
continue;
|
||||
}
|
||||
if visible_installed == 0 {
|
||||
list = list.push(text("Installed").size(13).style(primary_style));
|
||||
}
|
||||
visible_installed += 1;
|
||||
let selected = repository.as_deref() == market.selected_repo;
|
||||
let update_tag = repository
|
||||
.as_ref()
|
||||
.and_then(|repo| market.release_tags.get(repo))
|
||||
.and_then(|tags| newest_update(&p.version, tags));
|
||||
list = list.push(external_card(
|
||||
p,
|
||||
repository,
|
||||
update_tag,
|
||||
loaded.contains(&p.id),
|
||||
disabled.contains(&p.id),
|
||||
selected,
|
||||
));
|
||||
}
|
||||
if visible_installed == 0 && !market.search.trim().is_empty() {
|
||||
list = list.push(
|
||||
text("No installed plugins match your search.")
|
||||
.size(12)
|
||||
.style(muted_style),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Marketplace: install from a linked repository's releases.
|
||||
list = list.push(Space::new().height(14));
|
||||
list = list.push(marketplace_section(&market));
|
||||
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
|
||||
list = list.push(marketplace_section(&market, externals));
|
||||
let gutter = iced::Padding {
|
||||
top: 0.0,
|
||||
right: SCROLLBAR_GUTTER,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
};
|
||||
let catalog = scrollable(container(list.width(Fill)).padding(gutter))
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
let search = text_input("Search plugins…", market.search)
|
||||
.on_input(Message::PluginSearchInput)
|
||||
.size(13)
|
||||
.padding([7, 10])
|
||||
.width(Fill);
|
||||
let catalog_pane = column![search, catalog].spacing(10).width(Fill).height(Fill);
|
||||
let details = readme_panel(&market, theme);
|
||||
let body = row![
|
||||
container(catalog_pane)
|
||||
.width(Length::Fixed(410.0))
|
||||
.height(Fill),
|
||||
details,
|
||||
]
|
||||
.spacing(14)
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
|
||||
container(
|
||||
column![title, subtitle, Space::new().height(12), body]
|
||||
.spacing(4)
|
||||
.padding(20)
|
||||
.padding(18)
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
|
|
@ -328,3 +728,27 @@ pub fn view_window<'a>(
|
|||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{newest_update, repository_display_name};
|
||||
|
||||
#[test]
|
||||
fn newest_update_uses_semver_not_release_order() {
|
||||
let tags = vec![
|
||||
"v1.4.0".to_string(),
|
||||
"v2.0.0".to_string(),
|
||||
"v1.9.9".to_string(),
|
||||
];
|
||||
assert_eq!(newest_update("1.3.0", &tags).as_deref(), Some("v2.0.0"));
|
||||
assert_eq!(newest_update("2.0.0", &tags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_name_is_human_readable() {
|
||||
assert_eq!(
|
||||
repository_display_name("owner/opencad-storm_sewer-plugin"),
|
||||
"OpenCAD Storm Sewer Plugin",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue