diff --git a/apps/gridcraft-web/src/api.rs b/apps/gridcraft-web/src/api.rs new file mode 100644 index 0000000..4aea18b --- /dev/null +++ b/apps/gridcraft-web/src/api.rs @@ -0,0 +1,119 @@ +//! Community web command bridge for gridcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use gridcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use wasm_bindgen::prelude::*; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "agent.execute" | "engine.commands" | "agent.state" | "sheet.read" | "cell.get") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("GridCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies() { + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request(if p.get("expected").is_some() { "agent.execute" } else { "engine.execute" }.into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + js_sys::Reflect::set(&win, &"gridcraft".into(), &obj).map_err(|_| "could not install window.gridcraft")?; + Ok(rx) +} diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 2920a87..479d469 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -14,6 +14,8 @@ //! notes and comments. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")] diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c0fcb22..5c8e26f 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -48,6 +48,8 @@ pub fn start() { bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone()); } let mut app = SheetApp::new(session, services); + // Community bridge: use the existing UI control queue in the browser. + app.control_rx = Some(crate::api::install(cc.egui_ctx.clone())?); app.ui.system_theme = true; // Follow the browser's language, except a CJK one: the web build has no system // CJK fonts (none are bundled), so that interface would render as tofu. @@ -113,6 +115,7 @@ impl eframe::App for WebShell { }); } self.app.logic(ctx); + crate::api::poll_replies(); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfa..79c0132 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -164,10 +164,49 @@ fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect() } +// Community web bridge: stable workbook identity and revision checked on the UI thread. +fn agent_token(app: &SheetApp) -> Result { + let d = app.session.doc().map_err(|e| e.to_string())?; + Ok(json!({"uid": d.uid.to_string(), "revision": d.revision.to_string(), "sheet": d.wb.active_sheet})) +} + +fn agent_state(app: &mut SheetApp) -> Result { + let token = agent_token(app)?; + let mut document = app.session.run("document.inspect", json!({})).map_err(|e| e.to_string())?; + if let Some(fields) = document.as_object_mut() { + fields.remove("path"); + } + Ok(json!({"token": token, "document": document})) +} + pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome { + if let Some(expected) = p.get("expected") { + match agent_token(app) { + Ok(current) if *expected == current => {} + _ => return err("workbook changed since inspection; start a new request"), + } + } let s = |k: &str| p.get(k).and_then(Json::as_str); let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32; let out = match method { + "agent.state" => wrap(agent_state(app)), + "agent.execute" => { + if p.get("expected").is_none() { + return err("expected workbook state required"); + } + let Some(id) = s("command") else { return err("missing command") }; + let params = p.get("params").cloned().unwrap_or(json!({})); + if !params.is_object() { + return err("params must be an object"); + } + match app.run(id, params) { + Ok(result) => match agent_state(app) { + Ok(state) => ok(json!({"result": result, "state": state})), + Err(e) => err(e), + }, + Err(e) => err(e), + } + } "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); @@ -484,6 +523,28 @@ pub fn save_image(image: &egui::ColorImage, path: Option<&str>) -> Json { mod tests { use super::*; + #[test] + fn community_agent_writes_formula_undoes_and_rejects_stale_or_other_workbooks() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + let token = agent_token(&app).unwrap(); + let p = json!({"command":"range.setValues","params":{"range":"A1","values":[[10,20,"=SUM(A1:B1)"]]},"expected":token}); + let Outcome::Done(reply) = handle(&mut app, &ctx, "agent.execute", &p) else { panic!("immediate reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(app.session.run("cell.get", json!({"cell":"C1"})).unwrap()["value"], 30.0); + let Outcome::Done(stale) = handle(&mut app, &ctx, "agent.execute", &p) else { panic!("immediate reply") }; + assert_eq!(stale["ok"], false); + let undo = json!({"command":"edit.undo","params":{},"expected":reply["result"]["state"]["token"]}); + let Outcome::Done(undone) = handle(&mut app, &ctx, "agent.execute", &undo) else { panic!("immediate reply") }; + assert_eq!(undone["ok"], true); + assert_eq!(app.session.run("cell.get", json!({"cell":"A1"})).unwrap()["value"], Json::Null); + app.session.new_workbook(); + let Outcome::Done(switched) = handle(&mut app, &ctx, "agent.execute", &undo) else { panic!("immediate reply") }; + assert_eq!(switched["ok"], false); + } + fn sample() -> Vec { vec![ json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}),