/* 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, """); } 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() ? "" : ""; return '

AI agents (MCP)

' + '

The 15 isokit MCP tools run inside this page: ✦ Agent (top bar) chats with a model that uses them, browsers with WebMCP offer them to their assistant, and scripts call isokit.mcp(message).

' + (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:") + "isokit mcp " + esc(dir) + "
"; } }; } 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", '' + '' + ''); document.body.insertAdjacentHTML("beforeend", '' + '

Projects

' + '
' + '
    ' + '
    ' + '
    Import: drop a project folder or .zip here, or ' + (canOpenFolder ? '' : "") + "
    " + '

    Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (isokit mcp), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "

    " + '' + '
    ' + '' + '
    Code' + '' + '
    ' + '
      ' + '
      ' + ''); $("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 ? "" + esc(cur.name) + " " + (cur.kind === "folder" ? "folder on disk" : "in this browser") + "" + '' : "No project open"; $("wb-plist").innerHTML = list.map(function (r) { var when = new Date(r.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); return '
    • " + (r.kind === "folder" ? "📁" : "▣") + "" + esc(r.name) + "" + esc(when) + "" + '
    • "; }).join("") || '
    • No projects yet.
    • '; 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 '
    • ' + esc(p) + "
    • "; }).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 = '
      ' + html + "
      "; 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("

      " + esc(P.rec.name) + " does not load

      " + esc(String(e)) + "
      " + '

      Fix the file in the code editor, or open another project.

      ' + ' '); 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("

      Open the folder \u201c" + esc(rec.name) + "\u201d

      The browser asks once per visit before the Studio may edit files on disk.

      " + ' '); 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("

      The engine stopped

      " + esc(e.message || e) + "

      Your last saved state is kept. Reload to continue; the browser console has the details.

      " + ''); 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("

      The web Studio could not start

      " + esc(e && e.stack || e) + "

      It needs a current browser (WebAssembly, ES modules, IndexedDB).

      "); });