Skip to main content

brep_app/
plugins.rs

1//! Explicit installation and host-only acquisition. Document opening never installs code.
2use crate::store::{ModelStore, PLUGINS_KEY};
3use brep_render::engine_state::EngineState;
4use eframe::egui;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use std::{cell::RefCell, rc::Rc, sync::mpsc::Receiver};
8
9const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024;
10
11#[derive(Clone, Serialize, Deserialize)]
12pub struct InstalledBundle {
13    /// Original UTF-8 bytes, preserved for portable export and offline reopening.
14    pub bytes: String,
15    pub enabled: bool,
16    pub id: String,
17    pub digest: String,
18    pub version: String,
19    pub source: Option<String>,
20}
21
22pub type SharedPackages = Rc<RefCell<Vec<InstalledBundle>>>;
23
24pub fn restore(engine: &mut EngineState, packages: &[InstalledBundle]) -> Result<u64, String> {
25    let entries: Result<Vec<_>, String> = packages
26        .iter()
27        .map(|p| {
28            let bundle: brep_plugins::PackageBundle =
29                serde_json::from_str(&p.bytes).map_err(|e| e.to_string())?;
30            let pin = bundle.pin()?;
31            if pin.id != p.id || pin.version != p.version || pin.digest != p.digest {
32                return Err(format!(
33                    "Stored plugin {} content does not match its pinned identity",
34                    p.id
35                ));
36            }
37            Ok(json!({"bundle": bundle, "enabled": p.enabled}))
38        })
39        .collect();
40    engine.submit_plugin_installations(Value::Array(entries?))
41}
42
43/// Parse only the portable container here; runtime validates modules and registrations.
44pub fn parse_bundle(bytes: &[u8]) -> Result<(String, Value), String> {
45    if bytes.len() > MAX_BUNDLE_BYTES {
46        return Err("Plugin bundle exceeds 16 MiB".into());
47    }
48    let text = std::str::from_utf8(bytes).map_err(|e| format!("Bundle must be UTF-8: {e}"))?;
49    let value: Value =
50        serde_json::from_str(text).map_err(|e| format!("Invalid plugin bundle JSON: {e}"))?;
51    if !value["manifest"].is_object() || !value["modules"].is_object() {
52        return Err("Expected a bundle containing manifest and modules objects".into());
53    }
54    Ok((text.to_owned(), value))
55}
56
57/// GitHub blob links resolve their ref through the commits API before downloading.
58/// Direct HTTP(S) bundles are pinned by their validated content digest.
59#[derive(Debug, PartialEq)]
60enum Acquisition {
61    Direct(String),
62    Github {
63        owner: String,
64        repo: String,
65        revision: String,
66        path: String,
67    },
68}
69fn acquisition(url: &str) -> Result<Acquisition, String> {
70    let url = url.trim();
71    if let Some(rest) = url.strip_prefix("https://github.com/") {
72        let parts: Vec<_> = rest.split('/').collect();
73        if parts.len() < 5
74            || parts[2] != "blob"
75            || parts
76                .iter()
77                .any(|p| p.is_empty() || *p == ".." || p.contains(['?', '#', '\\']))
78        {
79            return Err("Use a GitHub blob URL to a .brep-plugin.json bundle (refs containing / require an immutable commit URL)".into());
80        }
81        return Ok(Acquisition::Github {
82            owner: parts[0].into(),
83            repo: parts[1].into(),
84            revision: parts[3].into(),
85            path: parts[4..].join("/"),
86        });
87    }
88    if !(url.starts_with("https://") || url.starts_with("http://")) {
89        return Err("Enter an HTTP(S) bundle URL or GitHub blob URL".into());
90    }
91    Ok(Acquisition::Direct(url.into()))
92}
93
94struct Download {
95    rx: Receiver<Result<Vec<u8>, String>>,
96    source: String,
97    github: Option<(String, String, String)>,
98}
99
100pub struct PluginsPanel {
101    pub open: bool,
102    pub packages: SharedPackages,
103    pub error: Option<String>,
104    pub message: Option<String>,
105    pub(crate) storage_error: Option<String>,
106    url: String,
107    #[cfg(not(target_arch = "wasm32"))]
108    local_path: String,
109    pasted: String,
110    download: Option<Download>,
111    importing: bool,
112    action: Option<ActionForm>,
113    panel_params: std::collections::HashMap<String, Value>,
114    panel_hits: std::collections::HashMap<String, egui::Rect>,
115    ui_package_stamp: String,
116    synced: std::collections::HashMap<u64, String>,
117    pub(crate) active_document: u64,
118    pending_install: Option<(u64, u64, Vec<InstalledBundle>)>,
119    /// A package's Export asked: the shell hands these to the file dialog's
120    /// export door ([`Self::take_save_requests`]), which on desktop asks where.
121    exports: Vec<crate::panels::file::SaveRequest>,
122}
123struct ActionForm {
124    id: String,
125    package_digest: Value,
126    label: String,
127    schema: Value,
128    params: Value,
129    error: Option<String>,
130    pending: bool,
131}
132
133impl PluginsPanel {
134    pub fn new(store: &dyn ModelStore) -> Self {
135        let (packages, error) = match store.read(PLUGINS_KEY) {
136            Some(text) => match serde_json::from_str::<Vec<InstalledBundle>>(&text) {
137                Ok(v) => (v, None),
138                Err(e) => (
139                    vec![],
140                    Some(format!("Installed plugins could not be read: {e}")),
141                ),
142            },
143            None => (vec![], None),
144        };
145        Self {
146            open: false,
147            packages: Rc::new(RefCell::new(packages)),
148            error,
149            message: None,
150            storage_error: None,
151            url: String::new(),
152            #[cfg(not(target_arch = "wasm32"))]
153            local_path: String::new(),
154            pasted: String::new(),
155            download: None,
156            importing: false,
157            action: None,
158            panel_params: Default::default(),
159            panel_hits: Default::default(),
160            ui_package_stamp: String::new(),
161            synced: Default::default(),
162            active_document: 0,
163            pending_install: None,
164            exports: Vec::new(),
165        }
166    }
167
168    pub fn sync_documents(&mut self, docs: &mut crate::document::Documents) {
169        let packages = self.packages.borrow();
170        let stamp = packages
171            .iter()
172            .map(|p| format!("{}:{}:{}", p.id, p.digest, p.enabled))
173            .collect::<Vec<_>>()
174            .join("|");
175        if self.ui_package_stamp != stamp { self.panel_params.clear(); self.action = None; self.ui_package_stamp = stamp.clone(); }
176        if self.active_document != docs.active_id() { self.panel_params.clear(); self.action = None; }
177        self.active_document = docs.active_id();
178        self.synced
179            .retain(|id, _| docs.iter().any(|d| d.id() == *id));
180        for doc in docs.iter_mut() {
181            if self
182                .pending_install
183                .as_ref()
184                .is_some_and(|p| p.0 == doc.id())
185            {
186                continue;
187            }
188            let status = doc.engine.plugin_installation_status();
189            if status["state"] == "error" {
190                self.error = status["error"].as_str().map(str::to_owned);
191            }
192            if status["state"] == "pending" {
193                continue;
194            }
195            if self.synced.get(&doc.id()) != Some(&stamp) {
196                let expected: Vec<_> = packages
197                    .iter()
198                    .map(|p| json!([p.id, p.digest, p.enabled]))
199                    .collect();
200                let actual: Vec<_> = doc
201                    .engine
202                    .installed_plugins()
203                    .as_array()
204                    .into_iter()
205                    .flatten()
206                    .map(|p| json!([p["id"], p["digest"], p["enabled"]]))
207                    .collect();
208                if expected == actual {
209                    self.synced.insert(doc.id(), stamp.clone());
210                    continue;
211                }
212                match restore(&mut doc.engine, &packages) {
213                    Ok(_) => {
214                        self.synced.insert(doc.id(), stamp.clone());
215                    }
216                    Err(e) => {
217                        self.error = Some(e);
218                        self.synced.insert(doc.id(), stamp.clone());
219                    }
220                }
221            }
222        }
223    }
224
225    pub fn close_action(&mut self) {
226        self.action = None;
227    }
228
229    fn persist(&self, store: &dyn ModelStore, next: &[InstalledBundle]) -> Result<(), String> {
230        store.write(
231            PLUGINS_KEY,
232            &serde_json::to_string(next).map_err(|e| e.to_string())?,
233        )
234    }
235
236    pub fn install(
237        &mut self,
238        engine: &mut EngineState,
239        _store: &dyn ModelStore,
240        bytes: &[u8],
241        source: Option<String>,
242    ) -> Result<u64, String> {
243        let (bytes, bundle) = parse_bundle(bytes)?;
244        let typed: brep_plugins::PackageBundle =
245            serde_json::from_value(bundle).map_err(|e| e.to_string())?;
246        let pin = typed.pin()?;
247        let mut next = self.packages.borrow().clone();
248        next.retain(|p| !(p.id == pin.id && p.digest == pin.digest));
249        next.push(InstalledBundle {
250            bytes,
251            enabled: true,
252            id: pin.id,
253            digest: pin.digest,
254            version: pin.version,
255            source,
256        });
257        self.queue_install(engine, next)
258    }
259
260    fn queue_install(
261        &mut self,
262        engine: &mut EngineState,
263        next: Vec<InstalledBundle>,
264    ) -> Result<u64, String> {
265        if self.pending_install.is_some() {
266            return Err("A plugin installation is already pending".into());
267        }
268        let id = restore(engine, &next)?;
269        self.pending_install = Some((self.active_document, id, next));
270        self.error = None;
271        self.storage_error = None;
272        self.message = Some("Validating plugin registrations in the history worker…".into());
273        Ok(id)
274    }
275
276    pub fn finish_installations(
277        &mut self,
278        docs: &mut crate::document::Documents,
279        store: &dyn ModelStore,
280    ) {
281        let Some((owner, id, _)) = &self.pending_install else {
282            return;
283        };
284        let Some(doc) = docs.iter_mut().find(|d| d.id() == *owner) else {
285            self.pending_install = None;
286            self.error = Some("Installation cancelled because its document was closed".into());
287            return;
288        };
289        let status = doc.engine.plugin_installation_status();
290        if status["id"].as_u64() != Some(*id) {
291            return;
292        }
293        match status["state"].as_str() {
294            Some("complete") => {
295                let (_, _, next) = self.pending_install.take().unwrap();
296                match self.persist(store, &next) {
297                    Ok(()) => {
298                        *self.packages.borrow_mut() = next;
299                        self.message = Some(
300                            "Installed exact package bytes. Existing document pins stay unchanged."
301                                .into(),
302                        );
303                    }
304                    Err(e) => {
305                        self.storage_error = Some(e.clone());
306                        self.error = Some(e);
307                        let _ = restore(&mut doc.engine, &self.packages.borrow());
308                    }
309                }
310            }
311            Some("error") => {
312                self.error = Some(
313                    status["error"]
314                        .as_str()
315                        .unwrap_or("Plugin installation failed")
316                        .into(),
317                );
318                self.pending_install = None;
319            }
320            _ => {}
321        }
322    }
323
324    pub fn open_action(&mut self, engine: &EngineState, id: &str) -> Result<(), String> {
325        if self.action.as_ref().is_some_and(|a| a.pending) {
326            return Err("Wait for or cancel the pending action before opening another".into());
327        }
328        let catalogue = engine.plugin_catalogue();
329        let action = catalogue["actions"]
330            .as_array()
331            .and_then(|a| a.iter().find(|a| a["id"] == id))
332            .ok_or_else(|| format!("Plugin action {id} is unavailable"))?;
333        let schema = action["inputParamsSchema"].clone();
334        let mut params = json!({});
335        if let Some(fields) = schema.as_object() {
336            for (key, field) in fields {
337                if let Some(default) = field.get("default_value") {
338                    params[key] = default.clone();
339                }
340            }
341        }
342        self.action = Some(ActionForm {
343            id: id.into(),
344            package_digest: action["package"]["digest"].clone(),
345            label: action["ribbonPath"].as_str().and_then(|p| p.rsplit('/').next()).or(action["label"].as_str()).unwrap_or(id).into(),
346            schema,
347            params,
348            error: None,
349            pending: false,
350        });
351        Ok(())
352    }
353
354    pub fn poll(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
355        if self.importing {
356            if let Some(file) = store.take_import() {
357                self.importing = false;
358                if let Err(e) = self.install(engine, store, &file.bytes, None) {
359                    self.error = Some(e);
360                }
361            }
362        }
363        let result = self.download.as_ref().and_then(|d| d.rx.try_recv().ok());
364        if let Some(result) = result {
365            let download = self.download.take().unwrap();
366            match result {
367                Err(e) => self.error = Some(e),
368                Ok(bytes) => {
369                    if let Some((owner, repo, path)) = download.github {
370                        let commit = serde_json::from_slice::<Value>(&bytes)
371                            .ok()
372                            .and_then(|v| v["sha"].as_str().map(str::to_owned));
373                        match commit.filter(|sha| {
374                            sha.len() == 40 && sha.bytes().all(|c| c.is_ascii_hexdigit())
375                        }) {
376                            Some(sha) => {
377                                let source = format!(
378                                    "https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path}"
379                                );
380                                self.download = Some(Download {
381                                    rx: crate::http::fetch_bytes(ctx, source.clone()),
382                                    source,
383                                    github: None,
384                                });
385                            }
386                            None => {
387                                self.error =
388                                    Some("GitHub did not return an immutable commit SHA".into())
389                            }
390                        }
391                    } else if let Err(e) =
392                        self.install(engine, store, &bytes, Some(download.source))
393                    {
394                        self.error = Some(e);
395                    }
396                }
397            }
398        }
399        if let Some(result) = engine.poll_plugin_action() {
400            match result {
401                Ok(value) => {
402                    self.message = Some(value.to_string());
403                    if let Some(form) = &mut self.action {
404                        form.pending = false;
405                    }
406                }
407                Err(e) => {
408                    self.error = Some(e.clone());
409                    if let Some(form) = &mut self.action {
410                        form.error = Some(e);
411                        form.pending = false;
412                    }
413                }
414            }
415        }
416        if self.download.is_some()
417            || self.pending_install.is_some()
418            || engine.plugin_installation_status()["state"] == "pending"
419            || self.action.as_ref().is_some_and(|a| a.pending)
420        {
421            ctx.request_repaint_after(std::time::Duration::from_millis(30));
422        }
423    }
424
425    /// The bundles Export asked to save, once: the shell hands them to
426    /// [`crate::panels::file::FileDialog::request_save_file`].
427    pub fn take_save_requests(&mut self) -> Vec<crate::panels::file::SaveRequest> {
428        std::mem::take(&mut self.exports)
429    }
430
431    pub fn show(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
432        let mut open = self.open;
433        egui::Window::new("Plugins").open(&mut open).default_width(560.0).default_pos(crate::panels::below_chrome(ctx, 400.0, 0.0)).show(ctx, |ui| {
434            ui.label("Install trusted JavaScript package bundles. Code runs with the CAD host API; installation is separate from opening a document.");
435            if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
436            if let Some(message) = &self.message { ui.label(message); }
437            if engine.plugin_installation_status()["state"] == "pending" { ui.spinner(); ui.label("Validating package registrations…"); }
438            for error in engine.plugin_catalogue()["errors"].as_array().into_iter().flatten() { if let Some(error) = error.as_str() { ui.colored_label(egui::Color32::LIGHT_RED, error); } }
439            ui.horizontal(|ui| {
440                // Labelled like the local path row below it; the field said nothing (lane U item 15).
441                ui.label("Package URL");
442                ui.add(egui::TextEdit::singleline(&mut self.url).hint_text("https://… or a github.com repository"));
443                if ui.add_enabled(self.download.is_none(), egui::Button::new("Install URL / GitHub")).clicked() {
444                    match acquisition(&self.url) {
445                        Ok(Acquisition::Direct(source)) => self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: None }),
446                        Ok(Acquisition::Github { owner, repo, revision, path }) => { let source = format!("https://api.github.com/repos/{owner}/{repo}/commits/{revision}"); self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: Some((owner, repo, path)) }); },
447                        Err(e) => self.error = Some(e),
448                    }
449                }
450            });
451            if self.download.is_some() { ui.spinner(); ui.label("Acquiring pinned package bytes…"); }
452            if store.supports_file_interchange() && ui.button("Import local bundle…").clicked() {
453                match store.begin_import_filtered(("Plugin bundle", &["json"])) { Ok(()) => self.importing = true, Err(e) => self.error = Some(e) }
454            }
455            #[cfg(not(target_arch = "wasm32"))]
456            ui.horizontal(|ui| { ui.label("Local bundle path"); ui.text_edit_singleline(&mut self.local_path); if ui.button("Import").clicked() { let result = std::fs::read(&self.local_path).map_err(|e| e.to_string()).and_then(|bytes| self.install(engine, store, &bytes, None)); if let Err(e) = result { self.error = Some(e); } } });
457            ui.collapsing("Paste portable bundle JSON", |ui| { ui.add(egui::TextEdit::multiline(&mut self.pasted).code_editor().desired_rows(6)); if ui.button("Install pasted bundle").clicked() { let bytes = self.pasted.as_bytes().to_vec(); if let Err(e) = self.install(engine, store, &bytes, None) { self.error = Some(e); } } });
458            ui.separator();
459            let installed = self.packages.borrow().clone();
460            for package in &installed {
461                ui.group(|ui| {
462                    ui.label(format!("{}  {}", package.id, package.version));
463                    ui.small(format!("SHA-256 {}", package.digest));
464                    if let Some(source) = &package.source { ui.small(source); }
465                    ui.horizontal_wrapped(|ui| {
466                        let mut enabled = package.enabled;
467                        if ui.checkbox(&mut enabled, "Enabled").changed() { self.change(engine, store, package, Some(enabled)); }
468                        if ui.button("Use in document").clicked() { if let Err(e) = engine.select_plugin(&package.id, &package.digest) { self.error = Some(e); } }
469                        if ui.button("Migrate document").clicked() { if let Err(e) = engine.migrate_plugin(&package.id, &package.digest) { self.error = Some(e); } }
470                        if ui.button("Reload stored bytes").clicked() { if let Err(e) = self.queue_install(engine, installed.clone()) { self.error = Some(e); } }
471                        if ui.button("Export").clicked() { self.exports.push(crate::panels::file::SaveRequest::text("Export the plugin bundle", format!("{}-{}.brep-plugin.json", package.id.replace('/', "_"), package.version), package.bytes.clone())); }
472                        if ui.button("Remove").clicked() { self.change(engine, store, package, None); }
473                    });
474                });
475            }
476            ui.small("Updates keep old bundles. Reload revalidates stored bytes; import again to load edited source. Panels appear in their claimed workbench; typed annotations are authored in PMI.");
477        });
478        self.open = open;
479        self.show_action(ctx, engine);
480    }
481
482    pub(crate) fn change(
483        &mut self,
484        engine: &mut EngineState,
485        _store: &dyn ModelStore,
486        package: &InstalledBundle,
487        enabled: Option<bool>,
488    ) {
489        let result = (|| {
490            let mut next = self.packages.borrow().clone();
491            if let Some(enabled) = enabled {
492                for p in &mut next {
493                    if p.id == package.id && p.digest == package.digest {
494                        p.enabled = enabled;
495                    }
496                }
497            } else {
498                next.retain(|p| !(p.id == package.id && p.digest == package.digest));
499            }
500            self.queue_install(engine, next)?;
501            self.action = None;
502            Ok::<_, String>(())
503        })();
504        if let Err(e) = result {
505            self.error = Some(e);
506        }
507    }
508
509    pub fn clear_panel_hits(&mut self) { self.panel_hits.clear(); }
510    pub fn panel_hits_json(&self) -> String { crate::automation::hit_rects::hits_json(&self.panel_hits) }
511
512    /// Draw validated static controls. Only button clicks submit worker actions.
513    pub fn show_panel(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, id: &str) {
514        let catalogue = engine.plugin_catalogue();
515        let Some(panel) = catalogue["panels"].as_array().into_iter().flatten().find(|p| p["id"] == id) else {
516            ui.label("Panel owner is unavailable for this document."); return;
517        };
518        let pending = engine.plugin_action_status()["state"] == "pending";
519        egui::ScrollArea::vertical().id_salt(("plugin-panel", id)).show(ui, |ui| {
520            for (index, control) in panel["controls"].as_array().into_iter().flatten().enumerate() {
521                ui.push_id((id, index), |ui| {
522                    match control["type"].as_str().unwrap_or("") {
523                        "text" => { ui.label(control["text"].as_str().unwrap_or("")); }
524                        "table" => { egui::Grid::new("table").striped(true).show(ui, |ui| {
525                            for column in control["columns"].as_array().into_iter().flatten() { ui.strong(column.as_str().unwrap_or("")); } ui.end_row();
526                            for row in control["rows"].as_array().into_iter().flatten() {
527                                for cell in row.as_array().into_iter().flatten() { ui.label(cell.as_str().unwrap_or("")); } ui.end_row();
528                            }
529                        }); }
530                        "action" | "form" => {
531                            let action_id = control["action"].as_str().unwrap_or("");
532                            let Some(action) = catalogue["actions"].as_array().into_iter().flatten().find(|a| a["id"] == action_id) else {
533                                ui.label("Action owner unavailable"); return;
534                            };
535                            let key = format!("{}:{}:{}", panel["package"]["digest"], id, control["id"]);
536                            let params = self.panel_params.entry(key).or_insert_with(|| {
537                                let mut params = schema_defaults(&action["inputParamsSchema"]);
538                                if let Some(overrides) = control["params"].as_object() { params.as_object_mut().unwrap().extend(overrides.clone()); } params
539                            });
540                            ui.add_enabled_ui(!pending && engine.history.locked().is_none(), |ui| {
541                                if control["type"] == "form" { schema_fields(ui, engine, &action["inputParamsSchema"], params); }
542                                let button = ui.button(control["label"].as_str().unwrap_or(action_id));
543                                self.panel_hits.insert(format!("panel:{id}:{}", control["id"].as_str().unwrap_or("")), button.rect);
544                                if button.clicked() {
545                                    let selection = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
546                                    match engine.plugin_action(action_id, params.clone(), selection) {
547                                        Ok(_) => { self.error = None; ui.ctx().request_repaint(); }
548                                        Err(e) => self.error = Some(e),
549                                    }
550                                }
551                            });
552                        }
553                        _ => { ui.label("Unsupported panel control"); }
554                    }
555                });
556            }
557            if pending { ui.spinner(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); }
558            if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
559        });
560    }
561
562    fn show_action(&mut self, ctx: &egui::Context, engine: &mut EngineState) {
563        let Some(form) = &mut self.action else { return };
564        if !engine.plugin_catalogue()["actions"].as_array().into_iter().flatten().any(|a| a["id"] == form.id && a["package"]["digest"] == form.package_digest) {
565            self.action = None; return;
566        }
567        let mut open = true;
568        egui::Window::new(&form.label)
569            .id(egui::Id::new("plugin-action-form"))
570            .open(&mut open)
571            .show(ctx, |ui| {
572                ui.small(&form.id);
573                if let Some(error) = &form.error {
574                    ui.colored_label(egui::Color32::LIGHT_RED, error);
575                }
576                ui.add_enabled_ui(!form.pending, |ui| {
577                    schema_fields(ui, engine, &form.schema, &mut form.params);
578                    if ui.button("Run action").clicked() {
579                        match engine.plugin_action(
580                            &form.id,
581                            form.params.clone(),
582                            serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null),
583                        ) {
584                            Ok(_) => {
585                                form.pending = true;
586                                form.error = None;
587                            }
588                            Err(e) => form.error = Some(e),
589                        }
590                    }
591                });
592                if form.pending {
593                    ui.spinner();
594                    if ui.button("Cancel").clicked() {
595                        engine.cancel_plugin_action();
596                        form.pending = false;
597                    }
598                }
599            });
600        if !open {
601            if form.pending {
602                engine.cancel_plugin_action();
603            }
604            self.action = None;
605        }
606    }
607}
608
609pub(crate) fn schema_defaults(schema: &Value) -> Value {
610    let mut params = json!({});
611    for (key, field) in schema.as_object().into_iter().flatten() {
612        if let Some(default) = field.get("default_value") { params[key] = default.clone(); }
613    }
614    params
615}
616
617pub(crate) fn schema_fields(ui: &mut egui::Ui, engine: &EngineState, schema: &Value, params: &mut Value) {
618    let entry = json!({"inputParamsSchema": schema});
619    let fields = brep_render::features::plugin_form_fields_from_schema(&entry);
620    let selection: Value = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
621    for field in &fields {
622        ui.label(&field.label);
623        let mut actions = crate::form::FieldActions::default();
624        crate::form::field_input(ui, field, params, None, &mut actions);
625        if actions.clicked.is_some() {
626            if let brep_render::style::FieldKind::Reference { filter, multiple } = &field.kind {
627                let names: Vec<Value> = filter.iter().flat_map(|kind| {
628                    let key = match kind.to_ascii_lowercase().as_str() {
629                        "solid" => "solids", "face" => "faces", "edge" => "edges", "datum" => "datums", _ => "",
630                    };
631                    selection[key].as_array().into_iter().flatten().cloned()
632                }).collect();
633                params[field.key()] = if *multiple { Value::Array(names) } else { names.into_iter().next().unwrap_or(Value::Null) };
634            }
635        }
636        if matches!(&field.kind, brep_render::style::FieldKind::Reference { .. }) { ui.small("Select uses the current viewport selection."); }
637    }
638}
639
640pub fn feature_label(engine: &EngineState, ty: &str) -> String {
641    if !ty.contains('/') {
642        return brep_render::features::feature_plain_name(ty);
643    }
644    engine
645        .feature_schema(ty)
646        .and_then(|s| s["longName"].as_str().map(str::to_owned))
647        .unwrap_or_else(|| ty.to_owned())
648}
649
650
651pub const HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[crate::automation::hit_keys::HitKeyDoc { panel: "plugins", prefix: "panel:", meaning: "submit the named action for a declarative panel control", command: Some("plugin_action") }];