concept-collection / numbl-web-ide
numbl web IDE: run .m files in the browser with numbl
Project-picker app built on minwebide. Runs execute in a Web Worker via numbl's executeCode with the whole project as workspace (numbl.org semantics); text streams to the Output panel, figures render with numbl/graphics FigureView as secondary-side-bar views; hard-kill stop; VFS write-back to the project; MATLAB Monarch highlighting for .m files. Deploys to GitHub Pages with cross-origin isolation (headers in dev, coi-serviceworker injected at build).
Jeremy Magland <jmagland@flatironinstitute.org> committed commit ecdc86802a07 Browse files
24 changed files+5144−0
.github/workflows/deploy.ymladded+81−0View file
@@ -0,0 +1,81 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: false
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - name: Checkout numbl-web-ide
22+ uses: actions/checkout@v4
23+ with:
24+ path: numbl-web-ide
25+
26+ # minwebide is consumed as a sibling checkout (file:../minwebide)
27+ - name: Checkout minwebide
28+ uses: actions/checkout@v4
29+ with:
30+ repository: magland/minwebide
31+ path: minwebide
32+
33+ - uses: actions/setup-node@v4
34+ with:
35+ node-version: 22
36+
37+ # the pinned VS Code source checkout is large; cache it by pinned version
38+ - name: Cache VS Code source
39+ uses: actions/cache@v4
40+ with:
41+ path: minwebide/vendor/vscode
42+ key: vscode-vendor-${{ hashFiles('minwebide/.vscode-version') }}
43+
44+ - name: Install minwebide (fetches VS Code source on postinstall)
45+ working-directory: minwebide
46+ env:
47+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
48+ run: npm ci
49+
50+ - name: Install numbl-web-ide
51+ working-directory: numbl-web-ide
52+ env:
53+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
54+ run: npm ci
55+
56+ - name: Build
57+ working-directory: numbl-web-ide
58+ env:
59+ DEPLOY_BASE: /numbl-web-ide/
60+ run: npm run build
61+
62+ - name: Setup Pages
63+ uses: actions/configure-pages@v5
64+ with:
65+ enablement: true
66+
67+ - name: Upload artifact
68+ uses: actions/upload-pages-artifact@v3
69+ with:
70+ path: numbl-web-ide/dist
71+
72+ deploy:
73+ needs: build
74+ runs-on: ubuntu-latest
75+ environment:
76+ name: github-pages
77+ url: ${{ steps.deployment.outputs.page_url }}
78+ steps:
79+ - name: Deploy to GitHub Pages
80+ id: deployment
81+ uses: actions/deploy-pages@v4
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+47−0View file
@@ -0,0 +1,47 @@
1+# numbl web IDE
2+
3+Run MATLAB-syntax `.m` files in your browser with
4+[numbl](https://numbl.org), inside a VS Code-style IDE built on
5+[minwebide](https://github.com/magland/minwebide). Projects live in your
6+browser's IndexedDB.
7+
8+**Live site:** https://concept-collection.github.io/numbl-web-ide/
9+
10+The landing page manages **projects** (create / rename / duplicate / delete),
11+each with its own file system, addressed as `#/project/<id>`. Create a
12+**sample project** and press **▶** on a script:
13+
14+- Text output streams to the **Output** panel; figures open as **Figure N**
15+ views in the secondary side bar (rendered by numbl's own figure renderer —
16+ 2-D canvas, 3-D three.js).
17+- The whole project is the workspace, numbl.org-style: functions next to the
18+ running script resolve automatically, other folders via `addpath`.
19+- Runs use the current editor contents (saved or not), execute in a Web
20+ Worker, and can be stopped with **⏹**.
21+- Files a script writes (`fopen`/`fprintf`) appear in the Explorer.
22+- The project name in the status bar takes you back to the project list.
23+
24+## Development
25+
26+minwebide is consumed as a sibling checkout (`file:../minwebide`):
27+
28+```sh
29+git clone https://github.com/magland/minwebide ../minwebide
30+(cd ../minwebide && npm install) # fetches the pinned VS Code source
31+npm install
32+npm run dev
33+```
34+
35+- `npm run build` — static bundle in `dist/`
36+- `npm run typecheck` — typechecks app code (vendor diagnostics suppressed)
37+- `npm run smoke` — headless end-to-end test against the built bundle
38+- `bash scripts/generate-builtins.sh` — refresh the syntax-highlighting
39+ builtin list after upgrading the `numbl` dependency
40+
41+Cross-origin isolation: dev/preview servers send COOP/COEP headers so
42+`SharedArrayBuffer` is available (numbl uses it for `pause()` timing). The
43+deployed site gets the same via `coi-serviceworker.js`, injected into
44+`index.html` only at build time — dev never registers a service worker.
45+
46+CI checks out `magland/minwebide` next to this repo, installs both, builds,
47+and publishes `dist/` to GitHub Pages.
index.htmladded+24−0View file
@@ -0,0 +1,24 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23d95f18'/%3E%3C/svg%3E" />
7+ <title>numbl web IDE</title>
8+ <style>
9+ html,
10+ body {
11+ height: 100%;
12+ margin: 0;
13+ padding: 0;
14+ }
15+ #app {
16+ height: 100%;
17+ }
18+ </style>
19+ </head>
20+ <body>
21+ <div id="app"></div>
22+ <script type="module" src="/src/main.ts"></script>
23+ </body>
24+</html>
package-lock.jsonadded+3019−0View file
This diff is 3,024 lines long and is not shown.
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "numbl-web-ide",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "typecheck": "bash scripts/typecheck.sh",
11+ "smoke": "node scripts/smoke.mjs"
12+ },
13+ "dependencies": {
14+ "minwebide": "file:../minwebide",
15+ "numbl": "^0.4.8",
16+ "react": "^19.2.7",
17+ "react-dom": "^19.2.7"
18+ },
19+ "devDependencies": {
20+ "@types/react": "^19.2.17",
21+ "@types/react-dom": "^19.2.3",
22+ "playwright": "^1.61.1",
23+ "typescript": "^5.9.0",
24+ "vite": "^7.0.0"
25+ }
26+}
public/coi-serviceworker.jsadded+72−0View file
@@ -0,0 +1,72 @@
1+/*
2+ * Cross-Origin Isolation Service Worker
3+ *
4+ * Adds COOP/COEP headers to responses so that SharedArrayBuffer is available
5+ * on hosts that don't allow custom response headers (e.g. GitHub Pages).
6+ *
7+ * Based on https://github.com/niccokunzmann/coi-serviceworker (MIT).
8+ */
9+
10+/* global self, caches, fetch, Response, clients */
11+
12+if (typeof window === "undefined") {
13+ // --- Service Worker scope ---
14+ self.addEventListener("install", () => self.skipWaiting());
15+ self.addEventListener("activate", event =>
16+ event.waitUntil(self.clients.claim())
17+ );
18+
19+ self.addEventListener("fetch", event => {
20+ const request = event.request;
21+ if (request.cache === "only-if-cached" && request.mode !== "same-origin") {
22+ return; // Chrome bug workaround
23+ }
24+
25+ // Only add isolation headers to same-origin responses.
26+ // Wrapping cross-origin responses in a new Response strips CORS
27+ // internal flags, which breaks cross-origin fetch requests.
28+ if (new URL(request.url).origin !== self.location.origin) {
29+ return; // let the browser handle cross-origin requests normally
30+ }
31+
32+ event.respondWith(
33+ fetch(request).then(response => {
34+ if (response.status === 0) return response; // opaque response
35+
36+ const headers = new Headers(response.headers);
37+ // must match the value the dev/preview servers send — a document and
38+ // its dedicated workers with mismatched COEP values fail to load
39+ headers.set("Cross-Origin-Embedder-Policy", "require-corp");
40+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
41+
42+ return new Response(response.body, {
43+ status: response.status,
44+ statusText: response.statusText,
45+ headers,
46+ });
47+ })
48+ );
49+ });
50+} else {
51+ // --- Window scope (registration) ---
52+
53+ // Capture currentScript synchronously — it becomes null after script runs.
54+ const scriptUrl = document.currentScript && document.currentScript.src;
55+
56+ if (!window.crossOriginIsolated && navigator.serviceWorker) {
57+ navigator.serviceWorker.register(scriptUrl || "/coi-serviceworker.js").then(
58+ reg => {
59+ if (reg.installing || reg.waiting) {
60+ const sw = reg.installing || reg.waiting;
61+ sw.addEventListener("statechange", () => {
62+ if (sw.state === "activated") window.location.reload();
63+ });
64+ } else if (reg.active && !navigator.serviceWorker.controller) {
65+ // Active but not yet controlling — reload to let it intercept.
66+ window.location.reload();
67+ }
68+ },
69+ err => console.error("COI service worker registration failed:", err)
70+ );
71+ }
72+}
scripts/generate-builtins.shadded+18−0View file
@@ -0,0 +1,18 @@
1+#!/usr/bin/env bash
2+# Regenerates src/numbl/builtinNames.ts from the installed numbl package's
3+# builtin registry (used only for editor syntax highlighting). Re-run after
4+# upgrading the numbl dependency.
5+set -euo pipefail
6+
7+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8+OUT="$ROOT/src/numbl/builtinNames.ts"
9+
10+{
11+ echo "// Generated by scripts/generate-builtins.sh — do not edit."
12+ echo "// Builtin function names of the installed numbl version, for syntax highlighting."
13+ echo "export const numblBuiltinNames: string[] = ["
14+ (cd "$ROOT" && npx numbl list-builtins) | sed "s/^/\t'/; s/$/',/"
15+ echo "];"
16+} > "$OUT"
17+
18+echo "wrote $OUT ($(grep -c "'," "$OUT") names)"
scripts/smoke.mjsadded+109−0View file
@@ -0,0 +1,109 @@
1+// End-to-end smoke test: landing → sample project → run .m scripts with
2+// numbl (streamed output, figures in the side bar, stop button, file
3+// write-back) → project lifecycle basics.
4+import { chromium } from 'playwright';
5+import { spawn } from 'node:child_process';
6+
7+const root = new URL('..', import.meta.url).pathname;
8+const out = process.argv[2] ?? '.';
9+const previewProc = spawn('npx', ['vite', 'preview', '--port', '4181', '--strictPort'], { stdio: 'ignore', cwd: root });
10+await new Promise((r) => setTimeout(r, 1500));
11+
12+const browser = await chromium.launch({ channel: 'chrome', headless: true });
13+const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
14+const errors = [];
15+page.on('pageerror', (e) => errors.push(e.message));
16+page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
17+const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
18+// monaco renders spaces as U+00A0 — normalize before matching
19+const outputText = async () => (await page.locator('.mw-output').innerText()).replace(/ /g, ' ');
20+const waitForOutput = async (needle, timeout = 20000) => {
21+ const start = Date.now();
22+ while (Date.now() - start < timeout) {
23+ if ((await outputText()).includes(needle)) return true;
24+ await page.waitForTimeout(250);
25+ }
26+ return false;
27+};
28+
29+try {
30+ await page.goto('http://localhost:4181/', { waitUntil: 'networkidle' });
31+ await page.waitForTimeout(1200);
32+ check('landing renders', await page.locator('.landing-empty').count() === 1);
33+ await page.screenshot({ path: out + '/n-landing.png' });
34+
35+ // sample project → IDE opens waves.m
36+ await page.getByRole('button', { name: 'New sample project' }).click();
37+ await page.waitForTimeout(1800);
38+ check('URL has project route', /#\/project\/[a-z0-9]+/i.test(page.url()));
39+ check('IDE opened with waves.m', await page.locator('.mw-tab-label', { hasText: 'waves.m' }).count() === 1);
40+
41+ // run waves.m: streamed text + Figure 1 in the secondary side bar
42+ await page.locator('.mw-tab-action[title="numbl"]').click();
43+ check('run output streamed', await waitForOutput('peak amplitude'));
44+ await page.waitForTimeout(1500); // lazy graphics stack + first render
45+ check('figure view appears', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1);
46+ check('figure canvas rendered', await page.locator('.mw-auxbar canvas').count() >= 1);
47+ await page.screenshot({ path: out + '/n-waves.png' });
48+
49+ // cross-folder run: scripts/surface_demo.m (addpath + 3-D surface)
50+ await page.getByText('scripts', { exact: true }).click();
51+ await page.waitForTimeout(400);
52+ await page.getByText('surface_demo.m', { exact: true }).click();
53+ await page.waitForTimeout(600);
54+ await page.locator('.mw-tab-action[title="numbl"]').click();
55+ check('surface demo output', await waitForOutput('surface rendered'));
56+ await page.waitForTimeout(1000);
57+ check('surface figure view', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1);
58+ await page.screenshot({ path: out + '/n-surface.png' });
59+
60+ // stop button: animation.m runs for seconds; ▶ becomes ⏹ while running
61+ await page.getByText('animation.m', { exact: true }).click();
62+ await page.waitForTimeout(600);
63+ await page.locator('.mw-tab-action[title="numbl"]').click();
64+ const stopButton = page.locator('.mw-tab-action[title="Stop numbl"]');
65+ let stopSeen = false;
66+ for (let i = 0; i < 20 && !(stopSeen = await stopButton.count() === 1); i++) {
67+ await page.waitForTimeout(150);
68+ }
69+ check('stop button shown while running', stopSeen);
70+ if (stopSeen) {
71+ // force: the aux bar reveal shifts layout while frames stream
72+ await page.waitForTimeout(1000);
73+ await stopButton.click({ force: true });
74+ }
75+ await page.waitForTimeout(700);
76+ check('play button back after stop', await page.locator('.mw-tab-action[title="numbl"]').count() === 1);
77+
78+ // file write-back: write_results.m creates scripts/results/summary.txt
79+ await page.getByText('write_results.m', { exact: true }).click();
80+ await page.waitForTimeout(600);
81+ await page.locator('.mw-tab-action[title="numbl"]').click();
82+ check('write_results output', await waitForOutput('wrote results/summary.txt'));
83+ await page.waitForTimeout(800);
84+ await page.getByText('results', { exact: true }).click();
85+ await page.waitForTimeout(400);
86+ check('written file appears in explorer', await page.getByText('summary.txt', { exact: true }).count() === 1);
87+ await page.screenshot({ path: out + '/n-results.png' });
88+
89+ // project lifecycle basics
90+ await page.getByTitle('Back to projects').click();
91+ await page.waitForTimeout(800);
92+ check('back on landing', await page.locator('.landing-project').count() === 1);
93+ await page.getByRole('button', { name: 'New project', exact: true }).click();
94+ await page.waitForTimeout(1500);
95+ check('empty project opens main.m', await page.locator('.mw-tab-label', { hasText: 'main.m' }).count() === 1);
96+ await page.goto('http://localhost:4181/#/project/nope1234', { waitUntil: 'networkidle' });
97+ await page.waitForTimeout(800);
98+ check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
99+
100+ if (errors.length) {
101+ console.log('page errors:');
102+ for (const e of errors.slice(0, 10)) console.log(' ' + e);
103+ } else {
104+ console.log('no page errors');
105+ }
106+} finally {
107+ await browser.close();
108+ previewProc.kill();
109+}
scripts/typecheck.shadded+21−0View file
@@ -0,0 +1,21 @@
1+#!/usr/bin/env bash
2+# Typecheck the app. Diagnostics inside the minwebide vendor tree (VS Code
3+# source) are reported as a count only — they stem from TS-version and
4+# ambient-type differences with VS Code's own build and never affect the
5+# bundle. Errors in the app's own code fail the check.
6+set -uo pipefail
7+
8+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9+OUTPUT="$(cd "$ROOT" && npx tsc --noEmit --pretty false 2>&1)"
10+
11+VENDOR_COUNT="$(printf '%s\n' "$OUTPUT" | grep -cE '/vendor/vscode/' || true)"
12+OURS="$(printf '%s\n' "$OUTPUT" | grep -vE '/vendor/vscode/|^ ' | grep -v '^$' || true)"
13+
14+if [ -n "$VENDOR_COUNT" ] && [ "$VENDOR_COUNT" != "0" ]; then
15+ echo "note: $VENDOR_COUNT vendor diagnostics suppressed (run 'npx tsc --noEmit' to see them)"
16+fi
17+if [ -n "$OURS" ]; then
18+ printf '%s\n' "$OURS"
19+ exit 1
20+fi
21+echo "typecheck OK"
src/ide.tsadded+46−0View file
@@ -0,0 +1,46 @@
1+import { createWorkbench, type WorkbenchTheme } from 'minwebide';
2+import { createNumblRunner } from './numbl/runner';
3+import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
4+
5+/** Opens the IDE for a project. Returns a disposable view. */
6+export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
7+ touchProject(project.id);
8+ document.title = `${project.name} — numbl web IDE`;
9+
10+ const fs = await openProjectFileSystem(project.id);
11+
12+ const workbench = createWorkbench(container, {
13+ fileSystem: fs,
14+ theme,
15+ workspaceName: project.name,
16+ });
17+
18+ const numbl = createNumblRunner(fs, workbench);
19+ workbench.registerRunner(numbl.runner);
20+
21+ // the project indicator: click to go back to the project list
22+ workbench.statusBar.setItem('project', 'left', project.name, {
23+ icon: 'folder-opened',
24+ title: 'Back to projects',
25+ onClick: () => { location.hash = '#/'; },
26+ });
27+ // replace the default branding item with the project indicator
28+ workbench.statusBar.removeItem('branding');
29+
30+ // open the most useful starting file
31+ for (const path of ['/waves.m', '/main.m', '/README.md']) {
32+ const uri = fs.root.with({ path });
33+ if (await fs.fileService.exists(uri)) {
34+ await workbench.openFile(uri);
35+ break;
36+ }
37+ }
38+
39+ return {
40+ dispose() {
41+ numbl.dispose();
42+ workbench.dispose();
43+ fs.dispose();
44+ },
45+ };
46+}
src/landing.cssadded+157−0View file
@@ -0,0 +1,157 @@
1+/* Project-picker landing page, themed with the same --vscode-* variables as
2+ * the workbench (in the spirit of VS Code's welcome page). */
3+
4+.landing {
5+ height: 100%;
6+ overflow-y: auto;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 14px;
11+}
12+
13+.landing-inner {
14+ max-width: 720px;
15+ margin: 0 auto;
16+ padding: 48px 32px 64px;
17+}
18+
19+.landing-header h1 {
20+ margin: 0 0 6px;
21+ font-size: 34px;
22+ font-weight: 300;
23+ letter-spacing: 0.5px;
24+}
25+
26+.landing-subtitle {
27+ margin: 0 0 4px;
28+ color: var(--vscode-descriptionForeground);
29+}
30+
31+.landing-links {
32+ margin: 0;
33+ color: var(--vscode-descriptionForeground);
34+ font-size: 13px;
35+}
36+
37+.landing-link {
38+ color: var(--vscode-textLink-foreground);
39+ text-decoration: none;
40+ font-size: 13px;
41+}
42+
43+.landing-link:hover {
44+ text-decoration: underline;
45+}
46+
47+.landing-section {
48+ margin-top: 36px;
49+}
50+
51+.landing-section h2 {
52+ margin: 0 0 12px;
53+ font-size: 18px;
54+ font-weight: 400;
55+ border-bottom: 1px solid var(--vscode-panel-border);
56+ padding-bottom: 6px;
57+}
58+
59+.landing-start {
60+ display: flex;
61+ gap: 10px;
62+}
63+
64+.landing-button {
65+ padding: 6px 14px;
66+ font-size: 13px;
67+ font-family: inherit;
68+ cursor: pointer;
69+ border-radius: 3px;
70+ border: 1px solid var(--vscode-button-border, transparent);
71+ background-color: var(--vscode-button-secondaryBackground);
72+ color: var(--vscode-button-secondaryForeground);
73+}
74+
75+.landing-button:hover {
76+ background-color: var(--vscode-button-secondaryHoverBackground);
77+}
78+
79+.landing-button.primary {
80+ background-color: var(--vscode-button-background);
81+ color: var(--vscode-button-foreground);
82+}
83+
84+.landing-button.primary:hover {
85+ background-color: var(--vscode-button-hoverBackground);
86+}
87+
88+.landing-button:disabled {
89+ opacity: 0.6;
90+ cursor: default;
91+}
92+
93+.landing-projects {
94+ display: flex;
95+ flex-direction: column;
96+}
97+
98+.landing-empty {
99+ color: var(--vscode-descriptionForeground);
100+ padding: 8px 0;
101+}
102+
103+.landing-project {
104+ display: flex;
105+ align-items: center;
106+ gap: 12px;
107+ padding: 7px 8px;
108+ border-radius: 4px;
109+}
110+
111+.landing-project:hover {
112+ background-color: var(--vscode-list-hoverBackground);
113+}
114+
115+.landing-project-name {
116+ color: var(--vscode-textLink-foreground);
117+ text-decoration: none;
118+ font-size: 14px;
119+ overflow: hidden;
120+ text-overflow: ellipsis;
121+ white-space: nowrap;
122+}
123+
124+.landing-project-name:hover {
125+ text-decoration: underline;
126+}
127+
128+.landing-project-meta {
129+ color: var(--vscode-descriptionForeground);
130+ font-size: 12px;
131+ flex: 1;
132+}
133+
134+.landing-project-actions {
135+ display: flex;
136+ gap: 2px;
137+ visibility: hidden;
138+}
139+
140+.landing-project:hover .landing-project-actions {
141+ visibility: visible;
142+}
143+
144+.landing-action {
145+ background: none;
146+ border: none;
147+ padding: 2px 7px;
148+ font-size: 12px;
149+ font-family: inherit;
150+ cursor: pointer;
151+ border-radius: 3px;
152+ color: var(--vscode-textLink-foreground);
153+}
154+
155+.landing-action:hover {
156+ background-color: var(--vscode-toolbar-hoverBackground);
157+}
src/landing.tsadded+157−0View file
@@ -0,0 +1,157 @@
1+import { applyThemeToElement, type WorkbenchTheme } from 'minwebide';
2+import { createProject, deleteProject, duplicateProject, listProjects, nextUntitledName, openProjectFileSystem, renameProject, type ProjectInfo } from './projects';
3+import { sampleWorkspace } from './sampleWorkspace';
4+import './landing.css';
5+
6+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
7+ const node = document.createElement(tag);
8+ if (className) {
9+ node.className = className;
10+ }
11+ if (text !== undefined) {
12+ node.textContent = text;
13+ }
14+ return node;
15+}
16+
17+function openProject(id: string): void {
18+ location.hash = `#/project/${id}`;
19+}
20+
21+async function createSampleProject(): Promise<ProjectInfo> {
22+ const project = createProject(nextUntitledName('sample'));
23+ const fs = await openProjectFileSystem(project.id);
24+ try {
25+ await fs.seed(sampleWorkspace);
26+ } finally {
27+ fs.dispose();
28+ }
29+ return project;
30+}
31+
32+async function createEmptyProject(): Promise<ProjectInfo> {
33+ const project = createProject(nextUntitledName());
34+ const fs = await openProjectFileSystem(project.id);
35+ try {
36+ await fs.seed({
37+ '/main.m': `% ${project.name}\n% Write MATLAB-syntax code here and press the run button (▶).\n\ndisp('hello from numbl');\n`,
38+ });
39+ } finally {
40+ fs.dispose();
41+ }
42+ return project;
43+}
44+
45+function formatWhen(timestamp: number): string {
46+ const delta = Date.now() - timestamp;
47+ if (delta < 60_000) {
48+ return 'just now';
49+ }
50+ if (delta < 3_600_000) {
51+ return `${Math.round(delta / 60_000)}m ago`;
52+ }
53+ if (delta < 86_400_000) {
54+ return `${Math.round(delta / 3_600_000)}h ago`;
55+ }
56+ return new Date(timestamp).toLocaleDateString();
57+}
58+
59+/** Renders the project-picker landing page. Returns a disposable view. */
60+export function renderLanding(container: HTMLElement, theme: WorkbenchTheme): { dispose(): void } {
61+ const root = el('div', 'landing');
62+ applyThemeToElement(theme, root);
63+ container.appendChild(root);
64+ document.title = 'numbl web IDE';
65+
66+ const inner = el('div', 'landing-inner');
67+ root.appendChild(inner);
68+
69+ const header = el('header', 'landing-header');
70+ header.appendChild(el('h1', undefined, 'numbl web IDE'));
71+ header.appendChild(el('p', 'landing-subtitle', 'Run MATLAB-syntax .m files in your browser. Projects are stored locally, in your browser.'));
72+ const links = el('p', 'landing-links');
73+ const numblLink = el('a', 'landing-link', 'numbl.org');
74+ numblLink.href = 'https://numbl.org';
75+ const ghLink = el('a', 'landing-link', 'github.com/concept-collection/numbl-web-ide');
76+ ghLink.href = 'https://github.com/concept-collection/numbl-web-ide';
77+ links.append(numblLink, ' · ', ghLink);
78+ header.appendChild(links);
79+ inner.appendChild(header);
80+
81+ // start section
82+ const start = el('section', 'landing-section');
83+ start.appendChild(el('h2', undefined, 'Start'));
84+ const startButtons = el('div', 'landing-start');
85+ const newButton = el('button', 'landing-button primary', 'New project');
86+ newButton.addEventListener('click', async () => {
87+ newButton.disabled = true;
88+ openProject((await createEmptyProject()).id);
89+ });
90+ const sampleButton = el('button', 'landing-button', 'New sample project');
91+ sampleButton.title = 'Seeded with runnable scripts: plots, a 3-D surface, an animation, and file output';
92+ sampleButton.addEventListener('click', async () => {
93+ sampleButton.disabled = true;
94+ openProject((await createSampleProject()).id);
95+ });
96+ startButtons.append(newButton, sampleButton);
97+ start.appendChild(startButtons);
98+ inner.appendChild(start);
99+
100+ // projects section
101+ const section = el('section', 'landing-section');
102+ section.appendChild(el('h2', undefined, 'Projects'));
103+ const list = el('div', 'landing-projects');
104+ section.appendChild(list);
105+ inner.appendChild(section);
106+
107+ const renderList = () => {
108+ list.textContent = '';
109+ const projects = listProjects();
110+ if (projects.length === 0) {
111+ list.appendChild(el('div', 'landing-empty', 'No projects yet.'));
112+ return;
113+ }
114+ for (const project of projects) {
115+ const row = el('div', 'landing-project');
116+
117+ const name = el('a', 'landing-project-name', project.name);
118+ name.href = `#/project/${project.id}`;
119+ row.appendChild(name);
120+
121+ row.appendChild(el('span', 'landing-project-meta', `opened ${formatWhen(project.lastOpenedAt)}`));
122+
123+ const actions = el('span', 'landing-project-actions');
124+ const action = (label: string, handler: () => void | Promise<void>) => {
125+ const button = el('button', 'landing-action', label);
126+ button.addEventListener('click', () => handler());
127+ actions.appendChild(button);
128+ };
129+ action('Rename', () => {
130+ const name = prompt('Project name', project.name);
131+ if (name !== null) {
132+ renameProject(project.id, name);
133+ renderList();
134+ }
135+ });
136+ action('Duplicate', async () => {
137+ await duplicateProject(project.id);
138+ renderList();
139+ });
140+ action('Delete', async () => {
141+ if (confirm(`Delete project "${project.name}" and all of its files?`)) {
142+ await deleteProject(project.id);
143+ renderList();
144+ }
145+ });
146+ row.appendChild(actions);
147+ list.appendChild(row);
148+ }
149+ };
150+ renderList();
151+
152+ return {
153+ dispose() {
154+ root.remove();
155+ },
156+ };
157+}
src/main.tsadded+68−0View file
@@ -0,0 +1,68 @@
1+import { loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2+import { openIde } from './ide';
3+import { renderLanding } from './landing';
4+import { registerMatlabLanguage } from './numbl/language';
5+import { getProject } from './projects';
6+
7+// Routes:
8+// #/ project picker (landing page)
9+// #/project/<id> the IDE, opened on that project's file system
10+
11+async function start(): Promise<void> {
12+ // dev never uses a service worker (isolation comes from server headers) —
13+ // unregister any stale coi-serviceworker left over from a production
14+ // build or an earlier version, since a controlling stale SW can block
15+ // module workers with mismatched COEP headers
16+ if (import.meta.env.DEV && 'serviceWorker' in navigator) {
17+ const registrations = await navigator.serviceWorker.getRegistrations();
18+ if (registrations.length > 0) {
19+ await Promise.all(registrations.map(r => r.unregister()));
20+ if (navigator.serviceWorker.controller) {
21+ location.reload();
22+ return;
23+ }
24+ }
25+ }
26+
27+ const app = document.getElementById('app')!;
28+
29+ // one-time global setup: theme + languages are shared by all views;
30+ // MATLAB registers last so it wins the .m extension
31+ const theme = await loadBuiltinTheme('dark_modern');
32+ await registerBuiltinLanguages(theme);
33+ registerMatlabLanguage();
34+
35+ let current: { dispose(): void } | undefined;
36+ let navigating = false;
37+
38+ const route = async () => {
39+ if (navigating) {
40+ return;
41+ }
42+ navigating = true;
43+ try {
44+ current?.dispose();
45+ current = undefined;
46+ app.textContent = '';
47+
48+ const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
49+ if (match) {
50+ const project = getProject(match[1]);
51+ if (project) {
52+ current = await openIde(app, project, theme);
53+ return;
54+ }
55+ // unknown project id: fall through to the landing page
56+ history.replaceState(null, '', '#/');
57+ }
58+ current = renderLanding(app, theme);
59+ } finally {
60+ navigating = false;
61+ }
62+ };
63+
64+ window.addEventListener('hashchange', route);
65+ await route();
66+}
67+
68+start();
src/numbl/builtinNames.tsadded+479−0View file
@@ -0,0 +1,479 @@
1+// Generated by scripts/generate-builtins.sh — do not edit.
2+// Builtin function names of the installed numbl version, for syntax highlighting.
3+export const numblBuiltinNames: string[] = [
4+ 'Inf',
5+ 'MException',
6+ 'NaN',
7+ 'abs',
8+ 'accumarray',
9+ 'acos',
10+ 'acosd',
11+ 'acosh',
12+ 'acot',
13+ 'acotd',
14+ 'acoth',
15+ 'acsc',
16+ 'acscd',
17+ 'acsch',
18+ 'addedge',
19+ 'addprop',
20+ 'airy',
21+ 'all',
22+ 'and',
23+ 'angle',
24+ 'any',
25+ 'asec',
26+ 'asecd',
27+ 'asech',
28+ 'asin',
29+ 'asind',
30+ 'asinh',
31+ 'assert',
32+ 'atan',
33+ 'atan2',
34+ 'atan2d',
35+ 'atand',
36+ 'atanh',
37+ 'autumn',
38+ 'bandwidth',
39+ 'besselh',
40+ 'besseli',
41+ 'besselj',
42+ 'besselk',
43+ 'bessely',
44+ 'beta',
45+ 'bin2dec',
46+ 'bitand',
47+ 'bitget',
48+ 'bitor',
49+ 'bitset',
50+ 'bitshift',
51+ 'bitxor',
52+ 'blanks',
53+ 'blkdiag',
54+ 'bone',
55+ 'camlight',
56+ 'cart2pol',
57+ 'cart2sph',
58+ 'cat',
59+ 'ceil',
60+ 'cell',
61+ 'cell2mat',
62+ 'cell2struct',
63+ 'char',
64+ 'chol',
65+ 'circshift',
66+ 'class',
67+ 'clc',
68+ 'clear',
69+ 'clf',
70+ 'clock',
71+ 'close',
72+ 'colon',
73+ 'colorbar',
74+ 'complex',
75+ 'computer',
76+ 'cond',
77+ 'configureDictionary',
78+ 'conj',
79+ 'conncomp',
80+ 'contains',
81+ 'conv',
82+ 'conv2',
83+ 'convhull',
84+ 'convhulln',
85+ 'cool',
86+ 'copper',
87+ 'corrcoef',
88+ 'cos',
89+ 'cosd',
90+ 'cosh',
91+ 'cot',
92+ 'cotd',
93+ 'coth',
94+ 'count',
95+ 'cov',
96+ 'cross',
97+ 'csc',
98+ 'cscd',
99+ 'csch',
100+ 'ctranspose',
101+ 'cummax',
102+ 'cummin',
103+ 'cumprod',
104+ 'cumsum',
105+ 'cumtrapz',
106+ 'datestr',
107+ 'datetime',
108+ 'days',
109+ 'deal',
110+ 'deblank',
111+ 'dec2bin',
112+ 'dec2hex',
113+ 'deconv',
114+ 'degree',
115+ 'delaunay',
116+ 'delaunayn',
117+ 'det',
118+ 'diag',
119+ 'dictionary',
120+ 'diff',
121+ 'dot',
122+ 'double',
123+ 'eig',
124+ 'ellipj',
125+ 'endsWith',
126+ 'entries',
127+ 'eps',
128+ 'eq',
129+ 'erase',
130+ 'erf',
131+ 'erfc',
132+ 'erfcinv',
133+ 'erfcx',
134+ 'erfinv',
135+ 'error',
136+ 'etime',
137+ 'exp',
138+ 'expm',
139+ 'expm1',
140+ 'extractAfter',
141+ 'extractBefore',
142+ 'extractBetween',
143+ 'eye',
144+ 'factorial',
145+ 'false',
146+ 'fft',
147+ 'fftshift',
148+ 'fieldnames',
149+ 'fields',
150+ 'figure',
151+ 'fileparts',
152+ 'filesep',
153+ 'find',
154+ 'fix',
155+ 'flip',
156+ 'fliplr',
157+ 'flipud',
158+ 'floor',
159+ 'full',
160+ 'fullfile',
161+ 'func2str',
162+ 'gallery',
163+ 'gamma',
164+ 'gammaln',
165+ 'gca',
166+ 'gcf',
167+ 'ge',
168+ 'get',
169+ 'getReport',
170+ 'getappdata',
171+ 'getfield',
172+ 'gradient',
173+ 'graph',
174+ 'gray',
175+ 'grid',
176+ 'groot',
177+ 'gt',
178+ 'hex2dec',
179+ 'histc',
180+ 'hold',
181+ 'horzcat',
182+ 'hot',
183+ 'hours',
184+ 'hsv',
185+ 'hypot',
186+ 'idivide',
187+ 'ifft',
188+ 'ifftshift',
189+ 'imag',
190+ 'ind2sub',
191+ 'inf',
192+ 'inferiorto',
193+ 'inpolygon',
194+ 'insert',
195+ 'insertAfter',
196+ 'insertBefore',
197+ 'int16',
198+ 'int2str',
199+ 'int32',
200+ 'int64',
201+ 'int8',
202+ 'interp1',
203+ 'intersect',
204+ 'inv',
205+ 'ipermute',
206+ 'isConfigured',
207+ 'isKey',
208+ 'isappdata',
209+ 'iscell',
210+ 'ischar',
211+ 'iscolumn',
212+ 'isempty',
213+ 'isequal',
214+ 'isfield',
215+ 'isfinite',
216+ 'isfloat',
217+ 'isgraphics',
218+ 'ishandle',
219+ 'ishghandle',
220+ 'ishold',
221+ 'isinf',
222+ 'isinteger',
223+ 'iskeyword',
224+ 'islogical',
225+ 'ismac',
226+ 'ismatrix',
227+ 'ismembc',
228+ 'ismember',
229+ 'isnan',
230+ 'isnumbl',
231+ 'isnumeric',
232+ 'isobject',
233+ 'ispc',
234+ 'isprop',
235+ 'isreal',
236+ 'isrow',
237+ 'isscalar',
238+ 'isspace',
239+ 'issparse',
240+ 'isstr',
241+ 'isstring',
242+ 'isstrprop',
243+ 'isstruct',
244+ 'isunix',
245+ 'isvarname',
246+ 'isvector',
247+ 'jet',
248+ 'jsondecode',
249+ 'jsonencode',
250+ 'keys',
251+ 'kron',
252+ 'laplacian',
253+ 'lastwarn',
254+ 'ldivide',
255+ 'le',
256+ 'legend',
257+ 'legendre',
258+ 'length',
259+ 'linsolve',
260+ 'linspace',
261+ 'listfonts',
262+ 'log',
263+ 'log10',
264+ 'log1p',
265+ 'log2',
266+ 'logical',
267+ 'logspace',
268+ 'lookup',
269+ 'lower',
270+ 'lt',
271+ 'lu',
272+ 'magic',
273+ 'mat2cell',
274+ 'mat2str',
275+ 'max',
276+ 'mean',
277+ 'median',
278+ 'meshgrid',
279+ 'mexext',
280+ 'min',
281+ 'minus',
282+ 'minutes',
283+ 'mkpp',
284+ 'mldivide',
285+ 'mod',
286+ 'mode',
287+ 'mpower',
288+ 'mrdivide',
289+ 'mtimes',
290+ 'mustBeFinite',
291+ 'mustBeInRange',
292+ 'mustBeInteger',
293+ 'mustBeMember',
294+ 'mustBeNonempty',
295+ 'mustBeNonnegative',
296+ 'mustBeNonzero',
297+ 'mustBeNumeric',
298+ 'mustBePositive',
299+ 'mustBeScalarOrEmpty',
300+ 'mustBeVector',
301+ 'namedargs2cell',
302+ 'nan',
303+ 'nargin',
304+ 'nchoosek',
305+ 'ndgrid',
306+ 'ndims',
307+ 'ne',
308+ 'newplot',
309+ 'nextpow2',
310+ 'nexttile',
311+ 'nnz',
312+ 'nonzeros',
313+ 'norm',
314+ 'not',
315+ 'now',
316+ 'nthroot',
317+ 'null',
318+ 'num2cell',
319+ 'num2str',
320+ 'numEntries',
321+ 'numedges',
322+ 'numel',
323+ 'numnodes',
324+ 'odeget',
325+ 'odeset',
326+ 'ones',
327+ 'or',
328+ 'pad',
329+ 'pagemtimes',
330+ 'pagetranspose',
331+ 'parula',
332+ 'pathdef',
333+ 'peaks',
334+ 'permute',
335+ 'pink',
336+ 'pinv',
337+ 'plus',
338+ 'pol2cart',
339+ 'poly',
340+ 'polyfit',
341+ 'polyval',
342+ 'pow2',
343+ 'power',
344+ 'ppval',
345+ 'prod',
346+ 'qr',
347+ 'qz',
348+ 'rand',
349+ 'randi',
350+ 'randn',
351+ 'randperm',
352+ 'rank',
353+ 'rcond',
354+ 'rdivide',
355+ 'real',
356+ 'regexp',
357+ 'regexpi',
358+ 'regexprep',
359+ 'rem',
360+ 'remove',
361+ 'repelem',
362+ 'replace',
363+ 'repmat',
364+ 'reshape',
365+ 'rethrow',
366+ 'reverse',
367+ 'rmappdata',
368+ 'rmfield',
369+ 'rng',
370+ 'roots',
371+ 'rot90',
372+ 'round',
373+ 'sec',
374+ 'secd',
375+ 'sech',
376+ 'seconds',
377+ 'setappdata',
378+ 'setdiff',
379+ 'setfield',
380+ 'sgtitle',
381+ 'shading',
382+ 'shg',
383+ 'sign',
384+ 'sin',
385+ 'sind',
386+ 'single',
387+ 'sinh',
388+ 'size',
389+ 'sort',
390+ 'sortrows',
391+ 'spalloc',
392+ 'sparse',
393+ 'spconvert',
394+ 'spdiags',
395+ 'speye',
396+ 'sph2cart',
397+ 'spparms',
398+ 'spring',
399+ 'sprintf',
400+ 'sqrt',
401+ 'squeeze',
402+ 'sscanf',
403+ 'startsWith',
404+ 'std',
405+ 'str2double',
406+ 'str2num',
407+ 'strcat',
408+ 'strcmp',
409+ 'strcmpi',
410+ 'strfind',
411+ 'string',
412+ 'strip',
413+ 'strjoin',
414+ 'strlength',
415+ 'strmatch',
416+ 'strncmp',
417+ 'strncmpi',
418+ 'strrep',
419+ 'strsplit',
420+ 'strtok',
421+ 'strtrim',
422+ 'struct',
423+ 'struct2cell',
424+ 'sub2ind',
425+ 'subplot',
426+ 'substruct',
427+ 'sum',
428+ 'summer',
429+ 'superiorto',
430+ 'svd',
431+ 'symvar',
432+ 'tan',
433+ 'tand',
434+ 'tanh',
435+ 'throw',
436+ 'throwAsCaller',
437+ 'tic',
438+ 'tiledlayout',
439+ 'times',
440+ 'title',
441+ 'toeplitz',
442+ 'trace',
443+ 'transpose',
444+ 'trapz',
445+ 'tril',
446+ 'triu',
447+ 'true',
448+ 'typecast',
449+ 'types',
450+ 'uint16',
451+ 'uint32',
452+ 'uint64',
453+ 'uint8',
454+ 'uminus',
455+ 'union',
456+ 'unique',
457+ 'uniquetol',
458+ 'uplus',
459+ 'upper',
460+ 'usejava',
461+ 'validateattributes',
462+ 'values',
463+ 'var',
464+ 'vecnorm',
465+ 'ver',
466+ 'verLessThan',
467+ 'version',
468+ 'vertcat',
469+ 'waitbar',
470+ 'weboptions',
471+ 'winter',
472+ 'xlabel',
473+ 'xlim',
474+ 'xor',
475+ 'ylabel',
476+ 'ylim',
477+ 'zeros',
478+ 'zlabel',
479+];
src/numbl/figures.tsadded+129−0View file
@@ -0,0 +1,129 @@
1+import type { Workbench, AuxiliaryView } from 'minwebide';
2+import type { PlotInstruction } from 'numbl';
3+import type { FiguresState } from 'numbl/graphics';
4+import type { Root } from 'react-dom/client';
5+
6+// Renders a run's figures the way numbl.org does — one pane per MATLAB-style
7+// figure handle, rendered by numbl's own <FigureView> — but docked as views
8+// in the secondary side bar ("Figure 1", "Figure 2", ...), the minwebide
9+// analog of numbl.org's figure tabs. The renderer stack (numbl/graphics +
10+// react + three.js) is loaded on demand the first time a run plots anything,
11+// so it stays out of the initial bundle.
12+
13+interface GraphicsStack {
14+ FigureView: typeof import('numbl/graphics').FigureView;
15+ figuresReducer: typeof import('numbl/graphics').figuresReducer;
16+ createElement: typeof import('react').createElement;
17+ createRoot: typeof import('react-dom/client').createRoot;
18+}
19+
20+interface MountedFigure {
21+ view: AuxiliaryView;
22+ root: Root;
23+}
24+
25+export class FigureManager {
26+ private gfx: GraphicsStack | undefined;
27+ private loading: Promise<void> | undefined;
28+ private state: FiguresState | undefined;
29+ private pending: PlotInstruction[] = [];
30+ private readonly mounted = new Map<number, MountedFigure>();
31+ private revealedThisRun = false;
32+
33+ constructor(private readonly workbench: Workbench) {}
34+
35+ /** Clears all figures — call at the start of each run. */
36+ beginRun(): void {
37+ this.pending = [];
38+ if (this.gfx && this.state) {
39+ this.state = this.gfx.figuresReducer(this.state, { type: 'clear' });
40+ }
41+ for (const [handle, figure] of this.mounted) {
42+ figure.root.unmount();
43+ figure.view.dispose();
44+ this.mounted.delete(handle);
45+ }
46+ this.revealedThisRun = false;
47+ }
48+
49+ /** Folds a batch of plot instructions into the figure panes. */
50+ apply(instructions: PlotInstruction[]): void {
51+ if (!this.gfx || !this.state) {
52+ this.pending.push(...instructions);
53+ void this.ensureLoaded();
54+ return;
55+ }
56+ for (const instruction of instructions) {
57+ this.state = this.gfx.figuresReducer(this.state, instruction);
58+ }
59+ this.sync();
60+ }
61+
62+ private ensureLoaded(): Promise<void> {
63+ if (!this.loading) {
64+ this.loading = (async () => {
65+ const [graphics, react, reactDom] = await Promise.all([
66+ import('numbl/graphics'),
67+ import('react'),
68+ import('react-dom/client'),
69+ ]);
70+ this.gfx = {
71+ FigureView: graphics.FigureView,
72+ figuresReducer: graphics.figuresReducer,
73+ createElement: react.createElement,
74+ createRoot: reactDom.createRoot,
75+ };
76+ this.state = graphics.initialFiguresState;
77+ const buffered = this.pending;
78+ this.pending = [];
79+ if (buffered.length > 0) {
80+ this.apply(buffered);
81+ }
82+ })();
83+ }
84+ return this.loading;
85+ }
86+
87+ private sync(): void {
88+ const gfx = this.gfx!;
89+ const state = this.state!;
90+ const handles = Object.keys(state.figs).map(Number).sort((a, b) => a - b);
91+
92+ // figures closed by the script (close/close all)
93+ for (const [handle, figure] of this.mounted) {
94+ if (!state.figs[handle]) {
95+ figure.root.unmount();
96+ figure.view.dispose();
97+ this.mounted.delete(handle);
98+ }
99+ }
100+
101+ for (const handle of handles) {
102+ let figure = this.mounted.get(handle);
103+ if (!figure) {
104+ const view = this.workbench.createAuxiliaryView(`numbl.figure.${handle}`, `Figure ${handle}`);
105+ view.element.style.overflow = 'hidden';
106+ const host = document.createElement('div');
107+ host.style.width = '100%';
108+ host.style.height = '100%';
109+ host.style.backgroundColor = '#fff';
110+ view.element.appendChild(host);
111+ figure = { view, root: gfx.createRoot(host) };
112+ this.mounted.set(handle, figure);
113+ if (!this.revealedThisRun) {
114+ this.revealedThisRun = true;
115+ view.show();
116+ }
117+ }
118+ figure.root.render(gfx.createElement(gfx.FigureView, { figure: state.figs[handle] }));
119+ }
120+ }
121+
122+ dispose(): void {
123+ for (const [handle, figure] of this.mounted) {
124+ figure.root.unmount();
125+ figure.view.dispose();
126+ this.mounted.delete(handle);
127+ }
128+ }
129+}
src/numbl/language.tsadded+168−0View file
@@ -0,0 +1,168 @@
1+import { monaco } from 'minwebide';
2+import { numblBuiltinNames } from './builtinNames';
3+
4+// MATLAB-syntax language support for .m files, adapted from numbl.org's own
5+// Monaco language definition (numbl's src/numblLanguage.ts, which is not
6+// published on npm). Registered after the built-in languages so its claim on
7+// the .m extension takes precedence over VS Code's Objective-C mapping.
8+
9+const languageConfig: monaco.languages.LanguageConfiguration = {
10+ comments: {
11+ lineComment: '%',
12+ blockComment: ['%{', '%}'],
13+ },
14+ brackets: [
15+ ['{', '}'],
16+ ['[', ']'],
17+ ['(', ')'],
18+ ],
19+ autoClosingPairs: [
20+ { open: '{', close: '}' },
21+ { open: '[', close: ']' },
22+ { open: '(', close: ')' },
23+ { open: '"', close: '"' },
24+ ],
25+ surroundingPairs: [
26+ { open: '{', close: '}' },
27+ { open: '[', close: ']' },
28+ { open: '(', close: ')' },
29+ { open: '"', close: '"' },
30+ ],
31+};
32+
33+const builtinConstants = ['pi', 'e', 'eps', 'Inf', 'inf', 'NaN', 'nan', 'i', 'j'];
34+
35+// numbl's "special" builtins (I/O, plotting, path/fs commands) are dispatched
36+// outside its regular builtin registry, so `numbl list-builtins` (the source
37+// of builtinNames.ts) doesn't include them — copied from numbl 0.4.8's
38+// SPECIAL_BUILTIN_NAMES plus the plot-dispatch names.
39+const specialBuiltinNames = [
40+ 'help', 'disp', 'fprintf', 'arrayfun', 'cellfun', 'structfun', 'feval', 'bsxfun',
41+ 'subsref', 'subsasgn', 'builtin', 'fopen', 'fclose', 'fgetl', 'fgets', 'fileread',
42+ 'feof', 'ferror', 'fread', 'fwrite', 'frewind', 'fseek', 'ftell', 'fileparts',
43+ 'fullfile', 'assignin', 'evalin', 'set', 'get', 'drawnow', 'pause',
44+ 'plot', 'plot3', 'line', 'patch', 'trimesh', 'fill', 'surf', 'surface', 'scatter',
45+ 'imagesc', 'pcolor', 'contour', 'contourf', 'mesh', 'waterfall', 'isosurface',
46+ 'bar', 'barh', 'bar3', 'bar3h', 'stairs', 'errorbar', 'semilogx', 'semilogy',
47+ 'loglog', 'area', 'fplot', 'fplot3', 'scatter3', 'histogram', 'histogram2',
48+ 'boxchart', 'swarmchart', 'swarmchart3', 'piechart', 'donutchart', 'heatmap',
49+ 'quiver', 'quiver3', 'streamline', 'stream2', 'ishold', 'figure', 'uihtml',
50+ 'uigridlayout', 'subplot', 'tiledlayout', 'nexttile', 'title', 'xlabel', 'ylabel',
51+ 'zlabel', 'hold', 'grid', 'box', 'legend', 'close', 'sgtitle', 'shading', 'clf',
52+ 'cla', 'colormap', 'view', 'colorbar', 'axis', 'caxis', 'clim', 'gcf', 'gca',
53+ 'mfilename', 'addpath', 'rmpath', 'savepath', 'path', 'mkdir', 'websave',
54+ 'webread', 'delete', 'rmdir', 'movefile', 'copyfile', 'fileattrib', 'unzip',
55+ 'dir', 'warning', 'input', 'tempdir', 'tempname', 'userpath', 'getenv', 'setenv',
56+ 'pwd', 'cd', 'ode45', 'ode23', 'deval', 'tic', 'toc', 'quadgk', 'gmres', 'eigs',
57+ 'onCleanup',
58+];
59+
60+function createTokensProvider(): monaco.languages.IMonarchLanguage {
61+ return {
62+ defaultToken: '',
63+
64+ keywords: [
65+ 'function', 'if', 'else', 'elseif', 'for', 'while', 'break', 'continue',
66+ 'return', 'end', 'classdef', 'properties', 'methods', 'events',
67+ 'enumeration', 'arguments', 'import', 'switch', 'case', 'otherwise',
68+ 'try', 'catch', 'global', 'persistent', 'true', 'false',
69+ ],
70+
71+ builtinFunctions: [...new Set([...numblBuiltinNames, ...specialBuiltinNames])],
72+ builtinConstants,
73+
74+ tokenizer: {
75+ root: [
76+ // section markers (must be at line start)
77+ [/^%%.*$/, 'comment.doc'],
78+
79+ // block comments
80+ [/%\{/, 'comment', '@blockComment'],
81+
82+ // line comments
83+ [/%.*$/, 'comment'],
84+
85+ // identifiers and keywords — push afterValue since they produce values
86+ [
87+ /[a-zA-Z_]\w*/,
88+ {
89+ cases: {
90+ '@keywords': { token: 'keyword', next: '@afterValue' },
91+ '@builtinFunctions': { token: 'predefined', next: '@afterValue' },
92+ '@builtinConstants': { token: 'constant.language', next: '@afterValue' },
93+ '@default': { token: 'identifier', next: '@afterValue' },
94+ },
95+ },
96+ ],
97+
98+ // numbers — push afterValue since they produce values
99+ [/\d+\.?\d*([eE][+-]?\d+)?/, { token: 'number.float', next: '@afterValue' }],
100+ [/\.\d+([eE][+-]?\d+)?/, { token: 'number.float', next: '@afterValue' }],
101+
102+ // strings (single and double quoted)
103+ [/"([^"\\]|\\.)*$/, 'string.invalid'],
104+ [/'([^'\\]|\\.)*$/, 'string.invalid'],
105+ [/"/, 'string', '@doubleQuotedString'],
106+ [/'/, 'string', '@singleQuotedString'],
107+
108+ // closing brackets produce values — push afterValue
109+ [/[)\]]/, { token: '@brackets', next: '@afterValue' }],
110+ [/\}/, { token: '@brackets', next: '@afterValue' }],
111+
112+ // opening brackets
113+ [/[{([]/, '@brackets'],
114+
115+ // delimiters and operators (no ' — handled in afterValue as transpose)
116+ [/[;,.]/, 'delimiter'],
117+ [/==|~=|<=|>=|&&|\|\||\.\.\.|\.\*|\.\/|\.\\|\.\^|[=<>~+\-*/\\^&|!@?:]/, 'operator'],
118+
119+ { include: '@whitespace' },
120+ ],
121+
122+ // state after a value-producing token (identifier, number, closing
123+ // bracket, or string) — here ' is transpose, not a string delimiter
124+ afterValue: [
125+ [/\.'/, 'operator'],
126+ [/'/, 'operator'],
127+ [/$/, { token: '', next: '@pop' }],
128+ [/(?=[\s\S])/, { token: '', next: '@pop' }],
129+ ],
130+
131+ blockComment: [
132+ [/%\}/, 'comment', '@pop'],
133+ [/./, 'comment'],
134+ ],
135+
136+ doubleQuotedString: [
137+ [/[^\\"]+/, 'string'],
138+ [/\\./, 'string.escape'],
139+ [/"/, { token: 'string', switchTo: '@afterValue' }],
140+ ],
141+
142+ singleQuotedString: [
143+ [/[^\\']+/, 'string'],
144+ [/''/, 'string.escape'],
145+ [/'/, { token: 'string', switchTo: '@afterValue' }],
146+ ],
147+
148+ whitespace: [[/[ \t\r\n]+/, 'white']],
149+ },
150+ };
151+}
152+
153+let registered = false;
154+
155+/** Registers MATLAB language support for .m files. Call once, after registerBuiltinLanguages. */
156+export function registerMatlabLanguage(): void {
157+ if (registered) {
158+ return;
159+ }
160+ registered = true;
161+ monaco.languages.register({
162+ id: 'matlab',
163+ extensions: ['.m'],
164+ aliases: ['MATLAB', 'matlab'],
165+ });
166+ monaco.languages.setLanguageConfiguration('matlab', languageConfig);
167+ monaco.languages.setMonarchTokensProvider('matlab', createTokensProvider());
168+}
src/numbl/numblWorker.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+import {
2+ BrowserFileIOAdapter,
3+ BrowserSystemAdapter,
4+ executeCode,
5+ RuntimeError,
6+ VirtualFileSystem,
7+} from 'numbl';
8+import type { RunRequest, WorkerResponse } from './protocol';
9+
10+// The numbl execution worker. executeCode is synchronous, so it must run off
11+// the main thread; this mirrors numbl's own (unpublished) numbl-worker.ts in
12+// its non-persistent mode: each run gets a fresh VFS seeded with the whole
13+// project, the script's directory becomes the cwd / first search path, and
14+// output + figures stream back as messages. Stopping a run is a hard kill —
15+// the app terminates this worker and spawns a fresh one.
16+
17+const post = (message: WorkerResponse) => (self as unknown as Worker).postMessage(message);
18+
19+self.onmessage = (event: MessageEvent<RunRequest>) => {
20+ const msg = event.data;
21+ if (msg.type !== 'run') {
22+ return;
23+ }
24+
25+ // project files live under the VFS default cwd, /project
26+ const vfs = new VirtualFileSystem();
27+ for (const file of msg.vfsFiles) {
28+ vfs.writeFile(file.path, file.content);
29+ }
30+ vfs.clearChangeTracking();
31+ const fileIO = new BrowserFileIOAdapter(vfs);
32+ const system = new BrowserSystemAdapter(vfs);
33+
34+ // run under the script's absolute VFS path with its directory as cwd,
35+ // mirroring the CLI `run` command — sibling functions and relative file
36+ // I/O then resolve against the script's folder
37+ const mainAbsPath = vfs.normalizePath(msg.mainFileName);
38+ const lastSlash = mainAbsPath.lastIndexOf('/');
39+ vfs.setCwd(lastSlash > 0 ? mainAbsPath.slice(0, lastSlash) : '/');
40+
41+ try {
42+ const result = executeCode(
43+ msg.code,
44+ {
45+ onOutput: (text) => post({ type: 'output', text }),
46+ onDrawnow: (plotInstructions) => post({ type: 'drawnow', plotInstructions }),
47+ displayResults: true,
48+ maxIterations: 10_000_000,
49+ fileIO,
50+ system,
51+ },
52+ msg.workspaceFiles,
53+ mainAbsPath,
54+ );
55+ // onDrawnow flushes (and clears) the instruction buffer mid-run, so
56+ // result.plotInstructions is only the tail since the last drawnow
57+ post({ type: 'done', plotInstructions: result.plotInstructions, vfsChanges: fileIO.getChanges() });
58+ } catch (error) {
59+ let message: string;
60+ if (error instanceof RuntimeError) {
61+ message = [error.toString(), error.snippet].filter(Boolean).join('\n');
62+ } else {
63+ message = error instanceof Error ? error.message : String(error);
64+ }
65+ post({ type: 'error', message, vfsChanges: fileIO.getChanges() });
66+ }
67+};
src/numbl/protocol.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import type { PlotInstruction, WorkspaceFile } from 'numbl';
2+
3+// Message protocol between the app and the numbl web worker — a minimal
4+// version of numbl's own numbl-worker protocol (which is not published on
5+// npm): one "run" request in, streamed output/drawnow messages out, ending
6+// with "done" or "error".
7+
8+/** A raw file placed into the run's virtual file system (under /project). */
9+export interface VfsFile {
10+ /** Project-relative path, e.g. 'scripts/demo.m'. */
11+ path: string;
12+ content: Uint8Array;
13+}
14+
15+export interface RunRequest {
16+ type: 'run';
17+ /** Source of the file being run (possibly unsaved editor contents). */
18+ code: string;
19+ /** Project-relative path of the file being run. */
20+ mainFileName: string;
21+ /** Every project file, as bytes, for the virtual file system. */
22+ vfsFiles: VfsFile[];
23+ /** Every other project .m file, as text, callable as functions. */
24+ workspaceFiles: WorkspaceFile[];
25+}
26+
27+export interface VfsChanges {
28+ created: { path: string; content: Uint8Array }[];
29+ modified: { path: string; content: Uint8Array }[];
30+ deleted: string[];
31+}
32+
33+export type WorkerResponse =
34+ | { type: 'output'; text: string }
35+ | { type: 'drawnow'; plotInstructions: PlotInstruction[] }
36+ | { type: 'done'; plotInstructions: PlotInstruction[]; vfsChanges?: VfsChanges }
37+ | { type: 'error'; message: string; vfsChanges?: VfsChanges };
src/numbl/runner.tsadded+147−0View file
@@ -0,0 +1,147 @@
1+import { monaco, type FileRunner, type RunContext, type Workbench, type WorkspaceFileSystem } from 'minwebide';
2+import type { WorkspaceFile } from 'numbl';
3+import { FigureManager } from './figures';
4+import type { RunRequest, VfsChanges, VfsFile, WorkerResponse } from './protocol';
5+
6+// The .m file runner. Follows numbl.org's IDE semantics: the whole project is
7+// the workspace — every file goes into the run's virtual file system, every
8+// other .m file becomes a callable function, and the script's directory is
9+// the cwd / first search path. Text output streams to the runner's output
10+// channel in the bottom panel; figures appear as views in the secondary side
11+// bar. Stop is a hard kill: the worker is terminated and replaced.
12+
13+/** Collects every project file as bytes, preferring open (unsaved) editor contents. */
14+async function collectProjectFiles(fs: WorkspaceFileSystem): Promise<VfsFile[]> {
15+ const encoder = new TextEncoder();
16+ const files: VfsFile[] = [];
17+ const walk = async (path: string): Promise<void> => {
18+ const stat = await fs.fileService.resolve(fs.root.with({ path }));
19+ for (const child of stat.children ?? []) {
20+ if (child.isDirectory) {
21+ await walk(child.resource.path);
22+ } else {
23+ const model = monaco.editor.getModel(child.resource);
24+ const content = model
25+ ? encoder.encode(model.getValue())
26+ : (await fs.fileService.readFile(child.resource)).value.buffer;
27+ files.push({ path: child.resource.path.replace(/^\//, ''), content });
28+ }
29+ }
30+ };
31+ await walk('/');
32+ return files;
33+}
34+
35+export function createNumblRunner(fs: WorkspaceFileSystem, workbench: Workbench): { runner: FileRunner; dispose(): void } {
36+ const figures = new FigureManager(workbench);
37+ const decoder = new TextDecoder();
38+
39+ let worker: Worker | undefined;
40+ let activeRun: { finish(): void } | undefined;
41+
42+ const ensureWorker = (): Worker => {
43+ if (!worker) {
44+ worker = new Worker(new URL('./numblWorker.ts', import.meta.url), { type: 'module' });
45+ }
46+ return worker;
47+ };
48+
49+ const applyVfsChanges = async (changes: VfsChanges | undefined): Promise<void> => {
50+ if (!changes) {
51+ return;
52+ }
53+ // scripts can write/delete files (save, csvwrite, delete, ...) — sync
54+ // those changes from the run's VFS back into the project
55+ for (const file of [...changes.created, ...changes.modified]) {
56+ if (file.path.startsWith('/project/')) {
57+ await fs.writeFile(file.path.slice('/project'.length), file.content);
58+ }
59+ }
60+ for (const path of changes.deleted) {
61+ if (path.startsWith('/project/')) {
62+ await fs.deleteFile(path.slice('/project'.length));
63+ }
64+ }
65+ };
66+
67+ const run = async (context: RunContext): Promise<void> => {
68+ context.output.clear();
69+ figures.beginRun();
70+
71+ const mainFileName = context.uri.path.replace(/^\//, '');
72+ const [code, allFiles] = await Promise.all([context.getText(), collectProjectFiles(fs)]);
73+ const workspaceFiles: WorkspaceFile[] = allFiles
74+ .filter(f => f.path !== mainFileName && f.path.endsWith('.m'))
75+ .map(f => ({ name: f.path, source: decoder.decode(f.content) }));
76+
77+ await new Promise<void>((resolve) => {
78+ const w = ensureWorker();
79+ const finish = () => {
80+ w.onmessage = null;
81+ w.onerror = null;
82+ w.onmessageerror = null;
83+ activeRun = undefined;
84+ resolve();
85+ };
86+ activeRun = { finish };
87+ // a worker that fails to load or crashes never posts done/error —
88+ // surface it instead of hanging the run
89+ w.onerror = (event) => {
90+ context.output.append(`\nWorker error: ${event.message ?? 'failed to load'}\n`);
91+ worker?.terminate();
92+ worker = undefined;
93+ finish();
94+ };
95+ w.onmessageerror = () => {
96+ context.output.append('\nWorker message could not be deserialized\n');
97+ finish();
98+ };
99+ w.onmessage = async (event: MessageEvent<WorkerResponse>) => {
100+ const msg = event.data;
101+ switch (msg.type) {
102+ case 'output':
103+ context.output.append(msg.text);
104+ break;
105+ case 'drawnow':
106+ figures.apply(msg.plotInstructions);
107+ break;
108+ case 'done':
109+ figures.apply(msg.plotInstructions);
110+ await applyVfsChanges(msg.vfsChanges);
111+ finish();
112+ break;
113+ case 'error':
114+ context.output.append(`\n${msg.message}\n`);
115+ await applyVfsChanges(msg.vfsChanges);
116+ finish();
117+ break;
118+ }
119+ };
120+ const request: RunRequest = { type: 'run', code, mainFileName, vfsFiles: allFiles, workspaceFiles };
121+ w.postMessage(request);
122+ });
123+ };
124+
125+ const runner: FileRunner = {
126+ id: 'numbl.run',
127+ displayName: 'numbl',
128+ selector: [{ filenamePattern: '*.m' }],
129+ run,
130+ stop: () => {
131+ if (activeRun) {
132+ worker?.terminate();
133+ worker = undefined;
134+ activeRun.finish();
135+ }
136+ },
137+ };
138+
139+ return {
140+ runner,
141+ dispose() {
142+ worker?.terminate();
143+ worker = undefined;
144+ figures.dispose();
145+ },
146+ };
147+}
src/projects.tsadded+123−0View file
@@ -0,0 +1,123 @@
1+import { createIndexedDBFileSystem, type WorkspaceFileSystem } from 'minwebide';
2+
3+// The project registry: a small localStorage index of projects, each backed
4+// by its own IndexedDB database (its own workspace file system).
5+
6+export interface ProjectInfo {
7+ readonly id: string;
8+ name: string;
9+ createdAt: number;
10+ lastOpenedAt: number;
11+}
12+
13+const REGISTRY_KEY = 'numbl-web-ide.projects';
14+
15+function readRegistry(): ProjectInfo[] {
16+ try {
17+ const raw = localStorage.getItem(REGISTRY_KEY);
18+ const parsed = raw ? JSON.parse(raw) : [];
19+ return Array.isArray(parsed) ? parsed : [];
20+ } catch {
21+ return [];
22+ }
23+}
24+
25+function writeRegistry(projects: ProjectInfo[]): void {
26+ localStorage.setItem(REGISTRY_KEY, JSON.stringify(projects));
27+}
28+
29+export function projectDbName(id: string): string {
30+ return `numbl-web-ide-project-${id}`;
31+}
32+
33+export function listProjects(): ProjectInfo[] {
34+ return readRegistry().sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
35+}
36+
37+export function getProject(id: string): ProjectInfo | undefined {
38+ return readRegistry().find(p => p.id === id);
39+}
40+
41+/** Picks 'untitled', 'untitled-2', ... skipping names already in use. */
42+export function nextUntitledName(base = 'untitled'): string {
43+ const names = new Set(readRegistry().map(p => p.name));
44+ if (!names.has(base)) {
45+ return base;
46+ }
47+ for (let i = 2; ; i++) {
48+ if (!names.has(`${base}-${i}`)) {
49+ return `${base}-${i}`;
50+ }
51+ }
52+}
53+
54+export function createProject(name: string): ProjectInfo {
55+ const project: ProjectInfo = {
56+ id: Math.random().toString(36).slice(2, 10),
57+ name,
58+ createdAt: Date.now(),
59+ lastOpenedAt: Date.now(),
60+ };
61+ writeRegistry([...readRegistry(), project]);
62+ return project;
63+}
64+
65+export function renameProject(id: string, name: string): void {
66+ const projects = readRegistry();
67+ const project = projects.find(p => p.id === id);
68+ if (project && name.trim()) {
69+ project.name = name.trim();
70+ writeRegistry(projects);
71+ }
72+}
73+
74+export function touchProject(id: string): void {
75+ const projects = readRegistry();
76+ const project = projects.find(p => p.id === id);
77+ if (project) {
78+ project.lastOpenedAt = Date.now();
79+ writeRegistry(projects);
80+ }
81+}
82+
83+export async function deleteProject(id: string): Promise<void> {
84+ writeRegistry(readRegistry().filter(p => p.id !== id));
85+ await new Promise<void>((resolve) => {
86+ const request = indexedDB.deleteDatabase(projectDbName(id));
87+ request.onsuccess = request.onerror = request.onblocked = () => resolve();
88+ });
89+}
90+
91+export async function openProjectFileSystem(id: string): Promise<WorkspaceFileSystem> {
92+ return createIndexedDBFileSystem({ dbName: projectDbName(id) });
93+}
94+
95+/** Copies all files of one project into a brand-new project. */
96+export async function duplicateProject(id: string): Promise<ProjectInfo | undefined> {
97+ const source = getProject(id);
98+ if (!source) {
99+ return undefined;
100+ }
101+ const copy = createProject(nextUntitledName(`${source.name}-copy`));
102+ const sourceFs = await openProjectFileSystem(source.id);
103+ const targetFs = await openProjectFileSystem(copy.id);
104+ try {
105+ const copyTree = async (path: string): Promise<void> => {
106+ const stat = await sourceFs.fileService.resolve(sourceFs.root.with({ path }));
107+ for (const child of stat.children ?? []) {
108+ if (child.isDirectory) {
109+ await targetFs.fileService.createFolder(targetFs.root.with({ path: child.resource.path }));
110+ await copyTree(child.resource.path);
111+ } else {
112+ const content = await sourceFs.fileService.readFile(child.resource);
113+ await targetFs.fileService.writeFile(targetFs.root.with({ path: child.resource.path }), content.value);
114+ }
115+ }
116+ };
117+ await copyTree('/');
118+ } finally {
119+ sourceFs.dispose();
120+ targetFs.dispose();
121+ }
122+ return copy;
123+}
src/sampleWorkspace.tsadded+110−0View file
@@ -0,0 +1,110 @@
1+// Files seeded into a new sample project.
2+
3+const readme = `# numbl sample project
4+
5+This project runs MATLAB-syntax \`.m\` files in your browser with
6+[numbl](https://numbl.org). Open a script and press the **▶** button in the
7+tab bar:
8+
9+- \`waves.m\` — damped oscillations: printed output goes to the **Output**
10+ panel at the bottom, the plot opens as **Figure 1** in the side bar to the
11+ right. Calls \`damped_wave.m\` (functions next to the script are found
12+ automatically, like MATLAB's path rules).
13+- \`scripts/surface_demo.m\` — a 3-D surface. Uses \`addpath\` to call a
14+ function from the \`lib/\` folder.
15+- \`scripts/animation.m\` — figures update live during the run (\`drawnow\`).
16+- \`scripts/write_results.m\` — writes a file; it appears in the Explorer
17+ when the run finishes.
18+
19+Edits are saved with **Ctrl+S** and persist in your browser. Runs use the
20+current editor contents, saved or not. Press **⏹** to stop a runaway run.
21+`;
22+
23+const wavesM = `% Damped harmonic oscillation — press the run button (▶) above.
24+f = 2.5; % frequency (Hz)
25+tau = 0.8; % decay time constant (s)
26+
27+t = linspace(0, 3, 600);
28+y = damped_wave(t, f, tau);
29+
30+fprintf('samples: %d\\n', numel(t));
31+fprintf('peak amplitude: %.4f\\n', max(abs(y)));
32+fprintf('rms amplitude: %.4f\\n', sqrt(mean(y.^2)));
33+
34+figure;
35+plot(t, y);
36+hold on;
37+plot(t, exp(-t / tau), '--');
38+plot(t, -exp(-t / tau), '--');
39+hold off;
40+title('Damped oscillation');
41+xlabel('t (s)');
42+ylabel('amplitude');
43+legend('signal', 'envelope', '-envelope');
44+`;
45+
46+const dampedWaveM = `function y = damped_wave(t, f, tau)
47+% DAMPED_WAVE Exponentially decaying sine wave.
48+y = exp(-t / tau) .* sin(2 * pi * f * t);
49+end
50+`;
51+
52+const surfaceDemoM = `% 3-D surface demo. mexican_hat lives in ../lib, so put that folder on
53+% the search path first (files next to the running script are found
54+% automatically; other folders follow MATLAB's addpath rules).
55+addpath('../lib');
56+
57+[X, Y] = meshgrid(linspace(-3, 3, 80), linspace(-3, 3, 80));
58+Z = mexican_hat(X, Y);
59+
60+figure;
61+surf(X, Y, Z);
62+title('Mexican hat');
63+xlabel('x');
64+ylabel('y');
65+
66+disp('surface rendered — drag to rotate');
67+`;
68+
69+const mexicanHatM = `function z = mexican_hat(x, y)
70+% MEXICAN_HAT Ricker wavelet surface.
71+r2 = x.^2 + y.^2;
72+z = (1 - r2) .* exp(-r2 / 2);
73+end
74+`;
75+
76+const animationM = `% Live figure updates: drawnow flushes the figure mid-run.
77+% This one takes a while — try the stop button (⏹) in the tab bar.
78+x = linspace(0, 4 * pi, 400);
79+figure;
80+for k = 1:200
81+ plot(x, sin(x - k / 4) .* exp(-x / 8));
82+ axis([0 4*pi -1 1]);
83+ title(sprintf('frame %d / 200', k));
84+ drawnow;
85+ pause(0.04);
86+end
87+disp('animation done');
88+`;
89+
90+const writeResultsM = `% Scripts can write files: results/summary.txt shows up in the Explorer
91+% when the run finishes.
92+mkdir('results');
93+fid = fopen('results/summary.txt', 'w');
94+fprintf(fid, 'n, n^2, sqrt(n)\\n');
95+for n = 1:10
96+ fprintf(fid, '%d, %d, %.4f\\n', n, n^2, sqrt(n));
97+end
98+fclose(fid);
99+disp('wrote results/summary.txt');
100+`;
101+
102+export const sampleWorkspace: Record<string, string> = {
103+ '/README.md': readme,
104+ '/waves.m': wavesM,
105+ '/damped_wave.m': dampedWaveM,
106+ '/scripts/surface_demo.m': surfaceDemoM,
107+ '/scripts/animation.m': animationM,
108+ '/scripts/write_results.m': writeResultsM,
109+ '/lib/mexican_hat.m': mexicanHatM,
110+};
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "extends": "minwebide/tsconfig.base.json",
3+ "compilerOptions": {
4+ "types": ["vite/client"]
5+ },
6+ "include": ["src"]
7+}
vite.config.tsadded+30−0View file
@@ -0,0 +1,30 @@
1+import { defineConfig, mergeConfig } from 'vite';
2+import { minwebide } from 'minwebide/vite';
3+
4+// Cross-origin isolation makes SharedArrayBuffer available (numbl uses it
5+// for pause()/sleep timing). Dev/preview get it from plain response headers —
6+// no service worker involved. Production builds are for GitHub Pages, which
7+// can't set headers, so only there the coi-serviceworker is injected (the
8+// same approach numbl.org uses).
9+const coiHeaders = {
10+ 'Cross-Origin-Embedder-Policy': 'require-corp',
11+ 'Cross-Origin-Opener-Policy': 'same-origin',
12+};
13+
14+const injectCoiServiceWorker = {
15+ name: 'inject-coi-serviceworker',
16+ apply: 'build' as const,
17+ transformIndexHtml() {
18+ // relative src so it resolves under the DEPLOY_BASE sub-path
19+ return [{ tag: 'script', attrs: { src: 'coi-serviceworker.js' }, injectTo: 'head' as const }];
20+ },
21+};
22+
23+// DEPLOY_BASE is set by CI when building for GitHub Pages
24+// (the site is served from /numbl-web-ide/, not the domain root).
25+export default defineConfig(mergeConfig(minwebide(), {
26+ base: process.env.DEPLOY_BASE ?? '/',
27+ plugins: [injectCoiServiceWorker],
28+ server: { headers: coiHeaders },
29+ preview: { headers: coiHeaders },
30+}));