diff --git a/Cargo.lock b/Cargo.lock index 794a9fd3..1c9d9466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8545571b..dcaad6fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index 6153aeaa..f2718db5 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -5,9 +5,10 @@ version = "0.1.0" description = "Short description of what this add-on does" author = "Your Name" license = "GPL-3.0-only" +repository = "your-account/your-plugin-repo" [opencad] api_version = 3 ribbon_order = 60 command_prefixes = ["MP_"] -xdata_apps = ["MYPLUGIN_RECORD"] \ No newline at end of file +xdata_apps = ["MYPLUGIN_RECORD"] diff --git a/src/app/mod.rs b/src/app/mod.rs index 96b0a68f..5d1aa6b0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -528,10 +528,19 @@ pub(super) struct OpenCADStudio { plugin_repos: Vec, /// 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>, /// The release tag currently selected per linked repo. repo_selected_tag: rustc_hash::FxHashMap, + /// Repository currently shown in the Plugin Manager detail pane. + selected_plugin_repo: Option, + /// Parsed GitHub README content or the last fetch error, cached per repo. + plugin_readmes: + rustc_hash::FxHashMap>, + /// README requests in flight, used to render a deterministic loading state. + plugin_readme_loading: rustc_hash::FxHashSet, /// 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, 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), /// 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), /// 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, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 87f67168..6d96b31a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 { + 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 { + 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 { @@ -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, ) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a49f7f1c..eeecf083 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 = 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"))] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6c0572d4..085f294e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 9e4f6b04..60ab761b 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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("?"); diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 245c8891..4e4e3600 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -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, pub api_version: u32, pub ribbon_order: i32, pub command_prefixes: Vec, @@ -125,6 +129,11 @@ pub fn discover() -> Vec { 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 { 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 = Vec::new(); @@ -175,6 +185,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { "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 { name, version, description, + repository, api_version, ribbon_order, command_prefixes, @@ -195,6 +207,26 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { }) } +/// Convert a plugin source URL or shorthand to the marketplace's canonical +/// `owner/repo` form. +pub(crate) fn normalize_repository(value: &str) -> Option { + 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"); } diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 462cf25d..86c882d7 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -148,6 +148,20 @@ pub fn fetch_releases(repo: &str) -> Result, String> { Ok(out) } +/// Fetch the repository README from its default branch as raw Markdown. +pub fn fetch_readme(repo: &str) -> Result { + 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 { agent() .get(url) @@ -176,7 +190,7 @@ fn download_bytes(url: &str) -> Result, 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 { +pub fn install(release: &Release, repository: &str) -> Result { 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 { 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) } diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 3f1b903e..efc4921b 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -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>, pub selected_tag: &'a FxHashMap, + pub selected_repo: Option<&'a str>, + pub readmes: &'a FxHashMap>, + pub readme_loading: &'a FxHashSet, 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 { + 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 { + 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, + update_tag: Option, + loaded: bool, + disabled: bool, + selected: bool, +) -> Element<'a, Message> { let (status, kind) = if loaded && disabled { ("Disabled", StatusKind::Muted) } else if loaded { @@ -116,7 +215,7 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen } else { ("Restart to load", StatusKind::Warning) }; - let mut header = row![ + let header = row![ text(p.name.clone()).size(15), Space::new().width(8), badge(format!("v{}", p.version)), @@ -126,33 +225,55 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen status_badge(status, kind), ] .align_y(iced::Center); - // A loaded plugin can be turned off (drops its ribbon tab + dispatch). - if loaded { - header = header.push(Space::new().width(10)); - header = header.push(toggle_button(&p.id, disabled)); - } - header = header.push(Space::new().width(6)); - header = header.push(pill_button( - "Uninstall", - Message::PluginUninstall(p.id.clone()), - button::danger, - )); - let id_line = text(p.id.clone()).size(11).style(primary_style); - let mut body = column![header, id_line].spacing(5); + let id_line = text(p.id.clone()).size(11).style(muted_style); + let mut info_body = column![header, id_line].spacing(5); if !p.description.is_empty() { - body = body.push(text(p.description.clone()).size(12).style(muted_style)); + info_body = info_body.push(text(p.description.clone()).size(12).style(muted_style)); } if !p.command_prefixes.is_empty() { - body = body.push( + info_body = info_body.push( text(format!("Commands: {}", p.command_prefixes.join(", "))) .size(11) .style(muted_style), ); } - container(body.padding([12, 14])) + + let info: Element<'a, Message> = if let Some(repo) = repository.clone() { + button(info_body.width(Fill)) + .width(Fill) + .padding(0) + .style(button::text) + .on_press(Message::PluginReadmeSelect(repo)) + .into() + } else { + info_body.width(Fill).into() + }; + + let mut actions = row![Space::new().width(Fill)].align_y(iced::Center); + if let (Some(repo), Some(tag)) = (repository, update_tag) { + let label = format!("Update to {tag}"); + actions = actions.push(pill_button( + &label, + Message::PluginUpdate(repo, tag), + button::primary, + )); + actions = actions.push(Space::new().width(6)); + } + // A loaded plugin can be turned off (drops its ribbon tab + dispatch). + if loaded { + actions = actions.push(toggle_button(&p.id, disabled)); + actions = actions.push(Space::new().width(6)); + } + actions = actions.push(pill_button( + "Uninstall", + Message::PluginUninstall(p.id.clone()), + button::danger, + )); + + container(column![info, actions].spacing(8).padding([10, 12])) .width(Fill) - .style(container::bordered_box) + .style(move |theme: &Theme| card_style(theme, selected)) .into() } @@ -221,74 +342,306 @@ fn install_controls<'a>( controls.into() } -fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> { +fn market_card<'a>( + body: iced::widget::Column<'a, Message>, + selected: bool, +) -> Element<'a, Message> { container(body.spacing(4).padding([10, 12])) .width(Fill) - .style(container::bordered_box) + .style(move |theme: &Theme| card_style(theme, selected)) .into() } -fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { +fn repository_display_name(repository: &str) -> String { + repository + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(repository) + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + if part.eq_ignore_ascii_case("opencad") { + "OpenCAD".to_string() + } else { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + } + }) + .collect::>() + .join(" ") +} + +fn add_repository_card<'a>(m: &MarketView) -> Element<'a, Message> { + let form = row![ + text_input("GitHub URL or owner/repository", m.input) + .on_input(Message::PluginRepoInput) + .on_submit(Message::PluginRepoAdd) + .size(13) + .width(Fill), + Space::new().width(8), + pill_button( + "Add repository", + Message::PluginRepoAdd, + button::primary, + ), + ] + .align_y(iced::Center); + + market_card( + column![ + text("Add from GitHub").size(14), + text( + "Paste a public repository URL. Compatible releases and the README \ + are detected automatically.", + ) + .size(11) + .style(muted_style), + Space::new().height(3), + form, + ] + .spacing(5), + false, + ) +} + +fn marketplace_section<'a>( + m: &MarketView, + externals: &[ExternalPlugin], +) -> Element<'a, Message> { let mut col = column![text("Available plugins").size(13).style(primary_style)].spacing(6); + let mut visible = 0usize; // Curated registry entries (from the OpenCADStudio repo). for e in m.registry { + if repository_is_installed(&e.repo, Some(&e.name), externals) + || !matches_search(m.search, &[&e.name, &e.description, &e.repo]) + { + continue; + } + visible += 1; let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(&e.repo).cloned(); - let header = row![ - text(e.name.clone()).size(14), + let mut info = column![text(e.name.clone()).size(14)].spacing(4); + if !e.description.is_empty() { + info = info.push(text(e.description.clone()).size(12).style(muted_style)); + } + let info = button(info.width(Fill)) + .width(Fill) + .padding(0) + .style(button::text) + .on_press(Message::PluginReadmeSelect(e.repo.clone())); + let actions = row![ Space::new().width(Fill), install_controls(&e.repo, tags, selected, false), ] .align_y(iced::Center); - let mut body = column![header, text(e.repo.clone()).size(11).style(primary_style)]; - if !e.description.is_empty() { - body = body.push(text(e.description.clone()).size(12).style(muted_style)); - } - col = col.push(market_card(body)); + col = col.push(market_card( + column![info, actions].spacing(8), + m.selected_repo == Some(e.repo.as_str()), + )); } - // Manual: link any repo by owner/repo. - col = col.push(Space::new().height(6)); - col = col.push(text("Add a repository").size(12).style(muted_style)); - col = col.push( - row![ - text_input("owner/repo", m.input) - .on_input(Message::PluginRepoInput) - .on_submit(Message::PluginRepoAdd) - .size(13) - .width(Fill), - Space::new().width(8), - pill_button("Add", Message::PluginRepoAdd, button::primary), - ] - .align_y(iced::Center), - ); + // User-linked repositories join the same catalog, but curated and installed + // repositories are suppressed so every plugin appears only once. for repo in m.repos { + if m.registry.iter().any(|entry| entry.repo == *repo) + || repository_is_installed(repo, None, externals) + { + continue; + } + let display_name = repository_display_name(repo); + if !matches_search(m.search, &[&display_name, repo]) { + continue; + } + visible += 1; let tags = m.release_tags.get(repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(repo).cloned(); - let header = row![ - text(repo.clone()).size(13), + let info = button( + column![ + text(display_name).size(14), + text("Added from GitHub").size(11).style(muted_style), + ] + .spacing(4) + .width(Fill), + ) + .width(Fill) + .padding(0) + .style(button::text) + .on_press(Message::PluginReadmeSelect(repo.clone())); + let actions = row![ Space::new().width(Fill), install_controls(repo, tags, selected, true), ] .align_y(iced::Center); - col = col.push(market_card(column![header])); + col = col.push(market_card( + column![info, actions].spacing(8), + m.selected_repo == Some(repo.as_str()), + )); } + if visible == 0 { + let message = if m.search.trim().is_empty() { + "No additional plugins are available." + } else { + "No available plugins match your search." + }; + col = col.push(text(message).size(12).style(muted_style)); + } + + col = col.push(Space::new().height(8)); + col = col.push(add_repository_card(m)); if !m.status.is_empty() { col = col.push(text(m.status.to_string()).size(11).style(muted_style)); } col.into() } +fn resolve_readme_link(repo: &str, uri: &str) -> String { + if uri.starts_with("https://") + || uri.starts_with("http://") + || uri.starts_with("mailto:") + { + uri.to_string() + } else if uri.starts_with('/') { + format!("https://github.com{uri}") + } else if uri.starts_with('#') { + format!("https://github.com/{repo}{uri}") + } else { + format!( + "https://github.com/{repo}/blob/HEAD/{}", + uri.trim_start_matches("./") + ) + } +} + +fn readme_panel<'a>(market: &MarketView<'a>, theme: &Theme) -> Element<'a, Message> { + let Some(repo) = market.selected_repo else { + return container( + column![ + text("Plugin details").size(16), + text("Select a plugin to read its GitHub README.") + .size(12) + .style(muted_style), + ] + .spacing(8), + ) + .center(Fill) + .width(Fill) + .height(Fill) + .style(container::bordered_box) + .into(); + }; + + let name = repo + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(repo) + .to_string(); + let header = row![ + column![ + text(name).size(16), + text(repo.to_string()).size(11).style(primary_style), + ] + .spacing(3) + .width(Fill), + pill_button( + "View on GitHub", + Message::OpenUrl(format!("https://github.com/{repo}")), + button::secondary, + ), + ] + .align_y(iced::Center); + + let content: Element<'a, Message> = if market.readme_loading.contains(repo) { + container( + column![ + text("Loading README…").size(13), + text("Fetching the default branch from GitHub.") + .size(11) + .style(muted_style), + ] + .spacing(6), + ) + .center(Fill) + .width(Fill) + .height(Fill) + .into() + } else { + match market.readmes.get(repo) { + Some(Ok(readme)) => { + let source_repo = repo.to_string(); + markdown::view( + readme.items(), + markdown::Settings::with_text_size(13, theme), + ) + .map(move |uri| { + Message::OpenUrl(resolve_readme_link(&source_repo, &uri)) + }) + } + Some(Err(error)) => container( + column![ + text("README could not be loaded").size(14), + text(error.clone()).size(11).style(muted_style), + Space::new().height(4), + pill_button( + "Retry", + Message::PluginReadmeSelect(repo.to_string()), + button::secondary, + ), + ] + .spacing(6), + ) + .center(Fill) + .width(Fill) + .height(Fill) + .into(), + None => container( + text("Select the plugin again to load its README.") + .size(12) + .style(muted_style), + ) + .center(Fill) + .width(Fill) + .height(Fill) + .into(), + } + }; + + let gutter = iced::Padding { + top: 8.0, + right: SCROLLBAR_GUTTER, + bottom: 8.0, + left: 0.0, + }; + let readme = scrollable(container(content).padding(gutter)) + .height(Fill) + .width(Fill); + + container( + column![header, rule::horizontal(1), readme] + .spacing(10) + .padding([12, 14]) + .width(Fill) + .height(Fill), + ) + .width(Fill) + .height(Fill) + .style(container::bordered_box) + .into() +} + pub fn view_window<'a>( disabled: &FxHashSet, externals: &[ExternalPlugin], loaded: &FxHashSet, - 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", + ); + } +}