Skip to main content

brep_app/
javascript.rs

1//! In-app scratch scripts. Source is saved only on request and never on a model.
2use crate::{
3    document::Documents,
4    store::{ModelStore, JAVASCRIPT_DRAFT_KEY},
5};
6use crate::automation::hit_keys::HitKeyDoc;
7use eframe::egui;
8use std::collections::HashMap;
9
10/// The window's egui id — one name for the panel, the tests and the host.
11pub const WINDOW_ID: &str = "brep-javascript-editor";
12
13/// The hit keys this window publishes under `__brepJavascriptHit`
14/// (see `automation::hit_keys`); `panel:clip` is the key every scrolling pane
15/// publishes for its visible region, so `click_widget` can wheel a row into view.
16pub static HIT_KEYS: &[HitKeyDoc] = &[
17    HitKeyDoc { panel: "javascript", prefix: "panel:clip", meaning: "the JavaScript editor window's OUTER rect — its whole visible region, frame included, so the title and grip handles below count as in view", command: None },
18    HitKeyDoc { panel: "javascript", prefix: "title", meaning: "a point on the window's title bar — a drag from it MOVES the window", command: None },
19    HitKeyDoc { panel: "javascript", prefix: "grip", meaning: "a point on the window's bottom-right resize corner — a drag from it RESIZES the window in both axes, up to the app surface", command: None },
20    HitKeyDoc { panel: "javascript", prefix: "run", meaning: "run the script in the active document", command: None },
21    HitKeyDoc { panel: "javascript", prefix: "cancel", meaning: "cancel the running script", command: None },
22    HitKeyDoc { panel: "javascript", prefix: "save", meaning: "save the source as the local draft", command: None },
23    HitKeyDoc { panel: "javascript", prefix: "open", meaning: "open a .js file into the editor (native; file-interchange stores only)", command: None },
24    HitKeyDoc { panel: "javascript", prefix: "export", meaning: "export the source as script.js through the file dialog's export door: the Save-file chooser on desktop, a download in the browser", command: None },
25    HitKeyDoc { panel: "javascript", prefix: "help", meaning: "the CAD API and examples collapsing header", command: None },
26    HitKeyDoc { panel: "javascript", prefix: "example", meaning: "load the box example into the editor (inside `help`)", command: None },
27    HitKeyDoc { panel: "javascript", prefix: "source", meaning: "the source editor's scroll region (the free space of the window: it grows with the window)", command: None },
28    HitKeyDoc { panel: "javascript", prefix: "output", meaning: "the output's scroll region, at the bottom of the window", command: None },
29];
30
31const EXAMPLE: &str = r#"// Run adds a box to the current document. Undo removes the whole run.
32cad.document.addFeature("P.CU", { sizeX: 30, sizeY: 20, sizeZ: 10 });
33console.log("Created a box");
34ui.notify("Edit its dimensions in the feature history");
35return { featuresBefore: cad.document.features.length };
36"#;
37
38/// Where Run sends a program when the app has a script runner: the app's
39/// own server's code mode (`crate::mcp::EditorHost`), the same realm, limits
40/// and generated `brep.<group>.<tool>` functions an agent's `run_script`
41/// gets. Without one (the web build, a bare test harness) Run uses the
42/// plugin runner's editor action.
43pub trait ScriptHost {
44    /// Whether the runner is up; the first call starts it.
45    fn ready(&self) -> Result<bool, String>;
46    /// Submit a program; answers its execution id.
47    fn run(&self, source: &str) -> Result<String, String>;
48    /// The execution's result once it has ended, `None` while it runs.
49    fn result(&self, id: &str) -> Result<Option<serde_json::Value>, String>;
50    /// Halt it: the worker is killed wherever it is.
51    fn halt(&self, id: &str) -> Result<(), String>;
52}
53
54/// A run on the [`ScriptHost`], in its document. Its edits fold into one
55/// undo group, kept when the program succeeds and rolled back otherwise, as
56/// the plugin runner's transaction is.
57enum Script {
58    Starting { doc: u64, program: String },
59    Running { doc: u64, id: String, token: u64 },
60    /// Ended: the group closes once the document's engine is idle, so a
61    /// staged feature still landing folds in (or rolls back) with the rest.
62    Closing { doc: u64, token: u64, rollback: bool, output: String, since: web_time::Instant, idle: u32 },
63}
64
65/// How long a closing run waits for its document to go idle before the
66/// group closes anyway (an edit still landing then stands on its own).
67const CLOSE_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
68
69/// A closing run's document must read idle this many polls running, and
70/// [`CLOSE_SETTLE`] must have passed: a tool call the program issued just
71/// before it ended (or was killed) may still be on its way to the app's
72/// queue, which drains AFTER this poll in the frame, and its edit belongs in
73/// the group.
74const CLOSE_IDLE_POLLS: u32 = 5;
75const CLOSE_SETTLE: std::time::Duration = std::time::Duration::from_millis(250);
76
77/// An editor script as a program for the [`ScriptHost`]. A module (a line
78/// starting `import` or `export`) runs as written. Anything else is a classic
79/// editor body: it runs with `cad.document` (the history snapshot with
80/// `addFeature(type, params, persistentData)`, `updateFeature(id, params)`
81/// (a merge, as `feature_set_params` merges) and `deleteFeature(id)`),
82/// `cad.selection`, `cad.brep` (every app tool) and `ui.notify`; the document
83/// commands apply in order after it returns, and a returned value prints as
84/// `Result:`. The body may `await`.
85pub fn program(source: &str) -> String {
86    if source.lines().any(|l| l.starts_with("import ") || l.starts_with("export ")) {
87        return source.to_string();
88    }
89    format!(
90        r#"import {{ brep }} from "@brep/cad";
91export default async function main() {{
92  const commands = [];
93  const stage = c => {{ if (commands.length >= 256) throw Error("too many document commands"); commands.push(c); }};
94  const snapshot = (await brep.document.doc_json({{}})).document;
95  const selection = (await brep.scene.selection({{}})).selection;
96  const document = Object.freeze({{
97    ...snapshot,
98    addFeature: (type, params = {{}}, persistentData = {{}}) => stage(() => brep.features.feature_add(Object.keys(persistentData).length ? {{ type, params, persistent_data: persistentData }} : {{ type, params }})),
99    updateFeature: (id, params) => stage(() => brep.features.feature_set_params({{ id, patch: params }})),
100    deleteFeature: id => stage(() => brep.history.feature_delete({{ id }})),
101  }});
102  const notify = message => console.log(String(message));
103  const cad = Object.freeze({{ document, selection, params: {{}}, notify, brep }});
104  const ui = Object.freeze({{ notify }});
105  const result = await (async function (cad, ui) {{
106{source}
107  }})(cad, ui);
108  for (const command of commands) await command();
109  if (result !== undefined) console.log("Result:", JSON.stringify(result));
110  return null;
111}}
112"#
113    )
114}
115
116/// What a finished execution shows: the outcome, the console lines, and a
117/// module's returned value.
118fn script_output(result: &serde_json::Value) -> String {
119    let mut lines = vec![match result["state"].as_str() {
120        Some("succeeded") => "Completed.".to_string(),
121        Some("cancelled") => "Cancelled.".to_string(),
122        _ => format!("Failed: {}", result["error"]["message"].as_str().unwrap_or("the script failed")),
123    }];
124    if let Some(log) = result["logText"].as_str().filter(|l| !l.is_empty()) {
125        lines.push(log.to_string());
126    }
127    if !result["output"].is_null() {
128        lines.push(format!("Result: {}", result["output"]));
129    }
130    lines.join("\n")
131}
132
133pub struct JavaScriptEditor {
134    pub open: bool,
135    source: String,
136    output: String,
137    pending: Option<u64>, // originating document, retained across tab switches
138    /// The app's script runner, when it has one (see [`ScriptHost`]).
139    pub host: Option<Box<dyn ScriptHost>>,
140    script: Option<Script>,
141    importing: bool,
142    /// This frame's widget rects (egui points) for the automation host; empty
143    /// while the window is closed.
144    hits: HashMap<String, egui::Rect>,
145    /// The window's outer rect this frame, `None` while it is closed.
146    rect: Option<egui::Rect>,
147    /// The surface the window may fill (`ctx.content_rect()`), for the host.
148    surface: egui::Rect,
149    /// "Export .js…" asked: the shell hands these to the file dialog's export
150    /// door ([`Self::take_save_requests`]), which on desktop asks where.
151    exports: Vec<crate::panels::file::SaveRequest>,
152}
153
154
155impl JavaScriptEditor {
156    pub fn running_in(&self, document: u64) -> bool {
157        self.pending == Some(document)
158    }
159
160    pub fn set_source(&mut self, source: String) {
161        self.source = source;
162    }
163
164    pub fn editing_source(ctx: &egui::Context) -> bool {
165        ctx.memory(|memory| memory.has_focus(egui::Id::new("cad-javascript-source")))
166    }
167
168    pub fn new(store: &dyn ModelStore) -> Self {
169        Self {
170            open: false,
171            source: store
172                .read(JAVASCRIPT_DRAFT_KEY)
173                .unwrap_or_else(|| EXAMPLE.into()),
174            output: String::new(),
175            pending: None,
176            host: None,
177            script: None,
178            importing: false,
179            hits: HashMap::new(),
180            exports: Vec::new(),
181            rect: None,
182            surface: egui::Rect::NOTHING,
183        }
184    }
185
186    /// Logical state for the automation host: `rect` is the window's outer
187    /// rect `[x, y, w, h]` in egui points (null while closed), `surface` the
188    /// screen rect it may fill `[w, h]`.
189    pub fn state_json(&self) -> String {
190        let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
191        serde_json::json!({
192            "open": self.open,
193            "rect": self.rect.map(rect),
194            "surface": [self.surface.width(), self.surface.height()],
195            "running": self.pending.is_some(),
196            "output": self.output,
197        })
198        .to_string()
199    }
200
201    /// The published widget hit-rects (`[x, y, w, h]`, egui points).
202    pub fn hits_json(&self) -> String {
203        crate::automation::hit_rects::hits_json(&self.hits)
204    }
205
206    /// Halt the running script and roll its edits back once its document
207    /// is idle; `output` says why.
208    fn halt_script(&mut self, docs: &mut Documents, output: String) {
209        match self.script.take() {
210            Some(Script::Starting { .. }) => {
211                self.pending = None;
212                self.output = output;
213            }
214            Some(Script::Running { doc, id, token }) => {
215                if let Some(host) = &self.host {
216                    let _ = host.halt(&id);
217                }
218                // Brake a history run the script started, as the app's
219                // cancel button does; a staged feature then rolls back.
220                if let Some(d) = docs.iter_mut().find(|d| d.id() == doc) {
221                    d.engine.cancel_run();
222                }
223                self.script = Some(Script::Closing { doc, token, rollback: true, output, since: web_time::Instant::now(), idle: 0 });
224            }
225            other => self.script = other,
226        }
227    }
228
229    /// One frame of a run on the [`ScriptHost`].
230    fn poll_script(&mut self, ctx: &egui::Context, docs: &mut Documents) {
231        ctx.request_repaint_after(std::time::Duration::from_millis(30));
232        let doc_id = match &self.script {
233            Some(Script::Starting { doc, .. } | Script::Running { doc, .. } | Script::Closing { doc, .. }) => *doc,
234            None => return,
235        };
236        if !docs.iter().any(|d| d.id() == doc_id) {
237            if let (Some(Script::Running { id, .. }), Some(host)) = (&self.script, &self.host) {
238                let _ = host.halt(id);
239            }
240            self.script = None;
241            self.pending = None;
242            self.output = "The script's document was closed.".into();
243            return;
244        }
245        // The app's tools act on the ACTIVE document: a run whose document
246        // is no longer active would write another one.
247        if docs.active_id() != doc_id && matches!(self.script, Some(Script::Starting { .. } | Script::Running { .. })) {
248            return self.halt_script(docs, "Halted: the active document changed while the script ran; its edits were rolled back.".into());
249        }
250        let Some(host) = &self.host else { return };
251        let doc = docs.iter_mut().find(|d| d.id() == doc_id).expect("checked above");
252        match self.script.take() {
253            Some(Script::Starting { doc: d, program }) => match host.ready() {
254                Ok(true) => {
255                    let token = doc.engine.history_group_begin();
256                    match host.run(&program) {
257                        Ok(id) => self.script = Some(Script::Running { doc: d, id, token }),
258                        Err(e) => {
259                            let _ = doc.engine.history_group_end(token, false);
260                            self.pending = None;
261                            self.output = e;
262                        }
263                    }
264                }
265                Ok(false) => self.script = Some(Script::Starting { doc: d, program }),
266                Err(e) => {
267                    self.pending = None;
268                    self.output = e;
269                }
270            },
271            Some(Script::Running { doc: d, id, token }) => match host.result(&id) {
272                Ok(None) => self.script = Some(Script::Running { doc: d, id, token }),
273                Ok(Some(result)) => {
274                    let rollback = result["state"] != "succeeded";
275                    let output = script_output(&result);
276                    self.script = Some(Script::Closing { doc: d, token, rollback, output, since: web_time::Instant::now(), idle: 0 });
277                }
278                Err(e) => self.script = Some(Script::Closing { doc: d, token, rollback: true, output: e, since: web_time::Instant::now(), idle: 0 }),
279            },
280            Some(Script::Closing { doc: d, token, rollback, output, since, idle }) => {
281                doc.engine.pump();
282                let quiet = !doc.engine.run_pending() && !doc.engine.stage_feature_pending();
283                let idle = if quiet { idle + 1 } else { 0 };
284                let settled = idle >= CLOSE_IDLE_POLLS && since.elapsed() >= CLOSE_SETTLE;
285                if !settled && since.elapsed() < CLOSE_WAIT {
286                    self.script = Some(Script::Closing { doc: d, token, rollback, output, since, idle });
287                    return;
288                }
289                self.pending = None;
290                self.output = match doc.engine.history_group_end(token, rollback) {
291                    Ok(true) => format!("{output}\nIts edits were rolled back."),
292                    Ok(false) => output,
293                    Err(e) => format!("{output}\n{e}"),
294                };
295            }
296            None => {}
297        }
298    }
299
300    /// Record a widget's VISIBLE rect for the host.
301    fn hit(&mut self, key: &str, resp: &egui::Response) {
302        self.hits.insert(key.to_string(), resp.interact_rect);
303    }
304
305    pub fn poll(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
306        if self.importing {
307            if let Some(file) = store.take_import() {
308                self.importing = false;
309                match String::from_utf8(file.bytes) {
310                    Ok(source) => self.source = source,
311                    Err(e) => self.output = format!("Cannot open script: {e}"),
312                }
313            }
314        }
315        if self.script.is_some() {
316            return self.poll_script(ctx, docs);
317        }
318        let Some(id) = self.pending else {
319            return;
320        };
321        let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) else {
322            self.pending = None;
323            self.output = "The script's document was closed.".into();
324            return;
325        };
326        doc.engine.pump();
327        let status = doc.engine.plugin_action_status();
328        match status["state"].as_str() {
329            Some("complete") => {
330                let lines: Vec<_> = status["result"]["notifications"]
331                    .as_array()
332                    .into_iter()
333                    .flatten()
334                    .filter_map(|v| v.as_str())
335                    .collect();
336                self.output = format!("Completed.\n{}", lines.join("\n"));
337                self.pending = None;
338            }
339            Some("error") => {
340                self.output = status["error"].as_str().unwrap_or("Script failed").into();
341                self.pending = None;
342            }
343            _ => {
344                ctx.request_repaint_after(std::time::Duration::from_millis(30));
345            }
346        }
347    }
348
349    /// The files "Export .js…" asked to save, once: the shell hands them to
350    /// [`crate::panels::file::FileDialog::request_save_file`].
351    pub fn take_save_requests(&mut self) -> Vec<crate::panels::file::SaveRequest> {
352        std::mem::take(&mut self.exports)
353    }
354
355    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
356        self.hits.clear();
357        self.rect = None;
358        self.surface = ctx.content_rect();
359        let mut open = self.open;
360        let shown = egui::Window::new("JavaScript editor").id(egui::Id::new(WINDOW_ID)).open(&mut open)
361            // Under the toolbar / Ribbon like Settings, Plugins and Info: without
362            // a position egui put it over the menu bar (ui-polish-2026-10-28 row 5).
363            .default_pos(crate::panels::below_chrome(ctx, 16.0, 0.0))
364            .default_size(egui::vec2(720.0, 620.0)).min_size(egui::vec2(420.0, 320.0)).resizable(true).show(ctx, |ui| {
365            ui.label(format!("Run in: {}", docs.active().title()));
366            if let Some(id) = self.pending {
367                if let Some(doc) = docs.iter().find(|doc| doc.id() == id) {
368                    ui.horizontal(|ui| {
369                        ui.spinner();
370                        ui.label(format!("Running in: {}", doc.title()));
371                    });
372                }
373            }
374            ui.horizontal(|ui| {
375                let busy = self.pending.is_some() || docs.engine().run_pending()
376                    || docs.engine().plugin_action_status()["state"] == "pending";
377                let run = ui.add_enabled(!busy, egui::Button::new("Run script"));
378                self.hit("run", &run);
379                // `cad.geometry` (kernel solids in an editor body) is the
380                // plugin runner's; such a script stays there.
381                if run.clicked() && self.host.is_some() && !self.source.contains("cad.geometry") {
382                    self.pending = Some(docs.active_id());
383                    self.script = Some(Script::Starting { doc: docs.active_id(), program: program(&self.source) });
384                    self.output = "Running…".into();
385                } else if run.clicked() {
386                    match docs.engine_mut().run_javascript(&self.source) {
387                        Ok(_) => { self.pending = Some(docs.active_id()); self.output = "Running…".into(); }
388                        Err(e) => self.output = e,
389                    }
390                }
391                let cancel = ui.add_enabled(self.pending.is_some(), egui::Button::new("Cancel"));
392                self.hit("cancel", &cancel);
393                if cancel.clicked() && self.script.is_some() {
394                    self.halt_script(docs, "Cancelled.".into());
395                } else if cancel.clicked() {
396                    if let Some(id) = self.pending {
397                        if let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) {
398                            doc.engine.cancel_plugin_action();
399                        }
400                    }
401                }
402                let save = ui.button("Save draft");
403                self.hit("save", &save);
404                if save.clicked() {
405                    self.output = match store.write(JAVASCRIPT_DRAFT_KEY, &self.source) {
406                        Ok(()) => "Draft saved.".into(), Err(e) => format!("Cannot save draft: {e}"),
407                    };
408                }
409                // Open .js… drives the browser's file input, so only where
410                // there is one. Export leaves through the file dialog's export
411                // door on every platform (the Save-file chooser on desktop),
412                // so it is always offered: gated with Open, desktop had no
413                // way to export a script at all.
414                if store.supports_file_interchange() {
415                    let open = ui.add_enabled(!self.importing, egui::Button::new("Open .js…"));
416                    self.hit("open", &open);
417                    if open.clicked() {
418                        match store.begin_import_filtered(("JavaScript", &["js"])) {
419                            Ok(()) => self.importing = true, Err(e) => self.output = e,
420                        }
421                    }
422                }
423                let export = ui.button("Export .js…");
424                self.hit("export", &export);
425                if export.clicked() {
426                    self.exports.push(crate::panels::file::SaveRequest::text("Export the script", "script.js", self.source.clone()));
427                }
428            });
429            let help = ui.collapsing("CAD API and examples", |ui| {
430                ui.label("Write JavaScript statements. cad.document is a snapshot of the current history; cad.selection contains the current selection.");
431                ui.monospace("cad.document.addFeature(type, params)\ncad.document.updateFeature(id, params)\ncad.document.deleteFeature(id)\nconsole.log / info / warn / error(...)\nui.notify(text)\nreturn { any: 'JSON result' };");
432                ui.label("Built-in types include P.CU (sizeX, sizeY, sizeZ), P.S (radius), and P.CY (radius, height). Successful changes form one undo step. Each Run executes the script again.");
433                if self.host.is_some() {
434                    ui.label("Every app tool is a function too: await cad.brep.<group>.<tool>(args), the same functions an agent's run_script gets (typed in the server's brep://cad/sdk.d.ts resource). A program that starts with import { brep } from \"@brep/cad\" and export default async function main() runs as written. A failed or cancelled run is rolled back.");
435                }
436                let example = ui.button("Load box example");
437                if example.clicked() { self.source = EXAMPLE.into(); }
438                example
439            });
440            self.hits.insert("help".into(), help.header_response.interact_rect);
441            if let Some(example) = help.body_returned {
442                self.hits.insert("example".into(), example.interact_rect);
443            }
444            // The rest of the window is the editor over the output. Two panels
445            // rather than two capped scroll areas: egui sizes a window's axis to
446            // its CONTENT (its Resize is not resizable, the content ui merely
447            // gets the dragged size as its max rect), so content that does not
448            // fill the dragged size snaps the window back to the content's
449            // height. Panels fill whatever the window is given, so the window
450            // follows the drag — up to the surface, which egui clamps to.
451            // The output panel keeps its own height (draggable at its top edge)
452            // and the editor takes everything above it.
453            egui::containers::panel::Panel::bottom(egui::Id::new("brep-javascript-output"))
454                .resizable(true)
455                .default_size(160.0)
456                .min_size(48.0)
457                .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 4)))
458                .show_inside(ui, |ui| {
459                    ui.label("Output");
460                    let output = egui::ScrollArea::both().id_salt("javascript-output").auto_shrink(false).show(ui, |ui| {
461                        ui.add(egui::Label::new(egui::RichText::new(&self.output).monospace()).selectable(true));
462                    });
463                    self.hits.insert("output".into(), output.inner_rect);
464                });
465            egui::CentralPanel::default().frame(egui::Frame::NONE).show_inside(ui, |ui| {
466                // The text box fills the free space, so a click in the empty
467                // area under a short script still lands in the editor. By ROWS:
468                // egui's `TextEdit::min_size` honours only the width, and the
469                // box's height is its rows (or its text, whichever is taller).
470                // Floored, so a box that fills the space never scrolls by the
471                // odd point.
472                let free = ui.available_height();
473                let row = ui.fonts_mut(|f| f.row_height(&egui::TextStyle::Monospace.resolve(ui.style())));
474                let rows = ((free - 4.0) / row).floor().max(4.0) as usize;
475                let source = egui::ScrollArea::both().id_salt("javascript-source").auto_shrink(false).show(ui, |ui| {
476                    ui.add(egui::TextEdit::multiline(&mut self.source).code_editor()
477                        .id(egui::Id::new("cad-javascript-source"))
478                        .desired_rows(rows).desired_width(f32::INFINITY));
479                });
480                self.hits.insert("source".into(), source.inner_rect);
481            });
482        });
483        // Published only while OPEN: egui keeps returning the window through its
484        // fade-out, and a host reading a rect for a window the user just closed
485        // would drag on nothing.
486        if let (Some(shown), true) = (shown, open) {
487            let rect = shown.response.rect;
488            self.rect = Some(rect);
489            // The window's handles, as points egui's own hit zones answer to: the
490            // title bar's centre (a drag moves the window) and a point just inside
491            // the bottom-right corner, within the corner grab radius (a drag
492            // resizes). `panel:clip` is the outer rect so both read as in view.
493            self.hits.insert("panel:clip".into(), rect);
494            self.hits.insert("title".into(), egui::Rect::from_center_size(egui::pos2(rect.center().x, rect.min.y + 12.0), egui::Vec2::ZERO));
495            self.hits.insert("grip".into(), egui::Rect::from_center_size(rect.max - egui::vec2(3.0, 3.0), egui::Vec2::ZERO));
496        } else {
497            self.hits.clear();
498        }
499        self.open = open;
500    }
501}