import { monaco, type FileRunner, type RunContext, type Workbench, type WorkspaceFileSystem } from 'minwebide'; import type { WorkspaceFile } from 'numbl'; import { FigureManager } from './figures'; import { MIP_SEARCH_PATH, type MipSystem } from './mipSystem'; import type { RunRequest, VfsChanges, VfsFile, WorkerResponse } from './protocol'; // The .m file runner. Follows numbl.org's IDE semantics: the whole project is // the workspace — every file goes into the run's virtual file system, every // other .m file becomes a callable function, and the script's directory is // the cwd / first search path. Text output streams to the runner's output // channel in the bottom panel; figures appear as views in the secondary side // bar. Stop is a hard kill: the worker is terminated and replaced. /** Collects every project file as bytes, preferring open (unsaved) editor contents. */ async function collectProjectFiles(fs: WorkspaceFileSystem): Promise { const encoder = new TextEncoder(); const files: VfsFile[] = []; const walk = async (path: string): Promise => { const stat = await fs.fileService.resolve(fs.root.with({ path })); for (const child of stat.children ?? []) { if (child.isDirectory) { await walk(child.resource.path); } else { const model = monaco.editor.getModel(child.resource); const content = model ? encoder.encode(model.getValue()) : (await fs.fileService.readFile(child.resource)).value.buffer; files.push({ path: child.resource.path.replace(/^\//, ''), content }); } } }; await walk('/'); return files; } export function createNumblRunner(fs: WorkspaceFileSystem, workbench: Workbench, mip: MipSystem): { runner: FileRunner; dispose(): void } { const figures = new FigureManager(workbench); const decoder = new TextDecoder(); let worker: Worker | undefined; let activeRun: { finish(): void } | undefined; const ensureWorker = (): Worker => { if (!worker) { worker = new Worker(new URL('./numblWorker.ts', import.meta.url), { type: 'module' }); } return worker; }; const applyVfsChanges = async (changes: VfsChanges | undefined): Promise => { if (!changes) { return; } // scripts can write/delete files (fopen/fprintf, delete, ...) — sync // project changes back into the project, and /system/ changes (mip // package installs) into the shared system store for (const file of [...changes.created, ...changes.modified]) { if (file.path.startsWith('/project/')) { await fs.writeFile(file.path.slice('/project'.length), file.content); } } for (const path of changes.deleted) { if (path.startsWith('/project/')) { await fs.deleteFile(path.slice('/project'.length)); } } await mip.applyChanges(changes); }; const run = async (context: RunContext): Promise => { context.output.clear(); figures.beginRun(); // mip core is fetched in the background when the IDE opens; only // narrate when a run actually has to wait for it if (!mip.isReady()) { context.output.appendLine('[mip] installing package manager…'); } await mip.ensureCore(); const mainFileName = context.uri.path.replace(/^\//, ''); const [code, projectFiles, systemFiles] = await Promise.all([ context.getText(), collectProjectFiles(fs), mip.collectFiles(), ]); const workspaceFiles: WorkspaceFile[] = [ ...projectFiles .filter(f => f.path !== mainFileName && f.path.endsWith('.m')) .map(f => ({ name: f.path, source: decoder.decode(f.content) })), ...systemFiles .filter(f => f.path.endsWith('.m')) .map(f => ({ name: f.path, source: decoder.decode(f.content) })), ]; const allFiles = [...projectFiles, ...systemFiles]; await new Promise((resolve) => { const w = ensureWorker(); const finish = () => { w.onmessage = null; w.onerror = null; w.onmessageerror = null; activeRun = undefined; resolve(); }; activeRun = { finish }; // a worker that fails to load or crashes never posts done/error — // surface it instead of hanging the run w.onerror = (event) => { context.output.append(`\nWorker error: ${event.message ?? 'failed to load'}\n`); worker?.terminate(); worker = undefined; finish(); }; w.onmessageerror = () => { context.output.append('\nWorker message could not be deserialized\n'); finish(); }; w.onmessage = async (event: MessageEvent) => { const msg = event.data; switch (msg.type) { case 'output': context.output.append(msg.text); break; case 'drawnow': figures.apply(msg.plotInstructions); break; case 'done': figures.apply(msg.plotInstructions); await applyVfsChanges(msg.vfsChanges); finish(); break; case 'error': context.output.append(`\n${msg.message}\n`); await applyVfsChanges(msg.vfsChanges); finish(); break; } }; const request: RunRequest = { type: 'run', code, mainFileName, vfsFiles: allFiles, workspaceFiles, searchPaths: [MIP_SEARCH_PATH], }; w.postMessage(request); }); }; const runner: FileRunner = { id: 'numbl.run', displayName: 'numbl', selector: [{ filenamePattern: '*.m' }], run, stop: () => { if (activeRun) { worker?.terminate(); worker = undefined; activeRun.finish(); } }, }; return { runner, dispose() { worker?.terminate(); worker = undefined; figures.dispose(); }, }; }