/ concept-collection / numbl-web-ide
Sign in
concept-collection / numbl-web-ide
numbl-web-ide / src / numbl / runner.ts
147 lines · 4.7 KBBlameHistoryRaw
1import { monaco, type FileRunner, type RunContext, type Workbench, type WorkspaceFileSystem } from 'minwebide';
2import type { WorkspaceFile } from 'numbl';
3import { FigureManager } from './figures';
4import type { RunRequest, VfsChanges, VfsFile, WorkerResponse } from './protocol';
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.
13/** Collects every project file as bytes, preferring open (unsaved) editor contents. */
14async 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;
35export function createNumblRunner(fs: WorkspaceFileSystem, workbench: Workbench): { runner: FileRunner; dispose(): void } {
36 const figures = new FigureManager(workbench);
37 const decoder = new TextDecoder();
39 let worker: Worker | undefined;
40 let activeRun: { finish(): void } | undefined;
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 };
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 };
67 const run = async (context: RunContext): Promise<void> => {
68 context.output.clear();
69 figures.beginRun();
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) }));
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 };
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 };
139 return {
140 runner,
141 dispose() {
142 worker?.terminate();
143 worker = undefined;
144 figures.dispose();
145 },
146 };
moveopenescclose