@webmcp-auto-ui/ui 2.5.28 → 2.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,823 +0,0 @@
1
- // @ts-nocheck
2
- // ---------------------------------------------------------------------------
3
- // notebook-compact — reactive minimalist layout (marimo-like)
4
- // Left gutter with type label + vertical line, named outputs, fresh/stale status.
5
- // ---------------------------------------------------------------------------
6
-
7
- import {
8
- createState, injectStyles, mountRunControls, mountHistoryPanel,
9
- setupDnD, deleteCellWithConfirm, restoreCellFromSnapshot, addCell,
10
- addImportedCells, registerExecutor, collectDataServers,
11
- autosize, openShareModal, registerHistoryObserver,
12
- renderCellLogs,
13
- createPublishControls, autoConnectFrontmatterServers,
14
- createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
15
- lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime,
16
- type NotebookState, type NotebookCell, type CellResult, type CellExecContext,
17
- type RuntimeOverlay,
18
- } from './shared.js';
19
- import { dispatchShare } from './share-handlers.js';
20
- import { renderProse } from './prose.js';
21
- import { openAddMdModal, openAddRecipeModal } from './import-modals.js';
22
- import {
23
- extractCellsFromRecipe, extractCellFromMarkdown,
24
- } from './resource-extractor.js';
25
- import { mountLeftPane } from './left-pane.js';
26
- import { callToolViaPostMessage, MultiMcpBridge } from '@webmcp-auto-ui/core';
27
- import { renderChart } from './chart-renderer.js';
28
-
29
- export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
30
- injectStyles();
31
- injectLayoutStyles();
32
-
33
- const state: NotebookState = createState({
34
- id: data.id as string,
35
- title: data.title as string,
36
- mode: (data.mode as any) ?? 'edit',
37
- cells: data.cells as any,
38
- autoRun: (data as any).autoRun === true,
39
- });
40
-
41
- // Live mode runtime overlay (created lazily when bootstrapping). Never mutates state.
42
- let overlay: RuntimeOverlay | null = null;
43
- let liveCleanup: (() => void) | null = null;
44
-
45
- // --- register executors ---------------------------------------------------
46
- registerExecutor(state, 'js', jsExecutor);
47
- registerExecutor(state, 'sql', makeSqlExecutor(data));
48
-
49
- // Auto-connect any servers declared in frontmatter to the canvas store.
50
- autoConnectFrontmatterServers(data, rerender);
51
-
52
- container.classList.add('nb-root');
53
- container.classList.toggle('nb-view-mode', state.mode === 'view');
54
-
55
- container.innerHTML = `
56
- <div class="nbc-outer">
57
- <div class="nbc-leftpane-slot"></div>
58
- <div class="nbc-shell">
59
- <div class="nbc-toolbar">
60
- <div class="nbc-status">
61
- <span class="nbc-status-dot"></span>
62
- <span class="nbc-status-text">reactive · 0 cells</span>
63
- </div>
64
- <div class="nbc-actions">
65
- <div class="nb-mode-switch">
66
- <button class="nb-mode-edit nb-on">edit</button>
67
- <button class="nb-mode-view">view</button>
68
- </div>
69
- <button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
70
- <button class="nb-btn nb-add-cell" data-add="js">+ js</button>
71
- <button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
72
- <button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
73
- <button class="nb-btn nbc-history-btn">⟲ history</button>
74
- <span class="nbc-publish-slot"></span>
75
- <span class="nbc-live-toggle-slot"></span>
76
- <button class="nb-btn nbc-share-btn">share</button>
77
- <span class="nbc-publish-badge-slot"></span>
78
- </div>
79
- </div>
80
- <div class="nbc-header"></div>
81
- <div class="nbc-empty-state-slot"></div>
82
- <div class="nb-history-panel nbc-history-panel"></div>
83
- <div class="nbc-cells"></div>
84
- <div class="nbc-publish-footer-slot"></div>
85
- </div>
86
- </div>`;
87
-
88
- const outer = container.querySelector('.nbc-outer') as HTMLElement;
89
- const shell = container.querySelector('.nbc-shell') as HTMLElement;
90
- const leftPaneHost = container.querySelector('.nbc-leftpane-slot') as HTMLElement;
91
- const cellsEl = shell.querySelector('.nbc-cells') as HTMLElement;
92
- const historyPanel = shell.querySelector('.nbc-history-panel') as HTMLElement;
93
-
94
- // active index for imports: last focused/edited cell, else end
95
- let lastActiveIdx: number | null = null;
96
- function activeIdx(): number | null {
97
- if (lastActiveIdx != null && lastActiveIdx >= 0 && lastActiveIdx < state.cells.length) {
98
- return lastActiveIdx;
99
- }
100
- return null;
101
- }
102
-
103
- function renderCells() {
104
- cellsEl.innerHTML = '';
105
- state.cells.forEach((cell, idx) => {
106
- const node = renderCell(cell, state, overlay, rerender);
107
- node.addEventListener('focusin', () => { lastActiveIdx = idx; });
108
- cellsEl.appendChild(node);
109
- });
110
- updateStatus();
111
- }
112
-
113
- function renderHeader() {
114
- const header = shell.querySelector('.nbc-header') as HTMLElement;
115
- if (state.mode === 'view' && state.autoRun === true) {
116
- const refreshedAt = lastRefreshedAt(overlay);
117
- const refreshedTxt = refreshedAt
118
- ? `Refreshed ${escapeHtml(fmtRelTime(refreshedAt))} ago`
119
- : (overlay?.startedAt && !overlay?.finishedAt ? 'Refreshing…' : '');
120
- header.innerHTML = `
121
- <div class="nbc-header-row">
122
- <span class="nbc-title">${escapeHtml(state.title || 'Untitled notebook')}</span>
123
- <span class="nb-live-badge">● Live</span>
124
- </div>
125
- ${refreshedTxt ? `<div class="nbc-refreshed-at">${refreshedTxt}</div>` : ''}
126
- `;
127
- } else {
128
- header.innerHTML = '';
129
- }
130
- }
131
-
132
- function renderLiveToggle() {
133
- const slot = shell.querySelector('.nbc-live-toggle-slot') as HTMLElement;
134
- if (state.mode === 'edit') {
135
- const checked = state.autoRun === true ? 'checked' : '';
136
- slot.innerHTML = `<label class="nbc-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
137
- const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
138
- cb.addEventListener('change', () => {
139
- state.autoRun = cb.checked;
140
- rerender();
141
- });
142
- } else {
143
- slot.innerHTML = '';
144
- }
145
- }
146
-
147
- function renderEmptyState() {
148
- const slot = shell.querySelector('.nbc-empty-state-slot') as HTMLElement;
149
- const showBanner = state.autoRun === true && state.mode === 'view' && overlay
150
- && (overlay.error || (overlay.finishedAt !== null && overlay.outputs.size === 0));
151
- if (!showBanner) {
152
- slot.innerHTML = '';
153
- return;
154
- }
155
- // Find the most recent frozen snapshot timestamp from cell history (if any)
156
- const snapTs = state.lastEditAt ? fmtRelTime(state.lastEditAt) : '—';
157
- slot.innerHTML = `
158
- <div class="nb-empty-state">
159
- <div class="nb-empty-icon">📡</div>
160
- <div class="nb-empty-body">
161
- <div class="nb-empty-title">Live mode active, but no data server is reachable.</div>
162
- <div class="nb-empty-desc">Showing snapshots from <time>${escapeHtml(snapTs)} ago</time>.</div>
163
- </div>
164
- <button class="nb-btn nb-empty-retry">retry connection</button>
165
- </div>
166
- `;
167
- (slot.querySelector('.nb-empty-retry') as HTMLElement).addEventListener('click', () => {
168
- bootstrapLive();
169
- rerender();
170
- });
171
- }
172
-
173
- function bootstrapLive() {
174
- // Tear down any prior cycle
175
- liveCleanup?.();
176
- liveCleanup = null;
177
- overlay = createRuntimeOverlay();
178
- liveCleanup = bootstrapLiveRefresh({
179
- state,
180
- data,
181
- overlay,
182
- MultiMcpBridgeCtor: MultiMcpBridge as any,
183
- onCellChange: (cellId) => {
184
- // Re-render only the affected cell node to preserve focus/scroll.
185
- const node = cellsEl.querySelector(`[data-id="${cellId}"]`) as HTMLElement | null;
186
- if (!node) { renderCells(); return; }
187
- const idx = state.cells.findIndex((c) => c.id === cellId);
188
- if (idx < 0) return;
189
- const fresh = renderCell(state.cells[idx], state, overlay, rerender);
190
- fresh.addEventListener('focusin', () => { lastActiveIdx = idx; });
191
- node.replaceWith(fresh);
192
- },
193
- onTick: () => {
194
- renderHeader();
195
- renderEmptyState();
196
- },
197
- });
198
- }
199
-
200
- function updateStatus() {
201
- const n = state.cells.length;
202
- const stale = state.cells.filter((c) => c.status === 'stale').length;
203
- (shell.querySelector('.nbc-status-text') as HTMLElement).textContent =
204
- stale > 0 ? `reactive · ${n} cells · ${stale} stale` : `reactive · ${n} cells · synced`;
205
- (shell.querySelector('.nbc-status-dot') as HTMLElement).classList.toggle('nbc-stale', stale > 0);
206
- }
207
-
208
- function rerender() {
209
- mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
210
- renderHeader();
211
- renderLiveToggle();
212
- renderEmptyState();
213
- renderCells();
214
- }
215
-
216
- // Toolbar: direct add (sql/js)
217
- shell.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
218
- btn.addEventListener('click', () => {
219
- const type = btn.dataset.add as any;
220
- addCell(state, type, { varname: type === 'sql' ? 'rows_' + (state.cells.length + 1) : undefined });
221
- rerender();
222
- });
223
- });
224
-
225
- // Toolbar: modal add (md / recipe)
226
- shell.querySelectorAll<HTMLElement>('[data-add-modal]').forEach((btn) => {
227
- btn.addEventListener('click', () => {
228
- const which = btn.dataset.addModal;
229
- if (which === 'md') {
230
- openAddMdModal((content) => {
231
- const cell = extractCellFromMarkdown(content);
232
- addImportedCells(state, [cell], activeIdx());
233
- rerender();
234
- });
235
- } else if (which === 'recipe') {
236
- const mcpServers = (Array.isArray((data as any)?.servers) ? (data as any).servers : [])
237
- .map((s: any) => ({ name: String(s?.name ?? ''), url: s?.url ? String(s.url) : undefined }))
238
- .filter((s: any) => s.name);
239
- openAddRecipeModal({
240
- mcpServers,
241
- scope: 'data',
242
- onPick: (recipe) => {
243
- const cells = extractCellsFromRecipe(recipe.body ?? '', {
244
- title: recipe.name, description: recipe.description,
245
- });
246
- addImportedCells(state, cells, activeIdx());
247
- rerender();
248
- },
249
- });
250
- }
251
- });
252
- });
253
-
254
- (shell.querySelector('.nbc-history-btn') as HTMLElement).addEventListener('click', () => {
255
- historyPanel.classList.toggle('nb-open');
256
- });
257
- (shell.querySelector('.nbc-share-btn') as HTMLElement).addEventListener('click', () => {
258
- openShareModal(state, (fmt) => {
259
- dispatchShare(fmt, state, {
260
- container,
261
- onResult: (info) => console.log('[notebook-compact share]', info),
262
- });
263
- });
264
- });
265
- const editBtn = shell.querySelector('.nb-mode-edit') as HTMLElement;
266
- const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
267
- editBtn.addEventListener('click', () => {
268
- state.mode = 'edit';
269
- container.classList.remove('nb-view-mode');
270
- editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
271
- // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
272
- liveCleanup?.(); liveCleanup = null; overlay = null;
273
- rerender();
274
- });
275
- viewBtn.addEventListener('click', () => {
276
- state.mode = 'view';
277
- container.classList.add('nb-view-mode');
278
- viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
279
- if (state.autoRun === true) bootstrapLive();
280
- rerender();
281
- });
282
-
283
- const publishCleanup = createPublishControls(state, {
284
- buttonSlot: shell.querySelector('.nbc-publish-slot') as HTMLElement,
285
- badgeSlot: shell.querySelector('.nbc-publish-badge-slot') as HTMLElement,
286
- footerSlot: shell.querySelector('.nbc-publish-footer-slot') as HTMLElement,
287
- onPublished: () => rerender(),
288
- });
289
-
290
- // Left pane (collapsed by default)
291
- const pane = mountLeftPane(leftPaneHost, state, collectDataServers(data), {
292
- onInjectCells: (cells) => {
293
- addImportedCells(state, cells, activeIdx());
294
- rerender();
295
- },
296
- });
297
-
298
- // Keep pane servers in sync with canvas changes
299
- let canvasUnsub: (() => void) | null = null;
300
- try {
301
- const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
302
- if (canvasAny?.subscribe) {
303
- canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectDataServers(data)));
304
- }
305
- } catch { /* ignore */ }
306
-
307
- setupDnD(cellsEl, state, rerender);
308
- const unsubHistory = registerHistoryObserver(() => mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); }));
309
-
310
- rerender();
311
-
312
- // Mount-time bootstrap: view + autoRun → start live refresh.
313
- if (state.autoRun === true && state.mode === 'view') {
314
- bootstrapLive();
315
- rerender();
316
- }
317
-
318
- return () => {
319
- unsubHistory();
320
- canvasUnsub?.();
321
- pane.destroy();
322
- publishCleanup();
323
- liveCleanup?.();
324
- };
325
- }
326
-
327
- // ---------------------------------------------------------------------------
328
- // Executors
329
- // ---------------------------------------------------------------------------
330
-
331
- async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
332
- const start = Date.now();
333
- const { cell, scope } = ctx;
334
- try {
335
- const keys = Object.keys(scope);
336
- const values = keys.map((k) => scope[k]);
337
- // Wrap body so bare expressions get returned (mimics REPL).
338
- const src = cell.content.trim();
339
- const body = /^\s*(return|var|let|const|function|class|if|for|while|\/\/|\/\*)/.test(src)
340
- ? src
341
- : `return (async () => { return (${src}); })();`;
342
- // eslint-disable-next-line no-new-func
343
- const fn = new Function(...keys, body);
344
- let result = fn(...values);
345
- if (result && typeof result.then === 'function') result = await result;
346
- const durationMs = Date.now() - start;
347
-
348
- if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs };
349
- if (Array.isArray(result)) {
350
- const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
351
- const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
352
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
353
- }
354
- if (result && typeof result === 'object') {
355
- const r: any = result;
356
- if (r.data || r.marks || r.mark || r.$schema) {
357
- return { ok: true, kind: 'chart', spec: result, durationMs };
358
- }
359
- }
360
- return { ok: true, kind: 'value', value: result, durationMs };
361
- } catch (err: any) {
362
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
363
- }
364
- }
365
-
366
- function makeSqlExecutor(data: Record<string, unknown>) {
367
- return async function sqlExecutor(ctx: CellExecContext): Promise<CellResult> {
368
- const start = Date.now();
369
- const sql = ctx.cell.content;
370
- // Find a suitable SQL tool among connected servers.
371
- const servers = collectDataServers(data);
372
- const candidates: string[] = [];
373
- for (const srv of servers) {
374
- for (const t of srv.tools ?? []) candidates.push(t.name);
375
- }
376
- const precise = candidates.find((n) => /^.*query_sql$/i.test(n));
377
- const loose = precise ?? candidates.find((n) => /^(query|run|execute)(_sql)?$/i.test(n));
378
- const toolName = precise ?? loose;
379
- if (!toolName) {
380
- return {
381
- ok: false,
382
- error: 'No SQL tool available on connected servers (looked for *query_sql or query/run/execute).',
383
- errorKind: 'schema',
384
- durationMs: Date.now() - start,
385
- };
386
- }
387
- try {
388
- const res: any = await callToolViaPostMessage(toolName, { sql });
389
- const text = res?.content?.find?.((c: any) => c.type === 'text')?.text ?? '';
390
- const durationMs = Date.now() - start;
391
- // Try JSON first
392
- let parsed: any = null;
393
- try { parsed = JSON.parse(text); } catch { /* not JSON */ }
394
- if (parsed) {
395
- // Common shapes: array of rows, {rows:[...]}, {data:[...]}
396
- const rows: any[] = Array.isArray(parsed) ? parsed
397
- : Array.isArray(parsed?.rows) ? parsed.rows
398
- : Array.isArray(parsed?.data) ? parsed.data
399
- : Array.isArray(parsed?.results) ? parsed.results
400
- : [];
401
- if (rows.length && rows.every((r) => r && typeof r === 'object')) {
402
- const columns = Array.isArray(parsed?.columns)
403
- ? parsed.columns.map(String)
404
- : Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
405
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
406
- }
407
- return { ok: true, kind: 'value', value: parsed, durationMs };
408
- }
409
- if (!text) return { ok: true, kind: 'empty', durationMs };
410
- return { ok: true, kind: 'value', value: text, durationMs };
411
- } catch (err: any) {
412
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
413
- }
414
- };
415
- }
416
-
417
- // ---------------------------------------------------------------------------
418
- // Cell rendering (compact layout)
419
- // ---------------------------------------------------------------------------
420
-
421
- function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOverlay | null, rerender: () => void): HTMLElement {
422
- const wrap = document.createElement('div');
423
- wrap.className = 'nb-cell-wrapper nbc-cell';
424
- wrap.dataset.id = cell.id;
425
-
426
- const row = document.createElement('div');
427
- row.className = 'nbc-row';
428
-
429
- const handle = document.createElement('span');
430
- handle.className = 'nb-drag-handle';
431
- handle.draggable = true;
432
- handle.textContent = '⋮⋮';
433
- row.appendChild(handle);
434
-
435
- const gutter = document.createElement('div');
436
- gutter.className = `nbc-gutter nbc-gutter-${cell.type}`;
437
- gutter.innerHTML = `
438
- <span class="nbc-type-label">${cell.type}</span>
439
- ${cell.varname ? `<span class="nbc-gutter-var">→ ${escapeHtml(cell.varname)}</span>` : ''}
440
- <span class="nbc-line"></span>`;
441
- row.appendChild(gutter);
442
-
443
- const body = document.createElement('div');
444
- body.className = 'nbc-body';
445
- body.style.minWidth = '0';
446
-
447
- if (cell.type === 'md') {
448
- const mdBody = document.createElement('div');
449
- mdBody.className = 'nbc-md-body';
450
- if (state.mode === 'view') {
451
- const rendered = document.createElement('div');
452
- rendered.className = 'nb-md-render';
453
- rendered.innerHTML = renderProse(cell.content || '');
454
- mdBody.appendChild(rendered);
455
- } else {
456
- const ta = document.createElement('textarea');
457
- ta.className = 'nb-md-edit';
458
- ta.value = cell.content;
459
- ta.rows = 2;
460
- ta.placeholder = 'write markdown…';
461
- ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); });
462
- mdBody.appendChild(ta);
463
- setTimeout(() => autosize(ta), 0);
464
- }
465
- body.appendChild(mdBody);
466
-
467
- const del = document.createElement('button');
468
- del.className = 'nb-icon-btn nb-danger nbc-md-del';
469
- del.textContent = '✕';
470
- del.title = 'delete cell';
471
- del.addEventListener('click', () => deleteCellWithConfirm(state, cell, () => 'markdown cell', rerender));
472
- wrap.appendChild(del);
473
- } else {
474
- const codeCell = document.createElement('div');
475
- codeCell.className = 'nb-code-cell nbc-code-cell';
476
-
477
- // Cell title row with run controls FIRST (left), then meta
478
- const rtStatus = cellRuntimeStatus(cell, overlay);
479
- const showLive = state.autoRun === true && state.mode === 'view';
480
- let liveBadge = '';
481
- if (showLive) {
482
- if (rtStatus === 'running') {
483
- liveBadge = `<span class="nbc-live-badge nbc-live-running" title="re-executing"><span class="nbc-spinner"></span>running</span>`;
484
- } else if (rtStatus === 'stale') {
485
- liveBadge = `<span class="nbc-live-badge nbc-live-stale" title="last live refresh failed">stale</span>`;
486
- } else if (rtStatus === 'frozen') {
487
- liveBadge = `<span class="nbc-live-badge nbc-live-frozen" title="JS cells are not re-executed in live mode">frozen</span>`;
488
- }
489
- }
490
- const titleRow = document.createElement('div');
491
- titleRow.className = 'nbc-title-row';
492
- titleRow.innerHTML = `
493
- <span class="nbc-run-controls"></span>
494
- ${cell.varname ? `<span class="nbc-arrow-var">→ ${escapeHtml(cell.varname)}</span>` : ''}
495
- <span class="nbc-meta-info">${metaInfoFor(cell, overlay)}</span>
496
- ${liveBadge}
497
- <button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
498
- <button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
499
- <button class="nb-icon-btn nb-danger nbc-code-del">✕</button>
500
- `;
501
- codeCell.appendChild(titleRow);
502
- mountRunControls(titleRow.querySelector('.nbc-run-controls') as HTMLElement, cell, wrap, state, rerender);
503
-
504
- const codeBody = document.createElement('div');
505
- codeBody.className = 'nbc-code-body' + (cell.hideSource ? ' nbc-hidden' : '');
506
- const ta = document.createElement('textarea');
507
- ta.className = 'nb-code-edit';
508
- ta.value = cell.content;
509
- ta.rows = 1;
510
- ta.spellcheck = false;
511
- ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); cell.status = 'stale'; updateStatusDot(wrap); });
512
- codeBody.appendChild(ta);
513
- codeCell.appendChild(codeBody);
514
- setTimeout(() => autosize(ta), 0);
515
-
516
- const result = document.createElement('div');
517
- result.className = 'nbc-result-body' + (cell.hideResult ? ' nbc-hidden' : '');
518
- renderResultInto(result, cell, overlay);
519
- codeCell.appendChild(result);
520
- body.appendChild(codeCell);
521
-
522
- (titleRow.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
523
- (titleRow.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
524
- (titleRow.querySelector('.nbc-code-del') as HTMLElement).addEventListener('click', () =>
525
- deleteCellWithConfirm(state, cell, (c) => `${c.type} cell${c.varname ? ' → ' + c.varname : ''}`, rerender)
526
- );
527
- }
528
-
529
- row.appendChild(body);
530
- wrap.appendChild(row);
531
-
532
- // fresh / stale stripe on the gutter
533
- wrap.classList.toggle('nbc-fresh', cell.status === 'fresh');
534
- wrap.classList.toggle('nbc-stale', cell.status === 'stale');
535
-
536
- return wrap;
537
- }
538
-
539
- function updateStatusDot(_wrap: HTMLElement): void {
540
- // Placeholder: stripe updates happen on full rerender; keeps edit reactivity cheap.
541
- }
542
-
543
- function metaInfoFor(cell: NotebookCell, overlay: RuntimeOverlay | null): string {
544
- const r = effectiveResult(cell, overlay) ?? cell.lastResult;
545
- if (!r) return cell.status === 'stale' ? 'stale · click ▶ to run' : '—';
546
- if (!r.ok) return 'error';
547
- if (r.kind === 'table') return `${r.rowCount} row${r.rowCount === 1 ? '' : 's'}`;
548
- if (r.kind === 'value') return typeof r.value === 'object' && r.value !== null ? 'object' : typeof r.value;
549
- if (r.kind === 'chart') return 'chart';
550
- return 'empty';
551
- }
552
-
553
- function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
554
- const r = effectiveResult(cell, overlay) ?? cell.lastResult;
555
- if (!r) {
556
- el.innerHTML = `<div class="nbc-result-empty">click ▶ to run</div>`;
557
- return;
558
- }
559
- renderResultBody(el, r);
560
- // Logs panel — shared across all widgets, prepended above the main result
561
- const logsEl = renderCellLogs(r);
562
- if (logsEl) el.insertBefore(logsEl, el.firstChild);
563
- }
564
-
565
- function renderResultBody(el: HTMLElement, r: CellResult): void {
566
- if (!r.ok) {
567
- el.innerHTML = `<div class="nbc-result-error">${escapeHtml(r.error || 'error')}</div>`;
568
- return;
569
- }
570
- if (r.kind === 'empty') {
571
- el.innerHTML = `<div class="nbc-result-empty">(no output)</div>`;
572
- return;
573
- }
574
- if (r.kind === 'value') {
575
- const s = safeJson(r.value);
576
- el.innerHTML = `<pre class="nbc-result-pre">${escapeHtml(s)}</pre>`;
577
- return;
578
- }
579
- if (r.kind === 'chart') {
580
- el.innerHTML = '';
581
- const mount = document.createElement('div');
582
- mount.className = 'nb-chart';
583
- el.appendChild(mount);
584
- renderChart(mount, r.spec).catch(() => { /* fallback handled inside renderChart */ });
585
- return;
586
- }
587
- // table
588
- const cols = r.columns && r.columns.length ? r.columns
589
- : (r.rows[0] ? Object.keys(r.rows[0]) : []);
590
- const maxRows = 50;
591
- const shown = r.rows.slice(0, maxRows);
592
- const thead = `<tr>${cols.map((c) => `<th>${escapeHtml(String(c))}</th>`).join('')}</tr>`;
593
- const tbody = shown.map((row) => {
594
- return `<tr>${cols.map((c) => {
595
- const v = (row as any)[c];
596
- const cellStr = v == null ? '' : typeof v === 'object' ? safeJson(v) : String(v);
597
- return `<td>${escapeHtml(cellStr)}</td>`;
598
- }).join('')}</tr>`;
599
- }).join('');
600
- const trunc = r.rows.length > maxRows ? `<div class="nbc-result-trunc">showing ${maxRows} of ${r.rowCount}</div>` : '';
601
- el.innerHTML = `<div class="nbc-result-table-wrap"><table class="nbc-result-table"><thead>${thead}</thead><tbody>${tbody}</tbody></table></div>${trunc}`;
602
- }
603
-
604
- function safeJson(v: unknown): string {
605
- try { return JSON.stringify(v, null, 2); } catch { return String(v); }
606
- }
607
-
608
- function escapeHtml(s: string): string {
609
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
610
- }
611
-
612
- // ---------------------------------------------------------------------------
613
- // Layout-specific styles
614
- // ---------------------------------------------------------------------------
615
-
616
- function injectLayoutStyles(): void {
617
- if (document.getElementById('nbc-styles')) return;
618
- const style = document.createElement('style');
619
- style.id = 'nbc-styles';
620
- style.textContent = `
621
- .nbc-outer {
622
- display: flex; align-items: flex-start; gap: 8px;
623
- }
624
- .nbc-leftpane-slot { flex-shrink: 0; }
625
- .nbc-shell {
626
- flex: 1; min-width: 0;
627
- background: var(--color-surface); border: 1px solid var(--color-border);
628
- border-radius: 12px; padding: 18px;
629
- }
630
- .nbc-toolbar {
631
- display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;
632
- gap: 8px; flex-wrap: wrap;
633
- }
634
- .nbc-status {
635
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
636
- font-size: 11px; color: var(--color-text2);
637
- display: inline-flex; align-items: center; gap: 8px;
638
- }
639
- .nbc-status-dot {
640
- width: 6px; height: 6px; border-radius: 50%;
641
- background: var(--color-teal);
642
- }
643
- .nbc-status-dot.nbc-stale { background: var(--color-amber); }
644
- .nbc-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
645
- .nbc-history-panel { margin-bottom: 12px; }
646
-
647
- .nbc-cell { margin-bottom: 14px; position: relative; }
648
- .nbc-cell:last-child { margin-bottom: 0; }
649
- .nbc-row {
650
- display: grid; grid-template-columns: 20px 48px minmax(0, 1fr); gap: 6px;
651
- }
652
- /* View mode: drag handle is hidden via shared.ts (display:none), which removes it
653
- from the grid auto-placement and caused the body to land in the 48px column.
654
- Collapse to a 2-column grid so the body keeps its full width. */
655
- .nb-root.nb-view-mode .nbc-row {
656
- grid-template-columns: 48px minmax(0, 1fr);
657
- }
658
- .nbc-gutter {
659
- display: flex; flex-direction: column; align-items: center; gap: 6px;
660
- padding-top: 2px;
661
- }
662
- .nbc-type-label {
663
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
664
- font-size: 10px; letter-spacing: 0.12em; color: var(--color-text2);
665
- }
666
- .nbc-gutter-var {
667
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
668
- font-size: 9.5px; color: var(--color-accent);
669
- writing-mode: initial; text-align: center;
670
- }
671
- .nbc-gutter-sql .nbc-type-label { color: var(--color-accent); }
672
- .nbc-gutter-js .nbc-type-label { color: var(--color-teal); }
673
- .nbc-line { width: 1px; flex: 1; background: var(--color-border); min-height: 12px; }
674
- .nbc-cell.nbc-fresh .nbc-line { background: var(--color-teal); }
675
- .nbc-cell.nbc-stale .nbc-line { background: var(--color-amber); }
676
-
677
- .nbc-md-body { padding: 4px 2px; border: 1px dashed transparent; border-radius: 4px; }
678
- .nbc-md-body:focus-within { border-color: var(--color-border); background: var(--color-bg); }
679
- .nbc-md-del {
680
- position: absolute; top: 4px; right: 4px;
681
- opacity: 0; transition: opacity 0.15s;
682
- }
683
- .nbc-cell:hover .nbc-md-del { opacity: 0.5; }
684
- .nbc-md-del:hover { opacity: 1 !important; }
685
-
686
- .nbc-code-cell {
687
- background: var(--color-bg); border: 1px solid var(--color-border);
688
- border-radius: 8px; overflow: hidden;
689
- transition: border-color 0.15s;
690
- }
691
- .nbc-code-cell:focus-within { border-color: var(--color-border2); }
692
- .nbc-title-row {
693
- display: flex; align-items: center; gap: 8px;
694
- padding: 8px 12px; border-bottom: 1px solid var(--color-border);
695
- background: var(--color-surface2);
696
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
697
- font-size: 10.5px; color: var(--color-text2);
698
- }
699
- .nbc-title-row .nbc-arrow-var { color: var(--color-accent); }
700
- .nbc-title-row .nbc-meta-info { margin-right: auto; }
701
- .nbc-code-body { padding: 10px 14px; }
702
- .nbc-hidden { display: none !important; }
703
- .nbc-result-body {
704
- padding: 10px 14px;
705
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
706
- font-size: 12px;
707
- border-top: 1px solid var(--color-border);
708
- color: var(--color-text1);
709
- }
710
- .nbc-result-empty, .nbc-result-chart-hint {
711
- color: var(--color-text2); font-style: italic; font-size: 11.5px;
712
- }
713
- .nbc-result-error {
714
- color: var(--color-accent2); white-space: pre-wrap; font-size: 12px;
715
- }
716
- .nbc-result-pre {
717
- margin: 4px 0 0; padding: 8px 10px; background: var(--color-surface2);
718
- border-radius: 4px; font-size: 11.5px; overflow: auto;
719
- max-height: 260px;
720
- }
721
- .nbc-result-table-wrap { overflow: auto; max-height: 320px; }
722
- .nbc-result-table {
723
- border-collapse: collapse; width: 100%; font-size: 11.5px;
724
- }
725
- .nbc-result-table th, .nbc-result-table td {
726
- text-align: left; padding: 4px 8px;
727
- border-bottom: 1px solid var(--color-border);
728
- white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis;
729
- }
730
- .nbc-result-table th {
731
- color: var(--color-text2); font-weight: 500;
732
- background: var(--color-surface2);
733
- position: sticky; top: 0;
734
- }
735
- .nbc-result-table td { font-variant-numeric: tabular-nums; }
736
- .nbc-result-trunc {
737
- padding: 4px 2px; color: var(--color-text2); font-size: 10.5px; font-style: italic;
738
- }
739
- .nbc-publish-slot { display: inline-flex; }
740
- .nbc-publish-badge-slot { margin-left: 8px; }
741
- .nbc-publish-footer-slot { margin-top: 12px; padding: 0 16px; }
742
-
743
- /* --- Live mode (autoRun) -------------------------------------------------- */
744
- .nbc-live-toggle {
745
- display: inline-flex; align-items: center; gap: 6px;
746
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
747
- font-size: 11px; color: var(--color-text2);
748
- cursor: pointer; user-select: none;
749
- padding: 2px 6px; border: 1px solid var(--color-border); border-radius: 4px;
750
- }
751
- .nbc-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
752
- .nbc-live-toggle input { margin: 0; cursor: pointer; }
753
-
754
- .nbc-header { margin-bottom: 14px; }
755
- .nbc-header:empty { margin-bottom: 0; }
756
- .nbc-header-row { display: flex; align-items: center; gap: 10px; }
757
- .nbc-title {
758
- font-size: 16px; font-weight: 600; color: var(--color-text1);
759
- }
760
- .nbc-refreshed-at {
761
- margin-top: 2px;
762
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
763
- font-size: 10.5px; color: var(--color-text2);
764
- }
765
-
766
- .nb-live-badge {
767
- display: inline-flex; align-items: center; gap: 4px;
768
- padding: 2px 7px; border-radius: 999px;
769
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
770
- font-size: 10px; font-weight: 500; letter-spacing: 0.04em;
771
- background: rgba(46, 160, 67, 0.12);
772
- color: #2ea043; border: 1px solid rgba(46, 160, 67, 0.35);
773
- }
774
-
775
- /* per-cell badges in title row */
776
- .nbc-live-badge {
777
- display: inline-flex; align-items: center; gap: 4px;
778
- padding: 1px 6px; border-radius: 999px;
779
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
780
- font-size: 9.5px; letter-spacing: 0.04em;
781
- border: 1px solid transparent;
782
- }
783
- .nbc-live-running {
784
- background: rgba(46, 160, 67, 0.10); color: #2ea043;
785
- border-color: rgba(46, 160, 67, 0.30);
786
- }
787
- .nbc-live-stale {
788
- background: rgba(210, 153, 34, 0.12); color: var(--color-amber, #d29922);
789
- border-color: rgba(210, 153, 34, 0.35);
790
- }
791
- .nbc-live-frozen {
792
- background: var(--color-surface2); color: var(--color-text2);
793
- border-color: var(--color-border);
794
- opacity: 0.75;
795
- }
796
- .nbc-spinner {
797
- width: 8px; height: 8px; border-radius: 50%;
798
- border: 1.5px solid currentColor; border-right-color: transparent;
799
- display: inline-block; animation: nbc-spin 0.8s linear infinite;
800
- }
801
- @keyframes nbc-spin { to { transform: rotate(360deg); } }
802
-
803
- /* empty-state banner */
804
- .nb-empty-state {
805
- display: flex; align-items: center; gap: 12px;
806
- padding: 12px 14px; margin-bottom: 14px;
807
- background: rgba(210, 153, 34, 0.08);
808
- border: 1px solid rgba(210, 153, 34, 0.40);
809
- border-radius: 8px;
810
- color: var(--color-amber, #d29922);
811
- }
812
- .nb-empty-icon { font-size: 22px; line-height: 1; }
813
- .nb-empty-body { flex: 1; min-width: 0; }
814
- .nb-empty-title { font-weight: 600; font-size: 12.5px; color: var(--color-text1); }
815
- .nb-empty-desc {
816
- margin-top: 2px;
817
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
818
- font-size: 11px; color: var(--color-text2);
819
- }
820
- .nb-empty-retry { white-space: nowrap; }
821
- `;
822
- document.head.appendChild(style);
823
- }