1use 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 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
43pub 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#[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).default_pos(crate::panels::below_chrome(ctx, 400.0, 0.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.label("Package URL");
432 ui.add(egui::TextEdit::singleline(&mut self.url).hint_text("https://… or a github.com repository"));
433 if ui.add_enabled(self.download.is_none(), egui::Button::new("Install URL / GitHub")).clicked() {
434 match acquisition(&self.url) {
435 Ok(Acquisition::Direct(source)) => self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: None }),
436 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)) }); },
437 Err(e) => self.error = Some(e),
438 }
439 }
440 });
441 if self.download.is_some() { ui.spinner(); ui.label("Acquiring pinned package bytes…"); }
442 if store.supports_file_interchange() && ui.button("Import local bundle…").clicked() {
443 match store.begin_import_filtered(("Plugin bundle", &["json"])) { Ok(()) => self.importing = true, Err(e) => self.error = Some(e) }
444 }
445 #[cfg(not(target_arch = "wasm32"))]
446 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); } } });
447 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); } } });
448 ui.separator();
449 let installed = self.packages.borrow().clone();
450 for package in &installed {
451 ui.group(|ui| {
452 ui.label(format!("{} {}", package.id, package.version));
453 ui.small(format!("SHA-256 {}", package.digest));
454 if let Some(source) = &package.source { ui.small(source); }
455 ui.horizontal_wrapped(|ui| {
456 let mut enabled = package.enabled;
457 if ui.checkbox(&mut enabled, "Enabled").changed() { self.change(engine, store, package, Some(enabled)); }
458 if ui.button("Use in document").clicked() { if let Err(e) = engine.select_plugin(&package.id, &package.digest) { self.error = Some(e); } }
459 if ui.button("Migrate document").clicked() { if let Err(e) = engine.migrate_plugin(&package.id, &package.digest) { self.error = Some(e); } }
460 if ui.button("Reload stored bytes").clicked() { if let Err(e) = self.queue_install(engine, installed.clone()) { self.error = Some(e); } }
461 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); } }
462 if ui.button("Remove").clicked() { self.change(engine, store, package, None); }
463 });
464 });
465 }
466 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.");
467 });
468 self.open = open;
469 self.show_action(ctx, engine);
470 }
471
472 pub(crate) fn change(
473 &mut self,
474 engine: &mut EngineState,
475 _store: &dyn ModelStore,
476 package: &InstalledBundle,
477 enabled: Option<bool>,
478 ) {
479 let result = (|| {
480 let mut next = self.packages.borrow().clone();
481 if let Some(enabled) = enabled {
482 for p in &mut next {
483 if p.id == package.id && p.digest == package.digest {
484 p.enabled = enabled;
485 }
486 }
487 } else {
488 next.retain(|p| !(p.id == package.id && p.digest == package.digest));
489 }
490 self.queue_install(engine, next)?;
491 self.action = None;
492 Ok::<_, String>(())
493 })();
494 if let Err(e) = result {
495 self.error = Some(e);
496 }
497 }
498
499 pub fn clear_panel_hits(&mut self) { self.panel_hits.clear(); }
500 pub fn panel_hits_json(&self) -> String { crate::automation::hit_rects::hits_json(&self.panel_hits) }
501
502 pub fn show_panel(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, id: &str) {
504 let catalogue = engine.plugin_catalogue();
505 let Some(panel) = catalogue["panels"].as_array().into_iter().flatten().find(|p| p["id"] == id) else {
506 ui.label("Panel owner is unavailable for this document."); return;
507 };
508 let pending = engine.plugin_action_status()["state"] == "pending";
509 egui::ScrollArea::vertical().id_salt(("plugin-panel", id)).show(ui, |ui| {
510 for (index, control) in panel["controls"].as_array().into_iter().flatten().enumerate() {
511 ui.push_id((id, index), |ui| {
512 match control["type"].as_str().unwrap_or("") {
513 "text" => { ui.label(control["text"].as_str().unwrap_or("")); }
514 "table" => { egui::Grid::new("table").striped(true).show(ui, |ui| {
515 for column in control["columns"].as_array().into_iter().flatten() { ui.strong(column.as_str().unwrap_or("")); } ui.end_row();
516 for row in control["rows"].as_array().into_iter().flatten() {
517 for cell in row.as_array().into_iter().flatten() { ui.label(cell.as_str().unwrap_or("")); } ui.end_row();
518 }
519 }); }
520 "action" | "form" => {
521 let action_id = control["action"].as_str().unwrap_or("");
522 let Some(action) = catalogue["actions"].as_array().into_iter().flatten().find(|a| a["id"] == action_id) else {
523 ui.label("Action owner unavailable"); return;
524 };
525 let key = format!("{}:{}:{}", panel["package"]["digest"], id, control["id"]);
526 let params = self.panel_params.entry(key).or_insert_with(|| {
527 let mut params = schema_defaults(&action["inputParamsSchema"]);
528 if let Some(overrides) = control["params"].as_object() { params.as_object_mut().unwrap().extend(overrides.clone()); } params
529 });
530 ui.add_enabled_ui(!pending && engine.history.locked().is_none(), |ui| {
531 if control["type"] == "form" { schema_fields(ui, engine, &action["inputParamsSchema"], params); }
532 let button = ui.button(control["label"].as_str().unwrap_or(action_id));
533 self.panel_hits.insert(format!("panel:{id}:{}", control["id"].as_str().unwrap_or("")), button.rect);
534 if button.clicked() {
535 let selection = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
536 match engine.plugin_action(action_id, params.clone(), selection) {
537 Ok(_) => { self.error = None; ui.ctx().request_repaint(); }
538 Err(e) => self.error = Some(e),
539 }
540 }
541 });
542 }
543 _ => { ui.label("Unsupported panel control"); }
544 }
545 });
546 }
547 if pending { ui.spinner(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); }
548 if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
549 });
550 }
551
552 fn show_action(&mut self, ctx: &egui::Context, engine: &mut EngineState) {
553 let Some(form) = &mut self.action else { return };
554 if !engine.plugin_catalogue()["actions"].as_array().into_iter().flatten().any(|a| a["id"] == form.id && a["package"]["digest"] == form.package_digest) {
555 self.action = None; return;
556 }
557 let mut open = true;
558 egui::Window::new(&form.label)
559 .id(egui::Id::new("plugin-action-form"))
560 .open(&mut open)
561 .show(ctx, |ui| {
562 ui.small(&form.id);
563 if let Some(error) = &form.error {
564 ui.colored_label(egui::Color32::LIGHT_RED, error);
565 }
566 ui.add_enabled_ui(!form.pending, |ui| {
567 schema_fields(ui, engine, &form.schema, &mut form.params);
568 if ui.button("Run action").clicked() {
569 match engine.plugin_action(
570 &form.id,
571 form.params.clone(),
572 serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null),
573 ) {
574 Ok(_) => {
575 form.pending = true;
576 form.error = None;
577 }
578 Err(e) => form.error = Some(e),
579 }
580 }
581 });
582 if form.pending {
583 ui.spinner();
584 if ui.button("Cancel").clicked() {
585 engine.cancel_plugin_action();
586 form.pending = false;
587 }
588 }
589 });
590 if !open {
591 if form.pending {
592 engine.cancel_plugin_action();
593 }
594 self.action = None;
595 }
596 }
597}
598
599pub(crate) fn schema_defaults(schema: &Value) -> Value {
600 let mut params = json!({});
601 for (key, field) in schema.as_object().into_iter().flatten() {
602 if let Some(default) = field.get("default_value") { params[key] = default.clone(); }
603 }
604 params
605}
606
607pub(crate) fn schema_fields(ui: &mut egui::Ui, engine: &EngineState, schema: &Value, params: &mut Value) {
608 let entry = json!({"inputParamsSchema": schema});
609 let fields = brep_render::features::plugin_form_fields_from_schema(&entry);
610 let selection: Value = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
611 for field in &fields {
612 ui.label(&field.label);
613 let mut actions = crate::form::FieldActions::default();
614 crate::form::field_input(ui, field, params, None, &mut actions);
615 if actions.clicked.is_some() {
616 if let brep_render::style::FieldKind::Reference { filter, multiple } = &field.kind {
617 let names: Vec<Value> = filter.iter().flat_map(|kind| {
618 let key = match kind.to_ascii_lowercase().as_str() {
619 "solid" => "solids", "face" => "faces", "edge" => "edges", "datum" => "datums", _ => "",
620 };
621 selection[key].as_array().into_iter().flatten().cloned()
622 }).collect();
623 params[field.key()] = if *multiple { Value::Array(names) } else { names.into_iter().next().unwrap_or(Value::Null) };
624 }
625 }
626 if matches!(&field.kind, brep_render::style::FieldKind::Reference { .. }) { ui.small("Select uses the current viewport selection."); }
627 }
628}
629
630pub fn feature_label(engine: &EngineState, ty: &str) -> String {
631 if !ty.contains('/') {
632 return brep_render::features::feature_plain_name(ty);
633 }
634 engine
635 .feature_schema(ty)
636 .and_then(|s| s["longName"].as_str().map(str::to_owned))
637 .unwrap_or_else(|| ty.to_owned())
638}
639
640
641pub 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") }];