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}
120struct ActionForm {
121    id: String,
122    package_digest: Value,
123    label: String,
124    schema: Value,
125    params: Value,
126    error: Option<String>,
127    pending: bool,
128}
129
130impl PluginsPanel {
131    pub fn new(store: &dyn ModelStore) -> Self {
132        let (packages, error) = match store.read(PLUGINS_KEY) {
133            Some(text) => match serde_json::from_str::<Vec<InstalledBundle>>(&text) {
134                Ok(v) => (v, None),
135                Err(e) => (
136                    vec![],
137                    Some(format!("Installed plugins could not be read: {e}")),
138                ),
139            },
140            None => (vec![], None),
141        };
142        Self {
143            open: false,
144            packages: Rc::new(RefCell::new(packages)),
145            error,
146            message: None,
147            storage_error: None,
148            url: String::new(),
149            #[cfg(not(target_arch = "wasm32"))]
150            local_path: String::new(),
151            pasted: String::new(),
152            download: None,
153            importing: false,
154            action: None,
155            panel_params: Default::default(),
156            panel_hits: Default::default(),
157            ui_package_stamp: String::new(),
158            synced: Default::default(),
159            active_document: 0,
160            pending_install: None,
161        }
162    }
163
164    pub fn sync_documents(&mut self, docs: &mut crate::document::Documents) {
165        let packages = self.packages.borrow();
166        let stamp = packages
167            .iter()
168            .map(|p| format!("{}:{}:{}", p.id, p.digest, p.enabled))
169            .collect::<Vec<_>>()
170            .join("|");
171        if self.ui_package_stamp != stamp { self.panel_params.clear(); self.action = None; self.ui_package_stamp = stamp.clone(); }
172        if self.active_document != docs.active_id() { self.panel_params.clear(); self.action = None; }
173        self.active_document = docs.active_id();
174        self.synced
175            .retain(|id, _| docs.iter().any(|d| d.id() == *id));
176        for doc in docs.iter_mut() {
177            if self
178                .pending_install
179                .as_ref()
180                .is_some_and(|p| p.0 == doc.id())
181            {
182                continue;
183            }
184            let status = doc.engine.plugin_installation_status();
185            if status["state"] == "error" {
186                self.error = status["error"].as_str().map(str::to_owned);
187            }
188            if status["state"] == "pending" {
189                continue;
190            }
191            if self.synced.get(&doc.id()) != Some(&stamp) {
192                let expected: Vec<_> = packages
193                    .iter()
194                    .map(|p| json!([p.id, p.digest, p.enabled]))
195                    .collect();
196                let actual: Vec<_> = doc
197                    .engine
198                    .installed_plugins()
199                    .as_array()
200                    .into_iter()
201                    .flatten()
202                    .map(|p| json!([p["id"], p["digest"], p["enabled"]]))
203                    .collect();
204                if expected == actual {
205                    self.synced.insert(doc.id(), stamp.clone());
206                    continue;
207                }
208                match restore(&mut doc.engine, &packages) {
209                    Ok(_) => {
210                        self.synced.insert(doc.id(), stamp.clone());
211                    }
212                    Err(e) => {
213                        self.error = Some(e);
214                        self.synced.insert(doc.id(), stamp.clone());
215                    }
216                }
217            }
218        }
219    }
220
221    pub fn close_action(&mut self) {
222        self.action = None;
223    }
224
225    fn persist(&self, store: &dyn ModelStore, next: &[InstalledBundle]) -> Result<(), String> {
226        store.write(
227            PLUGINS_KEY,
228            &serde_json::to_string(next).map_err(|e| e.to_string())?,
229        )
230    }
231
232    pub fn install(
233        &mut self,
234        engine: &mut EngineState,
235        _store: &dyn ModelStore,
236        bytes: &[u8],
237        source: Option<String>,
238    ) -> Result<u64, String> {
239        let (bytes, bundle) = parse_bundle(bytes)?;
240        let typed: brep_plugins::PackageBundle =
241            serde_json::from_value(bundle).map_err(|e| e.to_string())?;
242        let pin = typed.pin()?;
243        let mut next = self.packages.borrow().clone();
244        next.retain(|p| !(p.id == pin.id && p.digest == pin.digest));
245        next.push(InstalledBundle {
246            bytes,
247            enabled: true,
248            id: pin.id,
249            digest: pin.digest,
250            version: pin.version,
251            source,
252        });
253        self.queue_install(engine, next)
254    }
255
256    fn queue_install(
257        &mut self,
258        engine: &mut EngineState,
259        next: Vec<InstalledBundle>,
260    ) -> Result<u64, String> {
261        if self.pending_install.is_some() {
262            return Err("A plugin installation is already pending".into());
263        }
264        let id = restore(engine, &next)?;
265        self.pending_install = Some((self.active_document, id, next));
266        self.error = None;
267        self.storage_error = None;
268        self.message = Some("Validating plugin registrations in the history worker…".into());
269        Ok(id)
270    }
271
272    pub fn finish_installations(
273        &mut self,
274        docs: &mut crate::document::Documents,
275        store: &dyn ModelStore,
276    ) {
277        let Some((owner, id, _)) = &self.pending_install else {
278            return;
279        };
280        let Some(doc) = docs.iter_mut().find(|d| d.id() == *owner) else {
281            self.pending_install = None;
282            self.error = Some("Installation cancelled because its document was closed".into());
283            return;
284        };
285        let status = doc.engine.plugin_installation_status();
286        if status["id"].as_u64() != Some(*id) {
287            return;
288        }
289        match status["state"].as_str() {
290            Some("complete") => {
291                let (_, _, next) = self.pending_install.take().unwrap();
292                match self.persist(store, &next) {
293                    Ok(()) => {
294                        *self.packages.borrow_mut() = next;
295                        self.message = Some(
296                            "Installed exact package bytes. Existing document pins stay unchanged."
297                                .into(),
298                        );
299                    }
300                    Err(e) => {
301                        self.storage_error = Some(e.clone());
302                        self.error = Some(e);
303                        let _ = restore(&mut doc.engine, &self.packages.borrow());
304                    }
305                }
306            }
307            Some("error") => {
308                self.error = Some(
309                    status["error"]
310                        .as_str()
311                        .unwrap_or("Plugin installation failed")
312                        .into(),
313                );
314                self.pending_install = None;
315            }
316            _ => {}
317        }
318    }
319
320    pub fn open_action(&mut self, engine: &EngineState, id: &str) -> Result<(), String> {
321        if self.action.as_ref().is_some_and(|a| a.pending) {
322            return Err("Wait for or cancel the pending action before opening another".into());
323        }
324        let catalogue = engine.plugin_catalogue();
325        let action = catalogue["actions"]
326            .as_array()
327            .and_then(|a| a.iter().find(|a| a["id"] == id))
328            .ok_or_else(|| format!("Plugin action {id} is unavailable"))?;
329        let schema = action["inputParamsSchema"].clone();
330        let mut params = json!({});
331        if let Some(fields) = schema.as_object() {
332            for (key, field) in fields {
333                if let Some(default) = field.get("default_value") {
334                    params[key] = default.clone();
335                }
336            }
337        }
338        self.action = Some(ActionForm {
339            id: id.into(),
340            package_digest: action["package"]["digest"].clone(),
341            label: action["ribbonPath"].as_str().and_then(|p| p.rsplit('/').next()).or(action["label"].as_str()).unwrap_or(id).into(),
342            schema,
343            params,
344            error: None,
345            pending: false,
346        });
347        Ok(())
348    }
349
350    pub fn poll(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
351        if self.importing {
352            if let Some(file) = store.take_import() {
353                self.importing = false;
354                if let Err(e) = self.install(engine, store, &file.bytes, None) {
355                    self.error = Some(e);
356                }
357            }
358        }
359        let result = self.download.as_ref().and_then(|d| d.rx.try_recv().ok());
360        if let Some(result) = result {
361            let download = self.download.take().unwrap();
362            match result {
363                Err(e) => self.error = Some(e),
364                Ok(bytes) => {
365                    if let Some((owner, repo, path)) = download.github {
366                        let commit = serde_json::from_slice::<Value>(&bytes)
367                            .ok()
368                            .and_then(|v| v["sha"].as_str().map(str::to_owned));
369                        match commit.filter(|sha| {
370                            sha.len() == 40 && sha.bytes().all(|c| c.is_ascii_hexdigit())
371                        }) {
372                            Some(sha) => {
373                                let source = format!(
374                                    "https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path}"
375                                );
376                                self.download = Some(Download {
377                                    rx: crate::http::fetch_bytes(ctx, source.clone()),
378                                    source,
379                                    github: None,
380                                });
381                            }
382                            None => {
383                                self.error =
384                                    Some("GitHub did not return an immutable commit SHA".into())
385                            }
386                        }
387                    } else if let Err(e) =
388                        self.install(engine, store, &bytes, Some(download.source))
389                    {
390                        self.error = Some(e);
391                    }
392                }
393            }
394        }
395        if let Some(result) = engine.poll_plugin_action() {
396            match result {
397                Ok(value) => {
398                    self.message = Some(value.to_string());
399                    if let Some(form) = &mut self.action {
400                        form.pending = false;
401                    }
402                }
403                Err(e) => {
404                    self.error = Some(e.clone());
405                    if let Some(form) = &mut self.action {
406                        form.error = Some(e);
407                        form.pending = false;
408                    }
409                }
410            }
411        }
412        if self.download.is_some()
413            || self.pending_install.is_some()
414            || engine.plugin_installation_status()["state"] == "pending"
415            || self.action.as_ref().is_some_and(|a| a.pending)
416        {
417            ctx.request_repaint_after(std::time::Duration::from_millis(30));
418        }
419    }
420
421    pub fn show(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
422        let mut open = self.open;
423        egui::Window::new("Plugins").open(&mut open).default_width(560.0).show(ctx, |ui| {
424            ui.label("Install trusted JavaScript package bundles. Code runs with the CAD host API; installation is separate from opening a document.");
425            if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
426            if let Some(message) = &self.message { ui.label(message); }
427            if engine.plugin_installation_status()["state"] == "pending" { ui.spinner(); ui.label("Validating package registrations…"); }
428            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); } }
429            ui.horizontal(|ui| {
430                ui.text_edit_singleline(&mut self.url);
431                if ui.add_enabled(self.download.is_none(), egui::Button::new("Install URL / GitHub")).clicked() {
432                    match acquisition(&self.url) {
433                        Ok(Acquisition::Direct(source)) => self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: None }),
434                        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)) }); },
435                        Err(e) => self.error = Some(e),
436                    }
437                }
438            });
439            if self.download.is_some() { ui.spinner(); ui.label("Acquiring pinned package bytes…"); }
440            if store.supports_file_interchange() && ui.button("Import local bundle…").clicked() {
441                match store.begin_import_filtered(("Plugin bundle", &["json"])) { Ok(()) => self.importing = true, Err(e) => self.error = Some(e) }
442            }
443            #[cfg(not(target_arch = "wasm32"))]
444            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); } } });
445            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); } } });
446            ui.separator();
447            let installed = self.packages.borrow().clone();
448            for package in &installed {
449                ui.group(|ui| {
450                    ui.label(format!("{}  {}", package.id, package.version));
451                    ui.small(format!("SHA-256 {}", package.digest));
452                    if let Some(source) = &package.source { ui.small(source); }
453                    ui.horizontal_wrapped(|ui| {
454                        let mut enabled = package.enabled;
455                        if ui.checkbox(&mut enabled, "Enabled").changed() { self.change(engine, store, package, Some(enabled)); }
456                        if ui.button("Use in document").clicked() { if let Err(e) = engine.select_plugin(&package.id, &package.digest) { self.error = Some(e); } }
457                        if ui.button("Migrate document").clicked() { if let Err(e) = engine.migrate_plugin(&package.id, &package.digest) { self.error = Some(e); } }
458                        if ui.button("Reload stored bytes").clicked() { if let Err(e) = self.queue_install(engine, installed.clone()) { self.error = Some(e); } }
459                        if ui.button("Export").clicked() { if let Err(e) = store.export_file_named(&format!("{}-{}.brep-plugin.json", package.id.replace('/', "_"), package.version), &package.bytes) { self.error = Some(e); } }
460                        if ui.button("Remove").clicked() { self.change(engine, store, package, None); }
461                    });
462                });
463            }
464            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.");
465        });
466        self.open = open;
467        self.show_action(ctx, engine);
468    }
469
470    pub(crate) fn change(
471        &mut self,
472        engine: &mut EngineState,
473        _store: &dyn ModelStore,
474        package: &InstalledBundle,
475        enabled: Option<bool>,
476    ) {
477        let result = (|| {
478            let mut next = self.packages.borrow().clone();
479            if let Some(enabled) = enabled {
480                for p in &mut next {
481                    if p.id == package.id && p.digest == package.digest {
482                        p.enabled = enabled;
483                    }
484                }
485            } else {
486                next.retain(|p| !(p.id == package.id && p.digest == package.digest));
487            }
488            self.queue_install(engine, next)?;
489            self.action = None;
490            Ok::<_, String>(())
491        })();
492        if let Err(e) = result {
493            self.error = Some(e);
494        }
495    }
496
497    pub fn clear_panel_hits(&mut self) { self.panel_hits.clear(); }
498    pub fn panel_hits_json(&self) -> String { crate::automation::hit_rects::hits_json(&self.panel_hits) }
499
500    /// Draw validated static controls. Only button clicks submit worker actions.
501    pub fn show_panel(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, id: &str) {
502        let catalogue = engine.plugin_catalogue();
503        let Some(panel) = catalogue["panels"].as_array().into_iter().flatten().find(|p| p["id"] == id) else {
504            ui.label("Panel owner is unavailable for this document."); return;
505        };
506        let pending = engine.plugin_action_status()["state"] == "pending";
507        egui::ScrollArea::vertical().id_salt(("plugin-panel", id)).show(ui, |ui| {
508            for (index, control) in panel["controls"].as_array().into_iter().flatten().enumerate() {
509                ui.push_id((id, index), |ui| {
510                    match control["type"].as_str().unwrap_or("") {
511                        "text" => { ui.label(control["text"].as_str().unwrap_or("")); }
512                        "table" => { egui::Grid::new("table").striped(true).show(ui, |ui| {
513                            for column in control["columns"].as_array().into_iter().flatten() { ui.strong(column.as_str().unwrap_or("")); } ui.end_row();
514                            for row in control["rows"].as_array().into_iter().flatten() {
515                                for cell in row.as_array().into_iter().flatten() { ui.label(cell.as_str().unwrap_or("")); } ui.end_row();
516                            }
517                        }); }
518                        "action" | "form" => {
519                            let action_id = control["action"].as_str().unwrap_or("");
520                            let Some(action) = catalogue["actions"].as_array().into_iter().flatten().find(|a| a["id"] == action_id) else {
521                                ui.label("Action owner unavailable"); return;
522                            };
523                            let key = format!("{}:{}:{}", panel["package"]["digest"], id, control["id"]);
524                            let params = self.panel_params.entry(key).or_insert_with(|| {
525                                let mut params = schema_defaults(&action["inputParamsSchema"]);
526                                if let Some(overrides) = control["params"].as_object() { params.as_object_mut().unwrap().extend(overrides.clone()); } params
527                            });
528                            ui.add_enabled_ui(!pending && engine.history.locked().is_none(), |ui| {
529                                if control["type"] == "form" { schema_fields(ui, engine, &action["inputParamsSchema"], params); }
530                                let button = ui.button(control["label"].as_str().unwrap_or(action_id));
531                                self.panel_hits.insert(format!("panel:{id}:{}", control["id"].as_str().unwrap_or("")), button.rect);
532                                if button.clicked() {
533                                    let selection = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
534                                    match engine.plugin_action(action_id, params.clone(), selection) {
535                                        Ok(_) => { self.error = None; ui.ctx().request_repaint(); }
536                                        Err(e) => self.error = Some(e),
537                                    }
538                                }
539                            });
540                        }
541                        _ => { ui.label("Unsupported panel control"); }
542                    }
543                });
544            }
545            if pending { ui.spinner(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); }
546            if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
547        });
548    }
549
550    fn show_action(&mut self, ctx: &egui::Context, engine: &mut EngineState) {
551        let Some(form) = &mut self.action else { return };
552        if !engine.plugin_catalogue()["actions"].as_array().into_iter().flatten().any(|a| a["id"] == form.id && a["package"]["digest"] == form.package_digest) {
553            self.action = None; return;
554        }
555        let mut open = true;
556        egui::Window::new(&form.label)
557            .id(egui::Id::new("plugin-action-form"))
558            .open(&mut open)
559            .show(ctx, |ui| {
560                ui.small(&form.id);
561                if let Some(error) = &form.error {
562                    ui.colored_label(egui::Color32::LIGHT_RED, error);
563                }
564                ui.add_enabled_ui(!form.pending, |ui| {
565                    schema_fields(ui, engine, &form.schema, &mut form.params);
566                    if ui.button("Run action").clicked() {
567                        match engine.plugin_action(
568                            &form.id,
569                            form.params.clone(),
570                            serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null),
571                        ) {
572                            Ok(_) => {
573                                form.pending = true;
574                                form.error = None;
575                            }
576                            Err(e) => form.error = Some(e),
577                        }
578                    }
579                });
580                if form.pending {
581                    ui.spinner();
582                    if ui.button("Cancel").clicked() {
583                        engine.cancel_plugin_action();
584                        form.pending = false;
585                    }
586                }
587            });
588        if !open {
589            if form.pending {
590                engine.cancel_plugin_action();
591            }
592            self.action = None;
593        }
594    }
595}
596
597pub(crate) fn schema_defaults(schema: &Value) -> Value {
598    let mut params = json!({});
599    for (key, field) in schema.as_object().into_iter().flatten() {
600        if let Some(default) = field.get("default_value") { params[key] = default.clone(); }
601    }
602    params
603}
604
605pub(crate) fn schema_fields(ui: &mut egui::Ui, engine: &EngineState, schema: &Value, params: &mut Value) {
606    let entry = json!({"inputParamsSchema": schema});
607    let fields = brep_render::features::plugin_form_fields_from_schema(&entry);
608    let selection: Value = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
609    for field in &fields {
610        ui.label(&field.label);
611        let mut actions = crate::form::FieldActions::default();
612        crate::form::field_input(ui, field, params, None, &mut actions);
613        if actions.clicked.is_some() {
614            if let brep_render::style::FieldKind::Reference { filter, multiple } = &field.kind {
615                let names: Vec<Value> = filter.iter().flat_map(|kind| {
616                    let key = match kind.to_ascii_lowercase().as_str() {
617                        "solid" => "solids", "face" => "faces", "edge" => "edges", "datum" => "datums", _ => "",
618                    };
619                    selection[key].as_array().into_iter().flatten().cloned()
620                }).collect();
621                params[field.key()] = if *multiple { Value::Array(names) } else { names.into_iter().next().unwrap_or(Value::Null) };
622            }
623        }
624        if matches!(&field.kind, brep_render::style::FieldKind::Reference { .. }) { ui.small("Select uses the current viewport selection."); }
625    }
626}
627
628pub fn feature_label(engine: &EngineState, ty: &str) -> String {
629    if !ty.contains('/') {
630        return brep_render::features::feature_plain_name(ty);
631    }
632    engine
633        .feature_schema(ty)
634        .and_then(|s| s["longName"].as_str().map(str::to_owned))
635        .unwrap_or_else(|| ty.to_owned())
636}
637
638
639pub 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") }];