@webmcp-auto-ui/agent 2.5.27 → 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.
- package/package.json +10 -2
- package/src/autoui-server.ts +57 -84
- package/src/index.ts +7 -2
- package/src/loop.ts +53 -23
- package/src/providers/factory.ts +15 -1
- package/src/providers/hawk-models.ts +23 -0
- package/src/providers/hawk.ts +181 -0
- package/src/providers/transformers.worker.ts +5 -32
- package/src/providers/wasm.ts +7 -5
- package/src/recipes/_generated.ts +64 -54
- package/src/recipes/hackathon-assemblee-nationale.md +4 -10
- package/src/recipes/notebook-playbook.md +60 -44
- package/src/server/hawkProxy.ts +54 -0
- package/src/server/index.ts +2 -0
- package/src/tool-layers.ts +26 -4
- package/src/trace-observer.ts +4 -15
- package/src/util/opfs-cache.ts +101 -2
- package/src/util/storage-inventory.ts +195 -0
- package/src/notebook-widgets/compact.ts +0 -312
- package/src/notebook-widgets/document.ts +0 -372
- package/src/notebook-widgets/editorial.ts +0 -348
- package/src/notebook-widgets/recipes/compact.md +0 -104
- package/src/notebook-widgets/recipes/document.md +0 -100
- package/src/notebook-widgets/recipes/editorial.md +0 -104
- package/src/notebook-widgets/recipes/workspace.md +0 -94
- package/src/notebook-widgets/shared.ts +0 -1064
- package/src/notebook-widgets/workspace.ts +0 -328
package/src/util/opfs-cache.ts
CHANGED
|
@@ -252,13 +252,112 @@ export async function loadOrDownloadModel(
|
|
|
252
252
|
/**
|
|
253
253
|
* Remove every cached file for a given repo. Silently no-ops if the repo
|
|
254
254
|
* directory does not exist.
|
|
255
|
+
*
|
|
256
|
+
* Accepts either the original `owner/name` form OR the sanitized key form
|
|
257
|
+
* (`owner__name`). Both are tried so UIs that list cached repos via
|
|
258
|
+
* `listCachedModels()` (which only knows the sanitized key) can delete.
|
|
255
259
|
*/
|
|
256
260
|
export async function clearModelCache(repo: string): Promise<void> {
|
|
257
261
|
try {
|
|
258
262
|
const root = await navigator.storage.getDirectory();
|
|
259
263
|
const modelsDir = await root.getDirectoryHandle('webmcp-models', { create: false });
|
|
260
|
-
const
|
|
261
|
-
|
|
264
|
+
const candidates = new Set<string>([repo, sanitizeRepoKey(repo)]);
|
|
265
|
+
for (const key of candidates) {
|
|
266
|
+
try { await modelsDir.removeEntry(key, { recursive: true }); } catch { /* not present */ }
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Nothing to clear
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Info about a single cached model repo in OPFS.
|
|
275
|
+
*
|
|
276
|
+
* Note: `repo` is the sanitized folder name as it appears on disk
|
|
277
|
+
* (e.g. `google__gemma-3n-E2B-it-litert-preview`). The original `owner/name`
|
|
278
|
+
* is not recoverable after sanitization.
|
|
279
|
+
*/
|
|
280
|
+
export interface CachedModelInfo {
|
|
281
|
+
repo: string;
|
|
282
|
+
size: number;
|
|
283
|
+
fileCount: number;
|
|
284
|
+
lastModified: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Recursively sum file sizes under a directory handle, tracking count and
|
|
289
|
+
* max lastModified. Ignores entries that fail to enumerate.
|
|
290
|
+
*/
|
|
291
|
+
export async function walkDirectoryStats(
|
|
292
|
+
dir: FileSystemDirectoryHandle,
|
|
293
|
+
): Promise<{ size: number; fileCount: number; lastModified: number }> {
|
|
294
|
+
let size = 0;
|
|
295
|
+
let fileCount = 0;
|
|
296
|
+
let lastModified = 0;
|
|
297
|
+
try {
|
|
298
|
+
const iter = dir as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> };
|
|
299
|
+
for await (const [, handle] of iter.entries()) {
|
|
300
|
+
if (handle.kind === 'file') {
|
|
301
|
+
try {
|
|
302
|
+
const f = await (handle as FileSystemFileHandle).getFile();
|
|
303
|
+
size += f.size;
|
|
304
|
+
fileCount += 1;
|
|
305
|
+
if (f.lastModified > lastModified) lastModified = f.lastModified;
|
|
306
|
+
} catch { /* skip */ }
|
|
307
|
+
} else if (handle.kind === 'directory') {
|
|
308
|
+
const sub = await walkDirectoryStats(handle as FileSystemDirectoryHandle);
|
|
309
|
+
size += sub.size;
|
|
310
|
+
fileCount += sub.fileCount;
|
|
311
|
+
if (sub.lastModified > lastModified) lastModified = sub.lastModified;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch { /* iteration unsupported */ }
|
|
315
|
+
return { size, fileCount, lastModified };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* List every cached model repo in OPFS with cumulative size, file count and
|
|
320
|
+
* last-modified timestamp. Returns `[]` if `webmcp-models` does not exist
|
|
321
|
+
* or if OPFS itself is unavailable.
|
|
322
|
+
*/
|
|
323
|
+
export async function listCachedModels(): Promise<CachedModelInfo[]> {
|
|
324
|
+
try {
|
|
325
|
+
if (!navigator.storage?.getDirectory) return [];
|
|
326
|
+
const root = await navigator.storage.getDirectory();
|
|
327
|
+
let modelsDir: FileSystemDirectoryHandle;
|
|
328
|
+
try {
|
|
329
|
+
modelsDir = await root.getDirectoryHandle('webmcp-models', { create: false });
|
|
330
|
+
} catch {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
const out: CachedModelInfo[] = [];
|
|
334
|
+
const iter = modelsDir as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> };
|
|
335
|
+
try {
|
|
336
|
+
for await (const [name, handle] of iter.entries()) {
|
|
337
|
+
if (handle.kind !== 'directory') continue;
|
|
338
|
+
const stats = await walkDirectoryStats(handle as FileSystemDirectoryHandle);
|
|
339
|
+
if (stats.size === 0 || stats.fileCount === 0) {
|
|
340
|
+
// Orphan directory (e.g. worker bug that created an empty repo key).
|
|
341
|
+
try { await modelsDir.removeEntry(name, { recursive: true }); } catch { /* best-effort */ }
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
out.push({ repo: name, size: stats.size, fileCount: stats.fileCount, lastModified: stats.lastModified });
|
|
345
|
+
}
|
|
346
|
+
} catch { /* iteration unsupported */ }
|
|
347
|
+
out.sort((a, b) => b.size - a.size);
|
|
348
|
+
return out;
|
|
349
|
+
} catch {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Nuke the whole `webmcp-models` directory. No-op if it does not exist.
|
|
356
|
+
*/
|
|
357
|
+
export async function clearAllModelCaches(): Promise<void> {
|
|
358
|
+
try {
|
|
359
|
+
const root = await navigator.storage.getDirectory();
|
|
360
|
+
await root.removeEntry('webmcp-models', { recursive: true });
|
|
262
361
|
} catch {
|
|
263
362
|
// Nothing to clear
|
|
264
363
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storage-inventory — enumerate OPFS (outside webmcp-models), Cache Storage
|
|
3
|
+
* API and IndexedDB entries. Best-effort: any per-entry failure is swallowed.
|
|
4
|
+
*/
|
|
5
|
+
import { walkDirectoryStats } from './opfs-cache.js';
|
|
6
|
+
|
|
7
|
+
export type StorageSource = 'opfs' | 'cache-storage' | 'indexeddb';
|
|
8
|
+
|
|
9
|
+
export interface StorageEntry {
|
|
10
|
+
source: StorageSource;
|
|
11
|
+
key: string;
|
|
12
|
+
size: number;
|
|
13
|
+
sizeKnown: boolean;
|
|
14
|
+
itemCount: number;
|
|
15
|
+
lastModified: number;
|
|
16
|
+
modelLike: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MODEL_HINTS = [
|
|
20
|
+
'huggingface', 'hf-', 'hf_', 'gemma', 'litert', 'onnx',
|
|
21
|
+
'qwen', 'mistral', 'llama', 'transformers', 'tokenizer', 'mediapipe',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const BLOB_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB — skip blob() fallback above this
|
|
25
|
+
|
|
26
|
+
function isModelLike(key: string): boolean {
|
|
27
|
+
const k = key.toLowerCase();
|
|
28
|
+
return MODEL_HINTS.some((h) => k.includes(h));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** OPFS entries outside the `webmcp-models` directory (which has its own UI). */
|
|
32
|
+
async function listOpfsEntries(): Promise<StorageEntry[]> {
|
|
33
|
+
const out: StorageEntry[] = [];
|
|
34
|
+
try {
|
|
35
|
+
if (!navigator.storage?.getDirectory) return out;
|
|
36
|
+
const root = await navigator.storage.getDirectory();
|
|
37
|
+
const iter = root as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> };
|
|
38
|
+
for await (const [name, handle] of iter.entries()) {
|
|
39
|
+
if (name === 'webmcp-models') continue;
|
|
40
|
+
if (handle.kind !== 'directory') continue;
|
|
41
|
+
try {
|
|
42
|
+
const stats = await walkDirectoryStats(handle as FileSystemDirectoryHandle);
|
|
43
|
+
out.push({
|
|
44
|
+
source: 'opfs',
|
|
45
|
+
key: name,
|
|
46
|
+
size: stats.size,
|
|
47
|
+
sizeKnown: true,
|
|
48
|
+
itemCount: stats.fileCount,
|
|
49
|
+
lastModified: stats.lastModified,
|
|
50
|
+
modelLike: isModelLike(name),
|
|
51
|
+
});
|
|
52
|
+
} catch { /* skip entry */ }
|
|
53
|
+
}
|
|
54
|
+
} catch { /* OPFS unavailable */ }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function measureResponse(response: Response): Promise<{ size: number; sizeKnown: boolean; lastModified: number }> {
|
|
59
|
+
let size = 0;
|
|
60
|
+
let sizeKnown = false;
|
|
61
|
+
let lastModified = 0;
|
|
62
|
+
try {
|
|
63
|
+
const cl = response.headers.get('content-length');
|
|
64
|
+
const parsed = cl !== null ? parseInt(cl, 10) : NaN;
|
|
65
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
66
|
+
size = parsed;
|
|
67
|
+
sizeKnown = true;
|
|
68
|
+
} else {
|
|
69
|
+
// Fallback: blob() — but only for small responses to avoid GB memory hits.
|
|
70
|
+
try {
|
|
71
|
+
const blob = await response.clone().blob();
|
|
72
|
+
if (blob.size < BLOB_SIZE_LIMIT) {
|
|
73
|
+
size = blob.size;
|
|
74
|
+
sizeKnown = true;
|
|
75
|
+
}
|
|
76
|
+
} catch { /* blob failed */ }
|
|
77
|
+
}
|
|
78
|
+
const dateHdr = response.headers.get('date');
|
|
79
|
+
if (dateHdr) {
|
|
80
|
+
const t = new Date(dateHdr).getTime();
|
|
81
|
+
if (Number.isFinite(t)) lastModified = t;
|
|
82
|
+
}
|
|
83
|
+
} catch { /* header access failed */ }
|
|
84
|
+
return { size, sizeKnown, lastModified };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listCacheStorageEntries(): Promise<StorageEntry[]> {
|
|
88
|
+
const out: StorageEntry[] = [];
|
|
89
|
+
try {
|
|
90
|
+
if (typeof caches === 'undefined' || !caches.keys) return out;
|
|
91
|
+
const names = await caches.keys();
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
try {
|
|
94
|
+
const cache = await caches.open(name);
|
|
95
|
+
const requests = await cache.keys();
|
|
96
|
+
let totalSize = 0;
|
|
97
|
+
let anyUnknown = false;
|
|
98
|
+
let lastModified = 0;
|
|
99
|
+
for (const req of requests) {
|
|
100
|
+
try {
|
|
101
|
+
const resp = await cache.match(req);
|
|
102
|
+
if (!resp) continue;
|
|
103
|
+
const m = await measureResponse(resp);
|
|
104
|
+
if (m.sizeKnown) totalSize += m.size;
|
|
105
|
+
else anyUnknown = true;
|
|
106
|
+
if (m.lastModified > lastModified) lastModified = m.lastModified;
|
|
107
|
+
} catch { /* per-request skip */ }
|
|
108
|
+
}
|
|
109
|
+
out.push({
|
|
110
|
+
source: 'cache-storage',
|
|
111
|
+
key: name,
|
|
112
|
+
size: totalSize,
|
|
113
|
+
sizeKnown: !anyUnknown,
|
|
114
|
+
itemCount: requests.length,
|
|
115
|
+
lastModified,
|
|
116
|
+
modelLike: isModelLike(name),
|
|
117
|
+
});
|
|
118
|
+
} catch { /* skip cache */ }
|
|
119
|
+
}
|
|
120
|
+
} catch { /* Cache API unavailable */ }
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function listIndexedDbEntries(): Promise<StorageEntry[]> {
|
|
125
|
+
const out: StorageEntry[] = [];
|
|
126
|
+
try {
|
|
127
|
+
const idb = indexedDB as IDBFactory & { databases?: () => Promise<Array<{ name?: string; version?: number }>> };
|
|
128
|
+
if (typeof idb.databases !== 'function') return out;
|
|
129
|
+
const dbs = await idb.databases();
|
|
130
|
+
for (const db of dbs) {
|
|
131
|
+
if (!db.name) continue;
|
|
132
|
+
out.push({
|
|
133
|
+
source: 'indexeddb',
|
|
134
|
+
key: db.name,
|
|
135
|
+
size: 0,
|
|
136
|
+
sizeKnown: false,
|
|
137
|
+
itemCount: db.version ?? 0,
|
|
138
|
+
lastModified: 0,
|
|
139
|
+
modelLike: isModelLike(db.name),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch { /* IDB listing unsupported (e.g. older Safari) */ }
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Enumerate all Chrome-visible caches — OPFS (minus webmcp-models), Cache Storage, IndexedDB. */
|
|
147
|
+
export async function listAllStorage(): Promise<StorageEntry[]> {
|
|
148
|
+
const [opfs, cacheStorage, idb] = await Promise.all([
|
|
149
|
+
listOpfsEntries(),
|
|
150
|
+
listCacheStorageEntries(),
|
|
151
|
+
listIndexedDbEntries(),
|
|
152
|
+
]);
|
|
153
|
+
return [...opfs, ...cacheStorage, ...idb];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function deleteIdb(name: string): Promise<void> {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
try {
|
|
159
|
+
const req = indexedDB.deleteDatabase(name);
|
|
160
|
+
req.onsuccess = () => resolve();
|
|
161
|
+
req.onerror = () => resolve();
|
|
162
|
+
req.onblocked = () => resolve();
|
|
163
|
+
} catch { resolve(); }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Delete a single entry regardless of source. */
|
|
168
|
+
export async function deleteStorageEntry(entry: StorageEntry): Promise<void> {
|
|
169
|
+
try {
|
|
170
|
+
if (entry.source === 'opfs') {
|
|
171
|
+
const root = await navigator.storage.getDirectory();
|
|
172
|
+
try { await root.removeEntry(entry.key, { recursive: true }); } catch { /* best-effort */ }
|
|
173
|
+
} else if (entry.source === 'cache-storage') {
|
|
174
|
+
try { await caches.delete(entry.key); } catch { /* best-effort */ }
|
|
175
|
+
} else if (entry.source === 'indexeddb') {
|
|
176
|
+
await deleteIdb(entry.key);
|
|
177
|
+
}
|
|
178
|
+
} catch { /* best-effort */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Delete every entry of a given source. */
|
|
182
|
+
export async function clearAllStorage(source: StorageSource): Promise<void> {
|
|
183
|
+
try {
|
|
184
|
+
if (source === 'opfs') {
|
|
185
|
+
const entries = await listOpfsEntries();
|
|
186
|
+
for (const e of entries) await deleteStorageEntry(e);
|
|
187
|
+
} else if (source === 'cache-storage') {
|
|
188
|
+
const names = await caches.keys();
|
|
189
|
+
for (const n of names) { try { await caches.delete(n); } catch { /* skip */ } }
|
|
190
|
+
} else if (source === 'indexeddb') {
|
|
191
|
+
const entries = await listIndexedDbEntries();
|
|
192
|
+
for (const e of entries) await deleteIdb(e.key);
|
|
193
|
+
}
|
|
194
|
+
} catch { /* best-effort */ }
|
|
195
|
+
}
|
|
@@ -1,312 +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
|
-
logHistory, autosize, openShareModal, registerHistoryObserver,
|
|
11
|
-
buildServersButton,
|
|
12
|
-
type NotebookState, type NotebookCell,
|
|
13
|
-
} from './shared.js';
|
|
14
|
-
|
|
15
|
-
export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
|
|
16
|
-
injectStyles();
|
|
17
|
-
injectLayoutStyles();
|
|
18
|
-
|
|
19
|
-
const state: NotebookState = createState({
|
|
20
|
-
id: data.id as string,
|
|
21
|
-
title: data.title as string,
|
|
22
|
-
mode: (data.mode as any) ?? 'edit',
|
|
23
|
-
cells: data.cells as any,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
container.classList.add('nb-root');
|
|
27
|
-
container.classList.toggle('nb-view-mode', state.mode === 'view');
|
|
28
|
-
|
|
29
|
-
container.innerHTML = `
|
|
30
|
-
<div class="nbc-shell">
|
|
31
|
-
<div class="nbc-toolbar">
|
|
32
|
-
<div class="nbc-status">
|
|
33
|
-
<span class="nbc-status-dot"></span>
|
|
34
|
-
<span class="nbc-status-text">reactive · 0 cells</span>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="nbc-actions">
|
|
37
|
-
<div class="nb-mode-switch">
|
|
38
|
-
<button class="nb-mode-edit nb-on">edit</button>
|
|
39
|
-
<button class="nb-mode-view">view</button>
|
|
40
|
-
</div>
|
|
41
|
-
<button class="nb-btn nb-add-cell" data-add="md">+ md</button>
|
|
42
|
-
<button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
|
|
43
|
-
<button class="nb-btn nb-add-cell" data-add="js">+ js</button>
|
|
44
|
-
<button class="nb-btn nbc-history-btn">⟲ history</button>
|
|
45
|
-
<span class="nbc-servers-slot"></span>
|
|
46
|
-
<button class="nb-btn nbc-share-btn">share</button>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
<div class="nb-history-panel nbc-history-panel"></div>
|
|
50
|
-
<div class="nbc-cells"></div>
|
|
51
|
-
</div>`;
|
|
52
|
-
|
|
53
|
-
const shell = container.querySelector('.nbc-shell') as HTMLElement;
|
|
54
|
-
const cellsEl = shell.querySelector('.nbc-cells') as HTMLElement;
|
|
55
|
-
const historyPanel = shell.querySelector('.nbc-history-panel') as HTMLElement;
|
|
56
|
-
|
|
57
|
-
function renderCells() {
|
|
58
|
-
cellsEl.innerHTML = '';
|
|
59
|
-
state.cells.forEach((cell) => cellsEl.appendChild(renderCell(cell, state, rerender)));
|
|
60
|
-
updateStatus();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function updateStatus() {
|
|
64
|
-
const n = state.cells.length;
|
|
65
|
-
const stale = state.cells.filter((c) => c.status === 'stale').length;
|
|
66
|
-
(shell.querySelector('.nbc-status-text') as HTMLElement).textContent =
|
|
67
|
-
stale > 0 ? `reactive · ${n} cells · ${stale} stale` : `reactive · ${n} cells · synced`;
|
|
68
|
-
(shell.querySelector('.nbc-status-dot') as HTMLElement).classList.toggle('nbc-stale', stale > 0);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function rerender() {
|
|
72
|
-
mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
|
|
73
|
-
renderCells();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Toolbar bindings
|
|
77
|
-
shell.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
|
|
78
|
-
btn.addEventListener('click', () => {
|
|
79
|
-
const type = btn.dataset.add as any;
|
|
80
|
-
addCell(state, type, { varname: type === 'sql' ? 'rows_' + (state.cells.length + 1) : undefined });
|
|
81
|
-
rerender();
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
(shell.querySelector('.nbc-history-btn') as HTMLElement).addEventListener('click', () => {
|
|
85
|
-
historyPanel.classList.toggle('nb-open');
|
|
86
|
-
});
|
|
87
|
-
(shell.querySelector('.nbc-share-btn') as HTMLElement).addEventListener('click', () => {
|
|
88
|
-
openShareModal(state, (fmt) => console.log('[notebook-compact] share as', fmt, state));
|
|
89
|
-
});
|
|
90
|
-
const editBtn = shell.querySelector('.nb-mode-edit') as HTMLElement;
|
|
91
|
-
const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
|
|
92
|
-
editBtn.addEventListener('click', () => {
|
|
93
|
-
state.mode = 'edit';
|
|
94
|
-
container.classList.remove('nb-view-mode');
|
|
95
|
-
editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
|
|
96
|
-
});
|
|
97
|
-
viewBtn.addEventListener('click', () => {
|
|
98
|
-
state.mode = 'view';
|
|
99
|
-
container.classList.add('nb-view-mode');
|
|
100
|
-
viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
buildServersButton(state, shell.querySelector('.nbc-servers-slot') as HTMLElement, data, rerender);
|
|
104
|
-
|
|
105
|
-
setupDnD(cellsEl, state, rerender);
|
|
106
|
-
const unsubHistory = registerHistoryObserver(() => mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); }));
|
|
107
|
-
|
|
108
|
-
rerender();
|
|
109
|
-
|
|
110
|
-
return () => { unsubHistory(); };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Cell rendering (compact layout)
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
function renderCell(cell: NotebookCell, state: NotebookState, rerender: () => void): HTMLElement {
|
|
118
|
-
const wrap = document.createElement('div');
|
|
119
|
-
wrap.className = 'nb-cell-wrapper nbc-cell';
|
|
120
|
-
wrap.dataset.id = cell.id;
|
|
121
|
-
|
|
122
|
-
const row = document.createElement('div');
|
|
123
|
-
row.className = 'nbc-row';
|
|
124
|
-
|
|
125
|
-
const handle = document.createElement('span');
|
|
126
|
-
handle.className = 'nb-drag-handle';
|
|
127
|
-
handle.draggable = true;
|
|
128
|
-
handle.textContent = '⋮⋮';
|
|
129
|
-
row.appendChild(handle);
|
|
130
|
-
|
|
131
|
-
const gutter = document.createElement('div');
|
|
132
|
-
gutter.className = `nbc-gutter nbc-gutter-${cell.type}`;
|
|
133
|
-
gutter.innerHTML = `<span class="nbc-type-label">${cell.type}</span><span class="nbc-line"></span>`;
|
|
134
|
-
row.appendChild(gutter);
|
|
135
|
-
|
|
136
|
-
const body = document.createElement('div');
|
|
137
|
-
body.className = 'nbc-body';
|
|
138
|
-
body.style.minWidth = '0';
|
|
139
|
-
|
|
140
|
-
if (cell.type === 'md') {
|
|
141
|
-
const mdBody = document.createElement('div');
|
|
142
|
-
mdBody.className = 'nbc-md-body';
|
|
143
|
-
const ta = document.createElement('textarea');
|
|
144
|
-
ta.className = 'nb-md-edit';
|
|
145
|
-
ta.value = cell.content;
|
|
146
|
-
ta.rows = 2;
|
|
147
|
-
ta.placeholder = 'write markdown…';
|
|
148
|
-
ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); });
|
|
149
|
-
mdBody.appendChild(ta);
|
|
150
|
-
body.appendChild(mdBody);
|
|
151
|
-
setTimeout(() => autosize(ta), 0);
|
|
152
|
-
|
|
153
|
-
const del = document.createElement('button');
|
|
154
|
-
del.className = 'nb-icon-btn nb-danger nbc-md-del';
|
|
155
|
-
del.textContent = '✕';
|
|
156
|
-
del.title = 'delete cell';
|
|
157
|
-
del.addEventListener('click', () => deleteCellWithConfirm(state, cell, (c) => 'markdown cell', rerender));
|
|
158
|
-
wrap.appendChild(del);
|
|
159
|
-
} else {
|
|
160
|
-
const codeCell = document.createElement('div');
|
|
161
|
-
codeCell.className = 'nb-code-cell nbc-code-cell';
|
|
162
|
-
|
|
163
|
-
// Cell title row with run controls FIRST (left), then meta
|
|
164
|
-
const titleRow = document.createElement('div');
|
|
165
|
-
titleRow.className = 'nbc-title-row';
|
|
166
|
-
titleRow.innerHTML = `
|
|
167
|
-
<span class="nbc-run-controls"></span>
|
|
168
|
-
${cell.varname ? `<span class="nbc-arrow-var">→ ${cell.varname}</span>` : ''}
|
|
169
|
-
<span class="nbc-meta-info">${cell.type === 'sql' ? '4 rows' : 'depends on rows'}</span>
|
|
170
|
-
<button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
|
|
171
|
-
<button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
|
|
172
|
-
<button class="nb-icon-btn nb-danger nbc-code-del">✕</button>
|
|
173
|
-
`;
|
|
174
|
-
codeCell.appendChild(titleRow);
|
|
175
|
-
mountRunControls(titleRow.querySelector('.nbc-run-controls') as HTMLElement, cell, wrap, rerender);
|
|
176
|
-
|
|
177
|
-
const codeBody = document.createElement('div');
|
|
178
|
-
codeBody.className = 'nbc-code-body' + (cell.hideSource ? ' nbc-hidden' : '');
|
|
179
|
-
const ta = document.createElement('textarea');
|
|
180
|
-
ta.className = 'nb-code-edit';
|
|
181
|
-
ta.value = cell.content;
|
|
182
|
-
ta.rows = 1;
|
|
183
|
-
ta.spellcheck = false;
|
|
184
|
-
ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); cell.status = 'stale'; });
|
|
185
|
-
codeBody.appendChild(ta);
|
|
186
|
-
codeCell.appendChild(codeBody);
|
|
187
|
-
setTimeout(() => autosize(ta), 0);
|
|
188
|
-
|
|
189
|
-
const result = document.createElement('div');
|
|
190
|
-
result.className = 'nbc-result-body' + (cell.hideResult ? ' nbc-hidden' : '');
|
|
191
|
-
if (cell.type === 'sql') {
|
|
192
|
-
result.innerHTML = `
|
|
193
|
-
<div class="nbc-result-row">
|
|
194
|
-
<span>row_1</span><span>42</span>
|
|
195
|
-
<span>row_2</span><span>17</span>
|
|
196
|
-
<span>row_3</span><span>8</span>
|
|
197
|
-
<span>row_4</span><span>3</span>
|
|
198
|
-
</div>`;
|
|
199
|
-
} else {
|
|
200
|
-
result.className = 'nbc-chart-result' + (cell.hideResult ? ' nbc-hidden' : '');
|
|
201
|
-
result.innerHTML = `
|
|
202
|
-
<div class="nbc-bar" style="height:100%"></div>
|
|
203
|
-
<div class="nbc-bar" style="height:68%"></div>
|
|
204
|
-
<div class="nbc-bar" style="height:52%"></div>
|
|
205
|
-
<div class="nbc-bar" style="height:22%"></div>`;
|
|
206
|
-
}
|
|
207
|
-
codeCell.appendChild(result);
|
|
208
|
-
body.appendChild(codeCell);
|
|
209
|
-
|
|
210
|
-
(titleRow.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
|
|
211
|
-
(titleRow.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
|
|
212
|
-
(titleRow.querySelector('.nbc-code-del') as HTMLElement).addEventListener('click', () =>
|
|
213
|
-
deleteCellWithConfirm(state, cell, (c) => `${c.type} cell${c.varname ? ' → ' + c.varname : ''}`, rerender)
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
row.appendChild(body);
|
|
218
|
-
wrap.appendChild(row);
|
|
219
|
-
return wrap;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ---------------------------------------------------------------------------
|
|
223
|
-
// Layout-specific styles
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
|
|
226
|
-
function injectLayoutStyles(): void {
|
|
227
|
-
if (document.getElementById('nbc-styles')) return;
|
|
228
|
-
const style = document.createElement('style');
|
|
229
|
-
style.id = 'nbc-styles';
|
|
230
|
-
style.textContent = `
|
|
231
|
-
.nbc-shell {
|
|
232
|
-
background: var(--color-surface); border: 1px solid var(--color-border);
|
|
233
|
-
border-radius: 12px; padding: 18px;
|
|
234
|
-
}
|
|
235
|
-
.nbc-toolbar {
|
|
236
|
-
display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;
|
|
237
|
-
}
|
|
238
|
-
.nbc-status {
|
|
239
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
240
|
-
font-size: 11px; color: var(--color-text2);
|
|
241
|
-
display: inline-flex; align-items: center; gap: 8px;
|
|
242
|
-
}
|
|
243
|
-
.nbc-status-dot {
|
|
244
|
-
width: 6px; height: 6px; border-radius: 50%;
|
|
245
|
-
background: var(--color-teal);
|
|
246
|
-
}
|
|
247
|
-
.nbc-status-dot.nbc-stale { background: var(--color-amber); }
|
|
248
|
-
.nbc-actions { display: flex; gap: 6px; align-items: center; }
|
|
249
|
-
.nbc-history-panel { margin-bottom: 12px; }
|
|
250
|
-
|
|
251
|
-
.nbc-cell { margin-bottom: 14px; position: relative; }
|
|
252
|
-
.nbc-cell:last-child { margin-bottom: 0; }
|
|
253
|
-
.nbc-row {
|
|
254
|
-
display: grid; grid-template-columns: 20px 34px 1fr; gap: 6px;
|
|
255
|
-
}
|
|
256
|
-
.nbc-gutter {
|
|
257
|
-
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
258
|
-
}
|
|
259
|
-
.nbc-type-label {
|
|
260
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
261
|
-
font-size: 10px; letter-spacing: 0.12em; color: var(--color-text2);
|
|
262
|
-
}
|
|
263
|
-
.nbc-gutter-sql .nbc-type-label { color: var(--color-accent); }
|
|
264
|
-
.nbc-gutter-js .nbc-type-label { color: var(--color-teal); }
|
|
265
|
-
.nbc-line { width: 1px; flex: 1; background: var(--color-border); }
|
|
266
|
-
.nbc-md-body { padding: 4px 2px; border: 1px dashed transparent; border-radius: 4px; }
|
|
267
|
-
.nbc-md-body:focus-within { border-color: var(--color-border); background: var(--color-bg); }
|
|
268
|
-
.nbc-md-del {
|
|
269
|
-
position: absolute; top: 4px; right: 4px;
|
|
270
|
-
opacity: 0; transition: opacity 0.15s;
|
|
271
|
-
}
|
|
272
|
-
.nbc-cell:hover .nbc-md-del { opacity: 0.5; }
|
|
273
|
-
.nbc-md-del:hover { opacity: 1 !important; }
|
|
274
|
-
|
|
275
|
-
.nbc-code-cell {
|
|
276
|
-
background: var(--color-bg); border: 1px solid var(--color-border);
|
|
277
|
-
border-radius: 8px; overflow: hidden;
|
|
278
|
-
transition: border-color 0.15s;
|
|
279
|
-
}
|
|
280
|
-
.nbc-code-cell:focus-within { border-color: var(--color-border2); }
|
|
281
|
-
.nbc-title-row {
|
|
282
|
-
display: flex; align-items: center; gap: 8px;
|
|
283
|
-
padding: 8px 12px; border-bottom: 1px solid var(--color-border);
|
|
284
|
-
background: var(--color-surface2);
|
|
285
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
286
|
-
font-size: 10.5px; color: var(--color-text2);
|
|
287
|
-
}
|
|
288
|
-
.nbc-title-row .nbc-arrow-var { color: var(--color-accent); }
|
|
289
|
-
.nbc-title-row .nbc-meta-info { margin-right: auto; }
|
|
290
|
-
.nbc-code-body { padding: 10px 14px; }
|
|
291
|
-
.nbc-hidden { display: none !important; }
|
|
292
|
-
.nbc-result-body {
|
|
293
|
-
padding: 10px 14px;
|
|
294
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
295
|
-
font-size: 12px;
|
|
296
|
-
border-top: 1px solid var(--color-border);
|
|
297
|
-
}
|
|
298
|
-
.nbc-result-row {
|
|
299
|
-
display: grid; grid-template-columns: 1fr auto; gap: 3px 24px;
|
|
300
|
-
color: var(--color-text2);
|
|
301
|
-
}
|
|
302
|
-
.nbc-result-row span:nth-child(even) {
|
|
303
|
-
color: var(--color-text1); font-variant-numeric: tabular-nums;
|
|
304
|
-
}
|
|
305
|
-
.nbc-chart-result {
|
|
306
|
-
padding: 14px; display: flex; align-items: flex-end; gap: 6px;
|
|
307
|
-
height: 92px; border-top: 1px solid var(--color-border);
|
|
308
|
-
}
|
|
309
|
-
.nbc-bar { flex: 1; background: var(--color-accent); border-radius: 2px 2px 0 0; opacity: 0.55; }
|
|
310
|
-
`;
|
|
311
|
-
document.head.appendChild(style);
|
|
312
|
-
}
|