@webmcp-auto-ui/ui 2.5.26 → 2.5.28
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.
- package/README.md +2 -2
- package/package.json +15 -3
- package/src/agent/AgentConsole.svelte +1 -21
- package/src/agent/DataServersPanel.svelte +164 -0
- package/src/agent/LLMSelector.svelte +26 -8
- package/src/agent/ModelCacheManager.svelte +359 -0
- package/src/agent/{GemmaLoader.svelte → ModelLoader.svelte} +1 -1
- package/src/agent/SettingsPanel.svelte +16 -2
- package/src/index.ts +45 -31
- package/src/widgets/WidgetRenderer.svelte +118 -115
- package/src/widgets/export-widget.ts +28 -1
- package/src/widgets/helpers/safe-image.ts +78 -0
- package/src/widgets/notebook/.gitkeep +0 -0
- package/src/widgets/notebook/chart-renderer.ts +63 -0
- package/src/widgets/notebook/compact.ts +823 -0
- package/src/widgets/notebook/document.ts +1065 -0
- package/src/widgets/notebook/editorial.ts +936 -0
- package/src/widgets/notebook/executors/.gitkeep +1 -0
- package/src/widgets/notebook/executors/index.ts +4 -0
- package/src/widgets/notebook/executors/js-worker.ts +269 -0
- package/src/widgets/notebook/executors/sql.ts +206 -0
- package/src/widgets/notebook/import-modals.ts +553 -0
- package/src/widgets/notebook/left-pane.ts +249 -0
- package/src/widgets/notebook/prose.ts +280 -0
- package/src/widgets/notebook/recipe-browser.ts +350 -0
- package/src/widgets/notebook/recipes/compact.md +124 -0
- package/src/widgets/notebook/recipes/document.md +139 -0
- package/src/widgets/notebook/recipes/editorial.md +120 -0
- package/src/widgets/notebook/recipes/workspace.md +119 -0
- package/src/widgets/notebook/resource-extractor.ts +162 -0
- package/src/widgets/notebook/share-handlers.ts +222 -0
- package/src/widgets/notebook/shared.ts +1592 -0
- package/src/widgets/notebook/workspace.ts +852 -0
- package/src/widgets/rich/cards.ts +181 -0
- package/src/widgets/rich/carousel.ts +319 -0
- package/src/widgets/rich/chart-rich.ts +386 -0
- package/src/widgets/rich/d3.ts +503 -0
- package/src/widgets/rich/data-table.ts +342 -0
- package/src/widgets/rich/gallery.ts +350 -0
- package/src/widgets/rich/grid-data.ts +173 -0
- package/src/widgets/rich/hemicycle.ts +313 -0
- package/src/widgets/rich/js-sandbox.ts +106 -0
- package/src/widgets/rich/json-viewer.ts +202 -0
- package/src/widgets/rich/log.ts +143 -0
- package/src/widgets/rich/map.ts +218 -0
- package/src/widgets/rich/profile.ts +256 -0
- package/src/widgets/rich/sankey.ts +262 -0
- package/src/widgets/rich/stat-card.ts +125 -0
- package/src/widgets/rich/timeline.ts +179 -0
- package/src/widgets/rich/trombinoscope.ts +246 -0
- package/src/widgets/simple/actions.ts +89 -0
- package/src/widgets/simple/alert.ts +100 -0
- package/src/widgets/simple/chart.ts +189 -0
- package/src/widgets/simple/code.ts +79 -0
- package/src/widgets/simple/kv.ts +68 -0
- package/src/widgets/simple/list.ts +89 -0
- package/src/widgets/simple/stat.ts +58 -0
- package/src/widgets/simple/tags.ts +125 -0
- package/src/widgets/simple/text.ts +198 -0
- package/src/wm/FloatingLayout.svelte +2 -0
- package/src/wm/LinkIndicators.svelte +8 -15
- package/src/widgets/SafeImage.svelte +0 -76
- package/src/widgets/rich/Cards.svelte +0 -39
- package/src/widgets/rich/Carousel.svelte +0 -88
- package/src/widgets/rich/Chart.svelte +0 -142
- package/src/widgets/rich/D3Widget.svelte +0 -373
- package/src/widgets/rich/DataTable.svelte +0 -62
- package/src/widgets/rich/Gallery.svelte +0 -94
- package/src/widgets/rich/GridData.svelte +0 -44
- package/src/widgets/rich/Hemicycle.svelte +0 -78
- package/src/widgets/rich/JsSandbox.svelte +0 -51
- package/src/widgets/rich/JsonViewer.svelte +0 -42
- package/src/widgets/rich/LogViewer.svelte +0 -24
- package/src/widgets/rich/MapView.svelte +0 -140
- package/src/widgets/rich/ProfileCard.svelte +0 -59
- package/src/widgets/rich/Sankey.svelte +0 -38
- package/src/widgets/rich/StatCard.svelte +0 -35
- package/src/widgets/rich/Timeline.svelte +0 -43
- package/src/widgets/rich/Trombinoscope.svelte +0 -48
- package/src/widgets/simple/ActionsBlock.svelte +0 -15
- package/src/widgets/simple/AlertBlock.svelte +0 -11
- package/src/widgets/simple/ChartBlock.svelte +0 -21
- package/src/widgets/simple/CodeBlock.svelte +0 -11
- package/src/widgets/simple/KVBlock.svelte +0 -16
- package/src/widgets/simple/ListBlock.svelte +0 -17
- package/src/widgets/simple/StatBlock.svelte +0 -14
- package/src/widgets/simple/TagsBlock.svelte +0 -15
- package/src/widgets/simple/TextBlock.svelte +0 -122
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Notebook shared engine — vanilla JS
|
|
4
|
+
// Used by the four notebook layout renderers (compact/workspace/document/editorial)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export const NB_PUBLISH_HOST: string = (() => {
|
|
8
|
+
try {
|
|
9
|
+
const override = (import.meta as any)?.env?.PUBLIC_NB_HOST;
|
|
10
|
+
if (override && typeof override === 'string') return String(override);
|
|
11
|
+
} catch { /* ignore */ }
|
|
12
|
+
return 'https://nb.hyperskills.net';
|
|
13
|
+
})();
|
|
14
|
+
|
|
15
|
+
export type CellType = 'md' | 'sql' | 'js';
|
|
16
|
+
export type RunState = 'idle' | 'running' | 'done' | 'stopped';
|
|
17
|
+
export type NotebookMode = 'edit' | 'view';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Cell result — tagged union consumed by all 4 widgets
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export type CellResult =
|
|
23
|
+
| { ok: true; kind: 'table'; rows: Record<string, unknown>[]; columns: string[]; rowCount: number; truncated?: boolean; durationMs: number; logs?: string[] }
|
|
24
|
+
| { ok: true; kind: 'value'; value: unknown; durationMs: number; logs?: string[] }
|
|
25
|
+
| { ok: true; kind: 'chart'; spec: unknown; durationMs: number; logs?: string[] }
|
|
26
|
+
| { ok: true; kind: 'empty'; durationMs: number; logs?: string[] }
|
|
27
|
+
| { ok: false; error: string; errorKind?: 'syntax' | 'runtime' | 'timeout' | 'schema'; durationMs: number; logs?: string[] };
|
|
28
|
+
|
|
29
|
+
export interface CellExecContext {
|
|
30
|
+
cell: NotebookCell;
|
|
31
|
+
state: NotebookState;
|
|
32
|
+
scope: Record<string, unknown>;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CellExecutor = (ctx: CellExecContext) => Promise<CellResult>;
|
|
37
|
+
export type CellExecutors = Partial<Record<CellType, CellExecutor>>;
|
|
38
|
+
|
|
39
|
+
export interface NotebookCell {
|
|
40
|
+
id: string;
|
|
41
|
+
type: CellType;
|
|
42
|
+
content: string;
|
|
43
|
+
name?: string; // cell name (workspace/compact)
|
|
44
|
+
varname?: string; // named output (compact)
|
|
45
|
+
hideSource?: boolean;
|
|
46
|
+
hideResult?: boolean;
|
|
47
|
+
runState?: RunState;
|
|
48
|
+
lastMs?: number;
|
|
49
|
+
status?: 'fresh' | 'stale';
|
|
50
|
+
comment?: { who: string; when: string; body: string } | null;
|
|
51
|
+
lastResult?: CellResult;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NotebookState {
|
|
55
|
+
id: string;
|
|
56
|
+
title: string;
|
|
57
|
+
mode: NotebookMode;
|
|
58
|
+
cells: NotebookCell[];
|
|
59
|
+
history: HistoryEntry[];
|
|
60
|
+
scope: Record<string, unknown>;
|
|
61
|
+
executors: CellExecutors;
|
|
62
|
+
lastEditAt: number;
|
|
63
|
+
kicker?: string;
|
|
64
|
+
publishedSlug?: string;
|
|
65
|
+
publishedToken?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Live mode (opt-in). When true and `mode === 'view'`, SQL cells are
|
|
68
|
+
* re-executed against their declared data servers at mount time, producing
|
|
69
|
+
* a RuntimeOverlay consumed at render. Default false (frozen snapshots).
|
|
70
|
+
*/
|
|
71
|
+
autoRun?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HistoryEntry {
|
|
75
|
+
ts: number;
|
|
76
|
+
kind: 'add' | 'del' | 'edit' | 'move' | 'run';
|
|
77
|
+
summary: string;
|
|
78
|
+
snapshot?: { cell: NotebookCell; idx: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Utilities
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export function uid(): string {
|
|
86
|
+
return 'c_' + Math.random().toString(36).slice(2, 9);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatDuration(ms: number): string {
|
|
90
|
+
if (ms < 1000) return ms + 'ms';
|
|
91
|
+
return (ms / 1000).toFixed(1) + 's';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function fmtRelTime(ts: number): string {
|
|
95
|
+
const diff = Date.now() - ts;
|
|
96
|
+
if (diff < 5000) return 'now';
|
|
97
|
+
if (diff < 60000) return Math.floor(diff / 1000) + 's';
|
|
98
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + 'm';
|
|
99
|
+
return Math.floor(diff / 3600000) + 'h';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function autosize(ta: HTMLTextAreaElement): void {
|
|
103
|
+
ta.style.height = 'auto';
|
|
104
|
+
ta.style.height = ta.scrollHeight + 'px';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function defaultCellContent(type: CellType): string {
|
|
108
|
+
if (type === 'md') return '### new section\n\nwrite here…';
|
|
109
|
+
if (type === 'sql') return 'select *\nfrom source\nlimit 10';
|
|
110
|
+
return '// write js here';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// State factory
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function createState(initial?: Partial<NotebookState>): NotebookState {
|
|
118
|
+
return {
|
|
119
|
+
id: initial?.id ?? uid(),
|
|
120
|
+
title: initial?.title ?? 'Untitled notebook',
|
|
121
|
+
mode: initial?.mode ?? 'edit',
|
|
122
|
+
cells: initial?.cells ?? [
|
|
123
|
+
{ id: uid(), type: 'md', content: '### Untitled notebook\n\nAdd some context here.', hideSource: false, hideResult: false },
|
|
124
|
+
{ id: uid(), type: 'sql', content: 'select *\nfrom source\nlimit 5', varname: 'rows', hideSource: false, hideResult: false, status: 'fresh' },
|
|
125
|
+
{ id: uid(), type: 'js', content: 'console.log(rows)', hideSource: false, hideResult: false, status: 'stale' },
|
|
126
|
+
],
|
|
127
|
+
history: initial?.history ?? [],
|
|
128
|
+
scope: initial?.scope ?? {},
|
|
129
|
+
executors: initial?.executors ?? {},
|
|
130
|
+
lastEditAt: initial?.lastEditAt ?? Date.now(),
|
|
131
|
+
kicker: initial?.kicker,
|
|
132
|
+
autoRun: initial?.autoRun ?? false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Live mode (autoRun) — RuntimeOverlay + helpers
|
|
138
|
+
//
|
|
139
|
+
// Principle: when a notebook is viewed with `state.autoRun && state.mode==='view'`,
|
|
140
|
+
// SQL cells are re-executed against their declared data servers, but the
|
|
141
|
+
// canonical state is NEVER mutated. All live results live in a RuntimeOverlay
|
|
142
|
+
// (ephemeral, per-mount). Rendering reads `effectiveResult(cell, overlay)` which
|
|
143
|
+
// falls back to `cell.lastResult` when the overlay has nothing.
|
|
144
|
+
//
|
|
145
|
+
// This preserves two invariants:
|
|
146
|
+
// 1) The published JSON is an immutable source of truth.
|
|
147
|
+
// 2) Toggling autoRun OFF at runtime re-shows frozen snapshots immediately.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export type CellRuntimeStatus = 'idle' | 'pending' | 'running' | 'fresh' | 'stale' | 'frozen';
|
|
151
|
+
|
|
152
|
+
export interface RuntimeOverlay {
|
|
153
|
+
/** Fresh results keyed by cell id. */
|
|
154
|
+
outputs: Map<string, { result: CellResult; refreshedAt: number }>;
|
|
155
|
+
/** Per-cell status during/after the refresh cycle. */
|
|
156
|
+
status: Map<string, CellRuntimeStatus>;
|
|
157
|
+
startedAt: number | null;
|
|
158
|
+
finishedAt: number | null;
|
|
159
|
+
/** Last fatal reason (e.g. "no reachable server"). */
|
|
160
|
+
error: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createRuntimeOverlay(): RuntimeOverlay {
|
|
164
|
+
return {
|
|
165
|
+
outputs: new Map(),
|
|
166
|
+
status: new Map(),
|
|
167
|
+
startedAt: null,
|
|
168
|
+
finishedAt: null,
|
|
169
|
+
error: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Result to render for a cell: live if available, else frozen, else undefined. */
|
|
174
|
+
export function effectiveResult(cell: NotebookCell, overlay: RuntimeOverlay | null | undefined): CellResult | undefined {
|
|
175
|
+
const live = overlay?.outputs.get(cell.id);
|
|
176
|
+
if (live) return live.result;
|
|
177
|
+
return cell.lastResult;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Display status (drives badges). Defaults to 'frozen' for non-rerunnable, else 'idle'. */
|
|
181
|
+
export function cellRuntimeStatus(cell: NotebookCell, overlay: RuntimeOverlay | null | undefined): CellRuntimeStatus {
|
|
182
|
+
const s = overlay?.status.get(cell.id);
|
|
183
|
+
if (s) return s;
|
|
184
|
+
if (!isReRunnable(cell)) return 'frozen';
|
|
185
|
+
return 'idle';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Live-mode whitelist. Only SQL cells are re-executable publicly. */
|
|
189
|
+
export function isReRunnable(cell: NotebookCell): boolean {
|
|
190
|
+
return cell.type === 'sql';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Host-supplied runner for a single cell. Returns the fresh result, or throws.
|
|
195
|
+
* The host decides how to talk to MCP (bridge, postMessage, local, etc.).
|
|
196
|
+
*/
|
|
197
|
+
export type CellRunner = (cell: NotebookCell, signal: AbortSignal) => Promise<CellResult>;
|
|
198
|
+
|
|
199
|
+
export interface AutoRefreshOptions {
|
|
200
|
+
state: NotebookState;
|
|
201
|
+
overlay: RuntimeOverlay;
|
|
202
|
+
runner: CellRunner;
|
|
203
|
+
/** Per-cell sub-render request — invoked whenever the overlay changes for a cell. */
|
|
204
|
+
onCellChange?: (cellId: string) => void;
|
|
205
|
+
/** Global tick — invoked on start / per cell / on finish. UI uses it for toolbar badges. */
|
|
206
|
+
onTick?: (overlay: RuntimeOverlay) => void;
|
|
207
|
+
signal?: AbortSignal;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface AutoRefreshSummary {
|
|
211
|
+
rerun: number;
|
|
212
|
+
frozen: number;
|
|
213
|
+
stale: number;
|
|
214
|
+
failed: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Drive the live refresh cycle. Mutates ONLY the overlay (never the state).
|
|
219
|
+
* Calls runner sequentially per cell to keep load low and order predictable.
|
|
220
|
+
*/
|
|
221
|
+
export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefreshSummary> {
|
|
222
|
+
const { state, overlay, runner, onCellChange, onTick, signal } = opts;
|
|
223
|
+
overlay.startedAt = Date.now();
|
|
224
|
+
overlay.finishedAt = null;
|
|
225
|
+
overlay.error = null;
|
|
226
|
+
|
|
227
|
+
// Seed status
|
|
228
|
+
for (const c of state.cells) {
|
|
229
|
+
overlay.status.set(c.id, isReRunnable(c) ? 'pending' : 'frozen');
|
|
230
|
+
}
|
|
231
|
+
onTick?.(overlay);
|
|
232
|
+
|
|
233
|
+
const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
|
|
234
|
+
|
|
235
|
+
for (const cell of state.cells) {
|
|
236
|
+
if (signal?.aborted) break;
|
|
237
|
+
if (!isReRunnable(cell)) { summary.frozen++; continue; }
|
|
238
|
+
|
|
239
|
+
overlay.status.set(cell.id, 'running');
|
|
240
|
+
onCellChange?.(cell.id);
|
|
241
|
+
onTick?.(overlay);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const result = await runner(cell, signal ?? new AbortController().signal);
|
|
245
|
+
if (result.ok) {
|
|
246
|
+
overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
|
|
247
|
+
overlay.status.set(cell.id, 'fresh');
|
|
248
|
+
summary.rerun++;
|
|
249
|
+
} else {
|
|
250
|
+
overlay.status.set(cell.id, 'stale');
|
|
251
|
+
summary.stale++;
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
overlay.status.set(cell.id, 'stale');
|
|
255
|
+
summary.failed++;
|
|
256
|
+
if (!overlay.error) overlay.error = err instanceof Error ? err.message : String(err);
|
|
257
|
+
}
|
|
258
|
+
onCellChange?.(cell.id);
|
|
259
|
+
onTick?.(overlay);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
overlay.finishedAt = Date.now();
|
|
263
|
+
onTick?.(overlay);
|
|
264
|
+
return summary;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Last successful refresh timestamp (max refreshedAt across cells). */
|
|
268
|
+
export function lastRefreshedAt(overlay: RuntimeOverlay | null | undefined): number | null {
|
|
269
|
+
if (!overlay || overlay.outputs.size === 0) return null;
|
|
270
|
+
let max = 0;
|
|
271
|
+
for (const v of overlay.outputs.values()) if (v.refreshedAt > max) max = v.refreshedAt;
|
|
272
|
+
return max || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build a CellRunner backed by a MultiMcpBridge. Discovers a SQL-capable tool
|
|
277
|
+
* on the connected servers (matching `*_query_sql` then `query|run|execute`),
|
|
278
|
+
* calls it with `{ sql: cell.content }`, parses content-array into a table.
|
|
279
|
+
*
|
|
280
|
+
* Throws if no server is reachable / no SQL tool found. Callers are expected
|
|
281
|
+
* to surface this as a 'stale' status, not crash.
|
|
282
|
+
*/
|
|
283
|
+
export function createBridgeSqlRunner(bridge: {
|
|
284
|
+
hasServer?: (name: string) => boolean;
|
|
285
|
+
connectedServers?: () => string[];
|
|
286
|
+
multiClient: {
|
|
287
|
+
listTools?: (url: string) => Promise<{ name: string }[]>;
|
|
288
|
+
getToolsForUrl?: (url: string) => { name: string }[];
|
|
289
|
+
};
|
|
290
|
+
callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>;
|
|
291
|
+
}, getServerDescriptors: () => DataServerDescriptor[]): CellRunner {
|
|
292
|
+
const PATTERN_PRIMARY = /^.*query_sql$/i;
|
|
293
|
+
const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
|
|
294
|
+
|
|
295
|
+
function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string } | null {
|
|
296
|
+
for (const srv of servers) {
|
|
297
|
+
for (const t of srv.tools ?? []) {
|
|
298
|
+
if (PATTERN_PRIMARY.test(t.name)) return { serverName: srv.name, toolName: t.name };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const srv of servers) {
|
|
302
|
+
for (const t of srv.tools ?? []) {
|
|
303
|
+
if (PATTERN_FALLBACK.test(t.name)) return { serverName: srv.name, toolName: t.name };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseResult(raw: unknown, startedAt: number): CellResult {
|
|
310
|
+
const durationMs = Date.now() - startedAt;
|
|
311
|
+
try {
|
|
312
|
+
const content = (raw as { content?: unknown })?.content ?? raw;
|
|
313
|
+
let text: string | null = null;
|
|
314
|
+
if (Array.isArray(content)) {
|
|
315
|
+
const first = content.find((c: { type?: string; text?: string }) => c?.type === 'text' && typeof c.text === 'string');
|
|
316
|
+
text = first ? (first as { text: string }).text : null;
|
|
317
|
+
} else if (typeof content === 'string') {
|
|
318
|
+
text = content;
|
|
319
|
+
}
|
|
320
|
+
if (!text) return { ok: true, kind: 'empty', durationMs };
|
|
321
|
+
let parsed: unknown = text;
|
|
322
|
+
try { parsed = JSON.parse(text); } catch { /* keep raw string */ }
|
|
323
|
+
if (Array.isArray(parsed)) {
|
|
324
|
+
const rows = parsed as Record<string, unknown>[];
|
|
325
|
+
const cols = rows.length ? Object.keys(rows[0]!) : [];
|
|
326
|
+
return { ok: true, kind: 'table', rows, columns: cols, rowCount: rows.length, durationMs };
|
|
327
|
+
}
|
|
328
|
+
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { rows?: unknown }).rows)) {
|
|
329
|
+
const rows = (parsed as { rows: Record<string, unknown>[] }).rows;
|
|
330
|
+
const cols = Array.isArray((parsed as { columns?: string[] }).columns)
|
|
331
|
+
? (parsed as { columns: string[] }).columns
|
|
332
|
+
: (rows.length ? Object.keys(rows[0]!) : []);
|
|
333
|
+
return { ok: true, kind: 'table', rows, columns: cols, rowCount: rows.length, durationMs };
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, kind: 'value', value: parsed, durationMs };
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), errorKind: 'runtime', durationMs };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return async (cell, _signal) => {
|
|
342
|
+
const startedAt = Date.now();
|
|
343
|
+
const servers = getServerDescriptors();
|
|
344
|
+
if (!servers.length) {
|
|
345
|
+
return { ok: false, error: 'No data server reachable', errorKind: 'schema', durationMs: 0 };
|
|
346
|
+
}
|
|
347
|
+
const hit = findSqlTool(servers);
|
|
348
|
+
if (!hit) {
|
|
349
|
+
return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
|
|
350
|
+
}
|
|
351
|
+
const raw = await bridge.callTool(hit.serverName, hit.toolName, { sql: cell.content });
|
|
352
|
+
return parseResult(raw, startedAt);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* High-level bootstrap: auto-connect declared servers, wait for handshake, build
|
|
358
|
+
* a bridge-backed runner, fire runAutoRefresh. Safe to call from any layout at
|
|
359
|
+
* mount time when `state.autoRun && state.mode === 'view'`. Returns a cleanup.
|
|
360
|
+
*
|
|
361
|
+
* `MultiMcpBridgeCtor` is injected (via dynamic import from @webmcp-auto-ui/core
|
|
362
|
+
* by the caller) to keep this file free of a hard import cycle.
|
|
363
|
+
*/
|
|
364
|
+
export interface BootstrapLiveRefreshOptions {
|
|
365
|
+
state: NotebookState;
|
|
366
|
+
data: Record<string, unknown>;
|
|
367
|
+
overlay: RuntimeOverlay;
|
|
368
|
+
MultiMcpBridgeCtor: new (opts: { getCanvas: () => unknown }) => {
|
|
369
|
+
start(): void;
|
|
370
|
+
stop(): void;
|
|
371
|
+
waitForEnabledServers(timeoutMs?: number): Promise<void>;
|
|
372
|
+
connectedServers(): string[];
|
|
373
|
+
hasServer(name: string): boolean;
|
|
374
|
+
callTool(serverName: string, toolName: string, args: unknown): Promise<unknown>;
|
|
375
|
+
multiClient: unknown;
|
|
376
|
+
};
|
|
377
|
+
onCellChange?: (cellId: string) => void;
|
|
378
|
+
onTick?: (overlay: RuntimeOverlay) => void;
|
|
379
|
+
timeoutMs?: number;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
|
|
383
|
+
const { state, data, overlay, MultiMcpBridgeCtor, onCellChange, onTick, timeoutMs } = opts;
|
|
384
|
+
const ac = new AbortController();
|
|
385
|
+
|
|
386
|
+
void (async () => {
|
|
387
|
+
try {
|
|
388
|
+
autoConnectFrontmatterServers(data);
|
|
389
|
+
const canvas: unknown = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
|
|
390
|
+
.__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
|
|
391
|
+
if (!canvas) {
|
|
392
|
+
overlay.error = 'No canvas available';
|
|
393
|
+
overlay.finishedAt = Date.now();
|
|
394
|
+
onTick?.(overlay);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const bridge = new MultiMcpBridgeCtor({ getCanvas: () => canvas });
|
|
398
|
+
bridge.start();
|
|
399
|
+
await bridge.waitForEnabledServers(timeoutMs ?? 5000);
|
|
400
|
+
|
|
401
|
+
const runner = createBridgeSqlRunner(bridge, () => {
|
|
402
|
+
// filter collectDataServers to only connected ones
|
|
403
|
+
const all = collectDataServers(data);
|
|
404
|
+
return all.filter((s) => bridge.hasServer(s.name));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await runAutoRefresh({ state, overlay, runner, onCellChange, onTick, signal: ac.signal });
|
|
408
|
+
} catch (err) {
|
|
409
|
+
overlay.error = err instanceof Error ? err.message : String(err);
|
|
410
|
+
overlay.finishedAt = Date.now();
|
|
411
|
+
onTick?.(overlay);
|
|
412
|
+
}
|
|
413
|
+
})();
|
|
414
|
+
|
|
415
|
+
return () => { ac.abort(); };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
|
|
419
|
+
state.executors[type] = fn;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Insert imported cells into the notebook at a given position.
|
|
424
|
+
* - position undefined/null → end of notebook
|
|
425
|
+
* - position index → inserted just after that index
|
|
426
|
+
* Adds history entries and updates lastEditAt.
|
|
427
|
+
*/
|
|
428
|
+
export function addImportedCells(state: NotebookState, cells: NotebookCell[], position?: number | null): void {
|
|
429
|
+
if (!cells?.length) return;
|
|
430
|
+
const insertAt = (typeof position === 'number' && position >= -1)
|
|
431
|
+
? Math.min(Math.max(position + 1, 0), state.cells.length)
|
|
432
|
+
: state.cells.length;
|
|
433
|
+
state.cells.splice(insertAt, 0, ...cells);
|
|
434
|
+
for (const c of cells) {
|
|
435
|
+
logHistory(state, 'add', `added ${c.type} cell (import)`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Mark downstream cells stale when a varname is updated.
|
|
441
|
+
* Simple lexical match: any cell whose content references \bvarname\b is marked stale.
|
|
442
|
+
*/
|
|
443
|
+
export function propagateStale(state: NotebookState, varname: string): void {
|
|
444
|
+
if (!varname) return;
|
|
445
|
+
const re = new RegExp('\\b' + varname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
|
446
|
+
for (const c of state.cells) {
|
|
447
|
+
if (c.type === 'md') continue;
|
|
448
|
+
if (re.test(c.content)) c.status = 'stale';
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function logHistory(state: NotebookState, kind: HistoryEntry['kind'], summary: string, snapshot?: HistoryEntry['snapshot']): void {
|
|
453
|
+
state.history.unshift({ ts: Date.now(), kind, summary, snapshot: snapshot ? JSON.parse(JSON.stringify(snapshot)) : undefined });
|
|
454
|
+
if (state.history.length > 100) state.history.pop();
|
|
455
|
+
state.lastEditAt = Date.now();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function moveCell(state: NotebookState, fromIdx: number, toIdx: number): void {
|
|
459
|
+
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
|
460
|
+
const [moved] = state.cells.splice(fromIdx, 1);
|
|
461
|
+
state.cells.splice(toIdx, 0, moved);
|
|
462
|
+
logHistory(state, 'move', `moved ${moved.type} cell`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Run / Stop — dispatcher with pluggable executors
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
const runningTimers = new Map<string, { intervalId: any; timeoutId: any }>();
|
|
470
|
+
const runningAborts = new Map<string, AbortController>();
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Start running a cell using the executor registered on state for cell.type.
|
|
474
|
+
* If no executor is registered, the cell transitions to 'done' with a clear
|
|
475
|
+
* error result.
|
|
476
|
+
*/
|
|
477
|
+
export function startRun(cell: NotebookCell, state: NotebookState | null, onUpdate: () => void): void {
|
|
478
|
+
cell.runState = 'running';
|
|
479
|
+
cell.status = 'stale';
|
|
480
|
+
(cell as any).startedAt = Date.now();
|
|
481
|
+
onUpdate();
|
|
482
|
+
|
|
483
|
+
const exec = state?.executors?.[cell.type];
|
|
484
|
+
if (!exec) {
|
|
485
|
+
cell.lastResult = {
|
|
486
|
+
ok: false,
|
|
487
|
+
error: `No executor registered for cell type '${cell.type}'`,
|
|
488
|
+
errorKind: 'runtime',
|
|
489
|
+
durationMs: 0,
|
|
490
|
+
};
|
|
491
|
+
cell.lastMs = 0;
|
|
492
|
+
cell.runState = 'done';
|
|
493
|
+
cell.status = 'stale';
|
|
494
|
+
delete (cell as any).startedAt;
|
|
495
|
+
onUpdate();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const ac = new AbortController();
|
|
500
|
+
runningAborts.set(cell.id, ac);
|
|
501
|
+
const ctx: CellExecContext = { cell, state: state!, scope: state!.scope, signal: ac.signal };
|
|
502
|
+
const startedAt = (cell as any).startedAt as number;
|
|
503
|
+
exec(ctx).then((res) => {
|
|
504
|
+
if (cell.runState !== 'running') return;
|
|
505
|
+
cell.lastResult = res;
|
|
506
|
+
cell.lastMs = res.durationMs ?? (Date.now() - startedAt);
|
|
507
|
+
cell.runState = 'done';
|
|
508
|
+
cell.status = res.ok ? 'fresh' : 'stale';
|
|
509
|
+
runningAborts.delete(cell.id);
|
|
510
|
+
if (res.ok && cell.varname && state) {
|
|
511
|
+
state.scope[cell.varname] = pickScopeValue(res);
|
|
512
|
+
propagateStale(state, cell.varname);
|
|
513
|
+
// Current cell is fresh (just ran), keep it fresh
|
|
514
|
+
cell.status = 'fresh';
|
|
515
|
+
}
|
|
516
|
+
delete (cell as any).startedAt;
|
|
517
|
+
onUpdate();
|
|
518
|
+
}).catch((err) => {
|
|
519
|
+
if (cell.runState !== 'running') return;
|
|
520
|
+
cell.lastResult = { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
|
|
521
|
+
cell.lastMs = Date.now() - startedAt;
|
|
522
|
+
cell.runState = 'done';
|
|
523
|
+
cell.status = 'stale';
|
|
524
|
+
runningAborts.delete(cell.id);
|
|
525
|
+
delete (cell as any).startedAt;
|
|
526
|
+
onUpdate();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function pickScopeValue(res: CellResult): unknown {
|
|
531
|
+
if (!res.ok) return undefined;
|
|
532
|
+
if (res.kind === 'table') return res.rows;
|
|
533
|
+
if (res.kind === 'value') return res.value;
|
|
534
|
+
if (res.kind === 'chart') return res.spec;
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function stopRun(cell: NotebookCell, onUpdate: () => void): void {
|
|
539
|
+
const handles = runningTimers.get(cell.id);
|
|
540
|
+
if (handles) {
|
|
541
|
+
clearInterval(handles.intervalId);
|
|
542
|
+
clearTimeout(handles.timeoutId);
|
|
543
|
+
runningTimers.delete(cell.id);
|
|
544
|
+
}
|
|
545
|
+
const ac = runningAborts.get(cell.id);
|
|
546
|
+
if (ac) {
|
|
547
|
+
ac.abort();
|
|
548
|
+
runningAborts.delete(cell.id);
|
|
549
|
+
}
|
|
550
|
+
cell.lastMs = Date.now() - ((cell as any).startedAt || Date.now());
|
|
551
|
+
cell.runState = 'stopped';
|
|
552
|
+
cell.status = 'stale';
|
|
553
|
+
delete (cell as any).startedAt;
|
|
554
|
+
onUpdate();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function tickRunningCell(cell: NotebookCell, elapsedEl: HTMLElement, onDone: () => void): void {
|
|
558
|
+
const startedAt = (cell as any).startedAt || Date.now();
|
|
559
|
+
(cell as any).startedAt = startedAt;
|
|
560
|
+
const tick = () => { elapsedEl.textContent = formatDuration(Date.now() - startedAt); };
|
|
561
|
+
tick();
|
|
562
|
+
const intervalId = setInterval(tick, 50);
|
|
563
|
+
// Executor path: interval drives elapsed display;
|
|
564
|
+
// clear it when the cell transitions out of 'running'.
|
|
565
|
+
runningTimers.set(cell.id, { intervalId, timeoutId: null as any });
|
|
566
|
+
const pollOut = setInterval(() => {
|
|
567
|
+
if (cell.runState !== 'running') {
|
|
568
|
+
clearInterval(intervalId);
|
|
569
|
+
clearInterval(pollOut);
|
|
570
|
+
runningTimers.delete(cell.id);
|
|
571
|
+
onDone();
|
|
572
|
+
}
|
|
573
|
+
}, 100);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// Data server descriptors (merged from canvas store + recipe-provided data.servers)
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
export interface DataServerTool {
|
|
581
|
+
name: string;
|
|
582
|
+
description?: string;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export interface DataServerRecipe {
|
|
586
|
+
name: string;
|
|
587
|
+
description?: string;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export interface DataServerDescriptor {
|
|
591
|
+
name: string;
|
|
592
|
+
url?: string;
|
|
593
|
+
kind?: string;
|
|
594
|
+
tools?: DataServerTool[];
|
|
595
|
+
recipes?: DataServerRecipe[];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Return true when the server looks like a UI/webmcp server and should be hidden.
|
|
599
|
+
* Primary discriminator is the explicit `kind` field; the name fallback is an
|
|
600
|
+
* exact (case-insensitive) match so we don't accidentally filter custom servers
|
|
601
|
+
* like "autoui-data" or "my-webmcp-server". */
|
|
602
|
+
function isUiServer(name: string, kind?: string): boolean {
|
|
603
|
+
if (kind === 'ui' || kind === 'webmcp') return true;
|
|
604
|
+
const n = (name || '').toLowerCase();
|
|
605
|
+
return n === 'autoui' || n === 'webmcp';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Source of truth: canvas.dataServers (enabled entries). The bridge auto-connects
|
|
610
|
+
* enabled servers and populates recipes/tools on the store. When the canvas has no
|
|
611
|
+
* enabled servers, we fall back to the legacy `data.servers` from recipe frontmatter
|
|
612
|
+
* (read-only hint).
|
|
613
|
+
*/
|
|
614
|
+
export function collectDataServers(data: Record<string, unknown>): DataServerDescriptor[] {
|
|
615
|
+
const canvas: any = (globalThis as any).__canvasVanilla ?? (globalThis as any).canvasVanilla;
|
|
616
|
+
const fromCanvas: any[] = Array.isArray(canvas?.dataServers) ? canvas.dataServers : [];
|
|
617
|
+
const enabled = fromCanvas.filter((s) => s?.enabled);
|
|
618
|
+
if (enabled.length > 0) {
|
|
619
|
+
return enabled
|
|
620
|
+
.filter((s) => !isUiServer(String(s?.name ?? ''), s?.kind))
|
|
621
|
+
.map((s) => ({
|
|
622
|
+
name: String(s.name),
|
|
623
|
+
url: s.url ? String(s.url) : undefined,
|
|
624
|
+
recipes: Array.isArray(s.recipes) ? s.recipes : [],
|
|
625
|
+
tools: Array.isArray(s.tools) ? s.tools : [],
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
// Fallback: legacy data.servers from recipe frontmatter (read-only hint)
|
|
629
|
+
const fromData: any[] = Array.isArray((data as any)?.servers) ? (data as any).servers : [];
|
|
630
|
+
return fromData
|
|
631
|
+
.filter((s) => s?.name && !isUiServer(String(s.name), s?.kind))
|
|
632
|
+
.map((s) => ({
|
|
633
|
+
name: String(s.name),
|
|
634
|
+
url: s.url ? String(s.url) : undefined,
|
|
635
|
+
recipes: Array.isArray(s.recipes) ? s.recipes : [],
|
|
636
|
+
tools: Array.isArray(s.tools) ? s.tools : [],
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// Publish controls (button + optional badge + optional footer)
|
|
643
|
+
// Shared across all 4 notebook layouts so publish/update behaves identically.
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
export interface PublishControlsOptions {
|
|
647
|
+
/** DOM element where to append the publish button (required). */
|
|
648
|
+
buttonSlot: HTMLElement;
|
|
649
|
+
/** DOM element where to append the published badge (top-right). If absent, badge is not rendered. */
|
|
650
|
+
badgeSlot?: HTMLElement;
|
|
651
|
+
/** DOM element where to append the published footer (URL + open link). If absent, footer is not rendered. */
|
|
652
|
+
footerSlot?: HTMLElement;
|
|
653
|
+
/** Called after a successful publish/update so the caller can rerender. */
|
|
654
|
+
onPublished?: (info: { slug: string; url: string; updated: boolean }) => void;
|
|
655
|
+
/** Optional toast function — falls back to internal toast helper if absent. */
|
|
656
|
+
toast?: (message: string, isError?: boolean) => void;
|
|
657
|
+
/** Minimal projection of the state sent to the server. If absent, sends { id, title, kicker, mode, cells }. */
|
|
658
|
+
serializeState?: (state: NotebookState) => Record<string, unknown>;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
interface PublishControlsHandles {
|
|
662
|
+
btn: HTMLButtonElement;
|
|
663
|
+
badge: HTMLAnchorElement | null;
|
|
664
|
+
footer: HTMLElement | null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function publishUrlFor(slug: string): string {
|
|
668
|
+
return `${NB_PUBLISH_HOST}/p/${slug}`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function publishBtnLabel(state: NotebookState): string {
|
|
672
|
+
return state.publishedSlug ? '🔄 update' : '📤 publish';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
|
|
676
|
+
const { btn, badge, footer } = controls;
|
|
677
|
+
btn.textContent = publishBtnLabel(state);
|
|
678
|
+
btn.dataset.state = state.publishedSlug ? 'published' : 'draft';
|
|
679
|
+
if (state.publishedSlug) {
|
|
680
|
+
btn.title = `Update ${publishUrlFor(state.publishedSlug)}`;
|
|
681
|
+
} else {
|
|
682
|
+
btn.title = 'Publish this notebook';
|
|
683
|
+
}
|
|
684
|
+
if (badge) {
|
|
685
|
+
if (state.publishedSlug) {
|
|
686
|
+
const url = publishUrlFor(state.publishedSlug);
|
|
687
|
+
badge.href = url;
|
|
688
|
+
badge.textContent = `📤 ${state.publishedSlug}`;
|
|
689
|
+
badge.style.display = '';
|
|
690
|
+
} else {
|
|
691
|
+
badge.style.display = 'none';
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (footer) {
|
|
695
|
+
if (state.publishedSlug) {
|
|
696
|
+
const url = publishUrlFor(state.publishedSlug);
|
|
697
|
+
footer.innerHTML = `Published at <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`;
|
|
698
|
+
footer.style.display = '';
|
|
699
|
+
} else {
|
|
700
|
+
footer.innerHTML = '';
|
|
701
|
+
footer.style.display = 'none';
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function fallbackPublishToast(message: string, isError?: boolean): void {
|
|
707
|
+
// Reuse undo-toast styling as a generic ephemeral indicator.
|
|
708
|
+
const toast = document.createElement('div');
|
|
709
|
+
toast.className = 'nb-undo-toast' + (isError ? ' nb-undo-toast-error' : '');
|
|
710
|
+
toast.textContent = message;
|
|
711
|
+
document.body.appendChild(toast);
|
|
712
|
+
requestAnimationFrame(() => toast.classList.add('nb-show'));
|
|
713
|
+
setTimeout(() => {
|
|
714
|
+
toast.classList.remove('nb-show');
|
|
715
|
+
setTimeout(() => toast.parentNode?.removeChild(toast), 220);
|
|
716
|
+
}, 3200);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Create publish controls (button + optional badge + optional footer) wired
|
|
721
|
+
* against the shared `nb.hyperskills.net` endpoint. Returns a `destroy()`
|
|
722
|
+
* callback for cleanup.
|
|
723
|
+
*/
|
|
724
|
+
export function createPublishControls(state: NotebookState, opts: PublishControlsOptions): () => void {
|
|
725
|
+
const btn = document.createElement('button');
|
|
726
|
+
btn.className = 'nb-btn nb-publish-btn';
|
|
727
|
+
btn.type = 'button';
|
|
728
|
+
opts.buttonSlot.appendChild(btn);
|
|
729
|
+
|
|
730
|
+
let badge: HTMLAnchorElement | null = null;
|
|
731
|
+
if (opts.badgeSlot) {
|
|
732
|
+
badge = document.createElement('a');
|
|
733
|
+
badge.className = 'nb-published-badge';
|
|
734
|
+
badge.target = '_blank';
|
|
735
|
+
badge.rel = 'noopener';
|
|
736
|
+
opts.badgeSlot.appendChild(badge);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let footer: HTMLElement | null = null;
|
|
740
|
+
if (opts.footerSlot) {
|
|
741
|
+
footer = document.createElement('div');
|
|
742
|
+
footer.className = 'nb-published-footer';
|
|
743
|
+
opts.footerSlot.appendChild(footer);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const controls: PublishControlsHandles = { btn, badge, footer };
|
|
747
|
+
refreshPublishControls(state, controls);
|
|
748
|
+
|
|
749
|
+
const toast = opts.toast ?? fallbackPublishToast;
|
|
750
|
+
|
|
751
|
+
const onClick = async () => {
|
|
752
|
+
const prevLabel = btn.textContent ?? '';
|
|
753
|
+
btn.disabled = true;
|
|
754
|
+
btn.textContent = state.publishedSlug ? '… updating' : '… publishing';
|
|
755
|
+
try {
|
|
756
|
+
const minimal = opts.serializeState
|
|
757
|
+
? opts.serializeState(state)
|
|
758
|
+
: {
|
|
759
|
+
id: state.id,
|
|
760
|
+
title: state.title,
|
|
761
|
+
kicker: state.kicker,
|
|
762
|
+
mode: state.mode,
|
|
763
|
+
cells: state.cells,
|
|
764
|
+
};
|
|
765
|
+
const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
|
|
766
|
+
method: 'POST',
|
|
767
|
+
headers: { 'content-type': 'application/json' },
|
|
768
|
+
body: JSON.stringify({
|
|
769
|
+
state: minimal,
|
|
770
|
+
slug: state.publishedSlug,
|
|
771
|
+
token: state.publishedToken,
|
|
772
|
+
}),
|
|
773
|
+
});
|
|
774
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
775
|
+
const reply: any = await res.json();
|
|
776
|
+
state.publishedSlug = reply.slug;
|
|
777
|
+
state.publishedToken = reply.token;
|
|
778
|
+
state.lastEditAt = Date.now();
|
|
779
|
+
const url: string = reply.url ?? publishUrlFor(String(reply.slug));
|
|
780
|
+
try { await navigator.clipboard?.writeText?.(url); } catch { /* ignore */ }
|
|
781
|
+
const updated = Boolean(reply.updated);
|
|
782
|
+
toast(
|
|
783
|
+
updated
|
|
784
|
+
? `updated · ${url.replace(/^https?:\/\//, '')} (copied)`
|
|
785
|
+
: `published · ${url.replace(/^https?:\/\//, '')} (copied)`
|
|
786
|
+
);
|
|
787
|
+
opts.onPublished?.({ slug: String(reply.slug), url, updated });
|
|
788
|
+
} catch (err: any) {
|
|
789
|
+
toast(`publish failed · ${String(err?.message ?? err)}`, true);
|
|
790
|
+
btn.textContent = prevLabel;
|
|
791
|
+
} finally {
|
|
792
|
+
btn.disabled = false;
|
|
793
|
+
refreshPublishControls(state, controls);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
btn.addEventListener('click', onClick);
|
|
798
|
+
|
|
799
|
+
return () => {
|
|
800
|
+
btn.removeEventListener('click', onClick);
|
|
801
|
+
btn.parentNode?.removeChild(btn);
|
|
802
|
+
badge?.parentNode?.removeChild(badge);
|
|
803
|
+
footer?.parentNode?.removeChild(footer);
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Auto-connect any data servers declared in recipe frontmatter (`data.servers`)
|
|
809
|
+
* to the shared canvas store. No-op / no-throw if the canvas store is absent.
|
|
810
|
+
* Calls `refresh()` (if provided) once merging is done.
|
|
811
|
+
*/
|
|
812
|
+
export function autoConnectFrontmatterServers(
|
|
813
|
+
data: Record<string, unknown>,
|
|
814
|
+
refresh?: () => void
|
|
815
|
+
): void {
|
|
816
|
+
try {
|
|
817
|
+
const declared = Array.isArray((data as any)?.servers) ? (data as any).servers : [];
|
|
818
|
+
if (declared.length === 0) return;
|
|
819
|
+
const canvas: any = (globalThis as any).__canvasVanilla ?? (globalThis as any).canvasVanilla;
|
|
820
|
+
if (!canvas?.addDataServer) return;
|
|
821
|
+
for (const srv of declared) {
|
|
822
|
+
const name = srv?.name;
|
|
823
|
+
const url = srv?.url;
|
|
824
|
+
if (!name) continue;
|
|
825
|
+
const existing = canvas.getDataServer?.(name);
|
|
826
|
+
if (existing) {
|
|
827
|
+
if (existing.enabled === false) canvas.setDataServerEnabled?.(name, true);
|
|
828
|
+
} else {
|
|
829
|
+
canvas.addDataServer({ name: String(name), url: url ? String(url) : undefined });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
refresh?.();
|
|
833
|
+
} catch { /* no-op */ }
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// Modals (shared singletons, created on demand)
|
|
838
|
+
// NOTE: These overlays are page-level singletons — only one confirm/share modal
|
|
839
|
+
// can be open at a time per page, even if multiple notebook widgets are mounted.
|
|
840
|
+
// Acceptable trade-off given modals are transient and user-driven.
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
let confirmOverlay: HTMLElement | null = null;
|
|
844
|
+
let shareOverlay: HTMLElement | null = null;
|
|
845
|
+
|
|
846
|
+
function ensureConfirmOverlay(): HTMLElement {
|
|
847
|
+
if (confirmOverlay && document.body.contains(confirmOverlay)) return confirmOverlay;
|
|
848
|
+
confirmOverlay = document.createElement('div');
|
|
849
|
+
confirmOverlay.className = 'nb-confirm-overlay';
|
|
850
|
+
confirmOverlay.innerHTML = `
|
|
851
|
+
<div class="nb-confirm-modal">
|
|
852
|
+
<div class="nb-confirm-title"></div>
|
|
853
|
+
<div class="nb-confirm-msg"></div>
|
|
854
|
+
<div class="nb-confirm-actions">
|
|
855
|
+
<button class="nb-btn nb-btn-cancel">cancel</button>
|
|
856
|
+
<button class="nb-btn nb-btn-danger">delete</button>
|
|
857
|
+
</div>
|
|
858
|
+
</div>`;
|
|
859
|
+
document.body.appendChild(confirmOverlay);
|
|
860
|
+
return confirmOverlay;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export function askConfirm(title: string, msg: string, targetName?: string): Promise<boolean> {
|
|
864
|
+
const overlay = ensureConfirmOverlay();
|
|
865
|
+
(overlay.querySelector('.nb-confirm-title') as HTMLElement).textContent = title;
|
|
866
|
+
(overlay.querySelector('.nb-confirm-msg') as HTMLElement).innerHTML =
|
|
867
|
+
msg.replace('{target}', `<span class="nb-target">${targetName || ''}</span>`);
|
|
868
|
+
overlay.classList.add('open');
|
|
869
|
+
return new Promise<boolean>((resolve) => {
|
|
870
|
+
const cleanup = () => { overlay.classList.remove('open'); };
|
|
871
|
+
const onDanger = () => { cleanup(); resolve(true); off(); };
|
|
872
|
+
const onCancel = () => { cleanup(); resolve(false); off(); };
|
|
873
|
+
const onBackdrop = (e: Event) => { if (e.target === overlay) { cleanup(); resolve(false); off(); } };
|
|
874
|
+
const dangerBtn = overlay.querySelector('.nb-btn-danger') as HTMLElement;
|
|
875
|
+
const cancelBtn = overlay.querySelector('.nb-btn-cancel') as HTMLElement;
|
|
876
|
+
const off = () => {
|
|
877
|
+
dangerBtn.removeEventListener('click', onDanger);
|
|
878
|
+
cancelBtn.removeEventListener('click', onCancel);
|
|
879
|
+
overlay.removeEventListener('click', onBackdrop);
|
|
880
|
+
};
|
|
881
|
+
dangerBtn.addEventListener('click', onDanger);
|
|
882
|
+
cancelBtn.addEventListener('click', onCancel);
|
|
883
|
+
overlay.addEventListener('click', onBackdrop);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function ensureShareOverlay(): HTMLElement {
|
|
888
|
+
if (shareOverlay && document.body.contains(shareOverlay)) return shareOverlay;
|
|
889
|
+
shareOverlay = document.createElement('div');
|
|
890
|
+
shareOverlay.className = 'nb-share-overlay';
|
|
891
|
+
shareOverlay.innerHTML = `
|
|
892
|
+
<div class="nb-share-modal">
|
|
893
|
+
<div class="nb-share-title">Share notebook</div>
|
|
894
|
+
<div class="nb-share-sub">choose an export format</div>
|
|
895
|
+
<div class="nb-share-options">
|
|
896
|
+
<div class="nb-share-option" data-share="hyperskill">
|
|
897
|
+
<div class="nb-share-icon">HS</div>
|
|
898
|
+
<div class="nb-share-txt">
|
|
899
|
+
<div class="nb-share-name">Hyperskill link</div>
|
|
900
|
+
<div class="nb-share-desc">shareable url with the full state encoded</div>
|
|
901
|
+
</div>
|
|
902
|
+
<span class="nb-share-arrow">→</span>
|
|
903
|
+
</div>
|
|
904
|
+
<div class="nb-share-option" data-share="md">
|
|
905
|
+
<div class="nb-share-icon">MD</div>
|
|
906
|
+
<div class="nb-share-txt">
|
|
907
|
+
<div class="nb-share-name">Markdown</div>
|
|
908
|
+
<div class="nb-share-desc">portable .md with code blocks and prose</div>
|
|
909
|
+
</div>
|
|
910
|
+
<span class="nb-share-arrow">→</span>
|
|
911
|
+
</div>
|
|
912
|
+
<div class="nb-share-option" data-share="png">
|
|
913
|
+
<div class="nb-share-icon">PNG</div>
|
|
914
|
+
<div class="nb-share-txt">
|
|
915
|
+
<div class="nb-share-name">PNG snapshot</div>
|
|
916
|
+
<div class="nb-share-desc">static image of the rendered notebook</div>
|
|
917
|
+
</div>
|
|
918
|
+
<span class="nb-share-arrow">→</span>
|
|
919
|
+
</div>
|
|
920
|
+
<div class="nb-share-option" data-share="json">
|
|
921
|
+
<div class="nb-share-icon">JSON</div>
|
|
922
|
+
<div class="nb-share-txt">
|
|
923
|
+
<div class="nb-share-name">JSON export</div>
|
|
924
|
+
<div class="nb-share-desc">raw cell data for backup or re-import</div>
|
|
925
|
+
</div>
|
|
926
|
+
<span class="nb-share-arrow">→</span>
|
|
927
|
+
</div>
|
|
928
|
+
</div>
|
|
929
|
+
<button class="nb-btn nb-share-close">close</button>
|
|
930
|
+
</div>`;
|
|
931
|
+
document.body.appendChild(shareOverlay);
|
|
932
|
+
shareOverlay.addEventListener('click', (e) => {
|
|
933
|
+
if (e.target === shareOverlay) shareOverlay!.classList.remove('open');
|
|
934
|
+
});
|
|
935
|
+
(shareOverlay.querySelector('.nb-share-close') as HTMLElement).addEventListener('click', () => {
|
|
936
|
+
shareOverlay!.classList.remove('open');
|
|
937
|
+
});
|
|
938
|
+
return shareOverlay;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Phase 1 agents: pass `dispatchShare` from ./share-handlers.js as onFormat,
|
|
943
|
+
* wrapped with container ref. Example:
|
|
944
|
+
* openShareModal(state, (fmt) => dispatchShare(fmt, state, { container, onResult: toast }));
|
|
945
|
+
*/
|
|
946
|
+
export function openShareModal(state: NotebookState, onFormat: (fmt: string) => void): void {
|
|
947
|
+
const overlay = ensureShareOverlay();
|
|
948
|
+
// Rebind option clicks for the current callback
|
|
949
|
+
overlay.querySelectorAll<HTMLElement>('.nb-share-option').forEach((opt) => {
|
|
950
|
+
const clone = opt.cloneNode(true) as HTMLElement;
|
|
951
|
+
opt.parentNode!.replaceChild(clone, opt);
|
|
952
|
+
clone.addEventListener('click', () => {
|
|
953
|
+
const fmt = clone.dataset.share!;
|
|
954
|
+
onFormat(fmt);
|
|
955
|
+
overlay.classList.remove('open');
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
overlay.classList.add('open');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
// Styles — injected once per page
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
export function injectStyles(): void {
|
|
966
|
+
if (document.getElementById('nb-shared-styles')) return;
|
|
967
|
+
const style = document.createElement('style');
|
|
968
|
+
style.id = 'nb-shared-styles';
|
|
969
|
+
style.textContent = NOTEBOOK_STYLES;
|
|
970
|
+
document.head.appendChild(style);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const NOTEBOOK_STYLES = `
|
|
974
|
+
.nb-root { font-family: var(--font-sans, 'Syne', system-ui, sans-serif); color: var(--color-text1); }
|
|
975
|
+
.nb-root * { box-sizing: border-box; }
|
|
976
|
+
|
|
977
|
+
.nb-btn {
|
|
978
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
979
|
+
font-size: 11px;
|
|
980
|
+
color: var(--color-text2);
|
|
981
|
+
background: transparent;
|
|
982
|
+
border: 1px solid var(--color-border);
|
|
983
|
+
padding: 5px 11px;
|
|
984
|
+
border-radius: 6px;
|
|
985
|
+
cursor: pointer;
|
|
986
|
+
transition: border-color 0.15s, color 0.15s;
|
|
987
|
+
letter-spacing: 0.04em;
|
|
988
|
+
}
|
|
989
|
+
.nb-btn:hover { border-color: var(--color-border2); color: var(--color-text1); }
|
|
990
|
+
.nb-btn-primary { background: var(--color-accent); color: #fff; border-color: var(--color-accent); }
|
|
991
|
+
.nb-btn-primary:hover { filter: brightness(1.1); color: #fff; }
|
|
992
|
+
.nb-btn-danger { background: var(--color-accent2); color: #fff; border-color: var(--color-accent2); }
|
|
993
|
+
.nb-btn-danger:hover { filter: brightness(1.1); color: #fff; }
|
|
994
|
+
|
|
995
|
+
.nb-icon-btn {
|
|
996
|
+
background: transparent; border: none;
|
|
997
|
+
color: var(--color-text2); cursor: pointer;
|
|
998
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
999
|
+
font-size: 11px; padding: 2px 6px; border-radius: 3px;
|
|
1000
|
+
}
|
|
1001
|
+
.nb-icon-btn:hover { color: var(--color-text1); background: var(--color-surface2); }
|
|
1002
|
+
.nb-icon-btn.nb-danger:hover { color: var(--color-accent2); background: rgba(250,109,124,0.1); }
|
|
1003
|
+
|
|
1004
|
+
.nb-ctl-pill {
|
|
1005
|
+
width: 22px; height: 22px; border-radius: 50%;
|
|
1006
|
+
border: none; cursor: pointer; padding: 0;
|
|
1007
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1008
|
+
color: #fff; transition: transform 0.1s, filter 0.15s;
|
|
1009
|
+
flex-shrink: 0;
|
|
1010
|
+
}
|
|
1011
|
+
.nb-ctl-pill:hover { filter: brightness(1.15); }
|
|
1012
|
+
.nb-ctl-pill:active { transform: scale(0.92); }
|
|
1013
|
+
.nb-ctl-pill.nb-run { background: var(--color-teal); box-shadow: 0 0 0 1px rgba(62,207,178,0.35), 0 1px 2px rgba(0,0,0,0.25); }
|
|
1014
|
+
.nb-ctl-pill.nb-stop { background: var(--color-accent2); box-shadow: 0 0 0 1px rgba(250,109,124,0.35), 0 1px 2px rgba(0,0,0,0.25); }
|
|
1015
|
+
.nb-ctl-pill.nb-run::before {
|
|
1016
|
+
content: ''; width: 0; height: 0;
|
|
1017
|
+
border-left: 7px solid #fff;
|
|
1018
|
+
border-top: 4px solid transparent;
|
|
1019
|
+
border-bottom: 4px solid transparent;
|
|
1020
|
+
margin-left: 2px;
|
|
1021
|
+
}
|
|
1022
|
+
.nb-ctl-pill.nb-stop::before {
|
|
1023
|
+
content: ''; width: 8px; height: 8px; background: #fff; border-radius: 1px;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.nb-timer {
|
|
1027
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1028
|
+
font-size: 10.5px; color: var(--color-text2);
|
|
1029
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
1030
|
+
font-variant-numeric: tabular-nums;
|
|
1031
|
+
}
|
|
1032
|
+
.nb-timer.nb-running { color: var(--color-teal); }
|
|
1033
|
+
.nb-timer .nb-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--color-text2); }
|
|
1034
|
+
.nb-timer.nb-running .nb-dot { background: var(--color-teal); animation: nb-pulse 1s ease-in-out infinite; }
|
|
1035
|
+
@keyframes nb-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } }
|
|
1036
|
+
|
|
1037
|
+
.nb-cell-wrapper.nb-running .nb-code-cell,
|
|
1038
|
+
.nb-cell.nb-running {
|
|
1039
|
+
position: relative;
|
|
1040
|
+
}
|
|
1041
|
+
.nb-cell-wrapper.nb-running .nb-code-cell::before,
|
|
1042
|
+
.nb-cell.nb-running::before {
|
|
1043
|
+
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
|
1044
|
+
background: linear-gradient(90deg, transparent, var(--color-teal), transparent);
|
|
1045
|
+
background-size: 200% 100%; animation: nb-sweep 1.4s linear infinite; z-index: 1;
|
|
1046
|
+
}
|
|
1047
|
+
@keyframes nb-sweep {
|
|
1048
|
+
0% { background-position: 200% 0; }
|
|
1049
|
+
100% { background-position: -200% 0; }
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.nb-drag-handle {
|
|
1053
|
+
cursor: grab; color: var(--color-text2); opacity: 0;
|
|
1054
|
+
transition: opacity 0.15s; font-size: 14px; user-select: none; padding: 2px 4px;
|
|
1055
|
+
}
|
|
1056
|
+
.nb-cell-wrapper:hover .nb-drag-handle { opacity: 0.5; }
|
|
1057
|
+
.nb-drag-handle:hover { opacity: 1 !important; color: var(--color-text1); }
|
|
1058
|
+
.nb-drag-handle:active { cursor: grabbing; }
|
|
1059
|
+
.nb-cell-wrapper.nb-dragging { opacity: 0.3; }
|
|
1060
|
+
.nb-cell-wrapper.nb-drag-over-before { box-shadow: 0 -2px 0 var(--color-accent); }
|
|
1061
|
+
.nb-cell-wrapper.nb-drag-over-after { box-shadow: 0 2px 0 var(--color-accent); }
|
|
1062
|
+
|
|
1063
|
+
.nb-root.nb-view-mode .nb-drag-handle,
|
|
1064
|
+
.nb-root.nb-view-mode .nb-icon-btn.nb-danger,
|
|
1065
|
+
.nb-root.nb-view-mode .nb-ctl-pill,
|
|
1066
|
+
.nb-root.nb-view-mode .nb-toggle-src,
|
|
1067
|
+
.nb-root.nb-view-mode .nb-toggle-res,
|
|
1068
|
+
.nb-root.nb-view-mode .nb-add-cell { display: none !important; }
|
|
1069
|
+
.nb-root.nb-view-mode textarea,
|
|
1070
|
+
.nb-root.nb-view-mode [contenteditable] { pointer-events: none; }
|
|
1071
|
+
.nb-root.nb-view-mode input.nb-title-edit,
|
|
1072
|
+
.nb-root.nb-view-mode input.nb-doc-title,
|
|
1073
|
+
.nb-root.nb-view-mode input.nb-ed-title { pointer-events: none; }
|
|
1074
|
+
|
|
1075
|
+
.nb-mode-switch {
|
|
1076
|
+
display: inline-flex;
|
|
1077
|
+
border: 1px solid var(--color-border);
|
|
1078
|
+
border-radius: 999px; padding: 2px; background: var(--color-surface);
|
|
1079
|
+
margin-right: 4px;
|
|
1080
|
+
}
|
|
1081
|
+
.nb-mode-switch button {
|
|
1082
|
+
border: none; background: transparent; color: var(--color-text2);
|
|
1083
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1084
|
+
font-size: 10px; padding: 4px 10px; cursor: pointer;
|
|
1085
|
+
border-radius: 999px; text-transform: uppercase; letter-spacing: 0.08em;
|
|
1086
|
+
}
|
|
1087
|
+
.nb-mode-switch button.nb-on { background: var(--color-accent); color: #fff; }
|
|
1088
|
+
|
|
1089
|
+
/* History */
|
|
1090
|
+
.nb-history-panel {
|
|
1091
|
+
display: none;
|
|
1092
|
+
margin-top: 12px; padding: 12px 14px;
|
|
1093
|
+
background: var(--color-bg);
|
|
1094
|
+
border: 1px solid var(--color-border);
|
|
1095
|
+
border-radius: 8px;
|
|
1096
|
+
max-height: 240px; overflow-y: auto;
|
|
1097
|
+
}
|
|
1098
|
+
.nb-history-panel.nb-open { display: block; }
|
|
1099
|
+
.nb-history-panel .nb-hp-title {
|
|
1100
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1101
|
+
font-size: 10px; color: var(--color-text2);
|
|
1102
|
+
text-transform: uppercase; letter-spacing: 0.1em;
|
|
1103
|
+
margin-bottom: 10px; display: flex; justify-content: space-between;
|
|
1104
|
+
}
|
|
1105
|
+
.nb-history-panel .nb-hp-entry {
|
|
1106
|
+
display: flex; align-items: center; gap: 10px;
|
|
1107
|
+
padding: 6px 4px; font-size: 12px; border-radius: 4px;
|
|
1108
|
+
border-bottom: 1px solid var(--color-border);
|
|
1109
|
+
}
|
|
1110
|
+
.nb-history-panel .nb-hp-entry:last-child { border-bottom: none; }
|
|
1111
|
+
.nb-history-panel .nb-hp-entry .nb-when {
|
|
1112
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1113
|
+
font-size: 10px; color: var(--color-text2); min-width: 48px;
|
|
1114
|
+
}
|
|
1115
|
+
.nb-history-panel .nb-hp-entry .nb-action { flex: 1; color: var(--color-text1); }
|
|
1116
|
+
.nb-history-panel .nb-hp-entry .nb-kind {
|
|
1117
|
+
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
|
1118
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1119
|
+
font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 6px;
|
|
1120
|
+
}
|
|
1121
|
+
.nb-kind-add { background: rgba(62,207,178,0.15); color: var(--color-teal); }
|
|
1122
|
+
.nb-kind-del { background: rgba(250,109,124,0.15); color: var(--color-accent2); }
|
|
1123
|
+
.nb-kind-edit { background: rgba(124,109,250,0.15); color: var(--color-accent); }
|
|
1124
|
+
.nb-kind-move { background: rgba(160,160,184,0.15); color: var(--color-text2); }
|
|
1125
|
+
.nb-kind-run { background: rgba(240,160,80,0.15); color: var(--color-amber); }
|
|
1126
|
+
.nb-hp-restore {
|
|
1127
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1128
|
+
font-size: 10px; padding: 3px 8px;
|
|
1129
|
+
color: var(--color-text2); background: transparent;
|
|
1130
|
+
border: 1px solid var(--color-border); border-radius: 4px; cursor: pointer;
|
|
1131
|
+
}
|
|
1132
|
+
.nb-hp-restore:hover { color: var(--color-accent); border-color: var(--color-accent); }
|
|
1133
|
+
.nb-hp-empty { font-size: 12px; color: var(--color-text2); text-align: center; padding: 12px 0; font-style: italic; }
|
|
1134
|
+
|
|
1135
|
+
/* Modals */
|
|
1136
|
+
.nb-confirm-overlay, .nb-share-overlay {
|
|
1137
|
+
position: fixed; inset: 0;
|
|
1138
|
+
background: rgba(0, 0, 0, 0.55);
|
|
1139
|
+
display: none; align-items: center; justify-content: center;
|
|
1140
|
+
z-index: 1001; padding: 24px;
|
|
1141
|
+
}
|
|
1142
|
+
.nb-confirm-overlay.open, .nb-share-overlay.open { display: flex; }
|
|
1143
|
+
.nb-confirm-modal, .nb-share-modal {
|
|
1144
|
+
background: var(--color-surface);
|
|
1145
|
+
border: 1px solid var(--color-border);
|
|
1146
|
+
border-radius: 14px;
|
|
1147
|
+
width: 100%; max-width: 440px;
|
|
1148
|
+
padding: 22px;
|
|
1149
|
+
font-family: var(--font-sans, 'Syne', sans-serif);
|
|
1150
|
+
}
|
|
1151
|
+
.nb-confirm-title, .nb-share-title {
|
|
1152
|
+
font-size: 16px; font-weight: 600; margin: 0 0 6px;
|
|
1153
|
+
}
|
|
1154
|
+
.nb-confirm-msg {
|
|
1155
|
+
font-size: 13px; color: var(--color-text2); line-height: 1.5; margin-bottom: 18px;
|
|
1156
|
+
}
|
|
1157
|
+
.nb-target {
|
|
1158
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1159
|
+
font-size: 12px; background: var(--color-bg);
|
|
1160
|
+
padding: 1px 6px; border-radius: 3px; color: var(--color-accent);
|
|
1161
|
+
}
|
|
1162
|
+
.nb-confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
1163
|
+
|
|
1164
|
+
.nb-share-modal { max-width: 480px; }
|
|
1165
|
+
.nb-share-sub {
|
|
1166
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1167
|
+
font-size: 11px; color: var(--color-text2);
|
|
1168
|
+
letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 20px;
|
|
1169
|
+
}
|
|
1170
|
+
.nb-share-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
|
1171
|
+
.nb-share-option {
|
|
1172
|
+
display: flex; align-items: center; gap: 14px;
|
|
1173
|
+
padding: 12px 14px; background: var(--color-bg);
|
|
1174
|
+
border: 1px solid var(--color-border); border-radius: 8px;
|
|
1175
|
+
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
|
1176
|
+
}
|
|
1177
|
+
.nb-share-option:hover { border-color: var(--color-accent); background: var(--color-surface2); }
|
|
1178
|
+
.nb-share-icon {
|
|
1179
|
+
width: 30px; height: 30px; border-radius: 6px;
|
|
1180
|
+
background: var(--color-surface2);
|
|
1181
|
+
display: flex; align-items: center; justify-content: center;
|
|
1182
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1183
|
+
font-size: 10px; font-weight: 600; color: var(--color-accent);
|
|
1184
|
+
letter-spacing: 0.06em; flex-shrink: 0;
|
|
1185
|
+
}
|
|
1186
|
+
.nb-share-name { font-size: 13px; color: var(--color-text1); font-weight: 500; margin-bottom: 1px; }
|
|
1187
|
+
.nb-share-desc { font-size: 11px; color: var(--color-text2); }
|
|
1188
|
+
.nb-share-arrow {
|
|
1189
|
+
margin-left: auto; color: var(--color-text2);
|
|
1190
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace); font-size: 14px;
|
|
1191
|
+
}
|
|
1192
|
+
.nb-share-option:hover .nb-share-arrow { color: var(--color-accent); }
|
|
1193
|
+
.nb-share-close {
|
|
1194
|
+
width: 100%;
|
|
1195
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1196
|
+
font-size: 11px; padding: 8px;
|
|
1197
|
+
color: var(--color-text2); background: transparent;
|
|
1198
|
+
border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/* Edit surfaces */
|
|
1202
|
+
textarea.nb-code-edit, textarea.nb-md-edit {
|
|
1203
|
+
width: 100%; background: transparent; border: none; outline: none;
|
|
1204
|
+
color: var(--color-text1);
|
|
1205
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1206
|
+
font-size: 12.5px; line-height: 1.65;
|
|
1207
|
+
resize: none; padding: 0; overflow: hidden;
|
|
1208
|
+
}
|
|
1209
|
+
textarea.nb-md-edit {
|
|
1210
|
+
font-family: var(--font-sans, 'Syne', sans-serif) !important;
|
|
1211
|
+
font-size: 14px !important; line-height: 1.6 !important;
|
|
1212
|
+
}
|
|
1213
|
+
.nb-kw { color: var(--color-accent); }
|
|
1214
|
+
.nb-str { color: var(--color-teal); }
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
/* Chart rendering (Vega-Lite via vega-embed) — shared across all 4 widgets */
|
|
1218
|
+
.nb-chart {
|
|
1219
|
+
width: 100%; max-width: 100%;
|
|
1220
|
+
min-height: 180px;
|
|
1221
|
+
background: var(--color-bg);
|
|
1222
|
+
border-radius: 4px;
|
|
1223
|
+
padding: 8px;
|
|
1224
|
+
box-sizing: border-box;
|
|
1225
|
+
overflow: auto;
|
|
1226
|
+
}
|
|
1227
|
+
.nb-chart canvas, .nb-chart svg { max-width: 100%; height: auto; display: block; }
|
|
1228
|
+
.nb-chart-fallback {
|
|
1229
|
+
margin: 0;
|
|
1230
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1231
|
+
font-size: 11px; line-height: 1.5;
|
|
1232
|
+
color: var(--color-text1);
|
|
1233
|
+
background: var(--color-surface2);
|
|
1234
|
+
padding: 8px 10px; border-radius: 4px;
|
|
1235
|
+
max-height: 240px; overflow: auto;
|
|
1236
|
+
white-space: pre-wrap;
|
|
1237
|
+
}
|
|
1238
|
+
.nb-chart-fallback-note {
|
|
1239
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1240
|
+
font-size: 10px; color: var(--color-text2);
|
|
1241
|
+
margin-bottom: 4px; font-style: italic;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/* Cell execution logs — shared across all 4 widgets */
|
|
1245
|
+
.nb-logs {
|
|
1246
|
+
background: rgba(160,160,184,0.06);
|
|
1247
|
+
border-left: 2px solid var(--color-text2);
|
|
1248
|
+
border-radius: 0 4px 4px 0;
|
|
1249
|
+
margin: 4px 0 6px;
|
|
1250
|
+
padding: 6px 10px;
|
|
1251
|
+
max-height: 160px;
|
|
1252
|
+
overflow-y: auto;
|
|
1253
|
+
}
|
|
1254
|
+
.nb-logs-label {
|
|
1255
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1256
|
+
font-size: 9.5px; letter-spacing: 0.08em;
|
|
1257
|
+
text-transform: uppercase; color: var(--color-text2);
|
|
1258
|
+
margin-bottom: 4px;
|
|
1259
|
+
}
|
|
1260
|
+
.nb-logs pre {
|
|
1261
|
+
margin: 0;
|
|
1262
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1263
|
+
font-size: 11px; line-height: 1.5;
|
|
1264
|
+
color: var(--color-text1);
|
|
1265
|
+
white-space: pre-wrap; word-break: break-word;
|
|
1266
|
+
}
|
|
1267
|
+
.nb-logs .nb-log-warn { color: var(--color-amber, #f0a050); }
|
|
1268
|
+
.nb-logs .nb-log-error { color: var(--color-accent2, #fa6d7c); }
|
|
1269
|
+
|
|
1270
|
+
/* Publish controls (button + published badge + footer) */
|
|
1271
|
+
.nb-publish-btn {
|
|
1272
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1273
|
+
font-size: 11px;
|
|
1274
|
+
}
|
|
1275
|
+
.nb-publish-btn[data-state="published"] { color: var(--color-accent); }
|
|
1276
|
+
.nb-published-badge {
|
|
1277
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
1278
|
+
padding: 4px 10px;
|
|
1279
|
+
background: var(--color-accent); color: #fff;
|
|
1280
|
+
border-radius: 999px;
|
|
1281
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1282
|
+
font-size: 11px; font-weight: 600;
|
|
1283
|
+
text-decoration: none; cursor: pointer;
|
|
1284
|
+
}
|
|
1285
|
+
.nb-published-badge:hover { filter: brightness(1.1); }
|
|
1286
|
+
.nb-published-footer {
|
|
1287
|
+
padding: 8px 0; border-top: 1px dashed var(--color-border);
|
|
1288
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1289
|
+
font-size: 11px; color: var(--color-text2);
|
|
1290
|
+
}
|
|
1291
|
+
.nb-published-footer a { color: var(--color-accent); text-decoration: none; }
|
|
1292
|
+
.nb-published-footer a:hover { text-decoration: underline; }
|
|
1293
|
+
|
|
1294
|
+
/* Undo toast (used by deleteCellWithConfirm + generic publish toast fallback) */
|
|
1295
|
+
.nb-undo-toast {
|
|
1296
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
1297
|
+
background: var(--color-surface2, #1a1a1f); color: var(--color-text1, #fff);
|
|
1298
|
+
border: 1px solid var(--color-border, #333); border-radius: 8px;
|
|
1299
|
+
padding: 10px 16px; font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
1300
|
+
font-size: 12px; line-height: 1.4;
|
|
1301
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
|
1302
|
+
z-index: 1010; display: inline-flex; align-items: center; gap: 10px;
|
|
1303
|
+
max-width: 480px; opacity: 0; transition: opacity 0.2s;
|
|
1304
|
+
}
|
|
1305
|
+
.nb-undo-toast.nb-show { opacity: 1; }
|
|
1306
|
+
.nb-undo-toast-error { border-color: var(--color-accent2, #fa6d7c); color: var(--color-accent2, #fa6d7c); }
|
|
1307
|
+
.nb-undo-toast-undo {
|
|
1308
|
+
font-family: inherit; font-size: inherit; font-weight: 600;
|
|
1309
|
+
color: var(--color-accent, #6a55ff); cursor: pointer;
|
|
1310
|
+
background: none; border: none; padding: 0; text-decoration: underline;
|
|
1311
|
+
}
|
|
1312
|
+
.nb-undo-toast-undo:hover { filter: brightness(1.15); }
|
|
1313
|
+
`;
|
|
1314
|
+
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
// Shared UI parts: Run/Stop control, history panel, drag-drop
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
|
|
1319
|
+
export function mountRunControls(container: HTMLElement, cell: NotebookCell, cellWrap: HTMLElement, notebookState: NotebookState | null, rerender: () => void): void {
|
|
1320
|
+
container.innerHTML = '';
|
|
1321
|
+
const state = cell.runState || 'idle';
|
|
1322
|
+
|
|
1323
|
+
if (state === 'running') {
|
|
1324
|
+
const stop = document.createElement('button');
|
|
1325
|
+
stop.className = 'nb-ctl-pill nb-stop';
|
|
1326
|
+
stop.title = 'stop';
|
|
1327
|
+
container.appendChild(stop);
|
|
1328
|
+
|
|
1329
|
+
const timer = document.createElement('span');
|
|
1330
|
+
timer.className = 'nb-timer nb-running';
|
|
1331
|
+
timer.style.marginLeft = '6px';
|
|
1332
|
+
timer.innerHTML = '<span class="nb-dot"></span><span class="nb-elapsed">0ms</span>';
|
|
1333
|
+
container.appendChild(timer);
|
|
1334
|
+
|
|
1335
|
+
cellWrap.classList.add('nb-running');
|
|
1336
|
+
const wsCell = cellWrap.querySelector?.('.nb-cell');
|
|
1337
|
+
if (wsCell) wsCell.classList.add('nb-running');
|
|
1338
|
+
|
|
1339
|
+
tickRunningCell(cell, timer.querySelector('.nb-elapsed') as HTMLElement, rerender);
|
|
1340
|
+
|
|
1341
|
+
stop.addEventListener('click', () => stopRun(cell, rerender));
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
cellWrap.classList.remove('nb-running');
|
|
1346
|
+
const wsCell = cellWrap.querySelector?.('.nb-cell');
|
|
1347
|
+
if (wsCell) wsCell.classList.remove('nb-running');
|
|
1348
|
+
|
|
1349
|
+
// idle / done / stopped — single green Run button (replay = re-click Run)
|
|
1350
|
+
const run = document.createElement('button');
|
|
1351
|
+
run.className = 'nb-ctl-pill nb-run';
|
|
1352
|
+
run.title = state === 'done' ? 'replay' : state === 'stopped' ? 'stopped · run again' : 'run';
|
|
1353
|
+
run.addEventListener('click', () => startRun(cell, notebookState, rerender));
|
|
1354
|
+
container.appendChild(run);
|
|
1355
|
+
|
|
1356
|
+
if (cell.lastMs != null && state !== 'idle') {
|
|
1357
|
+
const tag = document.createElement('span');
|
|
1358
|
+
tag.className = 'nb-timer';
|
|
1359
|
+
tag.style.marginLeft = '6px';
|
|
1360
|
+
const dotColor = state === 'stopped' ? 'var(--color-accent2)' : 'var(--color-text2)';
|
|
1361
|
+
const label = state === 'stopped' ? 'stopped' : 'last run';
|
|
1362
|
+
tag.innerHTML = `<span class="nb-dot" style="background:${dotColor};"></span><span>${label} · ${formatDuration(cell.lastMs)}</span>`;
|
|
1363
|
+
container.appendChild(tag);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
export function mountHistoryPanel(
|
|
1368
|
+
panelEl: HTMLElement,
|
|
1369
|
+
state: NotebookState,
|
|
1370
|
+
onRestore: (snap: { cell: NotebookCell; idx: number }) => void
|
|
1371
|
+
): void {
|
|
1372
|
+
const entries = state.history;
|
|
1373
|
+
panelEl.innerHTML = `
|
|
1374
|
+
<div class="nb-hp-title">
|
|
1375
|
+
<span>history · ${entries.length} action${entries.length !== 1 ? 's' : ''}</span>
|
|
1376
|
+
<span>↻ restores state</span>
|
|
1377
|
+
</div>
|
|
1378
|
+
${entries.length === 0
|
|
1379
|
+
? '<div class="nb-hp-empty">no actions yet — edit, add, or delete cells</div>'
|
|
1380
|
+
: entries.map((h, idx) => `
|
|
1381
|
+
<div class="nb-hp-entry" data-idx="${idx}">
|
|
1382
|
+
<span class="nb-when">${fmtRelTime(h.ts)}</span>
|
|
1383
|
+
<span class="nb-action"><span class="nb-kind nb-kind-${h.kind}">${h.kind}</span>${escapeHtml(h.summary)}</span>
|
|
1384
|
+
${h.snapshot ? '<button class="nb-hp-restore">restore</button>' : ''}
|
|
1385
|
+
</div>`).join('')
|
|
1386
|
+
}`;
|
|
1387
|
+
panelEl.querySelectorAll<HTMLElement>('.nb-hp-restore').forEach((btn) => {
|
|
1388
|
+
btn.addEventListener('click', () => {
|
|
1389
|
+
const idx = parseInt(btn.closest('.nb-hp-entry')!.getAttribute('data-idx')!, 10);
|
|
1390
|
+
const snap = entries[idx].snapshot;
|
|
1391
|
+
if (snap) onRestore(snap);
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
export function setupDnD(container: HTMLElement, state: NotebookState, rerender: () => void): void {
|
|
1397
|
+
let draggedId: string | null = null;
|
|
1398
|
+
container.addEventListener('dragstart', (e) => {
|
|
1399
|
+
const handle = (e.target as HTMLElement).closest('.nb-drag-handle');
|
|
1400
|
+
if (!handle) { e.preventDefault(); return; }
|
|
1401
|
+
const wrap = handle.closest('.nb-cell-wrapper') as HTMLElement;
|
|
1402
|
+
draggedId = wrap.dataset.id!;
|
|
1403
|
+
wrap.classList.add('nb-dragging');
|
|
1404
|
+
e.dataTransfer!.effectAllowed = 'move';
|
|
1405
|
+
});
|
|
1406
|
+
container.addEventListener('dragend', () => {
|
|
1407
|
+
container.querySelectorAll('.nb-cell-wrapper').forEach((w) => {
|
|
1408
|
+
w.classList.remove('nb-dragging', 'nb-drag-over-before', 'nb-drag-over-after');
|
|
1409
|
+
});
|
|
1410
|
+
draggedId = null;
|
|
1411
|
+
});
|
|
1412
|
+
container.addEventListener('dragover', (e) => {
|
|
1413
|
+
e.preventDefault();
|
|
1414
|
+
const wrap = (e.target as HTMLElement).closest('.nb-cell-wrapper') as HTMLElement | null;
|
|
1415
|
+
container.querySelectorAll('.nb-cell-wrapper').forEach((w) => {
|
|
1416
|
+
w.classList.remove('nb-drag-over-before', 'nb-drag-over-after');
|
|
1417
|
+
});
|
|
1418
|
+
if (!wrap || wrap.dataset.id === draggedId) return;
|
|
1419
|
+
const rect = wrap.getBoundingClientRect();
|
|
1420
|
+
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
1421
|
+
wrap.classList.add(before ? 'nb-drag-over-before' : 'nb-drag-over-after');
|
|
1422
|
+
});
|
|
1423
|
+
container.addEventListener('drop', (e) => {
|
|
1424
|
+
e.preventDefault();
|
|
1425
|
+
const wrap = (e.target as HTMLElement).closest('.nb-cell-wrapper') as HTMLElement | null;
|
|
1426
|
+
if (!wrap || !draggedId || wrap.dataset.id === draggedId) return;
|
|
1427
|
+
const fromIdx = state.cells.findIndex((c) => c.id === draggedId);
|
|
1428
|
+
let toIdx = state.cells.findIndex((c) => c.id === wrap.dataset.id);
|
|
1429
|
+
const rect = wrap.getBoundingClientRect();
|
|
1430
|
+
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
1431
|
+
if (!before) toIdx++;
|
|
1432
|
+
if (fromIdx < toIdx) toIdx--;
|
|
1433
|
+
moveCell(state, fromIdx, toIdx);
|
|
1434
|
+
rerender();
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ---------------------------------------------------------------------------
|
|
1439
|
+
// Helpers for action handlers
|
|
1440
|
+
// ---------------------------------------------------------------------------
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Delete a cell and show an ephemeral "Undo" toast.
|
|
1444
|
+
* (Signature preserved for compatibility with the 4 widgets; the old
|
|
1445
|
+
* modal-confirm flow has been replaced by an undo toast pattern.)
|
|
1446
|
+
*/
|
|
1447
|
+
export function deleteCellWithConfirm(
|
|
1448
|
+
state: NotebookState,
|
|
1449
|
+
cell: NotebookCell,
|
|
1450
|
+
labelFor: (c: NotebookCell) => string,
|
|
1451
|
+
rerender: () => void
|
|
1452
|
+
): void {
|
|
1453
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
1454
|
+
if (idx < 0) return;
|
|
1455
|
+
const label = labelFor(cell);
|
|
1456
|
+
const snapshotCell: NotebookCell = typeof (globalThis as any).structuredClone === 'function'
|
|
1457
|
+
? (globalThis as any).structuredClone(cell)
|
|
1458
|
+
: JSON.parse(JSON.stringify(cell));
|
|
1459
|
+
// Manually push a history entry so we have a direct reference to remove on undo.
|
|
1460
|
+
const entry: HistoryEntry = {
|
|
1461
|
+
ts: Date.now(),
|
|
1462
|
+
kind: 'del',
|
|
1463
|
+
summary: `removed ${label}`,
|
|
1464
|
+
snapshot: { cell: snapshotCell, idx },
|
|
1465
|
+
};
|
|
1466
|
+
state.history.unshift(entry);
|
|
1467
|
+
if (state.history.length > 100) state.history.pop();
|
|
1468
|
+
state.cells.splice(idx, 1);
|
|
1469
|
+
state.lastEditAt = Date.now();
|
|
1470
|
+
rerender();
|
|
1471
|
+
showUndoToast(`${label} removed · restorable from history`, () => {
|
|
1472
|
+
const i = state.history.indexOf(entry);
|
|
1473
|
+
if (i >= 0) state.history.splice(i, 1);
|
|
1474
|
+
const insertAt = Math.min(idx, state.cells.length);
|
|
1475
|
+
state.cells.splice(insertAt, 0, snapshotCell);
|
|
1476
|
+
state.lastEditAt = Date.now();
|
|
1477
|
+
rerender();
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Internal: show a small "X removed · restorable from history" toast with an
|
|
1483
|
+
* Undo link. The toast auto-dismisses after ~5s. Clicking Undo invokes the
|
|
1484
|
+
* callback and dismisses the toast early.
|
|
1485
|
+
*/
|
|
1486
|
+
function showUndoToast(message: string, onUndo: () => void): void {
|
|
1487
|
+
const toast = document.createElement('div');
|
|
1488
|
+
toast.className = 'nb-undo-toast';
|
|
1489
|
+
const msgEl = document.createElement('span');
|
|
1490
|
+
msgEl.className = 'nb-undo-toast-msg';
|
|
1491
|
+
msgEl.textContent = message;
|
|
1492
|
+
const undoBtn = document.createElement('button');
|
|
1493
|
+
undoBtn.className = 'nb-undo-toast-undo';
|
|
1494
|
+
undoBtn.type = 'button';
|
|
1495
|
+
undoBtn.textContent = 'Undo';
|
|
1496
|
+
toast.appendChild(msgEl);
|
|
1497
|
+
toast.appendChild(undoBtn);
|
|
1498
|
+
document.body.appendChild(toast);
|
|
1499
|
+
// Next tick: trigger fade-in.
|
|
1500
|
+
requestAnimationFrame(() => toast.classList.add('nb-show'));
|
|
1501
|
+
|
|
1502
|
+
let dismissed = false;
|
|
1503
|
+
const dismiss = () => {
|
|
1504
|
+
if (dismissed) return;
|
|
1505
|
+
dismissed = true;
|
|
1506
|
+
toast.classList.remove('nb-show');
|
|
1507
|
+
setTimeout(() => { toast.parentNode?.removeChild(toast); }, 220);
|
|
1508
|
+
};
|
|
1509
|
+
const timeoutId = setTimeout(dismiss, 5000);
|
|
1510
|
+
undoBtn.addEventListener('click', () => {
|
|
1511
|
+
clearTimeout(timeoutId);
|
|
1512
|
+
try { onUndo(); } catch { /* ignore */ }
|
|
1513
|
+
dismiss();
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export function restoreCellFromSnapshot(state: NotebookState, snapshot: { cell: NotebookCell; idx: number }): void {
|
|
1518
|
+
const insertAt = Math.min(snapshot.idx, state.cells.length);
|
|
1519
|
+
state.cells.splice(insertAt, 0, snapshot.cell);
|
|
1520
|
+
logHistory(state, 'add', `restored ${snapshot.cell.type} cell`);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
export function addCell(state: NotebookState, type: CellType, opts?: Partial<NotebookCell>): NotebookCell {
|
|
1524
|
+
const cell: NotebookCell = {
|
|
1525
|
+
id: uid(),
|
|
1526
|
+
type,
|
|
1527
|
+
content: opts?.content ?? defaultCellContent(type),
|
|
1528
|
+
hideSource: false,
|
|
1529
|
+
hideResult: false,
|
|
1530
|
+
status: 'stale',
|
|
1531
|
+
...(opts || {}),
|
|
1532
|
+
};
|
|
1533
|
+
state.cells.push(cell);
|
|
1534
|
+
logHistory(state, 'add', `added ${type} cell`);
|
|
1535
|
+
return cell;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function escapeHtml(s: string): string {
|
|
1539
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]!));
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Build a `.nb-logs` panel for a CellResult, or return null if there are no logs.
|
|
1544
|
+
* Each log is rendered on its own line; the panel is scrollable when long.
|
|
1545
|
+
* Prefixes like "[warn]" / "[error]" get color-coded.
|
|
1546
|
+
*/
|
|
1547
|
+
export function renderCellLogs(result: CellResult | undefined | null): HTMLElement | null {
|
|
1548
|
+
if (!result || !result.logs || result.logs.length === 0) return null;
|
|
1549
|
+
const box = document.createElement('div');
|
|
1550
|
+
box.className = 'nb-logs';
|
|
1551
|
+
const label = document.createElement('div');
|
|
1552
|
+
label.className = 'nb-logs-label';
|
|
1553
|
+
label.textContent = `console · ${result.logs.length} line${result.logs.length === 1 ? '' : 's'}`;
|
|
1554
|
+
box.appendChild(label);
|
|
1555
|
+
const pre = document.createElement('pre');
|
|
1556
|
+
pre.innerHTML = result.logs.map((line) => {
|
|
1557
|
+
const esc = escapeHtml(String(line ?? ''));
|
|
1558
|
+
if (/^\[warn\]/i.test(line)) return `<span class="nb-log-warn">${esc}</span>`;
|
|
1559
|
+
if (/^\[error\]/i.test(line)) return `<span class="nb-log-error">${esc}</span>`;
|
|
1560
|
+
return esc;
|
|
1561
|
+
}).join('\n');
|
|
1562
|
+
box.appendChild(pre);
|
|
1563
|
+
return box;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ---------------------------------------------------------------------------
|
|
1567
|
+
// Keep history timestamps fresh across all notebooks
|
|
1568
|
+
// ---------------------------------------------------------------------------
|
|
1569
|
+
|
|
1570
|
+
const historyObservers = new Set<() => void>();
|
|
1571
|
+
let historyTickerId: any = null;
|
|
1572
|
+
|
|
1573
|
+
function startHistoryTicker(): void {
|
|
1574
|
+
if (historyTickerId != null) return;
|
|
1575
|
+
if (typeof setInterval === 'undefined') return;
|
|
1576
|
+
historyTickerId = setInterval(() => { historyObservers.forEach((fn) => fn()); }, 15000);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function stopHistoryTicker(): void {
|
|
1580
|
+
if (historyTickerId == null) return;
|
|
1581
|
+
clearInterval(historyTickerId);
|
|
1582
|
+
historyTickerId = null;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
export function registerHistoryObserver(fn: () => void): () => void {
|
|
1586
|
+
historyObservers.add(fn);
|
|
1587
|
+
if (historyObservers.size === 1) startHistoryTicker();
|
|
1588
|
+
return () => {
|
|
1589
|
+
historyObservers.delete(fn);
|
|
1590
|
+
if (historyObservers.size === 0) stopHistoryTicker();
|
|
1591
|
+
};
|
|
1592
|
+
}
|