isokit: web Studio (engine as WASM, projects in the browser or a live folder)
- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind the same JSON API as `isokit studio`, plus the MCP tools - isokit-project: Store trait (FsStore folder, MemStore memory) under a generic Workspace; Project::from_files; shared Studio API module (api.rs) used by serve.rs and the WASM build; web_time for clocks in the browser - studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio keys while a dialog is open - web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and deflated), folder import by drop or picker, folders opened live via File System Access with outside edits followed, code editor, page build and preview, two tabs in step, isokit.mcp() and WebMCP registration - tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
This commit is contained in:
1 parent
564aeb75f6
commit
5eb60d2a0c
20 files changed
+1950
-178
No files matched your search
Generated
+14
@@ -251,9 +251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -318,6 +320,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml",
|
"toml",
|
||||||
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -337,6 +340,16 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "isokit-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"isokit-project",
|
||||||
|
"rhai",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -441,6 +454,7 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
|
"getrandom 0.3.4",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rhai_codegen",
|
"rhai_codegen",
|
||||||
|
|||||||
+12
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/isokit-theme",
|
"crates/isokit-theme",
|
||||||
"crates/isokit-project",
|
"crates/isokit-project",
|
||||||
"crates/isokit-cli",
|
"crates/isokit-cli",
|
||||||
|
"crates/isokit-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -27,7 +28,18 @@ rhai = { version = "1.27", default-features = true }
|
|||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
web-time = "1"
|
||||||
|
# keep in step with the wasm-bindgen CLI (tools/build_web.py checks)
|
||||||
|
wasm-bindgen = "=0.2.129"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 4
|
codegen-units = 4
|
||||||
|
|
||||||
|
# the web Studio's engine (tools/build_web.py): small over fast
|
||||||
|
[profile.web]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
@@ -6,6 +6,9 @@ click for details. One Rust binary does everything:
|
|||||||
|
|
||||||
- **Studio**: a GUI editor in the browser for people who don't code. You add
|
- **Studio**: a GUI editor in the browser for people who don't code. You add
|
||||||
objects from a palette, drag them into place, and edit their properties in forms.
|
objects from a palette, drag them into place, and edit their properties in forms.
|
||||||
|
- **Web Studio**: the same Studio running entirely in the browser, with the
|
||||||
|
engine compiled to WebAssembly. Nothing to install: projects live in the
|
||||||
|
browser, or in a folder on disk (Chrome / Edge) that agents edit alongside.
|
||||||
- **MCP server**: AI agents (Hermes and others) edit the same scene with the
|
- **MCP server**: AI agents (Hermes and others) edit the same scene with the
|
||||||
same operations; their changes show up live in an open Studio.
|
same operations; their changes show up live in an open Studio.
|
||||||
- **Build**: a self-contained HTML page with every view prerendered, or
|
- **Build**: a self-contained HTML page with every view prerendered, or
|
||||||
@@ -47,6 +50,33 @@ Every change is saved to `scene.json` at once. When the file changes on disk
|
|||||||
(an agent, git pull, another editor), the Studio reloads it, and that change can
|
(an agent, git pull, another editor), the Studio reloads it, and that change can
|
||||||
be undone like any other.
|
be undone like any other.
|
||||||
|
|
||||||
|
### Web Studio (no install)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
cargo install wasm-bindgen-cli --version 0.2.129 # must match Cargo.lock
|
||||||
|
python tools/build_web.py # -> dist/web/ (static files, ~2.8 MB, 0.75 MB gzipped)
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist/web/` runs from any static web server (http or https, not `file://`).
|
||||||
|
The first visit opens a copy of the demo. Everything the desktop Studio does
|
||||||
|
works the same, because the page runs the same engine and answers the same
|
||||||
|
requests. In addition:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Projects ▾** | list, open, delete; new project; demo copy; export as `.zip`; import a `.zip` or a project folder (drop it on the strip, or pick it) |
|
||||||
|
| **Open folder live** | Chrome / Edge: edits are written straight to the folder's files, and changes made there (an agent running `isokit mcp <folder>`, an editor, `git pull`) show up within 1.5 s and can be undone |
|
||||||
|
| **{ } Code** | edit `scene.rhai`, `lib/*.rhai` prefabs, `theme.toml`, `isokit.toml`; Ctrl+S applies, script errors show with file and line |
|
||||||
|
| **Build page** | downloads the self-contained HTML page (a folder project gets `dist/index.html` like `isokit build`) |
|
||||||
|
| **Preview** | the built page in a new tab |
|
||||||
|
|
||||||
|
Projects in the browser are stored in IndexedDB (per browser and site; export
|
||||||
|
them to keep a copy). Two tabs on the same project follow each other. The 15
|
||||||
|
MCP tools also run inside the page: `await isokit.mcp({jsonrpc: "2.0", id: 1,
|
||||||
|
method: "tools/list"})`, and browsers with WebMCP (`navigator.modelContext`)
|
||||||
|
get them as page tools.
|
||||||
|
|
||||||
### Agents (MCP)
|
### Agents (MCP)
|
||||||
|
|
||||||
While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`.
|
While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`.
|
||||||
@@ -109,15 +139,19 @@ crates/isokit-theme TOML palettes / materials / rules -> CSS; built-in defaul
|
|||||||
crates/isokit-script Rhai runtime: drawing API, prefab library (stdlib/*.rhai), scene document driver
|
crates/isokit-script Rhai runtime: drawing API, prefab library (stdlib/*.rhai), scene document driver
|
||||||
crates/isokit-project project folder, document ops, export, check, Workspace (undo, reload), MCP tools
|
crates/isokit-project project folder, document ops, export, check, Workspace (undo, reload), MCP tools
|
||||||
crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check
|
crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check
|
||||||
|
crates/isokit-wasm the engine for the browser (wasm-bindgen): a project in memory behind the Studio API
|
||||||
viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it)
|
viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it)
|
||||||
studio/ the Studio GUI (served by `isokit studio`)
|
studio/ the Studio GUI (served by `isokit studio`, and by the web Studio)
|
||||||
|
web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor
|
||||||
examples/demo demo project
|
examples/demo demo project
|
||||||
```
|
```
|
||||||
|
|
||||||
The layers are independent. A Rust program can draw with `isokit-core` alone.
|
The layers are independent. A Rust program can draw with `isokit-core` alone.
|
||||||
`isokit-script` reads sources from memory, with no disk access, so the same
|
`isokit-script` reads sources from memory, with no disk access, and a
|
||||||
runtime can later run in the browser as WASM. The Studio and MCP both apply the
|
`Workspace` reads and writes through a `Store` (a folder on disk, or memory),
|
||||||
same `ops::Op` JSON operations through a `Workspace`.
|
so the same code runs natively and in the browser. The Studio, the web Studio
|
||||||
|
and MCP all apply the same `ops::Op` JSON operations through a `Workspace`, and
|
||||||
|
both Studio front ends speak the JSON API in `isokit_project::api`.
|
||||||
|
|
||||||
Writing prefabs and scripts: [docs/SCRIPTING.md](docs/SCRIPTING.md).
|
Writing prefabs and scripts: [docs/SCRIPTING.md](docs/SCRIPTING.md).
|
||||||
Plans: [docs/ROADMAP.md](docs/ROADMAP.md).
|
Plans: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||||
@@ -129,8 +163,14 @@ cargo test # engine, runtime, ops, demo, MCP
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
isokit studio examples/demo --port 7890 --no-open &
|
isokit studio examples/demo --port 7890 --no-open &
|
||||||
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP end to end (Edge)
|
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP end to end (Edge)
|
||||||
|
cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings
|
||||||
|
python tools/build_web.py && uv run --with playwright python tools/check_web.py <shot-dir> # web Studio end to end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`python tools/build_web.py --skip-cargo` re-copies the front end (studio/,
|
||||||
|
viewer/, web/) without rebuilding the WASM. Code that runs in the browser must
|
||||||
|
not use `std::time` (it traps on wasm32-unknown-unknown): use `web_time`.
|
||||||
|
|
||||||
Set `ISOKIT_ASSETS=C:/dev/isokit` to serve `viewer/` and `studio/` from disk, so
|
Set `ISOKIT_ASSETS=C:/dev/isokit` to serve `viewer/` and `studio/` from disk, so
|
||||||
you can edit the GUI without rebuilding. `tools/css2theme.py` converted the
|
you can edit the GUI without rebuilding. `tools/css2theme.py` converted the
|
||||||
original views' CSS into `crates/isokit-theme/themes/default.toml`.
|
original views' CSS into `crates/isokit-theme/themes/default.toml`.
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
//! live. Single-threaded: one request at a time against one Workspace.
|
//! live. Single-threaded: one request at a time against one Workspace.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use isokit_project::ops::Op;
|
|
||||||
use isokit_project::workspace::Workspace;
|
use isokit_project::workspace::Workspace;
|
||||||
use isokit_project::{assets, check, mcp, page};
|
use isokit_project::{api, assets, check, mcp, page};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -100,18 +99,6 @@ fn origin_ok(req: &Request) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(ws: &Workspace) -> Value {
|
|
||||||
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn meta(ws: &Workspace) -> Value {
|
|
||||||
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
|
||||||
v["dir"] = json!(ws.dir.display().to_string());
|
|
||||||
v["load_error"] = json!(ws.load_error);
|
|
||||||
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||||
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||||
// Windows canonical paths carry a \?\ prefix that other tools do not accept
|
// Windows canonical paths carry a \?\ prefix that other tools do not accept
|
||||||
@@ -191,21 +178,16 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
(Method::Get, "/api/meta") => {
|
(Method::Get, "/api/meta") => {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
send_json(req, meta(&ws))
|
send_json(req, api::meta(&ws))
|
||||||
}
|
}
|
||||||
(Method::Get, "/api/doc") => {
|
(Method::Get, "/api/doc") => {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
send_json(req, state(&ws))
|
send_json(req, api::state(&ws))
|
||||||
}
|
}
|
||||||
(Method::Get, "/api/render") => {
|
(Method::Get, "/api/render") => {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
let q = query(&url);
|
let q = query(&url);
|
||||||
let p = &ws.project;
|
let level = q.get("level").and_then(|v| v.as_str());
|
||||||
let level = q
|
|
||||||
.get("level")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| p.manifest.default_level());
|
|
||||||
let rot = q
|
let rot = q
|
||||||
.get("rot")
|
.get("rot")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -216,85 +198,40 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
|
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
|
||||||
let dev = q.get("dev").is_some();
|
let dev = q.get("dev").is_some();
|
||||||
match p.render(&level, rot, cfg.as_ref(), dev) {
|
// script errors are an answer for the viewer, not a failed request
|
||||||
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
|
send(
|
||||||
Err(e) => send(
|
req,
|
||||||
req,
|
200,
|
||||||
200,
|
"application/json; charset=utf-8",
|
||||||
"application/json",
|
api::render(&ws, level, rot, cfg.as_ref(), dev).to_string(),
|
||||||
json!({ "error": e }).to_string(),
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(Method::Get, "/api/check") => {
|
(Method::Get, "/api/check") => {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
send_json(req, json!({ "findings": check::check(&ws.project) }))
|
send_json(req, json!({ "findings": check::check(&ws.project) }))
|
||||||
}
|
}
|
||||||
(Method::Post, "/api/op") => {
|
(Method::Post, "/api/op") => {
|
||||||
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
|
let r = api::op(&mut ws, &bodyv);
|
||||||
let ops: Result<Vec<Op>, String> = list
|
send(req, 200, "application/json; charset=utf-8", r.to_string())
|
||||||
.iter()
|
|
||||||
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
|
||||||
.collect();
|
|
||||||
ops.and_then(|l| ws.apply_all(l))
|
|
||||||
.map(|o| o.last().and_then(|x| x.id.clone()))
|
|
||||||
} else {
|
|
||||||
serde_json::from_value::<Op>(bodyv.clone())
|
|
||||||
.map_err(|e| format!("bad operation: {e}"))
|
|
||||||
.and_then(|op| ws.apply(op))
|
|
||||||
.map(|o| o.id)
|
|
||||||
};
|
|
||||||
match r {
|
|
||||||
Ok(id) => {
|
|
||||||
let mut s = state(&ws);
|
|
||||||
s["ok"] = json!(true);
|
|
||||||
s["id"] = json!(id);
|
|
||||||
send_json(req, s)
|
|
||||||
}
|
|
||||||
Err(e) => send(
|
|
||||||
req,
|
|
||||||
200,
|
|
||||||
"application/json",
|
|
||||||
json!({ "error": e }).to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
|
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
|
||||||
let r = if path.ends_with("undo") {
|
let r = api::undo(&mut ws, path.ends_with("redo"));
|
||||||
ws.undo()
|
send(req, 200, "application/json; charset=utf-8", r.to_string())
|
||||||
} else {
|
|
||||||
ws.redo()
|
|
||||||
};
|
|
||||||
match r {
|
|
||||||
Ok(_) => send_json(req, state(&ws)),
|
|
||||||
Err(e) => send(
|
|
||||||
req,
|
|
||||||
200,
|
|
||||||
"application/json",
|
|
||||||
json!({ "error": e }).to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(Method::Post, "/api/build") => {
|
(Method::Post, "/api/build") => {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
let p = &ws.project;
|
let out = ws
|
||||||
let out = p.dir.join(
|
.project
|
||||||
p.manifest
|
.manifest
|
||||||
.build
|
.build
|
||||||
.out
|
.out
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "dist/index.html".into()),
|
.unwrap_or_else(|| "dist/index.html".into());
|
||||||
);
|
let r = page::static_page(&ws.project)
|
||||||
let r = page::static_page(p)
|
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
.and_then(|html| {
|
.and_then(|html| ws.write_file(&out, &html));
|
||||||
if let Some(d) = out.parent() {
|
|
||||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
std::fs::write(&out, html).map_err(|e| e.to_string())
|
|
||||||
});
|
|
||||||
match r {
|
match r {
|
||||||
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
|
Ok(path) => send_json(req, json!({ "path": path.display().to_string() })),
|
||||||
Err(e) => send(
|
Err(e) => send(
|
||||||
req,
|
req,
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ isokit-theme = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
web-time = { workspace = true }
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! The Studio's JSON API, shared by `isokit studio` (HTTP, `/api/*`) and the
|
||||||
|
//! web Studio (WASM): the same requests and answers on both.
|
||||||
|
|
||||||
|
use crate::ops::Op;
|
||||||
|
use crate::store::Store;
|
||||||
|
use crate::workspace::Workspace;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
/// The document and the undo state.
|
||||||
|
pub fn state<S: Store>(ws: &Workspace<S>) -> Value {
|
||||||
|
// the version goes out as a string: u64 does not fit a JS number
|
||||||
|
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project facts: title, levels, parameters, catalog, materials, errors.
|
||||||
|
pub fn meta<S: Store>(ws: &Workspace<S>) -> Value {
|
||||||
|
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
||||||
|
v["dir"] = json!(ws.dir.display().to_string());
|
||||||
|
v["load_error"] = json!(ws.load_error);
|
||||||
|
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One operation, or `{ "ops": [...] }` as one undo step. Answers the new
|
||||||
|
/// state with `ok` and the id of the object made or changed, or `error`.
|
||||||
|
pub fn op<S: Store>(ws: &mut Workspace<S>, body: &Value) -> Value {
|
||||||
|
let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) {
|
||||||
|
let ops: Result<Vec<Op>, String> = list
|
||||||
|
.iter()
|
||||||
|
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
||||||
|
.collect();
|
||||||
|
ops.and_then(|l| ws.apply_all(l))
|
||||||
|
.map(|o| o.last().and_then(|x| x.id.clone()))
|
||||||
|
} else {
|
||||||
|
serde_json::from_value::<Op>(body.clone())
|
||||||
|
.map_err(|e| format!("bad operation: {e}"))
|
||||||
|
.and_then(|op| ws.apply(op))
|
||||||
|
.map(|o| o.id)
|
||||||
|
};
|
||||||
|
match r {
|
||||||
|
Ok(id) => {
|
||||||
|
let mut s = state(ws);
|
||||||
|
s["ok"] = json!(true);
|
||||||
|
s["id"] = json!(id);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(e) => json!({ "error": e }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo<S: Store>(ws: &mut Workspace<S>, redo: bool) -> Value {
|
||||||
|
let r = if redo { ws.redo() } else { ws.undo() };
|
||||||
|
match r {
|
||||||
|
Ok(_) => state(ws),
|
||||||
|
Err(e) => json!({ "error": e }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One view for the viewer (`level` default: the project's default level).
|
||||||
|
pub fn render<S: Store>(
|
||||||
|
ws: &Workspace<S>,
|
||||||
|
level: Option<&str>,
|
||||||
|
rot: u8,
|
||||||
|
cfg: Option<&Map<String, Value>>,
|
||||||
|
dev: bool,
|
||||||
|
) -> Value {
|
||||||
|
let p = &ws.project;
|
||||||
|
let level = level
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| p.manifest.default_level());
|
||||||
|
match p.render(&level, rot, cfg, dev) {
|
||||||
|
Ok(v) => serde_json::to_value(v).unwrap_or_default(),
|
||||||
|
Err(e) => json!({ "error": e }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,17 @@
|
|||||||
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
|
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
|
||||||
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
|
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
|
||||||
//! project loaded across edits (undo, reload on file changes) for the Studio
|
//! project loaded across edits (undo, reload on file changes) for the Studio
|
||||||
//! and the MCP server ([`mcp`]).
|
//! and the MCP server ([`mcp`]). Where the files live is a [`store::Store`]:
|
||||||
|
//! a folder on disk, or memory (the web Studio, WASM). [`api`] is the JSON
|
||||||
|
//! API both Studio front ends speak.
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod check;
|
pub mod check;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
pub mod store;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
use isokit_core::{render, Info, RenderOpts};
|
use isokit_core::{render, Info, RenderOpts};
|
||||||
@@ -29,12 +33,16 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Instant;
|
use web_time::Instant;
|
||||||
|
|
||||||
pub use isokit_script;
|
pub use isokit_script;
|
||||||
|
|
||||||
pub const MANIFEST: &str = "isokit.toml";
|
pub const MANIFEST: &str = "isokit.toml";
|
||||||
|
|
||||||
|
/// Project files by forward-slash relative path ("isokit.toml",
|
||||||
|
/// "lib/kiosk.rhai"): what a project is made of, wherever it is stored.
|
||||||
|
pub type Files = BTreeMap<String, String>;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct LevelDef {
|
pub struct LevelDef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -213,9 +221,18 @@ fn rd(path: &Path) -> Result<String, Error> {
|
|||||||
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
|
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
|
/// Folders a project walk skips.
|
||||||
/// keyed by forward-slash relative path.
|
pub fn skip_dir(name: &str) -> bool {
|
||||||
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
|
name.starts_with('.') || name == "dist" || name == "target" || name == "node_modules"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files that make up a project (scripts, manifest, themes, documents).
|
||||||
|
pub fn is_project_file(name: &str) -> bool {
|
||||||
|
name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every project file below `dir` (skipping dist, target and dot folders).
|
||||||
|
pub fn read_dir_files(dir: &Path) -> Result<Files, Error> {
|
||||||
let mut out = BTreeMap::new();
|
let mut out = BTreeMap::new();
|
||||||
let mut stack = vec![dir.to_path_buf()];
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
while let Some(d) = stack.pop() {
|
while let Some(d) = stack.pop() {
|
||||||
@@ -224,14 +241,10 @@ fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
|
|||||||
let p = ent.path();
|
let p = ent.path();
|
||||||
let name = ent.file_name().to_string_lossy().to_string();
|
let name = ent.file_name().to_string_lossy().to_string();
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
if !name.starts_with('.')
|
if !skip_dir(&name) {
|
||||||
&& name != "dist"
|
|
||||||
&& name != "target"
|
|
||||||
&& name != "node_modules"
|
|
||||||
{
|
|
||||||
stack.push(p);
|
stack.push(p);
|
||||||
}
|
}
|
||||||
} else if name.ends_with(".rhai") {
|
} else if is_project_file(&name) {
|
||||||
let rel = p
|
let rel = p
|
||||||
.strip_prefix(dir)
|
.strip_prefix(dir)
|
||||||
.unwrap_or(&p)
|
.unwrap_or(&p)
|
||||||
@@ -259,35 +272,49 @@ impl Project {
|
|||||||
/// document stays editable and [`Project::runtime`] holds the error.
|
/// document stays editable and [`Project::runtime`] holds the error.
|
||||||
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
|
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
|
||||||
let dir = dir.as_ref().to_path_buf();
|
let dir = dir.as_ref().to_path_buf();
|
||||||
let mpath = dir.join(MANIFEST);
|
if !dir.join(MANIFEST).exists() {
|
||||||
let manifest: Manifest = if mpath.exists() {
|
|
||||||
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
return Err(Error::Io(format!(
|
return Err(Error::Io(format!(
|
||||||
"{} not found (create a project with `isokit new`)",
|
"{} not found (create a project with `isokit new`)",
|
||||||
mpath.display()
|
dir.join(MANIFEST).display()
|
||||||
)));
|
)));
|
||||||
|
}
|
||||||
|
let files = read_dir_files(&dir)?;
|
||||||
|
Project::from_files(dir, &files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a project from its files (no disk access). `dir` names it in
|
||||||
|
/// messages and is where the CLI writes builds.
|
||||||
|
pub fn from_files(dir: impl Into<PathBuf>, files: &Files) -> Result<Project, Error> {
|
||||||
|
let dir = dir.into();
|
||||||
|
let get = |name: &str| {
|
||||||
|
files
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| Error::Io(format!("{name} not found")))
|
||||||
};
|
};
|
||||||
|
let manifest: Manifest =
|
||||||
|
toml::from_str(get(MANIFEST)?).map_err(|e| Error::Manifest(e.to_string()))?;
|
||||||
let mut layers = vec![];
|
let mut layers = vec![];
|
||||||
for t in &manifest.theme {
|
for t in &manifest.theme {
|
||||||
layers.push((t.clone(), rd(&dir.join(t))?));
|
layers.push((t.clone(), get(t)?.clone()));
|
||||||
}
|
}
|
||||||
let refs: Vec<(&str, &str)> = layers
|
let refs: Vec<(&str, &str)> = layers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
|
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
|
||||||
let dpath = dir.join(manifest.doc_path());
|
let doc: Doc = match files.get(&manifest.doc_path()) {
|
||||||
let doc: Doc = if dpath.exists() {
|
Some(text) => serde_json::from_str(text)
|
||||||
serde_json::from_str(&rd(&dpath)?)
|
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?,
|
||||||
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
|
None => Doc {
|
||||||
} else {
|
|
||||||
Doc {
|
|
||||||
version: 1,
|
version: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
let sources = collect_sources(&dir)?;
|
let sources: BTreeMap<String, String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k.ends_with(".rhai"))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
let main = match &manifest.script {
|
let main = match &manifest.script {
|
||||||
Some(s) => Some(s.clone()),
|
Some(s) => Some(s.clone()),
|
||||||
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
|
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
|
||||||
@@ -402,16 +429,12 @@ impl Project {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the document (pretty JSON, atomically).
|
/// The document as saved: pretty JSON with a final newline.
|
||||||
pub fn save_doc(&self) -> Result<(), Error> {
|
pub fn doc_text(&self) -> Result<String, Error> {
|
||||||
let path = self.dir.join(self.manifest.doc_path());
|
|
||||||
let mut s =
|
let mut s =
|
||||||
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
|
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
|
||||||
s.push('\n');
|
s.push('\n');
|
||||||
let tmp = path.with_extension("json.tmp");
|
Ok(s)
|
||||||
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
|
|
||||||
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,15 +453,10 @@ pub fn version(dir: &Path) -> u64 {
|
|||||||
let p = ent.path();
|
let p = ent.path();
|
||||||
let name = ent.file_name().to_string_lossy().to_string();
|
let name = ent.file_name().to_string_lossy().to_string();
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
if !name.starts_with('.')
|
if !skip_dir(&name) {
|
||||||
&& name != "dist"
|
|
||||||
&& name != "target"
|
|
||||||
&& name != "node_modules"
|
|
||||||
{
|
|
||||||
stack.push(p);
|
stack.push(p);
|
||||||
}
|
}
|
||||||
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
|
} else if is_project_file(&name) {
|
||||||
{
|
|
||||||
if let Ok(m) = ent.metadata() {
|
if let Ok(m) = ent.metadata() {
|
||||||
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
|
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
|
||||||
}
|
}
|
||||||
@@ -450,6 +468,19 @@ pub fn version(dir: &Path) -> u64 {
|
|||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The files of a new project with a starter scene.
|
||||||
|
pub fn scaffold_files(title: &str) -> Files {
|
||||||
|
let title = title.replace('\\', "").replace('"', "'");
|
||||||
|
let mut f = Files::new();
|
||||||
|
f.insert(
|
||||||
|
MANIFEST.into(),
|
||||||
|
assets::NEW_MANIFEST.replace("{title}", &title),
|
||||||
|
);
|
||||||
|
f.insert("scene.json".into(), assets::NEW_DOC.into());
|
||||||
|
f.insert("theme.toml".into(), assets::NEW_THEME.into());
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
/// Write a new project folder with a starter scene.
|
/// Write a new project folder with a starter scene.
|
||||||
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
||||||
if dir.join(MANIFEST).exists() {
|
if dir.join(MANIFEST).exists() {
|
||||||
@@ -462,9 +493,9 @@ pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
|||||||
let w = |name: &str, body: &str| {
|
let w = |name: &str, body: &str| {
|
||||||
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
|
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
|
||||||
};
|
};
|
||||||
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
|
for (name, body) in scaffold_files(title) {
|
||||||
w("scene.json", assets::NEW_DOC)?;
|
w(&name, &body)?;
|
||||||
w("theme.toml", assets::NEW_THEME)?;
|
}
|
||||||
w("lib/README.md", assets::NEW_LIB_README)?;
|
w("lib/README.md", assets::NEW_LIB_README)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
use crate::check::check;
|
use crate::check::check;
|
||||||
use crate::ops::Op;
|
use crate::ops::Op;
|
||||||
use crate::page;
|
use crate::page;
|
||||||
|
use crate::store::Store;
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ fn tool_error(msg: impl Into<String>) -> Value {
|
|||||||
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
|
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn boxes(ws: &Workspace) -> Map<String, Value> {
|
fn boxes<S: Store>(ws: &Workspace<S>) -> Map<String, Value> {
|
||||||
let p = &ws.project;
|
let p = &ws.project;
|
||||||
match p.render(&p.manifest.default_level(), 0, None, false) {
|
match p.render(&p.manifest.default_level(), 0, None, false) {
|
||||||
Ok(v) => v
|
Ok(v) => v
|
||||||
@@ -97,7 +98,7 @@ fn boxes(ws: &Workspace) -> Map<String, Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
|
fn object_json<S: Store>(ws: &Workspace<S>, id: &str, bx: &Map<String, Value>) -> Option<Value> {
|
||||||
let o = ws.project.doc.get(id)?;
|
let o = ws.project.doc.get(id)?;
|
||||||
let mut v = serde_json::to_value(o).ok()?;
|
let mut v = serde_json::to_value(o).ok()?;
|
||||||
if let Some(b) = bx.get(id) {
|
if let Some(b) = bx.get(id) {
|
||||||
@@ -111,7 +112,7 @@ fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run one tool.
|
/// Run one tool.
|
||||||
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
|
pub fn call_tool<S: Store>(ws: &mut Workspace<S>, name: &str, a: &Map<String, Value>) -> Value {
|
||||||
ws.refresh();
|
ws.refresh();
|
||||||
match name {
|
match name {
|
||||||
"project_info" => {
|
"project_info" => {
|
||||||
@@ -287,18 +288,12 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
|
|||||||
let out = str_arg(a, "out")
|
let out = str_arg(a, "out")
|
||||||
.or_else(|| p.manifest.build.out.clone())
|
.or_else(|| p.manifest.build.out.clone())
|
||||||
.unwrap_or_else(|| "dist/index.html".into());
|
.unwrap_or_else(|| "dist/index.html".into());
|
||||||
let path = p.dir.join(&out);
|
|
||||||
match page::static_page(p) {
|
match page::static_page(p) {
|
||||||
Err(e) => tool_error(e.to_string()),
|
Err(e) => tool_error(e.to_string()),
|
||||||
Ok(html) => {
|
Ok(html) => match ws.write_file(&out, &html) {
|
||||||
if let Some(d) = path.parent() {
|
Ok(path) => text(json!({ "path": path.display().to_string() })),
|
||||||
let _ = std::fs::create_dir_all(d);
|
Err(e) => tool_error(e),
|
||||||
}
|
},
|
||||||
match std::fs::write(&path, html) {
|
|
||||||
Ok(_) => text(json!({ "path": path.display().to_string() })),
|
|
||||||
Err(e) => tool_error(format!("{}: {e}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"undo" => match ws.undo() {
|
"undo" => match ws.undo() {
|
||||||
@@ -313,7 +308,7 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_warnings(ws: &Workspace) -> Vec<String> {
|
fn render_warnings<S: Store>(ws: &Workspace<S>) -> Vec<String> {
|
||||||
let p = &ws.project;
|
let p = &ws.project;
|
||||||
p.render(&p.manifest.default_level(), 0, None, false)
|
p.render(&p.manifest.default_level(), 0, None, false)
|
||||||
.map(|v| v.warnings)
|
.map(|v| v.warnings)
|
||||||
@@ -321,7 +316,7 @@ fn render_warnings(ws: &Workspace) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one JSON-RPC message.
|
/// Handle one JSON-RPC message.
|
||||||
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
|
pub fn handle<S: Store>(ws: &mut Workspace<S>, msg: &Value) -> Option<Value> {
|
||||||
let id = msg.get("id").cloned();
|
let id = msg.get("id").cloned();
|
||||||
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, Scr
|
|||||||
|
|
||||||
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
|
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
|
||||||
pub fn today() -> String {
|
pub fn today() -> String {
|
||||||
let secs = std::time::SystemTime::now()
|
// web_time: std's clock traps in the browser (wasm32-unknown-unknown)
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let secs = web_time::SystemTime::now()
|
||||||
|
.duration_since(web_time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let days = (secs / 86_400) as i64;
|
let days = (secs / 86_400) as i64;
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! Where a project's files live. [`FsStore`]: a folder on disk (the CLI,
|
||||||
|
//! `isokit studio`, `isokit mcp`). [`MemStore`]: memory, filled and drained
|
||||||
|
//! by a host (the web Studio keeps the files in the browser or in a folder
|
||||||
|
//! the user opened, and persists what the workspace writes).
|
||||||
|
|
||||||
|
use crate::{is_project_file, read_dir_files, Error, Files, MANIFEST};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub trait Store {
|
||||||
|
/// The project's name in messages (a folder path, or a label).
|
||||||
|
fn dir(&self) -> PathBuf;
|
||||||
|
/// All project files.
|
||||||
|
fn read(&self) -> Result<Files, Error>;
|
||||||
|
/// Changes whenever a file is edited, added or removed.
|
||||||
|
fn version(&self) -> u64;
|
||||||
|
/// Write one file (relative path).
|
||||||
|
fn write(&mut self, path: &str, body: &str) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A project folder on disk.
|
||||||
|
pub struct FsStore {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsStore {
|
||||||
|
pub fn new(dir: impl AsRef<Path>) -> FsStore {
|
||||||
|
FsStore {
|
||||||
|
dir: dir.as_ref().to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relative path that stays inside the project (no `..`, no root).
|
||||||
|
fn safe_rel(path: &str) -> Result<String, Error> {
|
||||||
|
let p = path.replace('\\', "/");
|
||||||
|
let p = p.trim_start_matches("./");
|
||||||
|
if p.is_empty()
|
||||||
|
|| p.starts_with('/')
|
||||||
|
|| p.contains(':')
|
||||||
|
|| p.split('/').any(|s| s == ".." || s.is_empty())
|
||||||
|
{
|
||||||
|
return Err(Error::Io(format!("{path}: not a path inside the project")));
|
||||||
|
}
|
||||||
|
Ok(p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for FsStore {
|
||||||
|
fn dir(&self) -> PathBuf {
|
||||||
|
self.dir.clone()
|
||||||
|
}
|
||||||
|
fn read(&self) -> Result<Files, Error> {
|
||||||
|
if !self.dir.join(MANIFEST).exists() {
|
||||||
|
return Err(Error::Io(format!(
|
||||||
|
"{} not found (create a project with `isokit new`)",
|
||||||
|
self.dir.join(MANIFEST).display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
read_dir_files(&self.dir)
|
||||||
|
}
|
||||||
|
fn version(&self) -> u64 {
|
||||||
|
crate::version(&self.dir)
|
||||||
|
}
|
||||||
|
/// Atomic: a temporary file renamed over the old one.
|
||||||
|
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
|
||||||
|
let path = self.dir.join(safe_rel(path)?);
|
||||||
|
if let Some(d) = path.parent() {
|
||||||
|
std::fs::create_dir_all(d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
|
||||||
|
}
|
||||||
|
let mut tmp = path.clone().into_os_string();
|
||||||
|
tmp.push(".tmp");
|
||||||
|
let tmp = PathBuf::from(tmp);
|
||||||
|
std::fs::write(&tmp, body).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
|
||||||
|
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project files in memory. The host fills it ([`MemStore::set`] for edits
|
||||||
|
/// made outside the workspace) and persists what the workspace wrote
|
||||||
|
/// ([`MemStore::take_written`]).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MemStore {
|
||||||
|
pub name: String,
|
||||||
|
files: Files,
|
||||||
|
version: u64,
|
||||||
|
written: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemStore {
|
||||||
|
pub fn new(name: impl Into<String>, files: Files) -> MemStore {
|
||||||
|
MemStore {
|
||||||
|
name: name.into(),
|
||||||
|
files,
|
||||||
|
version: 1,
|
||||||
|
written: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn files(&self) -> &Files {
|
||||||
|
&self.files
|
||||||
|
}
|
||||||
|
/// An edit from outside (code editor, another tab, a watched folder):
|
||||||
|
/// the workspace reloads on its next refresh. `None` removes the file.
|
||||||
|
pub fn set(&mut self, path: &str, body: Option<String>) -> Result<(), Error> {
|
||||||
|
let p = safe_rel(path)?;
|
||||||
|
match body {
|
||||||
|
Some(b) => {
|
||||||
|
if self.files.get(&p) == Some(&b) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.files.insert(p, b);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if self.files.remove(&p).is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.version += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Paths the workspace wrote since the last call (deduplicated, in order).
|
||||||
|
pub fn take_written(&mut self) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = vec![];
|
||||||
|
for p in self.written.drain(..) {
|
||||||
|
if !out.contains(&p) {
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for MemStore {
|
||||||
|
fn dir(&self) -> PathBuf {
|
||||||
|
PathBuf::from(&self.name)
|
||||||
|
}
|
||||||
|
fn read(&self) -> Result<Files, Error> {
|
||||||
|
if !self.files.contains_key(MANIFEST) {
|
||||||
|
return Err(Error::Io(format!("{MANIFEST} not found")));
|
||||||
|
}
|
||||||
|
Ok(self.files.clone())
|
||||||
|
}
|
||||||
|
fn version(&self) -> u64 {
|
||||||
|
self.version
|
||||||
|
}
|
||||||
|
/// Exports (dist/index.html) do not change the version: like on disk,
|
||||||
|
/// only project files make the workspace reload.
|
||||||
|
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
|
||||||
|
let p = safe_rel(path)?;
|
||||||
|
if is_project_file(&p) {
|
||||||
|
self.version += 1;
|
||||||
|
}
|
||||||
|
self.files.insert(p.clone(), body.to_string());
|
||||||
|
self.written.push(p);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paths_stay_inside_the_project() {
|
||||||
|
assert_eq!(safe_rel("lib/a.rhai").unwrap(), "lib/a.rhai");
|
||||||
|
assert_eq!(safe_rel("./scene.json").unwrap(), "scene.json");
|
||||||
|
assert_eq!(safe_rel("dist\\index.html").unwrap(), "dist/index.html");
|
||||||
|
for bad in ["../x", "/etc/passwd", "C:/x", "a//b", "", "lib/../../x"] {
|
||||||
|
assert!(safe_rel(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mem_store_tracks_versions_and_writes() {
|
||||||
|
let mut m = MemStore::new("t", Files::new());
|
||||||
|
assert!(m.read().is_err(), "no manifest yet");
|
||||||
|
let v0 = m.version();
|
||||||
|
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
|
||||||
|
assert!(m.version() > v0);
|
||||||
|
let v1 = m.version();
|
||||||
|
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
|
||||||
|
assert_eq!(m.version(), v1, "same content is not a change");
|
||||||
|
m.write("scene.json", "{}").unwrap();
|
||||||
|
m.write("scene.json", "{ }").unwrap();
|
||||||
|
let v2 = m.version();
|
||||||
|
assert!(v2 > v1);
|
||||||
|
m.write("dist/index.html", "<html>").unwrap();
|
||||||
|
assert_eq!(m.version(), v2, "an export is not a project change");
|
||||||
|
assert_eq!(
|
||||||
|
m.take_written(),
|
||||||
|
vec!["scene.json".to_string(), "dist/index.html".to_string()]
|
||||||
|
);
|
||||||
|
assert!(m.take_written().is_empty());
|
||||||
|
m.set("scene.json", None).unwrap();
|
||||||
|
assert!(!m.files().contains_key("scene.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
//! A project kept loaded across edits: reload on file changes, save after
|
//! A project kept loaded across edits: reload on file changes, save after
|
||||||
//! every operation, undo and redo. The Studio server and the MCP server
|
//! every operation, undo and redo. The Studio server, the MCP server and the
|
||||||
//! each hold one.
|
//! web Studio each hold one; the [`Store`] says where the files live.
|
||||||
|
|
||||||
use crate::ops::{self, Op, Outcome};
|
use crate::ops::{self, Op, Outcome};
|
||||||
use crate::{version, Error, Project};
|
use crate::store::{FsStore, Store};
|
||||||
|
use crate::{Error, Project};
|
||||||
use isokit_script::doc::Doc;
|
use isokit_script::doc::Doc;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace<S: Store = FsStore> {
|
||||||
pub dir: PathBuf,
|
pub dir: PathBuf,
|
||||||
|
pub store: S,
|
||||||
pub project: Project,
|
pub project: Project,
|
||||||
/// Fingerprint of the files the loaded project was read from.
|
/// Version of the files the loaded project was read from.
|
||||||
pub version: u64,
|
pub version: u64,
|
||||||
/// Error of the last reload attempt (the previous project stays loaded).
|
/// Error of the last reload attempt (the previous project stays loaded).
|
||||||
pub load_error: Option<String>,
|
pub load_error: Option<String>,
|
||||||
@@ -20,13 +22,21 @@ pub struct Workspace {
|
|||||||
|
|
||||||
const UNDO_DEPTH: usize = 200;
|
const UNDO_DEPTH: usize = 200;
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace<FsStore> {
|
||||||
|
/// A project folder on disk.
|
||||||
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
|
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
|
||||||
let dir = dir.as_ref().to_path_buf();
|
Workspace::with_store(FsStore::new(dir))
|
||||||
let project = Project::load(&dir)?;
|
}
|
||||||
let version = version(&dir);
|
}
|
||||||
|
|
||||||
|
impl<S: Store> Workspace<S> {
|
||||||
|
pub fn with_store(store: S) -> Result<Workspace<S>, Error> {
|
||||||
|
let dir = store.dir();
|
||||||
|
let project = Project::from_files(dir.clone(), &store.read()?)?;
|
||||||
|
let version = store.version();
|
||||||
Ok(Workspace {
|
Ok(Workspace {
|
||||||
dir,
|
dir,
|
||||||
|
store,
|
||||||
project,
|
project,
|
||||||
version,
|
version,
|
||||||
load_error: None,
|
load_error: None,
|
||||||
@@ -35,15 +45,19 @@ impl Workspace {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload when a project file changed on disk (another editor, an agent,
|
/// Reload when a project file changed in the store (another editor, an
|
||||||
/// a git pull). Returns true when something was reloaded.
|
/// agent, a git pull). Returns true when something was reloaded.
|
||||||
pub fn refresh(&mut self) -> bool {
|
pub fn refresh(&mut self) -> bool {
|
||||||
let v = version(&self.dir);
|
let v = self.store.version();
|
||||||
if v == self.version {
|
if v == self.version {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.version = v;
|
self.version = v;
|
||||||
match Project::load(&self.dir) {
|
match self
|
||||||
|
.store
|
||||||
|
.read()
|
||||||
|
.and_then(|f| Project::from_files(self.dir.clone(), &f))
|
||||||
|
{
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
if p.doc != self.project.doc {
|
if p.doc != self.project.doc {
|
||||||
self.push_undo(self.project.doc.clone());
|
self.push_undo(self.project.doc.clone());
|
||||||
@@ -65,11 +79,22 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save(&mut self) -> Result<(), String> {
|
fn save(&mut self) -> Result<(), String> {
|
||||||
self.project.save_doc().map_err(|e| e.to_string())?;
|
let text = self.project.doc_text().map_err(|e| e.to_string())?;
|
||||||
self.version = version(&self.dir);
|
let path = self.project.manifest.doc_path();
|
||||||
|
self.store.write(&path, &text).map_err(|e| e.to_string())?;
|
||||||
|
self.version = self.store.version();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a file that is not the document (an exported page), relative
|
||||||
|
/// to the project. Returns the path as the store names it.
|
||||||
|
pub fn write_file(&mut self, path: &str, body: &str) -> Result<PathBuf, String> {
|
||||||
|
self.refresh();
|
||||||
|
self.store.write(path, body).map_err(|e| e.to_string())?;
|
||||||
|
self.version = self.store.version();
|
||||||
|
Ok(self.dir.join(path))
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply an operation to the document and save it.
|
/// Apply an operation to the document and save it.
|
||||||
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
|
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
|
||||||
self.refresh();
|
self.refresh();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "isokit-wasm"
|
||||||
|
description = "isokit in the browser: the engine behind the web Studio (wasm-bindgen)"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
isokit-project = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
wasm-bindgen = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
# Rhai's hasher seeds from getrandom: use the browser's crypto
|
||||||
|
rhai = { workspace = true, features = ["wasm-bindgen"] }
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! isokit in the browser. [`Studio`] holds one project in memory (a
|
||||||
|
//! [`MemStore`]) and answers the same JSON as `isokit studio`'s `/api/*`
|
||||||
|
//! routes, so the Studio GUI runs unchanged on top of it; the page persists
|
||||||
|
//! the files it writes ([`Studio::take_written`]). Also exposes the MCP tools
|
||||||
|
//! ([`Studio::mcp`]) for agents working inside the page.
|
||||||
|
//!
|
||||||
|
//! Strings in, JSON strings out: no serde-wasm glue, and the same calls work
|
||||||
|
//! in native tests.
|
||||||
|
|
||||||
|
use isokit_project::store::MemStore;
|
||||||
|
use isokit_project::workspace::Workspace;
|
||||||
|
use isokit_project::{api, check, mcp, page, Files};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = console)]
|
||||||
|
fn error(s: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panics go to the browser console instead of "unreachable executed".
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[wasm_bindgen(start)]
|
||||||
|
pub fn start() {
|
||||||
|
std::panic::set_hook(Box::new(|info| error(&format!("isokit: {info}"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_files(files: &str) -> Result<Files, String> {
|
||||||
|
let v: Map<String, Value> = serde_json::from_str(files).map_err(|e| format!("files: {e}"))?;
|
||||||
|
Ok(v.into_iter()
|
||||||
|
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct Studio {
|
||||||
|
ws: Workspace<MemStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl Studio {
|
||||||
|
/// `files`: JSON object, relative path -> text. `name` labels the
|
||||||
|
/// project in messages ("browser: Demo", a folder name).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(name: &str, files: &str) -> Result<Studio, String> {
|
||||||
|
let store = MemStore::new(name, parse_files(files)?);
|
||||||
|
let ws = Workspace::with_store(store).map_err(|e| e.to_string())?;
|
||||||
|
Ok(Studio { ws })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/meta
|
||||||
|
pub fn meta(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
api::meta(&self.ws).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/doc
|
||||||
|
pub fn state(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
api::state(&self.ws).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/version
|
||||||
|
pub fn version(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
self.ws.version.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/op: one operation or `{"ops": [...]}`.
|
||||||
|
pub fn op(&mut self, body: &str) -> String {
|
||||||
|
match serde_json::from_str::<Value>(body) {
|
||||||
|
Ok(v) => api::op(&mut self.ws, &v).to_string(),
|
||||||
|
Err(e) => json!({ "error": format!("bad operation: {e}") }).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/undo
|
||||||
|
pub fn undo(&mut self) -> String {
|
||||||
|
api::undo(&mut self.ws, false).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/redo
|
||||||
|
pub fn redo(&mut self) -> String {
|
||||||
|
api::undo(&mut self.ws, true).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/render. `cfg`: JSON object of parameter values, or "".
|
||||||
|
pub fn render(&mut self, level: &str, rot: u8, cfg: &str, dev: bool) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
let cfg = serde_json::from_str::<Map<String, Value>>(cfg).ok();
|
||||||
|
let level = Some(level).filter(|l| !l.is_empty());
|
||||||
|
api::render(&self.ws, level, rot % 4, cfg.as_ref(), dev).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/check
|
||||||
|
pub fn check(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
json!({ "findings": check::check(&self.ws.project) }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The theme's CSS (built-in theme plus the project's layers).
|
||||||
|
pub fn theme_css(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
self.ws.project.theme.css()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The self-contained HTML page: `{"html"}` or `{"error"}`.
|
||||||
|
pub fn page(&mut self) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
match page::static_page(&self.ws.project) {
|
||||||
|
Ok(html) => json!({ "html": html }).to_string(),
|
||||||
|
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One view as a standalone SVG: `{"svg"}` or `{"error"}`.
|
||||||
|
pub fn svg(&mut self, level: &str, rot: u8, light: bool) -> String {
|
||||||
|
self.ws.refresh();
|
||||||
|
let level = if level.is_empty() {
|
||||||
|
self.ws.project.manifest.default_level()
|
||||||
|
} else {
|
||||||
|
level.to_string()
|
||||||
|
};
|
||||||
|
match page::svg(&self.ws.project, &level, rot % 4, light) {
|
||||||
|
Ok(svg) => json!({ "svg": svg }).to_string(),
|
||||||
|
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One MCP JSON-RPC message (or a batch); "" for notifications.
|
||||||
|
pub fn mcp(&mut self, msg: &str) -> String {
|
||||||
|
let v = match serde_json::from_str::<Value>(msg) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
return json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } })
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let out = match &v {
|
||||||
|
Value::Array(list) => {
|
||||||
|
let r: Vec<Value> = list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| mcp::handle(&mut self.ws, m))
|
||||||
|
.collect();
|
||||||
|
(!r.is_empty()).then_some(Value::Array(r))
|
||||||
|
}
|
||||||
|
m => mcp::handle(&mut self.ws, m),
|
||||||
|
};
|
||||||
|
out.map(|v| v.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An edit made outside the Studio (code editor, watched folder); null
|
||||||
|
/// removes the file. The project reloads on the next call, and a
|
||||||
|
/// document change can be undone. `{"ok"}` or `{"error"}`.
|
||||||
|
pub fn set_file(&mut self, path: &str, body: Option<String>) -> String {
|
||||||
|
match self.ws.store.set(path, body) {
|
||||||
|
Ok(()) => json!({ "ok": true }).to_string(),
|
||||||
|
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file(&self, path: &str) -> Option<String> {
|
||||||
|
self.ws.store.files().get(path).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All files as a JSON object (path -> text).
|
||||||
|
pub fn files(&self) -> String {
|
||||||
|
serde_json::to_string(self.ws.store.files()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files the Studio wrote since the last call (path -> text), for the
|
||||||
|
/// page to persist.
|
||||||
|
pub fn take_written(&mut self) -> String {
|
||||||
|
let paths = self.ws.store.take_written();
|
||||||
|
let files = self.ws.store.files();
|
||||||
|
let out: Map<String, Value> = paths
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| files.get(&p).map(|t| (p, Value::String(t.clone()))))
|
||||||
|
.collect();
|
||||||
|
Value::Object(out).to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files of a new project with a starter scene (JSON object).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn new_project_files(title: &str) -> String {
|
||||||
|
serde_json::to_string(&isokit_project::scaffold_files(title)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine version.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn engine_version() -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn demo_files() -> String {
|
||||||
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo");
|
||||||
|
serde_json::to_string(&isokit_project::read_dir_files(&dir).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn j(s: &str) -> Value {
|
||||||
|
serde_json::from_str(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_runs_through_the_web_api() {
|
||||||
|
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
|
||||||
|
let m = j(&s.meta());
|
||||||
|
assert_eq!(m["title"], "isokit demo works");
|
||||||
|
assert!(m["catalog"].as_array().unwrap().len() > 20);
|
||||||
|
assert!(m["script_error"].is_null());
|
||||||
|
let n = j(&s.state())["doc"]["objects"].as_array().unwrap().len();
|
||||||
|
let v = j(&s.render("site", 1, "", true));
|
||||||
|
assert!(
|
||||||
|
v["html"].as_str().unwrap().contains("data-obj=\"kiosk\""),
|
||||||
|
"project prefab drawn"
|
||||||
|
);
|
||||||
|
assert!(v["warnings"].as_array().unwrap().is_empty());
|
||||||
|
assert!(j(&s.render("", 2, "{\"labels\": false}", false))["error"].is_null());
|
||||||
|
|
||||||
|
// an edit is answered with the new state and handed out for saving
|
||||||
|
let r = j(&s.op(r#"{"op": "add", "type": "tree", "x": 3, "y": 4}"#));
|
||||||
|
assert_eq!(r["ok"], true);
|
||||||
|
assert_eq!(r["doc"]["objects"].as_array().unwrap().len(), n + 1);
|
||||||
|
let w = j(&s.take_written());
|
||||||
|
let saved: Value = serde_json::from_str(w["scene.json"].as_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(saved["objects"].as_array().unwrap().len(), n + 1);
|
||||||
|
assert_eq!(j(&s.take_written()), json!({}));
|
||||||
|
assert!(j(&s.op(r#"{"op": "add", "type": "ufo"}"#))["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("available"));
|
||||||
|
|
||||||
|
// undo, and an outside edit that reloads and can be undone
|
||||||
|
assert_eq!(j(&s.undo())["doc"]["objects"].as_array().unwrap().len(), n);
|
||||||
|
let v0 = s.version();
|
||||||
|
let mut doc = j(&s.file("scene.json").unwrap());
|
||||||
|
doc["objects"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|o| o["type"] != "lamp");
|
||||||
|
assert_eq!(
|
||||||
|
j(&s.set_file("scene.json", Some(doc.to_string())))["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_ne!(s.version(), v0);
|
||||||
|
let st = j(&s.state());
|
||||||
|
assert!(st["doc"]["objects"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|o| o["type"] != "lamp"));
|
||||||
|
assert_eq!(st["can_undo"], true);
|
||||||
|
|
||||||
|
// a script error keeps the document editable and is reported
|
||||||
|
let good = s.file("scene.rhai").unwrap();
|
||||||
|
s.set_file("scene.rhai", Some("let x = ;".into()));
|
||||||
|
assert!(!j(&s.meta())["script_error"].is_null());
|
||||||
|
assert!(j(&s.render("site", 0, "", false))["error"].is_object());
|
||||||
|
s.set_file("scene.rhai", Some(good));
|
||||||
|
assert!(j(&s.meta())["script_error"].is_null());
|
||||||
|
|
||||||
|
// exports and the theme
|
||||||
|
assert!(j(&s.page())["html"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("IsoKit.mount"));
|
||||||
|
assert!(j(&s.svg("", 0, true))["svg"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("<svg"));
|
||||||
|
assert!(s.theme_css().contains(".iso-brick"));
|
||||||
|
assert!(j(&s.check())["findings"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tools_work_in_memory() {
|
||||||
|
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
|
||||||
|
let r = j(&s.mcp(r#"{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}"#));
|
||||||
|
assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 15);
|
||||||
|
let r = j(&s.mcp(
|
||||||
|
r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "lamp", "x": 1, "y": 1}}}"#,
|
||||||
|
));
|
||||||
|
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||||
|
assert!(j(&s.take_written()).get("scene.json").is_some());
|
||||||
|
let r = j(&s.mcp(
|
||||||
|
r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "build_html", "arguments": {}}}"#,
|
||||||
|
));
|
||||||
|
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||||
|
assert!(j(&s.take_written())["dist/index.html"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("<html"));
|
||||||
|
assert_eq!(
|
||||||
|
s.mcp(r#"{"jsonrpc": "2.0", "method": "notifications/initialized"}"#),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_projects_and_bad_input() {
|
||||||
|
let files = new_project_files("My \"site\"");
|
||||||
|
let mut s = Studio::new("browser: new", &files).unwrap();
|
||||||
|
assert_eq!(j(&s.meta())["title"], "My 'site'");
|
||||||
|
assert!(j(&s.render("site", 1, "", false))["error"].is_null());
|
||||||
|
assert!(Studio::new("x", "{}").is_err(), "no manifest");
|
||||||
|
assert!(Studio::new("x", "not json").is_err());
|
||||||
|
assert!(j(&s.set_file("../evil.rhai", Some(String::new())))["error"].is_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -22,6 +22,20 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
|||||||
- [x] MCP: 15 tools over stdio and HTTP (`/mcp`), validation messages for agents
|
- [x] MCP: 15 tools over stdio and HTTP (`/mcp`), validation messages for agents
|
||||||
- [x] Demo project, 52 Rust tests, Playwright Studio + MCP test
|
- [x] Demo project, 52 Rust tests, Playwright Studio + MCP test
|
||||||
|
|
||||||
|
## Web Studio (done)
|
||||||
|
|
||||||
|
- [x] `isokit-wasm`: the engine in the browser (wasm-bindgen, 2.8 MB / 0.75 MB gzipped); `Store`
|
||||||
|
trait (folder or memory) under the `Workspace`; Studio JSON API shared with `isokit studio`
|
||||||
|
- [x] `studio.js` runs on a pluggable backend; the web shell answers it from WASM
|
||||||
|
- [x] Projects in IndexedDB; new, demo copy, delete; export / import `.zip` (stored or deflated) and
|
||||||
|
folders (drop or pick); two tabs on one project stay in step
|
||||||
|
- [x] Folder projects (File System Access, Chrome / Edge): edits written to disk, outside changes
|
||||||
|
followed live and undoable, Build page writes `dist/index.html`
|
||||||
|
- [x] Code editor for scripts, prefabs, theme and manifest, with script errors by file and line
|
||||||
|
- [x] MCP tools inside the page (`isokit.mcp`), WebMCP registration when the browser offers it
|
||||||
|
- [x] `tools/build_web.py` (static output), `tools/check_web.py` (44 checks, Edge)
|
||||||
|
- [ ] Deploy a hosted copy; render in a Web Worker for very large scenes
|
||||||
|
|
||||||
## Phase 2: richer editing
|
## Phase 2: richer editing
|
||||||
|
|
||||||
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
|
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
|
||||||
@@ -35,8 +49,9 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
|||||||
|
|
||||||
## Phase 3: embedding in the other projects
|
## Phase 3: embedding in the other projects
|
||||||
|
|
||||||
- [ ] WASM build of isokit-script + core: the same scene recomputes in the browser when a host
|
- [ ] Host pages on the WASM build (`isokit-wasm` exists, see Web Studio): the same scene
|
||||||
page's calculator changes the parameters (cnx_vinyl fleet calculator)
|
recomputes in the browser when a host page's calculator changes the parameters (cnx_vinyl
|
||||||
|
fleet calculator)
|
||||||
- [ ] `isokit build --fragment`: `<style>` + `<script>` blocks for the Python build scripts of the
|
- [ ] `isokit build --fragment`: `<style>` + `<script>` blocks for the Python build scripts of the
|
||||||
host pages (replace `inline.py`)
|
host pages (replace `inline.py`)
|
||||||
- [ ] Host API parity with CNXIso (`mount`, `update(cfg)`, `setLevel`, `focus`, `info`, `onPick`)
|
- [ ] Host API parity with CNXIso (`mount`, `update(cfg)`, `setLevel`, `focus`, `info`, `onPick`)
|
||||||
@@ -50,4 +65,4 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
|||||||
- [ ] `isokit check` rules from the QA checklist: props on paved areas, runs ending in the air,
|
- [ ] `isokit check` rules from the QA checklist: props on paved areas, runs ending in the air,
|
||||||
covers drawn over their contents, tag overlaps
|
covers drawn over their contents, tag overlaps
|
||||||
- [ ] Performance budget test (render ms per view, 60 fps pan) on a 3,500-node scene
|
- [ ] Performance budget test (render ms per view, 60 fps pan) on a 3,500-node scene
|
||||||
- [ ] Packaged desktop app (Tauri) for users without a terminal; hosted Studio behind login
|
- [ ] Packaged desktop app (Tauri); shared projects for the hosted web Studio (behind login)
|
||||||
+1
-1
@@ -22,7 +22,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
|||||||
<button type="button" id="redo" title="Redo (Ctrl+Y)" disabled>↷ Redo</button>
|
<button type="button" id="redo" title="Redo (Ctrl+Y)" disabled>↷ Redo</button>
|
||||||
<span class="st-save" id="save" title="Every change is saved to scene.json at once">Saved</span>
|
<span class="st-save" id="save" title="Every change is saved to scene.json at once">Saved</span>
|
||||||
<span class="st-sep"></span>
|
<span class="st-sep"></span>
|
||||||
<a class="st-btn" href="/view" target="_blank" rel="noopener" title="The scene as visitors see it (live)">Preview</a>
|
<a class="st-btn" id="preview" href="/view" target="_blank" rel="noopener" title="The scene as visitors see it (live)">Preview</a>
|
||||||
<button type="button" id="build" title="Write the self-contained HTML page">Build page</button>
|
<button type="button" id="build" title="Write the self-contained HTML page">Build page</button>
|
||||||
<button type="button" id="theme" title="Light / dark">◐</button>
|
<button type="button" id="theme" title="Light / dark">◐</button>
|
||||||
<button type="button" id="help" title="Keys and tips">?</button>
|
<button type="button" id="help" title="Keys and tips">?</button>
|
||||||
|
|||||||
+16
-8
@@ -1,13 +1,18 @@
|
|||||||
/* isokit Studio: the scene editor (talks to `isokit studio` over /api). */
|
/* isokit Studio: the scene editor. Talks to `isokit studio` over /api, or to
|
||||||
|
* window.IsoKitBackend when a page provides one (the web Studio runs the
|
||||||
|
* engine as WASM in the page and answers the same requests). */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
// { api(path, body) -> json | Promise, source(meta), agentBox(meta), preview(), saveTitle }
|
||||||
|
var B = window.IsoKitBackend || null;
|
||||||
var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
||||||
var ICON_DEFAULT = "▪";
|
var ICON_DEFAULT = "▪";
|
||||||
|
|
||||||
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||||||
function api(path, body) {
|
function api(path, body) {
|
||||||
|
if (B) return Promise.resolve().then(function () { return B.api(path, body); });
|
||||||
var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||||||
return fetch(path, o).then(function (r) { return r.json(); });
|
return fetch(path, o).then(function (r) { return r.json(); });
|
||||||
}
|
}
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
S.view.refresh();
|
S.view.refresh();
|
||||||
renderLists(); if (!quiet) renderRight();
|
renderLists(); if (!quiet) renderRight();
|
||||||
return r;
|
return r;
|
||||||
}, function (e) { $("save").textContent = "Offline"; $("save").classList.add("st-err"); toast("Studio server not reachable: " + e, true); });
|
}, function (e) { $("save").textContent = B ? "Not saved" : "Offline"; $("save").classList.add("st-err"); toast((B ? "Could not save: " : "Studio server not reachable: ") + e, true); });
|
||||||
}
|
}
|
||||||
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
|
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
|
||||||
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
// another editor or an agent changed the files: follow along
|
// another editor or an agent changed the files: follow along
|
||||||
function poll() {
|
function poll() {
|
||||||
api("/api/version").then(function (r) {
|
api("/api/version").then(function (r) {
|
||||||
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll("Scene updated outside the Studio (agent or file edit)"); }
|
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll(r.why || "Scene updated outside the Studio (agent or file edit)"); }
|
||||||
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
|
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@
|
|||||||
var drag = null;
|
var drag = null;
|
||||||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||||||
function mountView() {
|
function mountView() {
|
||||||
var src = IsoKit.httpSource("", toViewerMeta(S.meta));
|
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
||||||
S.view = IsoKit.mount($("view"), src, {
|
S.view = IsoKit.mount($("view"), src, {
|
||||||
note: false, noSelect: true, wheelZoom: true,
|
note: false, noSelect: true, wheelZoom: true,
|
||||||
onRender: function (d) {
|
onRender: function (d) {
|
||||||
@@ -120,7 +125,7 @@
|
|||||||
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
|
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.__studio = { S: S, view: S.view, op: op, select: select };
|
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast };
|
||||||
}
|
}
|
||||||
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
||||||
function remount() {
|
function remount() {
|
||||||
@@ -171,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
function keyHandler(e) {
|
function keyHandler(e) {
|
||||||
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
||||||
if (typing) return false;
|
if (typing || document.querySelector("dialog[open]")) return false;
|
||||||
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
||||||
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
||||||
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
||||||
@@ -284,10 +289,11 @@
|
|||||||
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
|
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
|
||||||
}).join("") + "</div>" : "") +
|
}).join("") + "</div>" : "") +
|
||||||
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
|
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
|
||||||
|
(B && B.agentBox ? B.agentBox(m) :
|
||||||
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
|
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
|
||||||
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
|
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
|
||||||
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
|
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
|
||||||
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>" +
|
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>") +
|
||||||
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
|
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
|
||||||
}
|
}
|
||||||
$("right").addEventListener("click", function (e) {
|
$("right").addEventListener("click", function (e) {
|
||||||
@@ -344,9 +350,11 @@
|
|||||||
try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ }
|
try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ }
|
||||||
});
|
});
|
||||||
$("build").addEventListener("click", function () {
|
$("build").addEventListener("click", function () {
|
||||||
api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast("Page written: " + r.path); });
|
api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast(r.message || "Page written: " + r.path); }, function (e) { toast(String(e), true); });
|
||||||
});
|
});
|
||||||
$("warnbtn").addEventListener("click", function () { select(null); });
|
$("warnbtn").addEventListener("click", function () { select(null); });
|
||||||
|
if (B && B.preview) $("preview").addEventListener("click", function (e) { e.preventDefault(); B.preview(); });
|
||||||
|
if (B && B.saveTitle) $("save").title = B.saveTitle;
|
||||||
|
|
||||||
// ------------------------------------------------------------ start
|
// ------------------------------------------------------------ start
|
||||||
Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Build the web Studio: the Studio GUI with the isokit engine compiled to WASM.
|
||||||
|
|
||||||
|
python tools/build_web.py [--out dist/web] [--skip-cargo]
|
||||||
|
|
||||||
|
Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown)
|
||||||
|
and the wasm-bindgen CLI at the version in Cargo.lock. wasm-opt (binaryen) is
|
||||||
|
used when it is on PATH. The output folder is static files: serve it from any
|
||||||
|
web server (it needs http(s), not file://).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKIP_DIRS = {"dist", "target", "node_modules"}
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kw):
|
||||||
|
print("+", " ".join(str(c) for c in cmd), flush=True)
|
||||||
|
subprocess.run([str(c) for c in cmd], check=True, cwd=ROOT, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_version(name):
|
||||||
|
lock = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'name = "%s"\nversion = "([^"]+)"' % re.escape(name), lock)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def demo_files():
|
||||||
|
"""The demo project as the web Studio stores projects: path -> text."""
|
||||||
|
base = ROOT / "examples" / "demo"
|
||||||
|
out = {}
|
||||||
|
for p in sorted(base.rglob("*")):
|
||||||
|
rel = p.relative_to(base).as_posix()
|
||||||
|
parts = rel.split("/")
|
||||||
|
if p.is_dir() or any(s.startswith(".") or s in SKIP_DIRS for s in parts[:-1]):
|
||||||
|
continue
|
||||||
|
if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md":
|
||||||
|
out[rel] = p.read_text(encoding="utf-8")
|
||||||
|
assert "isokit.toml" in out, "examples/demo has no isokit.toml"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def replace_once(s, old, new):
|
||||||
|
n = s.count(old)
|
||||||
|
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
|
||||||
|
return s.replace(old, new)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
|
||||||
|
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
out = Path(a.out).resolve()
|
||||||
|
web = out / "assets" / "web"
|
||||||
|
wasm = ROOT / "target" / "wasm32-unknown-unknown" / "web" / "isokit_wasm.wasm"
|
||||||
|
|
||||||
|
if not a.skip_cargo:
|
||||||
|
want = lock_version("wasm-bindgen")
|
||||||
|
try:
|
||||||
|
have = subprocess.run(["wasm-bindgen", "--version"], capture_output=True, text=True, check=True).stdout.split()[-1]
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
sys.exit(f"wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {want}")
|
||||||
|
if have != want:
|
||||||
|
sys.exit(f"wasm-bindgen CLI is {have}, Cargo.lock has {want}: cargo install wasm-bindgen-cli --version {want}")
|
||||||
|
run(["cargo", "build", "-p", "isokit-wasm", "--target", "wasm32-unknown-unknown", "--profile", "web"])
|
||||||
|
|
||||||
|
if out.exists():
|
||||||
|
shutil.rmtree(out)
|
||||||
|
web.mkdir(parents=True)
|
||||||
|
if not wasm.exists():
|
||||||
|
sys.exit(f"{wasm} missing: run without --skip-cargo")
|
||||||
|
run(["wasm-bindgen", "--target", "web", "--no-typescript", "--remove-name-section",
|
||||||
|
"--remove-producers-section", "--out-dir", web.as_posix(), wasm.as_posix()])
|
||||||
|
bg = web / "isokit_wasm_bg.wasm"
|
||||||
|
if shutil.which("wasm-opt"):
|
||||||
|
run(["wasm-opt", "-Oz", "--all-features", bg.as_posix(), "-o", bg.as_posix()])
|
||||||
|
else:
|
||||||
|
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
|
||||||
|
|
||||||
|
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
|
||||||
|
("studio/studio.js", "studio"), ("studio/studio.css", "studio"),
|
||||||
|
("web/web.js", "web"), ("web/web.css", "web")]:
|
||||||
|
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
|
||||||
|
(web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for p in sorted((out / "assets").rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
h.update(p.read_bytes())
|
||||||
|
v = "v=" + h.hexdigest()[:10]
|
||||||
|
|
||||||
|
html = (ROOT / "studio" / "index.html").read_text(encoding="utf-8")
|
||||||
|
html = replace_once(html, '<title>isokit Studio</title>',
|
||||||
|
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">')
|
||||||
|
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
|
||||||
|
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
|
||||||
|
html = replace_once(html, '<link rel="stylesheet" href="/assets/studio/studio.css">',
|
||||||
|
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
|
||||||
|
html = replace_once(html, 'href="/view"', 'href="#"')
|
||||||
|
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
|
||||||
|
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
|
||||||
|
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
|
||||||
|
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
|
||||||
|
assert "/assets/" not in html, "an absolute /assets/ URL is left in index.html"
|
||||||
|
(out / "index.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for p in sorted(out.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
total += p.stat().st_size
|
||||||
|
print(f" {p.relative_to(out).as_posix():40} {p.stat().st_size / 1024:8.1f} kB")
|
||||||
|
print(f"{out} ({total / 1024 / 1024:.1f} MB, {v})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""Functional test of the web Studio (dist/web), headless Edge.
|
||||||
|
|
||||||
|
python tools/build_web.py
|
||||||
|
uv run --with playwright python tools/check_web.py <screenshot dir> [dist/web]
|
||||||
|
|
||||||
|
Serves the build on a local port and checks: the demo loads from the bundled
|
||||||
|
files, placing / dragging / props / undo, persistence across reloads, the code
|
||||||
|
editor (script error and repair), new / export / import (.zip, stored and
|
||||||
|
deflated), page build and preview, MCP in the page and WebMCP, two tabs on one
|
||||||
|
project, and a folder project (the origin-private file system stands in for a
|
||||||
|
folder picked with showDirectoryPicker: same handle API) following outside edits.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots"
|
||||||
|
DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web"
|
||||||
|
PORT = 7931
|
||||||
|
BASE = f"http://127.0.0.1:{PORT}/"
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
print(("ok " if cond else "FAIL ") + msg, flush=True)
|
||||||
|
if not cond:
|
||||||
|
fails.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def doc(pg):
|
||||||
|
return pg.evaluate("() => JSON.parse(isokit.studio.state()).doc")
|
||||||
|
|
||||||
|
|
||||||
|
def obj(pg, id_):
|
||||||
|
return next((o for o in doc(pg)["objects"] if o["id"] == id_), None)
|
||||||
|
|
||||||
|
|
||||||
|
def ready(pg):
|
||||||
|
pg.wait_for_selector(".iso-svg [data-obj]", timeout=15000)
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
|
||||||
|
|
||||||
|
def shot(pg, name):
|
||||||
|
pg.screenshot(path=str(OUT / name), type="jpeg", quality=80)
|
||||||
|
|
||||||
|
|
||||||
|
def title(pg):
|
||||||
|
return pg.locator("#title").inner_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.__webmcp = c; } };"
|
||||||
|
|
||||||
|
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(1.0)
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch(channel="msedge", headless=True)
|
||||||
|
ctx = b.new_context(viewport={"width": 1500, "height": 950}, accept_downloads=True)
|
||||||
|
ctx.add_init_script(WEBMCP_STUB)
|
||||||
|
errs = []
|
||||||
|
|
||||||
|
def watch(page):
|
||||||
|
page.on("pageerror", lambda e: errs.append(str(e)))
|
||||||
|
page.on("console", lambda m: errs.append(m.text) if m.type == "error" else None)
|
||||||
|
|
||||||
|
pg = ctx.new_page()
|
||||||
|
watch(pg)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- first visit: the demo
|
||||||
|
t0 = time.time()
|
||||||
|
pg.goto(BASE)
|
||||||
|
ready(pg)
|
||||||
|
check(time.time() - t0 < 8, f"first visit opens the demo in {time.time() - t0:.1f} s")
|
||||||
|
check(title(pg) == "isokit demo works", "the demo project is open")
|
||||||
|
n0 = len(doc(pg)["objects"])
|
||||||
|
check(pg.locator("#objects li").count() == n0, f"object list shows {n0} objects")
|
||||||
|
check(pg.locator(".st-tile").count() >= 20, "palette lists the prefabs")
|
||||||
|
check(pg.locator("#wb-where").inner_text() == "in this browser", "header says where the project lives")
|
||||||
|
check("ms" in pg.locator("#stats").inner_text(), "render stats show: " + pg.locator("#stats").inner_text())
|
||||||
|
check(pg.evaluate("() => getComputedStyle(document.querySelector('.iso-svg .iso-t')).fill") not in ("", "rgb(0, 0, 0)"),
|
||||||
|
"theme CSS styles the scene")
|
||||||
|
check(pg.evaluate("() => isokit.webmcp") == 15, "15 MCP tools registered with WebMCP")
|
||||||
|
shot(pg, "web-start.jpg")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- edit: place, drag, props, undo
|
||||||
|
pg.locator('.st-tile[data-type="tree"]').click()
|
||||||
|
stage = pg.locator(".iso-stage").bounding_box()
|
||||||
|
pg.mouse.click(stage["x"] + stage["width"] * 0.30, stage["y"] + stage["height"] * 0.62)
|
||||||
|
pg.wait_for_timeout(500)
|
||||||
|
d = doc(pg)
|
||||||
|
check(len(d["objects"]) == n0 + 1 and d["objects"][-1]["type"] == "tree", "click on the map adds a tree")
|
||||||
|
check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new tree is selected")
|
||||||
|
check(pg.locator("#save").inner_text() == "Saved", "save indicator says Saved")
|
||||||
|
|
||||||
|
g = obj(pg, "genset")
|
||||||
|
box = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').nth(1).bounding_box()
|
||||||
|
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
||||||
|
pg.mouse.move(cx, cy)
|
||||||
|
pg.mouse.down()
|
||||||
|
for i in range(1, 11):
|
||||||
|
pg.mouse.move(cx + 9 * i, cy + 3 * i)
|
||||||
|
pg.wait_for_timeout(20)
|
||||||
|
pg.mouse.up()
|
||||||
|
pg.wait_for_timeout(500)
|
||||||
|
g2 = obj(pg, "genset")
|
||||||
|
check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({g2['x']},{g2['y']})")
|
||||||
|
|
||||||
|
pg.locator('#objects li[data-id="hall"]').click()
|
||||||
|
pg.locator("#p-floors").fill("3")
|
||||||
|
pg.locator("#p-floors").press("Enter")
|
||||||
|
pg.wait_for_timeout(500)
|
||||||
|
check(obj(pg, "hall")["props"].get("floors") == 3, "prop form edits the building (3 floors)")
|
||||||
|
pg.keyboard.press("Escape")
|
||||||
|
pg.locator("#undo").click()
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
check(obj(pg, "hall")["props"].get("floors") == 2, "undo restores 2 floors")
|
||||||
|
pg.locator("#redo").click()
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
check(obj(pg, "hall")["props"].get("floors") == 3, "redo")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- persistence
|
||||||
|
n1 = len(doc(pg)["objects"])
|
||||||
|
pg.reload()
|
||||||
|
ready(pg)
|
||||||
|
check(len(doc(pg)["objects"]) == n1 and obj(pg, "hall")["props"].get("floors") == 3, "edits survive a reload (IndexedDB)")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- code editor
|
||||||
|
pg.locator("#wb-code").click()
|
||||||
|
pg.wait_for_selector("#wb-codedlg[open]")
|
||||||
|
files = pg.locator("#wb-cfiles li").all_inner_texts()
|
||||||
|
check({"isokit.toml", "scene.json", "scene.rhai", "lib/kiosk.rhai", "theme.toml"} <= set(files), f"code editor lists the files {files}")
|
||||||
|
check(pg.locator("#wb-cpath").inner_text() == "scene.rhai", "it opens the main script")
|
||||||
|
good = pg.locator("#wb-ctext").input_value()
|
||||||
|
pg.locator("#wb-ctext").fill(good + "\nlet broken = ;\n")
|
||||||
|
pg.keyboard.press("Control+s")
|
||||||
|
pg.wait_for_timeout(1200)
|
||||||
|
msg = pg.locator("#wb-cmsg").inner_text()
|
||||||
|
check("scene.rhai:" in msg and pg.locator("#wb-cmsg.wb-bad").count() == 1, f"a script error shows with its line: {msg.strip()[:80]}")
|
||||||
|
shot(pg, "web-code-error.jpg")
|
||||||
|
pg.locator("#wb-ctext").fill(good + "\n// edited in the browser\n")
|
||||||
|
pg.locator("#wb-capply").click()
|
||||||
|
pg.wait_for_timeout(1200)
|
||||||
|
check(pg.locator("#wb-cmsg.wb-bad").count() == 0 and "Applied" in pg.locator("#wb-cmsg").inner_text(), "repaired script applies cleanly")
|
||||||
|
pg.locator("#wb-cfiles li", has_text="theme.toml").click()
|
||||||
|
check("palette" in pg.locator("#wb-ctext").input_value() or "#" in pg.locator("#wb-ctext").input_value(), "switching files shows theme.toml")
|
||||||
|
shot(pg, "web-code.jpg")
|
||||||
|
pg.locator("#wb-cclose").click()
|
||||||
|
pg.wait_for_timeout(1000)
|
||||||
|
check("script error" not in pg.locator("#stats").inner_text(), "the scene renders again after the fix")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- build page and preview
|
||||||
|
with pg.expect_download() as dl:
|
||||||
|
pg.locator("#build").click()
|
||||||
|
html = Path(dl.value.path()).read_text(encoding="utf-8")
|
||||||
|
check(dl.value.suggested_filename.endswith(".html") and "IsoKit.mount" in html and '"site/1"' in html,
|
||||||
|
f"Build page downloads {dl.value.suggested_filename} ({len(html) // 1024} kB)")
|
||||||
|
with ctx.expect_page() as pop:
|
||||||
|
pg.locator("#preview").click()
|
||||||
|
prev = pop.value
|
||||||
|
prev.wait_for_selector(".iso-svg [data-obj]", timeout=10000)
|
||||||
|
check(prev.locator(".iso-svg [data-obj]").count() > 10, "Preview opens the built page in a tab")
|
||||||
|
prev.close()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- MCP in the page and WebMCP
|
||||||
|
r = pg.evaluate("""() => isokit.mcp({jsonrpc: "2.0", id: 7, method: "tools/call",
|
||||||
|
params: {name: "add_object", arguments: {type: "lamp", x: 2, y: -14, id: "agent-lamp"}}})""")
|
||||||
|
check(r["result"]["isError"] is False, "isokit.mcp(add_object) works")
|
||||||
|
pg.wait_for_timeout(1200)
|
||||||
|
check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 1, "the Studio follows the agent's edit")
|
||||||
|
check("agent" in pg.locator("#toast").inner_text().lower(), "toast names the agent: " + pg.locator("#toast").inner_text())
|
||||||
|
r = pg.evaluate("""async () => { const t = window.__webmcp.tools.find(t => t.name === "update_object");
|
||||||
|
return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""")
|
||||||
|
check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- two tabs on one project
|
||||||
|
pg2 = ctx.new_page()
|
||||||
|
watch(pg2)
|
||||||
|
pg2.goto(pg.url)
|
||||||
|
ready(pg2)
|
||||||
|
pg2.evaluate("() => __studio.op({op: 'remove', id: 'agent-lamp'})")
|
||||||
|
pg.wait_for_timeout(1800)
|
||||||
|
check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 0, "an edit in another tab shows up here")
|
||||||
|
pg2.close()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- export, new, import
|
||||||
|
demo_id = pg.evaluate("() => location.hash")
|
||||||
|
pg.locator("#wb-projects").click()
|
||||||
|
pg.wait_for_selector("#wb-projdlg[open]")
|
||||||
|
shot(pg, "web-projects.jpg")
|
||||||
|
with pg.expect_download() as dl:
|
||||||
|
pg.locator('#wb-cur button[data-act="export"]').click()
|
||||||
|
zpath = OUT / dl.value.suggested_filename
|
||||||
|
dl.value.save_as(zpath)
|
||||||
|
with zipfile.ZipFile(zpath) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
check(z.testzip() is None and any(n.endswith("/isokit.toml") for n in names), f"Export .zip is a valid zip ({len(names)} files)")
|
||||||
|
check("// edited in the browser" in z.read(next(n for n in names if n.endswith("scene.rhai"))).decode(), "the export has the edited script")
|
||||||
|
pg.locator("#wb-ntitle").fill("Test site")
|
||||||
|
pg.locator("#wb-ncreate").click()
|
||||||
|
pg.wait_for_load_state()
|
||||||
|
ready(pg)
|
||||||
|
check(title(pg) == "Test site" and len(doc(pg)["objects"]) == 5, "New project opens the starter scene")
|
||||||
|
|
||||||
|
pg.locator("#wb-projects").click()
|
||||||
|
pg.locator("#wb-fzip").set_input_files(str(zpath))
|
||||||
|
pg.wait_for_timeout(1500)
|
||||||
|
ready(pg)
|
||||||
|
check(title(pg) == "isokit demo works" and len(doc(pg)["objects"]) == n1, "importing the exported .zip restores the project")
|
||||||
|
|
||||||
|
# a zip made elsewhere: deflated, nested one folder deeper, with junk
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for f in (ROOT / "examples" / "demo").rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
z.write(f, "repo/scenes/plant/" + f.relative_to(ROOT / "examples" / "demo").as_posix())
|
||||||
|
z.writestr("repo/README.md", "not part of it")
|
||||||
|
dz = OUT / "deflated.zip"
|
||||||
|
dz.write_bytes(buf.getvalue())
|
||||||
|
pg.locator("#wb-projects").click()
|
||||||
|
pg.locator("#wb-fzip").set_input_files(str(dz))
|
||||||
|
pg.wait_for_timeout(1500)
|
||||||
|
ready(pg)
|
||||||
|
fl = pg.evaluate("() => Object.keys(isokit.files()).sort()")
|
||||||
|
check(title(pg) == "isokit demo works" and "dist/index.html" not in fl and "lib/kiosk.rhai" in fl,
|
||||||
|
f"a deflated zip with the project in a subfolder imports: {fl}")
|
||||||
|
bad = OUT / "bad.zip"
|
||||||
|
with zipfile.ZipFile(bad, "w") as z:
|
||||||
|
z.writestr("x/readme.txt", "no project")
|
||||||
|
pg.locator("#wb-projects").click()
|
||||||
|
pg.locator("#wb-fzip").set_input_files(str(bad))
|
||||||
|
pg.wait_for_timeout(600)
|
||||||
|
check("isokit.toml" in pg.locator("#toast").inner_text(), "a zip without a project is refused: " + pg.locator("#toast").inner_text())
|
||||||
|
n_proj = pg.locator("#wb-plist li").count()
|
||||||
|
pg.once("dialog", lambda d: d.accept())
|
||||||
|
pg.locator("#wb-plist li", has_text="Test site").locator('button[data-act="del"]').click()
|
||||||
|
pg.wait_for_timeout(600)
|
||||||
|
check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted")
|
||||||
|
pg.keyboard.press("Escape")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
|
||||||
|
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")
|
||||||
|
for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts}
|
||||||
|
fid = pg.evaluate("""async (files) => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
await root.removeEntry("plant", {recursive: true}).catch(() => {});
|
||||||
|
const dir = await root.getDirectoryHandle("plant", {create: true});
|
||||||
|
for (const [path, text] of Object.entries(files)) {
|
||||||
|
const parts = path.split("/"); let d = dir;
|
||||||
|
for (const s of parts.slice(0, -1)) d = await d.getDirectoryHandle(s, {create: true});
|
||||||
|
const w = await (await d.getFileHandle(parts.at(-1), {create: true})).createWritable();
|
||||||
|
await w.write(text); await w.close();
|
||||||
|
}
|
||||||
|
const id = "p-folder-test";
|
||||||
|
await new Promise((res, rej) => { const r = indexedDB.open("isokit-studio", 1); r.onsuccess = () => {
|
||||||
|
const t = r.result.transaction("projects", "readwrite");
|
||||||
|
t.objectStore("projects").put({id, name: "plant", kind: "folder", handle: dir, updated: Date.now()});
|
||||||
|
t.oncomplete = res; t.onerror = rej; }; });
|
||||||
|
return id; }""", demo)
|
||||||
|
pg.goto("about:blank")
|
||||||
|
pg.goto(BASE + "#p=" + fid)
|
||||||
|
ready(pg)
|
||||||
|
check(pg.locator("#wb-where").inner_text() == "📁 plant", "a folder project opens from its handle")
|
||||||
|
pg.locator('.st-tile[data-type="tank"]').click()
|
||||||
|
stage = pg.locator(".iso-stage").bounding_box()
|
||||||
|
pg.mouse.click(stage["x"] + stage["width"] * 0.25, stage["y"] + stage["height"] * 0.70)
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
on_disk = pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant");
|
||||||
|
return JSON.parse(await (await (await d.getFileHandle("scene.json")).getFile()).text()); }""")
|
||||||
|
check(any(o["type"] == "tank" and o["id"] != "tank" for o in on_disk["objects"]), "an edit is written to the folder's scene.json")
|
||||||
|
# an agent edits the file on disk: the Studio follows
|
||||||
|
pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant");
|
||||||
|
const h = await d.getFileHandle("scene.json"); const doc = JSON.parse(await (await h.getFile()).text());
|
||||||
|
doc.objects = doc.objects.filter(o => o.type !== "lamp");
|
||||||
|
const w = await h.createWritable(); await w.write(JSON.stringify(doc, null, 2)); await w.close(); }""")
|
||||||
|
pg.wait_for_timeout(3500)
|
||||||
|
check(not any(o["type"] == "lamp" for o in doc(pg)["objects"]) and pg.locator("#objects li", has_text="Street lamp").count() == 0,
|
||||||
|
"a change made on disk shows up in the Studio")
|
||||||
|
check("folder" in pg.locator("#toast").inner_text().lower(), "toast: " + pg.locator("#toast").inner_text())
|
||||||
|
pg.locator("#undo").click()
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
check(any(o["type"] == "lamp" for o in doc(pg)["objects"]), "the outside change can be undone")
|
||||||
|
pg.locator("#build").click()
|
||||||
|
pg.wait_for_timeout(1500)
|
||||||
|
check("written to the folder" in pg.locator("#toast").inner_text(), "toast: " + pg.locator("#toast").inner_text())
|
||||||
|
built = pg.evaluate("""async () => { const d = await (await (await navigator.storage.getDirectory()).getDirectoryHandle("plant")).getDirectoryHandle("dist");
|
||||||
|
return (await (await d.getFileHandle("index.html")).getFile()).size; }""")
|
||||||
|
check(built > 10000 and "dist/index.html" not in pg.evaluate("() => Object.keys(isokit.files())"),
|
||||||
|
f"Build page writes dist/index.html into the folder ({built // 1024} kB) and keeps it out of the project files")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dark theme, narrow screen
|
||||||
|
pg.locator("#theme").click()
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
shot(pg, "web-dark.jpg")
|
||||||
|
pg.set_viewport_size({"width": 820, "height": 900})
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
shot(pg, "web-narrow.jpg")
|
||||||
|
|
||||||
|
check(not errs, f"no page errors {errs[:5]}")
|
||||||
|
b.close()
|
||||||
|
finally:
|
||||||
|
srv.terminate()
|
||||||
|
|
||||||
|
print("\n%d failure(s)" % len(fails))
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/* isokit web Studio: the parts the desktop Studio does not have (projects, code editor). */
|
||||||
|
.wb-where { color: var(--mute); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
|
||||||
|
|
||||||
|
.wb-projdlg { width: min(660px, 94vw); max-width: none; }
|
||||||
|
.wb-projdlg h3 { margin: 0 0 10px; }
|
||||||
|
.wb-cur { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--panel2); border-radius: 6px; margin-bottom: 10px; }
|
||||||
|
.wb-cur small { color: var(--mute); }
|
||||||
|
.wb-plist { list-style: none; margin: 0 0 10px; padding: 0; max-height: 40vh; overflow: auto; border: 1px solid var(--stroke); border-radius: 6px; }
|
||||||
|
.wb-plist li { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid var(--stroke); }
|
||||||
|
.wb-plist li:last-child { border-bottom: 0; }
|
||||||
|
.wb-plist li[aria-current=true] { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
|
||||||
|
.wb-plist li i { font-style: normal; width: 18px; text-align: center; }
|
||||||
|
.wb-plist li span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wb-plist li small { color: var(--mute); white-space: nowrap; }
|
||||||
|
.wb-plist li button { padding: 2px 8px; }
|
||||||
|
.wb-plist .wb-empty { color: var(--mute); }
|
||||||
|
.wb-new { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.wb-new input { flex: 1 1 auto; }
|
||||||
|
/* one compact line: drop target and pickers together */
|
||||||
|
.wb-drop { border: 1px dashed var(--stroke); border-radius: 6px; padding: 6px 8px; color: var(--mute); display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
|
||||||
|
.wb-drop button { padding: 1px 8px; }
|
||||||
|
.wb-drop.wb-over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--panel)); color: var(--fg); }
|
||||||
|
.wb-projdlg .st-help { margin: 8px 0 0; }
|
||||||
|
|
||||||
|
.wb-codedlg { width: min(1180px, 96vw); max-width: none; height: min(820px, 90vh); padding: 0; display: none; flex-direction: column; overflow: hidden; }
|
||||||
|
.wb-codedlg[open] { display: flex; }
|
||||||
|
.wb-chead { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--stroke); }
|
||||||
|
.wb-cpath { color: var(--mute); font-family: ui-monospace, Consolas, monospace; font-size: 12px; margin-left: 6px; }
|
||||||
|
.wb-cbody { flex: 1 1 auto; display: grid; grid-template-columns: 200px minmax(0, 1fr); min-height: 0; }
|
||||||
|
.wb-cfiles { list-style: none; margin: 0; padding: 6px; overflow: auto; border-right: 1px solid var(--stroke); font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
|
||||||
|
.wb-cfiles li { padding: 3px 6px; border-radius: 4px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wb-cfiles li:hover { background: var(--panel2); }
|
||||||
|
.wb-cfiles li[aria-selected=true] { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||||
|
.wb-cbody textarea { border: 0; border-radius: 0; resize: none; padding: 10px 12px; font: 12.5px/1.5 ui-monospace, "Cascadia Code", Consolas, monospace; tab-size: 2; white-space: pre; overflow: auto; background: var(--bg); }
|
||||||
|
.wb-cmsg { padding: 6px 10px; border-top: 1px solid var(--stroke); color: var(--mute); font: 12px/1.4 ui-monospace, Consolas, monospace; white-space: pre-wrap; max-height: 9em; overflow: auto; }
|
||||||
|
.wb-cmsg.wb-bad { color: var(--danger); }
|
||||||
|
|
||||||
|
.wb-gate[hidden] { display: none; }
|
||||||
|
.wb-gate { position: fixed; inset: 0; z-index: 40; background: color-mix(in srgb, var(--bg) 82%, transparent); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.wb-gatebox { background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; padding: 16px 18px; max-width: min(620px, 92vw); }
|
||||||
|
.wb-gatebox h3 { margin: 0 0 8px; }
|
||||||
|
.wb-gatebox pre { white-space: pre-wrap; color: var(--danger); font-size: 12px; max-height: 40vh; overflow: auto; }
|
||||||
+606
@@ -0,0 +1,606 @@
|
|||||||
|
/* isokit web Studio: the Studio GUI with the engine running in the page (WASM).
|
||||||
|
*
|
||||||
|
* Projects live in this browser (IndexedDB), or in a folder on disk the user
|
||||||
|
* opened (File System Access API, Chrome / Edge): edits are written straight
|
||||||
|
* to its files and changes made there by agents or editors show up live.
|
||||||
|
* This module answers the Studio's /api requests (window.IsoKitBackend), adds
|
||||||
|
* the project and code dialogs, and then starts the unchanged studio.js.
|
||||||
|
*/
|
||||||
|
import init, { Studio, new_project_files, engine_version } from "./isokit_wasm.js";
|
||||||
|
|
||||||
|
var Q = import.meta.url.split("?")[1] || "";
|
||||||
|
function asset(rel) { return new URL(rel + (Q ? "?" + Q : ""), import.meta.url); }
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||||||
|
function slug(s) { return String(s || "scene").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "scene"; }
|
||||||
|
function newId() { return "p-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||||
|
|
||||||
|
var toastT = 0;
|
||||||
|
function toast(msg, bad) {
|
||||||
|
if (window.__studio && window.__studio.toast) { window.__studio.toast(msg, bad); return; }
|
||||||
|
var t = $("toast");
|
||||||
|
t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad);
|
||||||
|
clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ project files
|
||||||
|
var SKIP_DIRS = { dist: 1, target: 1, node_modules: 1 };
|
||||||
|
function skipDir(name) { return name.charAt(0) === "." || SKIP_DIRS[name]; }
|
||||||
|
function projectFile(name) { return /\.(rhai|toml|json)$/.test(name); }
|
||||||
|
function keepFile(path) { return projectFile(path) || /(^|\/)README\.md$/.test(path); }
|
||||||
|
/** A file the project keeps (not an export under dist/, not in a dot folder). */
|
||||||
|
function inProject(path) { return keepFile(path) && !path.split("/").slice(0, -1).some(skipDir); }
|
||||||
|
|
||||||
|
/** Files of an import (path -> text), re-rooted at the folder that holds isokit.toml. */
|
||||||
|
function reroot(all) {
|
||||||
|
var roots = Object.keys(all).filter(function (p) { return /(^|\/)isokit\.toml$/.test(p); })
|
||||||
|
.sort(function (a, b) { return a.split("/").length - b.split("/").length; });
|
||||||
|
if (!roots.length) throw new Error("no isokit.toml found: an isokit project has one at its top");
|
||||||
|
var pre = roots[0].slice(0, roots[0].length - "isokit.toml".length), out = {};
|
||||||
|
Object.keys(all).forEach(function (p) {
|
||||||
|
if (p.indexOf(pre) !== 0) return;
|
||||||
|
var rel = p.slice(pre.length), parts = rel.split("/");
|
||||||
|
if (parts.slice(0, -1).some(skipDir) || !keepFile(rel)) return;
|
||||||
|
out[rel] = all[p];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ zip (store to write, store + deflate to read)
|
||||||
|
var CRC = (function () { var t = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
|
||||||
|
function crc32(b) { var c = 0xFFFFFFFF; for (var i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
|
||||||
|
function zip(files, root) {
|
||||||
|
var enc = new TextEncoder(), parts = [], cd = [], off = 0, n = 0, d = new Date();
|
||||||
|
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
||||||
|
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
||||||
|
Object.keys(files).sort().forEach(function (p) {
|
||||||
|
var name = enc.encode(root + p), data = enc.encode(files[p]), crc = crc32(data);
|
||||||
|
var h = new DataView(new ArrayBuffer(30));
|
||||||
|
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true);
|
||||||
|
h.setUint16(10, time, true); h.setUint16(12, date, true); h.setUint32(14, crc, true);
|
||||||
|
h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true);
|
||||||
|
parts.push(h.buffer, name, data);
|
||||||
|
var c = new DataView(new ArrayBuffer(46));
|
||||||
|
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true);
|
||||||
|
c.setUint16(12, time, true); c.setUint16(14, date, true); c.setUint32(16, crc, true);
|
||||||
|
c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true);
|
||||||
|
c.setUint32(42, off, true);
|
||||||
|
cd.push(c.buffer, name);
|
||||||
|
off += 30 + name.length + data.length; n++;
|
||||||
|
});
|
||||||
|
var size = cd.reduce(function (s, x) { return s + x.byteLength; }, 0);
|
||||||
|
var e = new DataView(new ArrayBuffer(22));
|
||||||
|
e.setUint32(0, 0x06054b50, true); e.setUint16(8, n, true); e.setUint16(10, n, true); e.setUint32(12, size, true); e.setUint32(16, off, true);
|
||||||
|
return new Blob(parts.concat(cd, [e.buffer]), { type: "application/zip" });
|
||||||
|
}
|
||||||
|
async function unzip(buf) {
|
||||||
|
var v = new DataView(buf), u8 = new Uint8Array(buf), dec = new TextDecoder(), out = {};
|
||||||
|
var i = buf.byteLength - 22;
|
||||||
|
while (i >= 0 && v.getUint32(i, true) !== 0x06054b50) i--;
|
||||||
|
if (i < 0) throw new Error("not a zip file");
|
||||||
|
var n = v.getUint16(i + 10, true), p = v.getUint32(i + 16, true);
|
||||||
|
for (var k = 0; k < n; k++) {
|
||||||
|
if (v.getUint32(p, true) !== 0x02014b50) throw new Error("damaged zip file");
|
||||||
|
var method = v.getUint16(p + 10, true), csize = v.getUint32(p + 20, true), nlen = v.getUint16(p + 28, true);
|
||||||
|
var skip = nlen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true), loc = v.getUint32(p + 42, true);
|
||||||
|
var name = dec.decode(u8.subarray(p + 46, p + 46 + nlen)).replace(/\\/g, "/");
|
||||||
|
p += 46 + skip;
|
||||||
|
if (/\/$/.test(name) || !keepFile(name) || csize > 8 << 20) continue;
|
||||||
|
var start = loc + 30 + v.getUint16(loc + 26, true) + v.getUint16(loc + 28, true), data = u8.subarray(start, start + csize);
|
||||||
|
if (method === 8) data = new Uint8Array(await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer());
|
||||||
|
else if (method !== 0) continue;
|
||||||
|
out[name] = dec.decode(data);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function download(name, blob) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob); a.download = name;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ folders (File System Access) and drops
|
||||||
|
var canOpenFolder = typeof window.showDirectoryPicker === "function";
|
||||||
|
async function readFolder(dir, pre, out, stats) {
|
||||||
|
out = out || {}; stats = stats || {}; pre = pre || "";
|
||||||
|
for await (var entry of dir.values()) {
|
||||||
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await readFolder(entry, pre + entry.name + "/", out, stats); }
|
||||||
|
else if (keepFile(pre + entry.name)) {
|
||||||
|
var f = await entry.getFile();
|
||||||
|
stats[pre + entry.name] = f.size + ":" + f.lastModified;
|
||||||
|
if (!(pre + entry.name in out)) out[pre + entry.name] = await f.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files: out, stats: stats };
|
||||||
|
}
|
||||||
|
async function folderStats(dir, pre, stats, handles) {
|
||||||
|
for await (var entry of dir.values()) {
|
||||||
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await folderStats(entry, pre + entry.name + "/", stats, handles); }
|
||||||
|
else if (keepFile(pre + entry.name)) { var f = await entry.getFile(); stats[pre + entry.name] = f.size + ":" + f.lastModified; handles[pre + entry.name] = f; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fileHandle(dir, path, create) {
|
||||||
|
var parts = path.split("/"), d = dir;
|
||||||
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i], { create: create });
|
||||||
|
return d.getFileHandle(parts[parts.length - 1], { create: create });
|
||||||
|
}
|
||||||
|
async function permitted(handle, ask) {
|
||||||
|
var o = { mode: "readwrite" };
|
||||||
|
if ((await handle.queryPermission(o)) === "granted") return true;
|
||||||
|
return ask ? (await handle.requestPermission(o)) === "granted" : false;
|
||||||
|
}
|
||||||
|
function readEntry(entry, path, out) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
if (!keepFile(path)) return Promise.resolve();
|
||||||
|
return new Promise(function (res, rej) { entry.file(function (f) { f.text().then(function (t) { out[path] = t; res(); }, rej); }, rej); });
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory || skipDir(entry.name)) return Promise.resolve();
|
||||||
|
var reader = entry.createReader(), jobs = [];
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
(function more() {
|
||||||
|
reader.readEntries(function (list) {
|
||||||
|
if (!list.length) { Promise.all(jobs).then(res, rej); return; }
|
||||||
|
list.forEach(function (e) { jobs.push(readEntry(e, path + "/" + e.name, out)); });
|
||||||
|
more();
|
||||||
|
}, rej);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ storage (IndexedDB)
|
||||||
|
var DB = null;
|
||||||
|
function db() {
|
||||||
|
if (!DB) DB = new Promise(function (res, rej) {
|
||||||
|
var r = indexedDB.open("isokit-studio", 1);
|
||||||
|
r.onupgradeneeded = function () { r.result.createObjectStore("projects", { keyPath: "id" }); };
|
||||||
|
r.onsuccess = function () { res(r.result); };
|
||||||
|
r.onerror = function () { rej(r.error); };
|
||||||
|
});
|
||||||
|
return DB;
|
||||||
|
}
|
||||||
|
function tx(mode, fn) {
|
||||||
|
return db().then(function (d) {
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
var t = d.transaction("projects", mode), r = fn(t.objectStore("projects")), out;
|
||||||
|
if (r) r.onsuccess = function () { out = r.result; };
|
||||||
|
t.oncomplete = function () { res(out); };
|
||||||
|
t.onerror = t.onabort = function () { rej(t.error); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var store = {
|
||||||
|
all: function () { return tx("readonly", function (s) { return s.getAll(); }); },
|
||||||
|
get: function (id) { return tx("readonly", function (s) { return s.get(id); }); },
|
||||||
|
put: function (rec) { return tx("readwrite", function (s) { return s.put(rec); }); },
|
||||||
|
del: function (id) { return tx("readwrite", function (s) { return s.delete(id); }); }
|
||||||
|
};
|
||||||
|
function titleOf(files) { var m = /^\s*title\s*=\s*"([^"]*)"/m.exec(files["isokit.toml"] || ""); return (m && m[1]) || "Untitled"; }
|
||||||
|
async function createProject(files, name) {
|
||||||
|
var rec = { id: newId(), name: name || titleOf(files), kind: "local", files: files, updated: Date.now() };
|
||||||
|
await store.put(rec);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
function openProject(id) { history.replaceState(null, "", "#p=" + encodeURIComponent(id)); location.reload(); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the open project
|
||||||
|
var P = { rec: null, files: {}, stats: {}, why: null, queue: Promise.resolve() };
|
||||||
|
/** Run storage jobs one after the other; a failed job does not block the next. */
|
||||||
|
function enqueue(job) {
|
||||||
|
var run = P.queue.then(job);
|
||||||
|
P.queue = run.catch(function (e) { console.error("isokit: storage", e); });
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
var studio = null, chan = typeof BroadcastChannel === "function" ? new BroadcastChannel("isokit-studio") : null;
|
||||||
|
function isFolder() { return P.rec && P.rec.kind === "folder"; }
|
||||||
|
|
||||||
|
/** Persist files written by the engine or the code editor (in order; null deletes). */
|
||||||
|
function persist(changes) {
|
||||||
|
var paths = Object.keys(changes);
|
||||||
|
if (!paths.length) return Promise.resolve();
|
||||||
|
return enqueue(async function () {
|
||||||
|
if (isFolder()) {
|
||||||
|
for (var p of paths) {
|
||||||
|
if (changes[p] === null) {
|
||||||
|
var parts = p.split("/"), d = P.rec.handle;
|
||||||
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i]);
|
||||||
|
await d.removeEntry(parts[parts.length - 1]).catch(function () { /* already gone */ });
|
||||||
|
delete P.files[p]; delete P.stats[p];
|
||||||
|
} else {
|
||||||
|
var h = await fileHandle(P.rec.handle, p, true), w = await h.createWritable();
|
||||||
|
await w.write(changes[p]); await w.close();
|
||||||
|
var f = await h.getFile();
|
||||||
|
if (inProject(p)) { P.files[p] = changes[p]; P.stats[p] = f.size + ":" + f.lastModified; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var kept = {};
|
||||||
|
paths.forEach(function (p) {
|
||||||
|
if (!inProject(p)) return; // exports (dist/index.html) stay out of the browser store
|
||||||
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
||||||
|
kept[p] = changes[p];
|
||||||
|
});
|
||||||
|
if (!Object.keys(kept).length) return;
|
||||||
|
P.rec.files = P.files; P.rec.updated = Date.now();
|
||||||
|
await store.put(P.rec);
|
||||||
|
if (chan) chan.postMessage({ id: P.rec.id, files: kept });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function flush() { return persist(JSON.parse(studio.take_written())); }
|
||||||
|
|
||||||
|
/** An edit from outside the Studio (code editor, another tab, the folder). */
|
||||||
|
function outside(changes, why) {
|
||||||
|
Object.keys(changes).forEach(function (p) {
|
||||||
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
||||||
|
if (studio) studio.set_file(p, changes[p]);
|
||||||
|
});
|
||||||
|
P.why = why;
|
||||||
|
}
|
||||||
|
if (chan) chan.onmessage = function (e) {
|
||||||
|
if (P.rec && !isFolder() && e.data && e.data.id === P.rec.id) outside(e.data.files, "Changed in another tab");
|
||||||
|
};
|
||||||
|
|
||||||
|
// a folder project follows changes made on disk (agents, editors, git)
|
||||||
|
function watchFolder() {
|
||||||
|
setInterval(function () {
|
||||||
|
if (document.hidden) return;
|
||||||
|
enqueue(async function () {
|
||||||
|
var stats = {}, got = {}, changes = {}, n = 0;
|
||||||
|
try { await folderStats(P.rec.handle, "", stats, got); } catch (e) { return; }
|
||||||
|
for (var p in stats) {
|
||||||
|
if (stats[p] === P.stats[p]) continue;
|
||||||
|
var t = await got[p].text();
|
||||||
|
P.stats[p] = stats[p];
|
||||||
|
if (t !== P.files[p]) { changes[p] = t; n++; }
|
||||||
|
}
|
||||||
|
Object.keys(P.files).forEach(function (p) { if (!(p in stats) && projectFile(p)) { changes[p] = null; delete P.stats[p]; n++; } });
|
||||||
|
if (n) outside(changes, "Files changed in the folder (agent or editor)");
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the backend studio.js talks to
|
||||||
|
var themeCss = null, lastVersion = null;
|
||||||
|
function J(s) { return JSON.parse(s); }
|
||||||
|
function backend() {
|
||||||
|
return {
|
||||||
|
web: true,
|
||||||
|
saveTitle: isFolder() ? "Every change is written to the folder at once" : "Every change is saved in this browser at once",
|
||||||
|
api: function (path, body) {
|
||||||
|
try { return route(path, body); } catch (e) { crashed(e); throw e; }
|
||||||
|
},
|
||||||
|
source: function (meta) {
|
||||||
|
return {
|
||||||
|
meta: meta, dev: true,
|
||||||
|
render: function (level, rot, cfg) {
|
||||||
|
try { return J(studio.render(level, rot, cfg ? JSON.stringify(cfg) : "", true)); } catch (e) { crashed(e); throw e; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
preview: function () {
|
||||||
|
var r = J(studio.page());
|
||||||
|
if (r.error) { toast(r.error.message || String(r.error), true); return; }
|
||||||
|
window.open(URL.createObjectURL(new Blob([r.html], { type: "text/html" })), "_blank", "noopener");
|
||||||
|
},
|
||||||
|
agentBox: function () {
|
||||||
|
var dir = isFolder() ? "<folder of " + P.rec.name + ">" : "<project folder>";
|
||||||
|
return '<div class="st-box"><h2>AI agents (MCP)</h2>' +
|
||||||
|
'<p class="st-help">The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
|
||||||
|
(isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") +
|
||||||
|
"<code>isokit mcp " + esc(dir) + "</code></div>";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function route(path, body) {
|
||||||
|
var get = body === undefined;
|
||||||
|
if (get && path === "/api/meta") {
|
||||||
|
var m = J(studio.meta()), css = studio.theme_css();
|
||||||
|
if (css !== themeCss) { themeCss = css; $("iso-theme").textContent = css; }
|
||||||
|
if (!isFolder() && m.title && m.title !== P.rec.name) { P.rec.name = m.title; store.put(P.rec); }
|
||||||
|
$("wb-where").textContent = isFolder() ? "📁 " + P.rec.name : "in this browser";
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
if (get && path === "/api/doc") return J(studio.state());
|
||||||
|
if (get && path === "/api/version") {
|
||||||
|
var v = studio.version(), r = { version: v };
|
||||||
|
if (v !== lastVersion) { lastVersion = v; if (P.why) { r.why = P.why; P.why = null; } }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if (get && path === "/api/check") return J(studio.check());
|
||||||
|
var res;
|
||||||
|
if (path === "/api/op") res = J(studio.op(JSON.stringify(body)));
|
||||||
|
else if (path === "/api/undo") res = J(studio.undo());
|
||||||
|
else if (path === "/api/redo") res = J(studio.redo());
|
||||||
|
else if (path === "/api/build") return buildPage();
|
||||||
|
else return { error: "unknown request " + path };
|
||||||
|
lastVersion = studio.version();
|
||||||
|
return flush().then(function () { return res; });
|
||||||
|
}
|
||||||
|
function buildPage() {
|
||||||
|
if (isFolder()) { // like `isokit build`: the page goes where isokit.toml says (dist/index.html)
|
||||||
|
var out = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: "build_html", arguments: {} } })));
|
||||||
|
var res = out.result || {};
|
||||||
|
if (res.isError) return { error: res.content[0].text };
|
||||||
|
return flush().then(function () { return { message: "Page written to the folder: " + J(res.content[0].text).path.replace(/\\/g, "/") }; });
|
||||||
|
}
|
||||||
|
var r = J(studio.page());
|
||||||
|
if (r.error) return { error: r.error.message || String(r.error) };
|
||||||
|
var name = slug(P.rec.name) + ".html";
|
||||||
|
download(name, new Blob([r.html], { type: "text/html" }));
|
||||||
|
return { message: "Page downloaded: " + name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ MCP inside the page (and WebMCP)
|
||||||
|
var rpcId = 0;
|
||||||
|
function mcpCall(msg) {
|
||||||
|
var out = studio.mcp(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||||
|
P.why = "Edited by an agent (MCP)";
|
||||||
|
return flush().then(function () { return out ? J(out) : null; });
|
||||||
|
}
|
||||||
|
function callTool(name, args) {
|
||||||
|
return mcpCall({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: name, arguments: args || {} } }).then(function (r) { return r.result; });
|
||||||
|
}
|
||||||
|
function registerWebMcp() {
|
||||||
|
var mc = navigator.modelContext;
|
||||||
|
if (!mc) return 0;
|
||||||
|
var tools = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" }))).result.tools.map(function (t) {
|
||||||
|
return { name: t.name, description: "isokit: " + t.description, inputSchema: t.inputSchema, execute: function (args) { return callTool(t.name, args); } };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (typeof mc.provideContext === "function") mc.provideContext({ tools: tools });
|
||||||
|
else if (typeof mc.registerTool === "function") tools.forEach(function (t) { mc.registerTool(t); });
|
||||||
|
else return 0;
|
||||||
|
} catch (e) { console.warn("isokit: WebMCP registration failed", e); return 0; }
|
||||||
|
return tools.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ UI: header, projects, code
|
||||||
|
function injectUI() {
|
||||||
|
var title = $("title");
|
||||||
|
title.insertAdjacentHTML("afterend",
|
||||||
|
'<span class="wb-where" id="wb-where"></span>' +
|
||||||
|
'<button type="button" id="wb-projects" title="Open, create, import and export projects">Projects ▾</button>' +
|
||||||
|
'<button type="button" id="wb-code" title="Edit the scripts, prefabs, theme and settings">{ } Code</button>');
|
||||||
|
document.body.insertAdjacentHTML("beforeend",
|
||||||
|
'<dialog id="wb-projdlg" class="st-dialog wb-projdlg">' +
|
||||||
|
'<h3>Projects</h3>' +
|
||||||
|
'<div class="wb-cur" id="wb-cur"></div>' +
|
||||||
|
'<ul class="wb-plist" id="wb-plist"></ul>' +
|
||||||
|
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button></div>' +
|
||||||
|
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
|
||||||
|
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
|
||||||
|
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
|
||||||
|
'<input type="file" id="wb-fzip" accept=".zip,application/zip" hidden><input type="file" id="wb-fdir" webkitdirectory multiple hidden>' +
|
||||||
|
'<form method="dialog"><button>Close</button></form></dialog>' +
|
||||||
|
'<dialog id="wb-codedlg" class="st-dialog wb-codedlg">' +
|
||||||
|
'<div class="wb-chead"><b>Code</b><span class="wb-cpath" id="wb-cpath"></span><span class="st-grow"></span>' +
|
||||||
|
'<button type="button" id="wb-cnew">New file</button><button type="button" id="wb-cdel">Delete file</button>' +
|
||||||
|
'<button type="button" id="wb-capply" title="Ctrl+S">Apply</button><button type="button" id="wb-cclose">Close</button></div>' +
|
||||||
|
'<div class="wb-cbody"><ul class="wb-cfiles" id="wb-cfiles"></ul><textarea id="wb-ctext" spellcheck="false" autocomplete="off" wrap="off"></textarea></div>' +
|
||||||
|
'<div class="wb-cmsg" id="wb-cmsg"></div></dialog>' +
|
||||||
|
'<div class="wb-gate" id="wb-gate" hidden></div>');
|
||||||
|
$("wb-projects").addEventListener("click", showProjects);
|
||||||
|
$("wb-code").addEventListener("click", function () { showCode(); });
|
||||||
|
wireProjects(); wireCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProjects() {
|
||||||
|
var list = (await store.all()).sort(function (a, b) { return b.updated - a.updated; });
|
||||||
|
var cur = P.rec;
|
||||||
|
$("wb-cur").innerHTML = cur ? "<b>" + esc(cur.name) + "</b> <small>" + (cur.kind === "folder" ? "folder on disk" : "in this browser") + "</small>" +
|
||||||
|
'<span class="st-grow"></span><button type="button" data-act="export" title="All project files as a .zip (for isokit on the desktop, git, a colleague)">Export .zip</button>' : "No project open";
|
||||||
|
$("wb-plist").innerHTML = list.map(function (r) {
|
||||||
|
var when = new Date(r.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
return '<li data-id="' + esc(r.id) + '"' + (cur && r.id === cur.id ? ' aria-current="true"' : "") + "><i>" + (r.kind === "folder" ? "📁" : "▣") + "</i><span>" + esc(r.name) + "</span><small>" + esc(when) + "</small>" +
|
||||||
|
'<button type="button" data-act="open"' + (cur && r.id === cur.id ? " disabled" : "") + '>Open</button><button type="button" data-act="del" title="' + (r.kind === "folder" ? "Forget the folder (its files stay)" : "Delete from this browser") + '">' + (r.kind === "folder" ? "Forget" : "Delete") + "</button></li>";
|
||||||
|
}).join("") || '<li class="wb-empty">No projects yet.</li>';
|
||||||
|
if (!$("wb-projdlg").open) $("wb-projdlg").showModal();
|
||||||
|
}
|
||||||
|
function wireProjects() {
|
||||||
|
$("wb-projdlg").addEventListener("click", async function (e) {
|
||||||
|
var b = e.target.closest("button[data-act]"); if (!b) return;
|
||||||
|
var a = b.getAttribute("data-act"), li = b.closest("li"), id = li && li.getAttribute("data-id");
|
||||||
|
if (a === "export") exportZip();
|
||||||
|
else if (a === "open") openProject(id);
|
||||||
|
else if (a === "del") {
|
||||||
|
var r = await store.get(id);
|
||||||
|
if (!r || !confirm((r.kind === "folder" ? "Forget the folder \u201c" : "Delete \u201c") + r.name + "\u201d" + (r.kind === "folder" ? "? Its files stay on disk." : " from this browser? Export it first to keep a copy."))) return;
|
||||||
|
await store.del(id);
|
||||||
|
if (P.rec && id === P.rec.id) { localStorage.removeItem("isokit-web-last"); location.hash = ""; location.reload(); return; }
|
||||||
|
showProjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("wb-ncreate").addEventListener("click", async function () {
|
||||||
|
var t = $("wb-ntitle").value.trim() || "New site";
|
||||||
|
openProject((await createProject(J(new_project_files(t)), t)).id);
|
||||||
|
});
|
||||||
|
$("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); });
|
||||||
|
$("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); });
|
||||||
|
$("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); });
|
||||||
|
$("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); });
|
||||||
|
$("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });
|
||||||
|
$("wb-fdir").addEventListener("change", async function () {
|
||||||
|
var all = {}, list = Array.prototype.slice.call(this.files);
|
||||||
|
for (var f of list) { var p = f.webkitRelativePath || f.name; if (inProject(p)) all[p] = await f.text(); }
|
||||||
|
this.value = "";
|
||||||
|
importFiles(all);
|
||||||
|
});
|
||||||
|
if (canOpenFolder) $("wb-ofolder").addEventListener("click", openFolder);
|
||||||
|
var drop = $("wb-drop");
|
||||||
|
["dragenter", "dragover"].forEach(function (k) { drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.add("wb-over"); }); });
|
||||||
|
drop.addEventListener("dragleave", function () { drop.classList.remove("wb-over"); });
|
||||||
|
drop.addEventListener("drop", async function (e) {
|
||||||
|
e.preventDefault(); drop.classList.remove("wb-over");
|
||||||
|
var items = Array.prototype.slice.call(e.dataTransfer.items || []), files = Array.prototype.slice.call(e.dataTransfer.files || []);
|
||||||
|
var zipFile = files.filter(function (f) { return /\.zip$/i.test(f.name); })[0];
|
||||||
|
if (zipFile) { importZip(zipFile); return; }
|
||||||
|
var all = {}, jobs = [];
|
||||||
|
items.forEach(function (it) { var en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (en) jobs.push(readEntry(en, en.name, all)); });
|
||||||
|
try { await Promise.all(jobs); importFiles(all); } catch (err) { toast("Could not read the drop: " + err, true); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function importFiles(all) {
|
||||||
|
try {
|
||||||
|
var files = reroot(all);
|
||||||
|
new Studio("check", JSON.stringify(files)).free();
|
||||||
|
openProject((await createProject(files)).id);
|
||||||
|
} catch (e) { toast("Not imported: " + (e.message || e), true); }
|
||||||
|
}
|
||||||
|
async function importZip(f) {
|
||||||
|
try { importFiles(await unzip(await f.arrayBuffer())); } catch (e) { toast("Not imported: " + (e.message || e), true); }
|
||||||
|
}
|
||||||
|
async function openFolder() {
|
||||||
|
var dir;
|
||||||
|
try { dir = await window.showDirectoryPicker({ id: "isokit", mode: "readwrite" }); } catch (e) { return; }
|
||||||
|
var got = await readFolder(dir);
|
||||||
|
if (!got.files["isokit.toml"]) {
|
||||||
|
if (!confirm("\u201c" + dir.name + "\u201d has no isokit.toml. Start a new project in it?")) return;
|
||||||
|
var fresh = J(new_project_files(dir.name));
|
||||||
|
for (var p in fresh) { var h = await fileHandle(dir, p, true), w = await h.createWritable(); await w.write(fresh[p]); await w.close(); }
|
||||||
|
}
|
||||||
|
var rec = { id: newId(), name: dir.name, kind: "folder", handle: dir, updated: Date.now() };
|
||||||
|
await store.put(rec);
|
||||||
|
openProject(rec.id);
|
||||||
|
}
|
||||||
|
function exportZip() {
|
||||||
|
var name = slug(P.rec.name);
|
||||||
|
download(name + ".zip", zip(P.files, name + "/"));
|
||||||
|
toast("Exported " + name + ".zip");
|
||||||
|
}
|
||||||
|
async function demoFiles() { return (await fetch(asset("demo.json"))).json(); }
|
||||||
|
|
||||||
|
// the code editor: the project's files, applied as one outside edit
|
||||||
|
var C = { path: null, dirty: false };
|
||||||
|
function codeFiles() { return Object.keys(P.files).sort(function (a, b) { return (a.indexOf("/") >= 0) - (b.indexOf("/") >= 0) || a.localeCompare(b); }); }
|
||||||
|
function showCode(path) {
|
||||||
|
var files = codeFiles();
|
||||||
|
path = path || C.path || (files.indexOf("scene.rhai") >= 0 ? "scene.rhai" : "isokit.toml");
|
||||||
|
if (!(path in P.files)) path = files[0];
|
||||||
|
C.path = path; C.dirty = false;
|
||||||
|
$("wb-cfiles").innerHTML = files.map(function (p) { return '<li data-path="' + esc(p) + '" aria-selected="' + (p === path) + '">' + esc(p) + "</li>"; }).join("");
|
||||||
|
$("wb-cpath").textContent = path || "";
|
||||||
|
$("wb-ctext").value = path ? P.files[path] : "";
|
||||||
|
$("wb-capply").disabled = true;
|
||||||
|
codeMsg();
|
||||||
|
if (!$("wb-codedlg").open) $("wb-codedlg").showModal();
|
||||||
|
$("wb-ctext").focus();
|
||||||
|
}
|
||||||
|
/** Errors of the loaded project (red), else `note`. */
|
||||||
|
function codeMsg(note) {
|
||||||
|
var m = $("wb-cmsg"), errs = [];
|
||||||
|
if (studio) {
|
||||||
|
var meta = J(studio.meta());
|
||||||
|
if (meta.load_error) errs.push(meta.load_error);
|
||||||
|
var e = meta.script_error;
|
||||||
|
if (e) errs.push((e.file || "script") + (e.line ? ":" + e.line : "") + (e.col ? ":" + e.col : "") + ": " + e.message);
|
||||||
|
}
|
||||||
|
m.textContent = errs.length ? errs.join("\n") : note || "Scripts run when you apply (Ctrl+S). Changes can be undone in the Studio.";
|
||||||
|
m.classList.toggle("wb-bad", errs.length > 0);
|
||||||
|
}
|
||||||
|
function leaveCode() { return !C.dirty || confirm("Discard the changes to " + C.path + "?"); }
|
||||||
|
async function applyCode() {
|
||||||
|
if (!C.path) return;
|
||||||
|
var ch = {}; ch[C.path] = $("wb-ctext").value;
|
||||||
|
outside(ch, "Code applied");
|
||||||
|
await persist(ch);
|
||||||
|
C.dirty = false; $("wb-capply").disabled = true;
|
||||||
|
if (!studio) { tryStart(); return; }
|
||||||
|
codeMsg("Applied: " + C.path);
|
||||||
|
}
|
||||||
|
function wireCode() {
|
||||||
|
var ta = $("wb-ctext");
|
||||||
|
ta.addEventListener("input", function () { C.dirty = true; $("wb-capply").disabled = false; });
|
||||||
|
ta.addEventListener("keydown", function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S" || e.key === "Enter")) { e.preventDefault(); applyCode(); return; }
|
||||||
|
if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end"); C.dirty = true; $("wb-capply").disabled = false; }
|
||||||
|
e.stopPropagation(); // the Studio's keys (Delete, arrows, R) must not fire while typing
|
||||||
|
});
|
||||||
|
$("wb-cfiles").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li && leaveCode()) showCode(li.getAttribute("data-path")); });
|
||||||
|
$("wb-capply").addEventListener("click", applyCode);
|
||||||
|
$("wb-cclose").addEventListener("click", function () { if (leaveCode()) $("wb-codedlg").close(); });
|
||||||
|
$("wb-codedlg").addEventListener("cancel", function (e) { if (!leaveCode()) e.preventDefault(); });
|
||||||
|
$("wb-cnew").addEventListener("click", async function () {
|
||||||
|
if (!leaveCode()) return;
|
||||||
|
var p = (prompt("New file (relative path), e.g. lib/kiosk.rhai", "lib/new.rhai") || "").trim().replace(/\\/g, "/");
|
||||||
|
if (!p) return;
|
||||||
|
if (!projectFile(p) || /(^|\/)\.\.(\/|$)/.test(p) || p.charAt(0) === "/") { toast("Use a relative path ending in .rhai, .toml or .json", true); return; }
|
||||||
|
if (p in P.files) { showCode(p); return; }
|
||||||
|
var body = /\.rhai$/.test(p) ? "// " + p + ": list it in isokit.toml under prelude to load its prefabs\n" : "";
|
||||||
|
var ch = {}; ch[p] = body; outside(ch, "File added: " + p); await persist(ch);
|
||||||
|
showCode(p);
|
||||||
|
});
|
||||||
|
$("wb-cdel").addEventListener("click", async function () {
|
||||||
|
if (!C.path || C.path === "isokit.toml") { toast("isokit.toml is the project itself and stays", true); return; }
|
||||||
|
if (!confirm("Delete " + C.path + "?")) return;
|
||||||
|
var ch = {}; ch[C.path] = null; outside(ch, "File deleted: " + C.path); await persist(ch);
|
||||||
|
C.path = null; showCode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ start
|
||||||
|
function gate(html) { var g = $("wb-gate"); g.innerHTML = '<div class="wb-gatebox">' + html + "</div>"; g.hidden = false; return g; }
|
||||||
|
function tryStart() {
|
||||||
|
try { studio = new Studio(isFolder() ? P.rec.name : "browser: " + P.rec.name, JSON.stringify(P.files)); }
|
||||||
|
catch (e) {
|
||||||
|
var g = gate("<h3>" + esc(P.rec.name) + " does not load</h3><pre>" + esc(String(e)) + "</pre>" +
|
||||||
|
'<p class="st-help">Fix the file in the code editor, or open another project.</p>' +
|
||||||
|
'<button type="button" id="wb-gcode">Edit the files</button> <button type="button" id="wb-gproj">Projects</button>');
|
||||||
|
g.querySelector("#wb-gcode").onclick = function () { showCode("isokit.toml"); };
|
||||||
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("wb-gate").hidden = true;
|
||||||
|
window.IsoKitBackend = backend();
|
||||||
|
window.isokit = {
|
||||||
|
engine: engine_version(), project: P.rec.name, studio: studio,
|
||||||
|
files: function () { return Object.assign({}, P.files); },
|
||||||
|
mcp: mcpCall, call: callTool, webmcp: registerWebMcp()
|
||||||
|
};
|
||||||
|
var s = document.createElement("script");
|
||||||
|
s.src = asset("../studio/studio.js").href;
|
||||||
|
document.body.appendChild(s);
|
||||||
|
}
|
||||||
|
async function boot() {
|
||||||
|
injectUI();
|
||||||
|
await init({ module_or_path: asset("isokit_wasm_bg.wasm") });
|
||||||
|
var m = /[#&]p=([^&]+)/.exec(location.hash), id = m ? decodeURIComponent(m[1]) : localStorage.getItem("isokit-web-last");
|
||||||
|
var rec = id ? await store.get(id) : null;
|
||||||
|
if (!rec) {
|
||||||
|
var all = await store.all();
|
||||||
|
rec = all.sort(function (a, b) { return b.updated - a.updated; })[0] || await createProject(await demoFiles());
|
||||||
|
}
|
||||||
|
P.rec = rec;
|
||||||
|
history.replaceState(null, "", "#p=" + encodeURIComponent(rec.id));
|
||||||
|
localStorage.setItem("isokit-web-last", rec.id);
|
||||||
|
window.addEventListener("hashchange", function () { location.reload(); });
|
||||||
|
if (isFolder()) {
|
||||||
|
if (!(await permitted(rec.handle, false))) {
|
||||||
|
var g = gate("<h3>Open the folder \u201c" + esc(rec.name) + "\u201d</h3><p>The browser asks once per visit before the Studio may edit files on disk.</p>" +
|
||||||
|
'<button type="button" id="wb-gallow">Allow access</button> <button type="button" id="wb-gproj">Other project</button>');
|
||||||
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
||||||
|
await new Promise(function (res) {
|
||||||
|
g.querySelector("#wb-gallow").onclick = async function () { if (await permitted(rec.handle, true)) res(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var got = await readFolder(rec.handle);
|
||||||
|
P.files = got.files; P.stats = got.stats;
|
||||||
|
watchFolder();
|
||||||
|
} else {
|
||||||
|
P.files = rec.files || {};
|
||||||
|
}
|
||||||
|
tryStart();
|
||||||
|
}
|
||||||
|
// an engine panic leaves the WASM instance unusable: say so instead of failing silently
|
||||||
|
function crashed(e) {
|
||||||
|
if (!(e instanceof WebAssembly.RuntimeError) && !/unsafe aliasing|unreachable/.test(String(e && e.message))) return;
|
||||||
|
var g = gate("<h3>The engine stopped</h3><pre>" + esc(e.message || e) + "</pre><p class=\"st-help\">Your last saved state is kept. Reload to continue; the browser console has the details.</p>" +
|
||||||
|
'<button type="button" id="wb-greload">Reload</button>');
|
||||||
|
g.querySelector("#wb-greload").onclick = function () { location.reload(); };
|
||||||
|
}
|
||||||
|
window.addEventListener("error", function (e) { crashed(e.error); });
|
||||||
|
window.addEventListener("unhandledrejection", function (e) { crashed(e.reason); });
|
||||||
|
boot().catch(function (e) {
|
||||||
|
console.error(e);
|
||||||
|
gate("<h3>The web Studio could not start</h3><pre>" + esc(e && e.stack || e) + "</pre><p class=\"st-help\">It needs a current browser (WebAssembly, ES modules, IndexedDB).</p>");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user