/ concept-collection / numbl-web-ide
Sign in
concept-collection / numbl-web-ide
numbl-web-ide / src / numbl / runner.ts
174 lines · 5.4 KBBlameHistoryRaw
1import { monaco, type FileRunner, type RunContext, type Workbench, type WorkspaceFileSystem } from 'minwebide';
2import type { WorkspaceFile } from 'numbl';
3import { FigureManager } from './figures';
4import { MIP_SEARCH_PATH, type MipSystem } from './mipSystem';
5import type { RunRequest, VfsChanges, VfsFile, WorkerResponse } from './protocol';
7// The .m file runner. Follows numbl.org's IDE semantics: the whole project is
8// the workspace — every file goes into the run's virtual file system, every
9// other .m file becomes a callable function, and the script's directory is
10// the cwd / first search path. Text output streams to the runner's output
11// channel in the bottom panel; figures appear as views in the secondary side
12// bar. Stop is a hard kill: the worker is terminated and replaced.
14/** Collects every project file as bytes, preferring open (unsaved) editor contents. */
15async function collectProjectFiles(fs: WorkspaceFileSystem): Promise<VfsFile[]> {
16 const encoder = new TextEncoder();
17 const files: VfsFile[] = [];
18 const walk = async (path: string): Promise<void> => {
19 const stat = await fs.fileService.resolve(fs.root.with({ path }));
20 for (const child of stat.children ?? []) {
21 if (child.isDirectory) {
22 await walk(child.resource.path);
23 } else {
24 const model = monaco.editor.getModel(child.resource);
25 const content = model
26 ? encoder.encode(model.getValue())
27 : (await fs.fileService.readFile(child.resource)).value.buffer;
28 files.push({ path: child.resource.path.replace(/^\//, ''), content });
29 }
30 }
31 };
32 await walk('/');
33 return files;
36export function createNumblRunner(fs: WorkspaceFileSystem, workbench: Workbench, mip: MipSystem): { runner: FileRunner; dispose(): void } {
37 const figures = new FigureManager(workbench);
38 const decoder = new TextDecoder();
40 let worker: Worker | undefined;
41 let activeRun: { finish(): void } | undefined;
43 const ensureWorker = (): Worker => {
44 if (!worker) {
45 worker = new Worker(new URL('./numblWorker.ts', import.meta.url), { type: 'module' });
46 }
47 return worker;
48 };
50 const applyVfsChanges = async (changes: VfsChanges | undefined): Promise<void> => {
51 if (!changes) {
52 return;
53 }
54 // scripts can write/delete files (fopen/fprintf, delete, ...) — sync
55 // project changes back into the project, and /system/ changes (mip
56 // package installs) into the shared system store
57 for (const file of [...changes.created, ...changes.modified]) {
58 if (file.path.startsWith('/project/')) {
59 await fs.writeFile(file.path.slice('/project'.length), file.content);
60 }
61 }
62 for (const path of changes.deleted) {
63 if (path.startsWith('/project/')) {
64 await fs.deleteFile(path.slice('/project'.length));
65 }
66 }
67 await mip.applyChanges(changes);
68 };
70 const run = async (context: RunContext): Promise<void> => {
71 context.output.clear();
72 figures.beginRun();
74 // mip core is fetched in the background when the IDE opens; only
75 // narrate when a run actually has to wait for it
76 if (!mip.isReady()) {
77 context.output.appendLine('[mip] installing package manager…');
78 }
79 await mip.ensureCore();
81 const mainFileName = context.uri.path.replace(/^\//, '');
82 const [code, projectFiles, systemFiles] = await Promise.all([
83 context.getText(),
84 collectProjectFiles(fs),
85 mip.collectFiles(),
86 ]);
87 const workspaceFiles: WorkspaceFile[] = [
88 ...projectFiles
89 .filter(f => f.path !== mainFileName && f.path.endsWith('.m'))
90 .map(f => ({ name: f.path, source: decoder.decode(f.content) })),
91 ...systemFiles
92 .filter(f => f.path.endsWith('.m'))
93 .map(f => ({ name: f.path, source: decoder.decode(f.content) })),
94 ];
95 const allFiles = [...projectFiles, ...systemFiles];
97 await new Promise<void>((resolve) => {
98 const w = ensureWorker();
99 const finish = () => {
100 w.onmessage = null;
101 w.onerror = null;
102 w.onmessageerror = null;
103 activeRun = undefined;
104 resolve();
105 };
106 activeRun = { finish };
107 // a worker that fails to load or crashes never posts done/error —
108 // surface it instead of hanging the run
109 w.onerror = (event) => {
110 context.output.append(`\nWorker error: ${event.message ?? 'failed to load'}\n`);
111 worker?.terminate();
112 worker = undefined;
113 finish();
114 };
115 w.onmessageerror = () => {
116 context.output.append('\nWorker message could not be deserialized\n');
117 finish();
118 };
119 w.onmessage = async (event: MessageEvent<WorkerResponse>) => {
120 const msg = event.data;
121 switch (msg.type) {
122 case 'output':
123 context.output.append(msg.text);
124 break;
125 case 'drawnow':
126 figures.apply(msg.plotInstructions);
127 break;
128 case 'done':
129 figures.apply(msg.plotInstructions);
130 await applyVfsChanges(msg.vfsChanges);
131 finish();
132 break;
133 case 'error':
134 context.output.append(`\n${msg.message}\n`);
135 await applyVfsChanges(msg.vfsChanges);
136 finish();
137 break;
138 }
139 };
140 const request: RunRequest = {
141 type: 'run',
142 code,
143 mainFileName,
144 vfsFiles: allFiles,
145 workspaceFiles,
146 searchPaths: [MIP_SEARCH_PATH],
147 };
148 w.postMessage(request);
149 });
150 };
152 const runner: FileRunner = {
153 id: 'numbl.run',
154 displayName: 'numbl',
155 selector: [{ filenamePattern: '*.m' }],
156 run,
157 stop: () => {
158 if (activeRun) {
159 worker?.terminate();
160 worker = undefined;
161 activeRun.finish();
162 }
163 },
164 };
166 return {
167 runner,
168 dispose() {
169 worker?.terminate();
170 worker = undefined;
171 figures.dispose();
172 },
173 };
moveopenescclose