1import { monaco, type CustomEditorProvider, type Workbench, type WorkspaceFileSystem } from 'minwebide';
2import { getRunState, onDidChangeRunState, type RunState } from './runEvents';
3import { dirnameOf, parseSampleFile, samplingDefaults, updateSampleYaml } from './sampleConfig';
4import './sampleEditor.css';
6// The default view for .sample files: a form over the YAML (shared text
7// model, so 'Reopen as Text Editor', dirty state, and Ctrl+S behave), plus
8// the run button and per-chain progress bars fed by runEvents.
10interface StopHandle {
11 stop(): void;
12}
14export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: Workbench, stopHandle: StopHandle): CustomEditorProvider {
15 return {
16 viewType: 'stan.sampleView',
17 displayName: 'Sampling Run',
18 selector: [{ filenamePattern: '*.sample' }],
19 priority: 'default',
20 async resolveCustomEditor(doc) {
21 const model = await doc.getTextModel();
22 const uriKey = doc.uri.toString();
23 const sampleDir = dirnameOf(doc.uri.path);
24 const disposables: { dispose(): void }[] = [];
26 const element = el('div', 'sample-editor');
27 const inner = el('div', 'sample-editor-inner');
28 element.appendChild(inner);
30 const fileName = doc.uri.path.split('/').pop() ?? doc.uri.path;
31 inner.appendChild(el('h2', undefined, fileName));
32 inner.appendChild(el('p', 'sample-editor-subtitle', 'A sampling run: the Stan program, the data, sampling parameters, and where results go. This form edits the underlying YAML (tab menu → Reopen as Text Editor).'));
34 const problems = el('div', 'sample-problems');
35 problems.style.display = 'none';
36 inner.appendChild(problems);
38 // --- fields ------------------------------------------------------
39 let applyingEdit = false;
40 const setKey = (key: string, value: string | number | undefined) => {
41 const updated = updateSampleYaml(model.getValue(), key, value);
42 if (updated !== model.getValue()) {
43 applyingEdit = true;
44 try {
45 model.pushEditOperations([], [{ range: model.getFullModelRange(), text: updated }], () => null);
46 } finally {
47 applyingEdit = false;
48 }
49 refresh();
50 }
51 };
53 const stanField = fileSelect('stan', 'the Stan program', '.stan');
54 const dataField = fileSelect('data', 'the data (JSON)', '.json');
56 const outputField = el('input');
57 outputField.type = 'text';
58 outputField.placeholder = 'e.g. out/fit1';
59 outputField.addEventListener('change', () => setKey('output_dir', outputField.value.trim() || undefined));
60 inner.appendChild(field('output_dir', 'results are written here (replaced on each run)', outputField));
62 const params = el('div', 'sample-params');
63 inner.appendChild(params);
64 const numberField = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', hint: string, opts: { min: number; max?: number; step?: string; optional?: boolean }) => {
65 const input = el('input');
66 input.type = 'number';
67 input.min = String(opts.min);
68 if (opts.max !== undefined) {
69 input.max = String(opts.max);
70 }
71 input.step = opts.step ?? '1';
72 if (opts.optional) {
73 input.placeholder = 'random';
74 }
75 input.addEventListener('change', () => {
76 const raw = input.value.trim();
77 if (!raw) {
78 setKey(key, opts.optional ? undefined : samplingDefaults[key as keyof typeof samplingDefaults]);
79 return;
80 }
81 const value = Number(raw);
82 if (Number.isFinite(value)) {
83 setKey(key, value);
84 }
85 });
86 params.appendChild(field(key, hint, input, true));
87 return input;
88 };
89 const chainsInput = numberField('num_chains', 'chains', { min: 1, max: 8 });
90 const warmupInput = numberField('num_warmup', 'warmup iterations', { min: 0 });
91 const samplesInput = numberField('num_samples', 'draws per chain', { min: 1 });
92 const radiusInput = numberField('init_radius', 'init radius', { min: 0, step: '0.1' });
93 const seedInput = numberField('seed', 'random seed', { min: 0, optional: true });
95 // --- run button + progress ---------------------------------------
96 const runRow = el('div', 'sample-run-row');
97 const runButton = el('button', 'sample-run-button', 'Run sampling');
98 runButton.addEventListener('click', () => {
99 const state = getRunState(uriKey);
100 if (isRunning(state)) {
101 stopHandle.stop();
102 } else {
103 void workbench.runFile(doc.uri);
104 }
105 });
106 const runStatus = el('span', 'sample-run-status', '');
107 runRow.append(runButton, runStatus);
108 inner.appendChild(runRow);
110 const chainsBox = el('div', 'sample-chains');
111 inner.appendChild(chainsBox);
113 const renderRunState = (state: RunState) => {
114 const running = isRunning(state);
115 runButton.textContent = running ? 'Stop' : 'Run sampling';
116 runButton.classList.toggle('stop', running);
117 runStatus.textContent = state.message ?? '';
118 runStatus.className = 'sample-run-status'
119 + (state.phase === 'failed' ? ' error' : state.phase === 'done' ? ' done' : '');
120 chainsBox.textContent = '';
121 if (state.chains) {
122 state.chains.forEach((chain, index) => {
123 const row = el('div', 'sample-chain');
124 const percent = chain.totalIterations > 0 ? Math.round((chain.iteration / chain.totalIterations) * 100) : 0;
125 row.appendChild(el('span', 'sample-chain-label',
126 `Chain ${index + 1} ${chain.iteration} / ${chain.totalIterations}${chain.iteration > 0 ? (chain.warmup ? ' (warmup)' : ' (sampling)') : ''}`));
127 const bar = el('div', 'sample-chain-bar');
128 const fill = el('div', 'sample-chain-fill' + (chain.warmup ? ' warmup' : ''));
129 fill.style.width = `${percent}%`;
130 bar.appendChild(fill);
131 row.appendChild(bar);
132 chainsBox.appendChild(row);
133 });
134 }
135 };
136 renderRunState(getRunState(uriKey));
137 disposables.push(onDidChangeRunState((key, state) => {
138 if (key === uriKey) {
139 renderRunState(state);
140 }
141 }));
143 // --- model → form ------------------------------------------------
144 const refresh = () => {
145 const { config, errors, warnings } = parseSampleFile(model.getValue());
146 const messages = [...errors, ...warnings.map(w => `warning: ${w}`)];
147 problems.style.display = messages.length ? '' : 'none';
148 problems.className = 'sample-problems' + (errors.length ? '' : ' warnings');
149 problems.textContent = messages.join('\n');
150 runButton.disabled = errors.length > 0;
152 setIfNotFocused(stanField.select, config.stan ?? '');
153 setIfNotFocused(dataField.select, config.data ?? '');
154 setIfNotFocused(outputField, config.output_dir ?? '');
155 setIfNotFocused(chainsInput, String(config.num_chains));
156 setIfNotFocused(warmupInput, String(config.num_warmup));
157 setIfNotFocused(samplesInput, String(config.num_samples));
158 setIfNotFocused(radiusInput, String(config.init_radius));
159 setIfNotFocused(seedInput, config.seed === undefined ? '' : String(config.seed));
160 };
162 disposables.push(model.onDidChangeContent(() => {
163 if (!applyingEdit) {
164 refresh();
165 }
166 }));
168 // keep the .stan/.json dropdowns in sync with the project's files
169 const refreshFileLists = async () => {
170 const all = await listProjectFiles(fs);
171 stanField.setOptions(all.filter(path => path.endsWith('.stan')));
172 dataField.setOptions(all.filter(path => path.endsWith('.json')));
173 refresh();
174 };
175 let fileListTimer: ReturnType<typeof setTimeout> | undefined;
176 disposables.push(fs.fileService.onDidFilesChange(() => {
177 clearTimeout(fileListTimer);
178 fileListTimer = setTimeout(() => void refreshFileLists(), 300);
179 }));
180 await refreshFileLists();
182 return {
183 element,
184 dispose() {
185 clearTimeout(fileListTimer);
186 for (const disposable of disposables) {
187 disposable.dispose();
188 }
189 },
190 };
192 // --- helpers scoped to this pane ---------------------------------
194 /** A <select> of project files with a given extension, storing
195 * .sample-dir-relative references in the YAML. */
196 function fileSelect(key: 'stan' | 'data', hint: string, extension: string) {
197 const select = el('select');
198 let options: string[] = [];
199 const setOptions = (paths: string[]) => {
200 options = paths.map(path => referenceFor(path));
201 renderOptions(select.value);
202 };
203 const renderOptions = (current: string) => {
204 select.textContent = '';
205 const empty = makeOption('', `— select a ${extension} file —`);
206 select.appendChild(empty);
207 const seen = new Set<string>();
208 for (const reference of options) {
209 seen.add(reference);
210 select.appendChild(makeOption(reference, reference));
211 }
212 if (current && !seen.has(current)) {
213 select.appendChild(makeOption(current, `${current} (missing)`));
214 }
215 select.value = current;
216 };
217 select.addEventListener('change', () => setKey(key, select.value || undefined));
218 inner.appendChild(field(key, hint, select));
219 return { select, setOptions };
220 }
222 function referenceFor(path: string): string {
223 return path.startsWith(`${sampleDir}/`) && sampleDir !== '/'
224 ? path.slice(sampleDir.length + 1)
225 : (sampleDir === '/' ? path.slice(1) : path);
226 }
228 function setIfNotFocused(input: HTMLInputElement | HTMLSelectElement, value: string): void {
229 if (document.activeElement === input) {
230 return;
231 }
232 if (input instanceof HTMLSelectElement) {
233 const has = [...input.options].some(option => option.value === value);
234 if (!has && value) {
235 input.appendChild(makeOption(value, `${value} (missing)`));
236 }
237 }
238 if (input.value !== value) {
239 input.value = value;
240 }
241 }
243 function makeOption(value: string, label: string): HTMLOptionElement {
244 const option = document.createElement('option');
245 option.value = value;
246 option.textContent = label;
247 return option;
248 }
249 },
250 };
252 function isRunning(state: RunState): boolean {
253 return state.phase === 'compiling' || state.phase === 'loading' || state.phase === 'sampling' || state.phase === 'writing';
254 }
255}
257function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
258 const node = document.createElement(tag);
259 if (className) {
260 node.className = className;
261 }
262 if (text !== undefined) {
263 node.textContent = text;
264 }
265 return node;
266}
268function field(label: string, hint: string, control: HTMLElement, compact = false): HTMLElement {
269 const wrap = el('div', 'sample-field');
270 const labelEl = el('label', undefined, label);
271 if (!compact) {
272 labelEl.appendChild(el('span', 'sample-field-hint', hint));
273 } else {
274 labelEl.title = hint;
275 }
276 wrap.append(labelEl, control);
277 return wrap;
278}
280async function listProjectFiles(fs: WorkspaceFileSystem, path = '/'): Promise<string[]> {
281 const result: string[] = [];
282 const stat = await fs.fileService.resolve(fs.root.with({ path }));
283 for (const child of stat.children ?? []) {
284 if (child.isDirectory) {
285 result.push(...await listProjectFiles(fs, child.resource.path));
286 } else {
287 result.push(child.resource.path);
288 }
289 }
290 return result.sort();
291}