@nbt-dev/components 0.0.5

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.
Files changed (66) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +10 -0
  3. package/TRADEMARKS.md +49 -0
  4. package/dist/chunk-3ZM6YOA4.js +704 -0
  5. package/dist/chunk-3ZM6YOA4.js.map +7 -0
  6. package/dist/chunk-7B2T5ZNG.js +467 -0
  7. package/dist/chunk-7B2T5ZNG.js.map +7 -0
  8. package/dist/chunk-S7VBQE6Y.js +636 -0
  9. package/dist/chunk-S7VBQE6Y.js.map +7 -0
  10. package/dist/chunk-UPEOXMLZ.js +625 -0
  11. package/dist/chunk-UPEOXMLZ.js.map +7 -0
  12. package/dist/core/auth.d.ts +13 -0
  13. package/dist/core/bulk-decoder.d.ts +13 -0
  14. package/dist/core/config.d.ts +10 -0
  15. package/dist/core/data-store.d.ts +20 -0
  16. package/dist/core/index.d.ts +9 -0
  17. package/dist/core/use-bulk-stream.d.ts +24 -0
  18. package/dist/core/use-cartridge-info.d.ts +14 -0
  19. package/dist/core/utils.d.ts +2 -0
  20. package/dist/editor/index.d.ts +7 -0
  21. package/dist/editor/index.js +16 -0
  22. package/dist/editor/index.js.map +7 -0
  23. package/dist/editor/lsp-client.d.ts +57 -0
  24. package/dist/editor/lsp-extensions.d.ts +4 -0
  25. package/dist/editor/nbt-editor.d.ts +13 -0
  26. package/dist/editor/nbt-language.d.ts +7 -0
  27. package/dist/generated/bulk-protocol.d.ts +36 -0
  28. package/dist/graph/diagram.d.ts +5 -0
  29. package/dist/graph/entity-graph-utils.d.ts +92 -0
  30. package/dist/graph/entity-node.d.ts +9 -0
  31. package/dist/graph/index.d.ts +5 -0
  32. package/dist/graph/index.js +19 -0
  33. package/dist/graph/index.js.map +7 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +134 -0
  36. package/dist/index.js.map +7 -0
  37. package/dist/styles.css +2 -0
  38. package/dist/table/data-table.d.ts +9 -0
  39. package/dist/table/index.d.ts +3 -0
  40. package/dist/table/index.js +11 -0
  41. package/dist/table/index.js.map +7 -0
  42. package/dist/table/value-popover.d.ts +18 -0
  43. package/package.json +77 -0
  44. package/src/core/auth.ts +100 -0
  45. package/src/core/bulk-decoder.ts +178 -0
  46. package/src/core/config.tsx +39 -0
  47. package/src/core/data-store.ts +113 -0
  48. package/src/core/index.ts +34 -0
  49. package/src/core/use-bulk-stream.ts +412 -0
  50. package/src/core/use-cartridge-info.ts +100 -0
  51. package/src/core/utils.ts +6 -0
  52. package/src/editor/index.ts +13 -0
  53. package/src/editor/lsp-client.ts +227 -0
  54. package/src/editor/lsp-extensions.ts +191 -0
  55. package/src/editor/nbt-editor.tsx +142 -0
  56. package/src/editor/nbt-language.ts +151 -0
  57. package/src/generated/bulk-protocol.ts +63 -0
  58. package/src/graph/diagram.tsx +296 -0
  59. package/src/graph/entity-graph-utils.ts +423 -0
  60. package/src/graph/entity-node.tsx +122 -0
  61. package/src/graph/index.ts +19 -0
  62. package/src/index.ts +7 -0
  63. package/src/styles.css +94 -0
  64. package/src/table/data-table.tsx +274 -0
  65. package/src/table/index.ts +5 -0
  66. package/src/table/value-popover.tsx +230 -0
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+
3
+ // Runtime config for the devtools, replacing the inspector's compile-time
4
+ // `NIMBIT_API_ENDPOINT` define. Hosts pass the console's base URL once via
5
+ // <NimbitDevTools apiBaseUrl="…" /> and every fetch/WebSocket derives from it.
6
+ // Empty string => same-origin (the inspector default when served by the console).
7
+ export type DevToolsConfig = {
8
+ apiBaseUrl: string;
9
+ };
10
+
11
+ const DevToolsConfigContext = React.createContext<DevToolsConfig>({
12
+ apiBaseUrl: "",
13
+ });
14
+
15
+ export const DevToolsConfigProvider: React.FC<{
16
+ apiBaseUrl?: string;
17
+ children: React.ReactNode;
18
+ }> = ({ apiBaseUrl = "", children }) => {
19
+ const value = React.useMemo(() => ({ apiBaseUrl }), [apiBaseUrl]);
20
+ return (
21
+ <DevToolsConfigContext.Provider value={value}>
22
+ {children}
23
+ </DevToolsConfigContext.Provider>
24
+ );
25
+ };
26
+
27
+ export function useDevToolsConfig(): DevToolsConfig {
28
+ return React.useContext(DevToolsConfigContext);
29
+ }
30
+
31
+ // Turn an http(s) base into a ws(s) base. Empty => derive from window.location.
32
+ export function wsBaseFrom(apiBaseUrl: string): string {
33
+ if (!apiBaseUrl) {
34
+ const loc = window.location;
35
+ const proto = loc.protocol === "https:" ? "wss:" : "ws:";
36
+ return `${proto}//${loc.host}`;
37
+ }
38
+ return apiBaseUrl.replace(/^http(s?):/, "ws$1:");
39
+ }
@@ -0,0 +1,113 @@
1
+ import {
2
+ FRAME_DELTA_INS,
3
+ FRAME_DELTA_UPD,
4
+ FRAME_DELTA_DEL,
5
+ type ColumnDef,
6
+ type SchemaFrame,
7
+ type DeltaFrame,
8
+ } from "../generated/bulk-protocol";
9
+
10
+ export class BulkDataStore {
11
+ columns: ColumnDef[] = [];
12
+ rows: string[][] = [];
13
+ totalRows = 0;
14
+ searchActive = false;
15
+
16
+ private _fullRows: string[][] = [];
17
+ private _fullTotalRows = 0;
18
+ private _idColIndex = -1;
19
+ private _query = "";
20
+
21
+ applySchema(schema: SchemaFrame): void {
22
+ this.columns = schema.columns;
23
+ this.totalRows = schema.totalRows;
24
+ this.rows = [];
25
+ this._fullRows = [];
26
+ this._fullTotalRows = schema.totalRows;
27
+ this.searchActive = false;
28
+ this._query = "";
29
+ this._idColIndex = schema.columns.findIndex((c) => c.name === "id");
30
+ }
31
+
32
+ appendChunk(chunk: string[][]): void {
33
+ for (let i = 0; i < chunk.length; i++) {
34
+ const row = chunk[i]!;
35
+ this._fullRows.push(row);
36
+ if (!this.searchActive) this.rows.push(row);
37
+ }
38
+ }
39
+
40
+ applyDelta(delta: DeltaFrame): void {
41
+ const target = this._fullRows;
42
+ if (delta.op === FRAME_DELTA_INS && delta.rowData) {
43
+ target.push(delta.rowData);
44
+ this._fullTotalRows++;
45
+ this.totalRows++;
46
+ if (!this.searchActive) this.rows = this._fullRows;
47
+ else this._applySearch();
48
+ return;
49
+ }
50
+ if (delta.op === FRAME_DELTA_UPD && delta.rowData) {
51
+ const idx = this._findRowById(target, delta.rowData);
52
+ if (idx >= 0) target[idx] = delta.rowData;
53
+ if (this.searchActive) this._applySearch();
54
+ return;
55
+ }
56
+ if (delta.op === FRAME_DELTA_DEL && delta.id !== undefined) {
57
+ const idStr = String(delta.id);
58
+ const idx = this._findRowByIdStr(target, idStr);
59
+ if (idx >= 0) {
60
+ target.splice(idx, 1);
61
+ this._fullTotalRows--;
62
+ this.totalRows--;
63
+ if (!this.searchActive) this.rows = this._fullRows;
64
+ else this._applySearch();
65
+ }
66
+ return;
67
+ }
68
+ }
69
+
70
+ search(query: string): void {
71
+ const q = query.trim().toLowerCase();
72
+ if (!q) { this.exitSearch(); return; }
73
+ this.searchActive = true;
74
+ this._query = q;
75
+ this._applySearch();
76
+ }
77
+
78
+ exitSearch(): void {
79
+ if (!this.searchActive) return;
80
+ this.rows = this._fullRows;
81
+ this.totalRows = this._fullTotalRows;
82
+ this.searchActive = false;
83
+ this._query = "";
84
+ }
85
+
86
+ getRowCount(): number {
87
+ return this.rows.length;
88
+ }
89
+
90
+ private _findRowById(rows: string[][], rowData: string[]): number {
91
+ if (this._idColIndex < 0) return -1;
92
+ const id = rowData[this._idColIndex];
93
+ for (let i = 0; i < rows.length; i++) {
94
+ if (rows[i]![this._idColIndex] === id) return i;
95
+ }
96
+ return -1;
97
+ }
98
+
99
+ private _findRowByIdStr(rows: string[][], idStr: string): number {
100
+ if (this._idColIndex < 0) return -1;
101
+ for (let i = 0; i < rows.length; i++) {
102
+ if (rows[i]![this._idColIndex] === idStr) return i;
103
+ }
104
+ return -1;
105
+ }
106
+
107
+ private _applySearch(): void {
108
+ this.rows = this._fullRows.filter((row) =>
109
+ row.some((value) => value.toLowerCase().includes(this._query)),
110
+ );
111
+ this.totalRows = this.rows.length;
112
+ }
113
+ }
@@ -0,0 +1,34 @@
1
+ // Shared runtime every primitive leans on: the console-base-URL config context,
2
+ // the one bulk-WS provider that table + graph demux off, the live cartridge
3
+ // registry, and the tailwind `cn` helper. Standalone apps compose:
4
+ // <DevToolsConfigProvider apiBaseUrl={...}><BulkStreamProvider registry={...}>
5
+ export {
6
+ DevToolsConfigProvider,
7
+ useDevToolsConfig,
8
+ wsBaseFrom,
9
+ } from "./config";
10
+ export type { DevToolsConfig } from "./config";
11
+ export { cn } from "./utils";
12
+ export {
13
+ BulkStreamProvider,
14
+ useBulkSubscription,
15
+ useBulkRowCounts,
16
+ } from "./use-bulk-stream";
17
+ export type { BulkSubscription } from "./use-bulk-stream";
18
+ export { useLiveBulkRegistry } from "./use-cartridge-info";
19
+ export type {
20
+ BulkEntity,
21
+ BulkRegistry,
22
+ LiveRegistryState,
23
+ } from "./use-cartridge-info";
24
+ export {
25
+ getDevToolsToken,
26
+ setDevToolsToken,
27
+ clearDevToolsToken,
28
+ authHeaders,
29
+ fetchWhoAmI,
30
+ devToolsSignIn,
31
+ devToolsSignOut,
32
+ wsAuthProtocols,
33
+ } from "./auth";
34
+ export type { WhoAmI } from "./auth";
@@ -0,0 +1,412 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import { BulkDataStore } from "./data-store";
9
+ import {
10
+ getFrameType,
11
+ getFrameSid,
12
+ parseSchema,
13
+ parseDataChunk,
14
+ parseDelta,
15
+ parseError,
16
+ encodeStreamCmd,
17
+ } from "./bulk-decoder";
18
+ import {
19
+ FRAME_SCHEMA,
20
+ FRAME_DATA,
21
+ FRAME_DATA_END,
22
+ FRAME_DELTA_INS,
23
+ FRAME_DELTA_UPD,
24
+ FRAME_DELTA_DEL,
25
+ FRAME_ERROR,
26
+ type ColumnDef,
27
+ } from "../generated/bulk-protocol";
28
+ import type { BulkRegistry } from "./use-cartridge-info";
29
+ import { useDevToolsConfig, wsBaseFrom } from "./config";
30
+ import { getDevToolsToken } from "./auth";
31
+
32
+ // One per-entity table view, demultiplexed off the shared socket by sid. The
33
+ // object is stable across renders and mutated in place; consumers re-render via
34
+ // the listener set (and the cheap per-chunk onRender for virtualization).
35
+ type View = {
36
+ sid: number;
37
+ cart: string;
38
+ entity: string;
39
+ store: BulkDataStore;
40
+ columns: ColumnDef[];
41
+ totalRows: number;
42
+ loadedRows: number;
43
+ streaming: boolean;
44
+ error: string | null;
45
+ streamRequested: boolean; // sent a `sub` (full stream) at least once
46
+ onRender: (() => void) | null;
47
+ listeners: Set<() => void>;
48
+ };
49
+
50
+ export type BulkSubscription = {
51
+ store: BulkDataStore;
52
+ connected: boolean;
53
+ streaming: boolean;
54
+ error: string | null;
55
+ columns: ColumnDef[];
56
+ totalRows: number;
57
+ loadedRows: number;
58
+ search: (q: string) => void;
59
+ clearSearch: () => void;
60
+ setOnRender: (fn: (() => void) | null) => void;
61
+ };
62
+
63
+ type Ctx = {
64
+ getView: (cart: string, entity: string) => View;
65
+ ensureStreamed: (view: View) => void;
66
+ search: (view: View, query: string) => void;
67
+ clearSearch: (view: View) => void;
68
+ subscribe: (view: View, cb: () => void) => () => void;
69
+ connected: boolean;
70
+ error: string | null;
71
+ };
72
+
73
+ const BulkStreamContext = createContext<Ctx | null>(null);
74
+
75
+ function base64UrlEncode(value: string): string {
76
+ if (typeof btoa === "function") {
77
+ return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
78
+ }
79
+ throw new Error("No base64 encoder available for bulk WS auth");
80
+ }
81
+
82
+ function bulkAuthProtocols(token: string): string[] {
83
+ return ["nimbit-bulk", `auth-${base64UrlEncode(token)}`];
84
+ }
85
+
86
+ let _wsTokenCache: { apiBaseUrl: string; token: string; at: number } | null = null;
87
+ const WS_TOKEN_TTL_MS = 60_000;
88
+
89
+ // Resolve the session token to forward in the WS subprotocol. Prefer the
90
+ // devtools-managed token (set by its own sign-in); otherwise fall back to the
91
+ // host page's cookie session via auth.Session.current. Returns null when there
92
+ // is neither — on an unconfigured/dev console the WS upgrade is open anyway, so
93
+ // we still connect without an auth subprotocol.
94
+ async function fetchWsToken(
95
+ signal: AbortSignal,
96
+ apiBaseUrl: string,
97
+ ): Promise<string | null> {
98
+ const own = getDevToolsToken();
99
+ if (own) return own;
100
+ const now = Date.now();
101
+ if (_wsTokenCache?.apiBaseUrl === apiBaseUrl && now - _wsTokenCache.at < WS_TOKEN_TTL_MS) {
102
+ return _wsTokenCache.token;
103
+ }
104
+ const r = await fetch(`${apiBaseUrl}/api/auth/session/current`, {
105
+ method: "POST",
106
+ signal,
107
+ credentials: "include",
108
+ headers: { "content-type": "application/json" },
109
+ });
110
+ if (!r.ok) return null;
111
+ const j = (await r.json()) as { session?: { token?: string } };
112
+ const token = j.session?.token;
113
+ if (!token) return null;
114
+ _wsTokenCache = { apiBaseUrl, token, at: now };
115
+ return token;
116
+ }
117
+
118
+ function invalidateWsToken() {
119
+ _wsTokenCache = null;
120
+ }
121
+
122
+ function notify(view: View) {
123
+ view.listeners.forEach((cb) => cb());
124
+ }
125
+
126
+ type Props = { registry: BulkRegistry; children: React.ReactNode };
127
+
128
+ export function BulkStreamProvider({ registry, children }: Props): React.ReactElement {
129
+ const { apiBaseUrl } = useDevToolsConfig();
130
+ const wsRef = useRef<WebSocket | null>(null);
131
+ const sendQueueRef = useRef<string[]>([]);
132
+ const sidCounterRef = useRef(1);
133
+ const viewsBySidRef = useRef<Map<number, View>>(new Map());
134
+ const viewsByKeyRef = useRef<Map<string, View>>(new Map());
135
+ const preloadedRef = useRef(false);
136
+
137
+ const [connected, setConnected] = useState(false);
138
+ const [error, setError] = useState<string | null>(null);
139
+
140
+ const sendCmd = (cmd: string) => {
141
+ const ws = wsRef.current;
142
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(cmd);
143
+ else sendQueueRef.current.push(cmd);
144
+ };
145
+
146
+ const flushQueue = () => {
147
+ const ws = wsRef.current;
148
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
149
+ const q = sendQueueRef.current;
150
+ sendQueueRef.current = [];
151
+ for (const cmd of q) ws.send(cmd);
152
+ };
153
+
154
+ const getView = (cart: string, entity: string): View => {
155
+ // The server registers entity ops under "<cart>/<entity-lower>" (route
156
+ // path casing), so normalize here — preload and subscription then share one
157
+ // view per entity regardless of the caller's casing.
158
+ const entLower = entity.toLowerCase();
159
+ const key = `${cart}/${entLower}`;
160
+ const existing = viewsByKeyRef.current.get(key);
161
+ if (existing) return existing;
162
+ const sid = sidCounterRef.current++;
163
+ const view: View = {
164
+ sid,
165
+ cart,
166
+ entity: entLower,
167
+ store: new BulkDataStore(),
168
+ columns: [],
169
+ totalRows: 0,
170
+ loadedRows: 0,
171
+ streaming: false,
172
+ error: null,
173
+ streamRequested: false,
174
+ onRender: null,
175
+ listeners: new Set(),
176
+ };
177
+ viewsByKeyRef.current.set(key, view);
178
+ viewsBySidRef.current.set(sid, view);
179
+ return view;
180
+ };
181
+
182
+ const ensureStreamed = (view: View) => {
183
+ if (view.streamRequested) return;
184
+ view.streamRequested = true;
185
+ sendCmd(encodeStreamCmd(view.sid, view.cart, view.entity));
186
+ };
187
+
188
+ const handleMessage = (ev: MessageEvent) => {
189
+ const buf = ev.data as ArrayBuffer;
190
+ const sid = getFrameSid(buf);
191
+ const view = viewsBySidRef.current.get(sid);
192
+ if (!view) return;
193
+ const ft = getFrameType(buf);
194
+ const store = view.store;
195
+ switch (ft) {
196
+ case FRAME_SCHEMA: {
197
+ const schema = parseSchema(buf);
198
+ store.applySchema(schema);
199
+ view.columns = schema.columns;
200
+ view.totalRows = schema.totalRows;
201
+ view.loadedRows = 0;
202
+ view.streaming = true;
203
+ notify(view);
204
+ break;
205
+ }
206
+ case FRAME_DATA:
207
+ {
208
+ const rows = parseDataChunk(buf, store.columns);
209
+ store.appendChunk(rows);
210
+ view.loadedRows = store.getRowCount();
211
+ view.onRender?.();
212
+ break;
213
+ }
214
+ case FRAME_DATA_END:
215
+ view.streaming = false;
216
+ notify(view);
217
+ break;
218
+ case FRAME_DELTA_INS:
219
+ case FRAME_DELTA_UPD:
220
+ case FRAME_DELTA_DEL: {
221
+ const delta = parseDelta(buf, store.columns);
222
+ store.applyDelta(delta);
223
+ view.totalRows = store.totalRows;
224
+ view.loadedRows = store.getRowCount();
225
+ view.onRender?.();
226
+ notify(view);
227
+ break;
228
+ }
229
+ case FRAME_ERROR:
230
+ view.error = parseError(buf);
231
+ view.streaming = false;
232
+ notify(view);
233
+ break;
234
+ }
235
+ };
236
+
237
+ // Open one shared socket for the provider's lifetime.
238
+ useEffect(() => {
239
+ const ac = new AbortController();
240
+ let cancelled = false;
241
+ let opened = false;
242
+ let ws: WebSocket | null = null;
243
+ sendQueueRef.current = [];
244
+ sidCounterRef.current = 1;
245
+ viewsBySidRef.current.clear();
246
+ viewsByKeyRef.current.clear();
247
+ preloadedRef.current = false;
248
+ setConnected(false);
249
+ setError(null);
250
+
251
+ (async () => {
252
+ let token: string | null = null;
253
+ try {
254
+ token = await fetchWsToken(ac.signal, apiBaseUrl);
255
+ } catch {
256
+ // Token lookup failed; still attempt the connection — an unconfigured
257
+ // dev console accepts it, a configured one will close it (handled below).
258
+ }
259
+ if (cancelled) return;
260
+ // With a token, authenticate via the subprotocol; without one, connect
261
+ // bare (dev/unconfigured consoles synthesize auth on their side).
262
+ ws = token
263
+ ? new WebSocket(`${wsBaseFrom(apiBaseUrl)}/_ws/bulk`, bulkAuthProtocols(token))
264
+ : new WebSocket(`${wsBaseFrom(apiBaseUrl)}/_ws/bulk`, ["nimbit-bulk"]);
265
+ ws.binaryType = "arraybuffer";
266
+ wsRef.current = ws;
267
+ ws.onopen = () => {
268
+ if (cancelled || wsRef.current !== ws) return;
269
+ opened = true;
270
+ setConnected(true);
271
+ setError(null);
272
+ flushQueue();
273
+ };
274
+ ws.onmessage = handleMessage;
275
+ ws.onerror = () => {};
276
+ ws.onclose = () => {
277
+ if (cancelled || wsRef.current !== ws) return;
278
+ setConnected(false);
279
+ wsRef.current = null;
280
+ if (!opened) {
281
+ invalidateWsToken();
282
+ setError("WebSocket connection closed");
283
+ }
284
+ };
285
+ })();
286
+
287
+ return () => {
288
+ cancelled = true;
289
+ ac.abort();
290
+ if (ws) {
291
+ ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
292
+ try {
293
+ ws.close();
294
+ } catch {}
295
+ if (wsRef.current === ws) wsRef.current = null;
296
+ }
297
+ };
298
+ // eslint-disable-next-line react-hooks/exhaustive-deps
299
+ }, [apiBaseUrl]);
300
+
301
+ // The live protocol exposes only sub/unsub. Subscribe every known entity once
302
+ // so schema, row counts, snapshots, and deltas stay available to all tabs.
303
+ useEffect(() => {
304
+ if (!connected) return;
305
+ if (preloadedRef.current) return;
306
+ const keys = Object.keys(registry);
307
+ if (keys.length === 0) return;
308
+ preloadedRef.current = true;
309
+ for (const cart of keys) {
310
+ for (const ent of registry[cart] ?? []) {
311
+ const view = getView(cart, ent.name);
312
+ ensureStreamed(view);
313
+ }
314
+ }
315
+ // eslint-disable-next-line react-hooks/exhaustive-deps
316
+ }, [connected, registry]);
317
+
318
+ const search = (view: View, query: string) => {
319
+ view.store.search(query);
320
+ view.totalRows = view.store.totalRows;
321
+ view.loadedRows = view.store.getRowCount();
322
+ notify(view);
323
+ view.onRender?.();
324
+ };
325
+
326
+ const clearSearch = (view: View) => {
327
+ view.store.exitSearch();
328
+ view.totalRows = view.store.totalRows;
329
+ view.loadedRows = view.store.getRowCount();
330
+ notify(view);
331
+ view.onRender?.();
332
+ };
333
+
334
+ const subscribe = (view: View, cb: () => void) => {
335
+ view.listeners.add(cb);
336
+ return () => {
337
+ view.listeners.delete(cb);
338
+ };
339
+ };
340
+
341
+ const ctx: Ctx = {
342
+ getView,
343
+ ensureStreamed,
344
+ search,
345
+ clearSearch,
346
+ subscribe,
347
+ connected,
348
+ error,
349
+ };
350
+
351
+ return React.createElement(BulkStreamContext.Provider, { value: ctx }, children);
352
+ }
353
+
354
+ // Row counts for every entity in the registry, sourced from the SCHEMA preload
355
+ // the provider already runs on connect (FRAME_SCHEMA carries totalRows). No row
356
+ // streaming is triggered — we subscribe to each view but never ensureStreamed.
357
+ // Keyed by the graph id `cart:Entity` (original casing) for the Diagram tab.
358
+ export function useBulkRowCounts(registry: BulkRegistry): Record<string, number> {
359
+ const ctx = useContext(BulkStreamContext);
360
+ if (!ctx) throw new Error("useBulkRowCounts must be used within BulkStreamProvider");
361
+ const [, force] = useState(0);
362
+
363
+ const pairs: Array<{ id: string; view: View }> = [];
364
+ for (const cart of Object.keys(registry)) {
365
+ for (const ent of registry[cart] ?? []) {
366
+ pairs.push({ id: `${cart}:${ent.name}`, view: ctx.getView(cart, ent.name) });
367
+ }
368
+ }
369
+
370
+ const key = pairs.map((p) => p.id).join("|");
371
+ useEffect(() => {
372
+ const unsubs = pairs.map((p) => ctx.subscribe(p.view, () => force((n) => n + 1)));
373
+ return () => unsubs.forEach((u) => u());
374
+ // eslint-disable-next-line react-hooks/exhaustive-deps
375
+ }, [key]);
376
+
377
+ const counts: Record<string, number> = {};
378
+ for (const p of pairs) counts[p.id] = p.view.totalRows;
379
+ return counts;
380
+ }
381
+
382
+ // Keyed to a single entity. The view (schema + rows + deltas) is shared and
383
+ // cached in the provider, so switching tabs is instant once visited.
384
+ export function useBulkSubscription(cart: string, entity: string): BulkSubscription {
385
+ const ctx = useContext(BulkStreamContext);
386
+ if (!ctx) throw new Error("useBulkSubscription must be used within BulkStreamProvider");
387
+
388
+ const view = ctx.getView(cart, entity);
389
+ const [, force] = useState(0);
390
+
391
+ useEffect(() => {
392
+ const unsub = ctx.subscribe(view, () => force((n) => n + 1));
393
+ ctx.ensureStreamed(view);
394
+ return unsub;
395
+ // eslint-disable-next-line react-hooks/exhaustive-deps
396
+ }, [view]);
397
+
398
+ return {
399
+ store: view.store,
400
+ connected: ctx.connected,
401
+ streaming: view.streaming,
402
+ error: view.error ?? ctx.error,
403
+ columns: view.columns,
404
+ totalRows: view.totalRows,
405
+ loadedRows: view.loadedRows,
406
+ search: (q: string) => ctx.search(view, q),
407
+ clearSearch: () => ctx.clearSearch(view),
408
+ setOnRender: (fn) => {
409
+ view.onRender = fn;
410
+ },
411
+ };
412
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useDevToolsConfig } from "./config";
3
+ import { authHeaders } from "./auth";
4
+
5
+ // Live cartridge/entity registry for the Data tab. Instead of the build-time
6
+ // `BULK_REGISTRY` (every cart in the repo, generated by `nbt generate`), we
7
+ // read the daemon's `/_console/contracts` — which only lists *running*
8
+ // cartridges and carries each entity's `searchFields`. Bulk WS routes are
9
+ // derived live (`/_ws/bulk/<cart>/<entity-lower>`) and fed straight into
10
+ // useBulkStream. So the tab reflects what is actually installed right now.
11
+
12
+ export type BulkEntity = {
13
+ name: string;
14
+ route: string;
15
+ searchFields: readonly string[];
16
+ };
17
+ export type BulkRegistry = Record<string, BulkEntity[]>;
18
+
19
+ type Contract = {
20
+ cartridge?: string;
21
+ core?: boolean;
22
+ owns?: Record<string, { searchFields?: string[] }>;
23
+ };
24
+
25
+ function buildRegistry(contracts: Contract[]): { reg: BulkRegistry; core: Set<string> } {
26
+ const reg: BulkRegistry = {};
27
+ const core = new Set<string>();
28
+ for (const c of contracts) {
29
+ const cart = c.cartridge;
30
+ if (!cart || !c.owns) continue;
31
+ const entities: BulkEntity[] = [];
32
+ for (const [name, ent] of Object.entries(c.owns)) {
33
+ const sf = Array.isArray(ent?.searchFields) ? ent.searchFields : [];
34
+ entities.push({
35
+ name,
36
+ route: `/_ws/bulk/${cart}/${name.toLowerCase()}`,
37
+ searchFields: sf,
38
+ });
39
+ }
40
+ if (entities.length > 0) {
41
+ reg[cart] = entities;
42
+ if (c.core) core.add(cart);
43
+ }
44
+ }
45
+ return { reg, core };
46
+ }
47
+
48
+ export type LiveRegistryState = {
49
+ registry: BulkRegistry;
50
+ carts: string[];
51
+ // Cartridge slugs flagged `core` by the daemon. The Data tab keeps `auth`
52
+ // visible but tucks the rest of these behind a "more cartridges" menu so
53
+ // deployed (user) cartridges are what's shown by default.
54
+ coreCarts: Set<string>;
55
+ loading: boolean;
56
+ error: string | null;
57
+ };
58
+
59
+ export function useLiveBulkRegistry(): LiveRegistryState {
60
+ const { apiBaseUrl } = useDevToolsConfig();
61
+ const [registry, setRegistry] = useState<BulkRegistry>({});
62
+ const [coreCarts, setCoreCarts] = useState<Set<string>>(new Set());
63
+ const [loading, setLoading] = useState(true);
64
+ const [error, setError] = useState<string | null>(null);
65
+
66
+ useEffect(() => {
67
+ const ac = new AbortController();
68
+ let cancelled = false;
69
+ setLoading(true);
70
+ setError(null);
71
+ (async () => {
72
+ try {
73
+ const r = await fetch(`${apiBaseUrl}/_console/contracts`, {
74
+ signal: ac.signal,
75
+ credentials: "include",
76
+ headers: authHeaders(),
77
+ });
78
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
79
+ const data = (await r.json()) as Contract[];
80
+ if (!Array.isArray(data)) throw new Error("malformed contracts response");
81
+ if (cancelled) return;
82
+ const { reg, core } = buildRegistry(data);
83
+ setRegistry(reg);
84
+ setCoreCarts(core);
85
+ } catch (e) {
86
+ if (cancelled || (e as { name?: string }).name === "AbortError") return;
87
+ setError(e instanceof Error ? e.message : String(e));
88
+ } finally {
89
+ if (!cancelled) setLoading(false);
90
+ }
91
+ })();
92
+ return () => {
93
+ cancelled = true;
94
+ ac.abort();
95
+ };
96
+ }, [apiBaseUrl]);
97
+
98
+ const carts = Object.keys(registry).sort();
99
+ return { registry, carts, coreCarts, loading, error };
100
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }