@rebasepro/studio 0.17.3 → 0.18.0

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 (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,1100 +0,0 @@
1
-
2
- import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
3
- import { Highlight, themes } from "prism-react-renderer";
4
- import { toSnakeCase } from "@rebasepro/utils";
5
- import {
6
- Button,
7
- CellRendererParams,
8
- Chip,
9
- CircularProgress,
10
- cls,
11
- defaultBorderMixin,
12
- Dialog,
13
- DialogActions,
14
- DialogContent,
15
- DialogTitle,
16
- DownloadIcon,
17
- IconButton,
18
- iconSize,
19
- Menu,
20
- MenuIcon,
21
- MenuItem,
22
- MoreVerticalIcon,
23
- PencilIcon,
24
- PlayIcon,
25
- PlusIcon,
26
- ResizablePanels,
27
- SaveIcon,
28
- Tab,
29
- Tabs,
30
- TerminalIcon,
31
- TextField,
32
- Tooltip,
33
- Typography,
34
- VirtualTable,
35
- VirtualTableColumn,
36
- XIcon
37
- } from "@rebasepro/ui";
38
- import { useStudioUrlController, useStudioCollectionRegistry, useStudioSidePanelController } from "@rebasepro/app";
39
- import { useRebaseContext, useRebaseClient, useSnackbarController, useApiConfig, useTranslation, useModeController, ErrorView, SelectableUser, IconForView } from "@rebasepro/app";
40
-
41
- import { createRebaseClient } from "@rebasepro/client";
42
- import { JSMonacoEditor } from "./JSMonacoEditor";
43
- import { JSEditorSidebar, JSSnippet } from "./JSEditorSidebar";
44
- import { AuthSimulationSelector } from "../AuthSimulationSelector";
45
- import type { AdminCollection } from "@rebasepro/cms-types";
46
-
47
- // ─── Types ───────────────────────────────────────────────────────────
48
-
49
- interface ConsoleEntry {
50
- type: "log" | "warn" | "error" | "info";
51
- args: unknown[];
52
- timestamp: number;
53
- }
54
-
55
- interface ExecutionResult {
56
- value: unknown;
57
- console: ConsoleEntry[];
58
- duration: number;
59
- error?: string;
60
- timestamp: number;
61
- }
62
-
63
- interface EditorTab {
64
- id: string;
65
- name: string;
66
- code: string;
67
- }
68
-
69
- // SelectedUser is now SelectableUser from @rebasepro/app
70
-
71
- // ─── Constants ───────────────────────────────────────────────────────
72
-
73
- const STORAGE_PREFIX = "rebase_js_";
74
- const MAX_HISTORY = 50;
75
-
76
- /**
77
- * The starter script.
78
- *
79
- * It runs. That is the point: the console opens with the Run button armed, and
80
- * a starter that reads `collection("your_collection")` made the first thing a
81
- * new user did be a 404 — a placeholder presented as working code. `slug` is
82
- * the project's first collection when one is known.
83
- */
84
- const makeDefaultCode = (slug?: string) => `// Available: client (RebaseClient)
85
- // Press Cmd+Enter (Ctrl+Enter) to run
86
- //
87
- // Examples:
88
- // const users = await client.admin.listUsers();
89
- // const session = client.auth.getSession();
90
- ${slug
91
- ? `
92
- const result = await client.data.collection(${JSON.stringify(slug)}).find({ limit: 10 });
93
- return result;
94
- `
95
- : `
96
- // Name one of your collections to query it:
97
- // const result = await client.data.collection("posts").find({ limit: 10 });
98
- // return result;
99
-
100
- return client.auth.getSession();
101
- `}`;
102
-
103
- const DEFAULT_CODE = makeDefaultCode();
104
-
105
- // ─── Helpers ─────────────────────────────────────────────────────────
106
-
107
- function loadFromStorage<T>(key: string, fallback: T): T {
108
- try {
109
- const raw = localStorage.getItem(STORAGE_PREFIX + key);
110
- return raw ? JSON.parse(raw) : fallback;
111
- } catch {
112
- return fallback;
113
- }
114
- }
115
-
116
- function saveToStorage<T>(key: string, value: T) {
117
- try {
118
- localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
119
- } catch { /* quota */ }
120
- }
121
-
122
- function formatJSON(value: unknown): string {
123
- try {
124
- return JSON.stringify(value, null, 2);
125
- } catch {
126
- return String(value);
127
- }
128
- }
129
-
130
- // ─── Helpers: Collection matching for JS results ────────────────────
131
-
132
- interface MatchedJSCollection {
133
- collectionSlug: string;
134
- collection: AdminCollection;
135
- pkColumn: string;
136
- }
137
-
138
- /**
139
- * Given the raw SDK result, try to detect which collections are present.
140
- * JS SDK results typically come back as `{ data: [{ id, values }] }` or plain arrays.
141
- * The heuristic: if the executed code contains `collection("<slug>")` or `client.data.<slug>`,
142
- * and the result has rows with an "id" column, we match those slugs.
143
- */
144
- function detectCollectionsInResult(
145
- code: string,
146
- resultValue: unknown,
147
- collections: AdminCollection[]
148
- ): MatchedJSCollection[] {
149
- if (!resultValue || !collections?.length) return [];
150
-
151
- // Extract collection slugs mentioned in the code
152
- const mentionedSlugs = new Set<string>();
153
-
154
- // Match: collection("slug") or collection('slug')
155
- const collectionCallRegex = /\.collection\(["']([^"']+)["']\)/g;
156
- let m: RegExpExecArray | null;
157
- while ((m = collectionCallRegex.exec(code)) !== null) {
158
- mentionedSlugs.add(m[1]);
159
- }
160
-
161
- // Match: client.data.<slug>. (dot-access pattern)
162
- const dotAccessRegex = /client\.data\.([a-zA-Z_][a-zA-Z0-9_]*)\./g;
163
- while ((m = dotAccessRegex.exec(code)) !== null) {
164
- if (!["collection", "find", "findById", "create", "update", "delete"].includes(m[1])) {
165
- mentionedSlugs.add(m[1]);
166
- }
167
- }
168
-
169
- if (mentionedSlugs.size === 0) return [];
170
-
171
- // Check if result has rows with an "id" field
172
- let rows: Record<string, unknown>[] = [];
173
- const rv = resultValue as Record<string, unknown>;
174
- if (rv?.data && Array.isArray(rv.data)) {
175
- rows = rv.data;
176
- } else if (Array.isArray(resultValue)) {
177
- rows = resultValue;
178
- }
179
-
180
- const hasId = rows.length > 0 && rows.some(r => r?.id != null);
181
- if (!hasId) return [];
182
-
183
- const matched: MatchedJSCollection[] = [];
184
- for (const slug of mentionedSlugs) {
185
- const normalised = toSnakeCase(slug);
186
- const col = collections.find(c => {
187
- const tableName = ("table" in c ? c.table : undefined) || toSnakeCase(c.slug);
188
- return c.slug === slug || tableName === normalised || toSnakeCase(c.slug) === normalised;
189
- });
190
- if (col) {
191
- matched.push({
192
- collectionSlug: col.slug,
193
- collection: col,
194
- pkColumn: "id"
195
- });
196
- }
197
- }
198
-
199
- return matched;
200
- }
201
-
202
- // ─── Main Component ─────────────────────────────────────────────────
203
-
204
- export function JSEditor() {
205
- // Contexts
206
- const rebaseContext = useRebaseContext();
207
- const rebaseClient = useRebaseClient();
208
- const apiConfig = useApiConfig();
209
- const snackbar = useSnackbarController();
210
- const collectionRegistry = useStudioCollectionRegistry();
211
- const sidePanelController = useStudioSidePanelController();
212
- const { t } = useTranslation();
213
-
214
- // User management for the "Run as" picker
215
- const currentUser = rebaseContext.authController?.user;
216
-
217
- // State
218
- const [tabs, setTabs] = useState<EditorTab[]>(() =>
219
- loadFromStorage("tabs", [{ id: "1",
220
- name: "Script 1",
221
- code: DEFAULT_CODE }])
222
- );
223
- const [activeTabId, setActiveTabId] = useState<string>(() =>
224
- loadFromStorage("activeTab", "1")
225
- );
226
- const [result, setResult] = useState<ExecutionResult | null>(null);
227
- const [isRunning, setIsRunning] = useState(false);
228
- const [snippets, setSnippets] = useState<JSSnippet[]>(() =>
229
- loadFromStorage("snippets", [])
230
- );
231
- const [history, setHistory] = useState<string[]>(() =>
232
- loadFromStorage("history", [])
233
- );
234
- const [showSaveDialog, setShowSaveDialog] = useState(false);
235
- const [snippetName, setSnippetName] = useState("");
236
- const [resultView, setResultView] = useState<"json" | "table" | "console">("json");
237
-
238
- // "Run as" user state — null means "self" (current admin)
239
- const [selectedUser, setSelectedUser] = useState<SelectableUser | null>(null);
240
- const [authMode, setAuthMode] = useState<"jwt" | "none">("jwt");
241
-
242
- const [sidebarSize, setSidebarSize] = useState<number>(() => {
243
- try {
244
- const stored = localStorage.getItem(STORAGE_PREFIX + "sidebar_size");
245
- return stored ? Number(stored) : 18;
246
- } catch { return 18; }
247
- });
248
- const [editorHeight, setEditorHeight] = useState<number>(() => {
249
- try {
250
- const stored = localStorage.getItem(STORAGE_PREFIX + "editor_height");
251
- return stored ? Number(stored) : 55;
252
- } catch { return 55; }
253
- });
254
-
255
- // Derived
256
- const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0];
257
-
258
- // Collection info for the sidebar and Monaco
259
- const collectionInfos = useMemo(() => {
260
- const collections = collectionRegistry?.collections ?? [];
261
- return collections.map(col => ({
262
- slug: col.slug,
263
- name: col.name,
264
- properties: Object.keys(col.properties ?? {})
265
- }));
266
- }, [collectionRegistry?.collections]);
267
-
268
- const collectionSlugs = useMemo(() => collectionInfos.map(c => c.slug), [collectionInfos]);
269
-
270
- /**
271
- * Name a real collection in the starter script once the registry knows one.
272
- *
273
- * The registry is empty on first paint — in the hosted console it arrives
274
- * over the network — so the placeholder-free default cannot be built in the
275
- * `useState` initialiser. Rewriting only a tab whose content is still
276
- * *exactly* the untouched default means this can never overwrite anything
277
- * anyone typed, and never fires twice.
278
- */
279
- const firstSlug = collectionSlugs[0];
280
- useEffect(() => {
281
- if (!firstSlug) return;
282
- setTabs(prev => {
283
- if (!prev.some(tab => tab.code === DEFAULT_CODE)) return prev;
284
- const seeded = makeDefaultCode(firstSlug);
285
- return prev.map(tab => (tab.code === DEFAULT_CODE ? { ...tab,
286
- code: seeded } : tab));
287
- });
288
- }, [firstSlug]);
289
-
290
- // Users list for the picker — mapped to SelectableUser shape
291
- const users = useMemo((): SelectableUser[] => {
292
- const managed: SelectableUser[] = [];
293
- // Ensure the current user is in the list
294
- if (currentUser) {
295
- managed.push({
296
- uid: currentUser.uid,
297
- displayName: currentUser.displayName,
298
- email: currentUser.email,
299
- photoURL: currentUser.photoURL,
300
- roles: currentUser.roles
301
- });
302
- }
303
- return managed;
304
- }, [currentUser]);
305
-
306
- // Current user as SelectableUser for the popover
307
- const currentSelectableUser = useMemo((): SelectableUser | null => {
308
- if (!currentUser) return null;
309
- return {
310
- uid: currentUser.uid,
311
- displayName: currentUser.displayName,
312
- email: currentUser.email,
313
- photoURL: currentUser.photoURL,
314
- roles: currentUser.roles
315
- };
316
- }, [currentUser]);
317
-
318
- // ─── Persistence ─────────────────────────────────────────────
319
-
320
- useEffect(() => { saveToStorage("tabs", tabs); }, [tabs]);
321
- useEffect(() => { saveToStorage("activeTab", activeTabId); }, [activeTabId]);
322
- useEffect(() => { saveToStorage("snippets", snippets); }, [snippets]);
323
- useEffect(() => { saveToStorage("history", history); }, [history]);
324
-
325
- useEffect(() => {
326
- try { localStorage.setItem(STORAGE_PREFIX + "sidebar_size", sidebarSize.toString()); } catch { /* ignore */ }
327
- }, [sidebarSize]);
328
-
329
- useEffect(() => {
330
- try { localStorage.setItem(STORAGE_PREFIX + "editor_height", editorHeight.toString()); } catch { /* ignore */ }
331
- }, [editorHeight]);
332
-
333
- // ─── Tab management ──────────────────────────────────────────
334
-
335
- const updateActiveCode = useCallback((code: string | undefined) => {
336
- setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t,
337
- code: code ?? "" } : t));
338
- }, [activeTabId]);
339
-
340
- const addTab = useCallback(() => {
341
- const id = String(Date.now());
342
- const newTab: EditorTab = { id,
343
- name: `Script ${tabs.length + 1}`,
344
- code: DEFAULT_CODE };
345
- setTabs(prev => [...prev, newTab]);
346
- setActiveTabId(id);
347
- }, [tabs.length]);
348
-
349
- const closeTab = useCallback((tabId: string) => {
350
- setTabs(prev => {
351
- const filtered = prev.filter(t => t.id !== tabId);
352
- if (filtered.length === 0) {
353
- const fresh = { id: String(Date.now()),
354
- name: "Script 1",
355
- code: DEFAULT_CODE };
356
- setActiveTabId(fresh.id);
357
- return [fresh];
358
- }
359
- if (activeTabId === tabId) {
360
- setActiveTabId(filtered[filtered.length - 1].id);
361
- }
362
- return filtered;
363
- });
364
- }, [activeTabId]);
365
-
366
- // ─── Create an authenticated client for execution ────────────
367
-
368
- const buildClient = useCallback(async () => {
369
- const apiUrl = apiConfig?.apiUrl;
370
- const getAuthToken = apiConfig?.getAuthToken;
371
-
372
- if (!apiUrl) {
373
- throw new Error("API URL not configured. Make sure apiUrl is set.");
374
- }
375
-
376
- if (authMode === "none") {
377
- const client = createRebaseClient({
378
- baseUrl: apiUrl,
379
- token: undefined
380
- });
381
- return { client,
382
- isScoped: true };
383
- }
384
-
385
- // If not running as another user safely reuse the application's global client.
386
- if (!selectedUser || (currentUser && selectedUser.uid === currentUser.uid)) {
387
- if (!rebaseClient) {
388
- throw new Error("Application client is not initialized.");
389
- }
390
- return { client: rebaseClient,
391
- isScoped: false };
392
- }
393
-
394
- // Get the current auth token
395
- let token: string | undefined;
396
- if (getAuthToken) {
397
- token = (await getAuthToken()) ?? undefined;
398
- }
399
-
400
- if (!token) {
401
- throw new Error("No auth token available. Please sign in first.");
402
- }
403
-
404
- // Create a fresh 'scoped' client. This enables us to attach custom auth later for this specific execution
405
- // without mutating the global client.
406
- const client = createRebaseClient({
407
- baseUrl: apiUrl,
408
- token
409
- });
410
-
411
- return { client,
412
- isScoped: true };
413
- }, [apiConfig, rebaseClient, selectedUser, currentUser, authMode]);
414
-
415
- // ─── Execution engine ────────────────────────────────────────
416
-
417
- const executeCode = useCallback(async (codeOverride?: string) => {
418
- const code = codeOverride ?? activeTab?.code ?? "";
419
- if (!code.trim()) return;
420
-
421
- setIsRunning(true);
422
- setResult(null);
423
-
424
- // Add to history
425
- setHistory(prev => {
426
- const deduped = prev.filter(h => h !== code);
427
- return [...deduped, code].slice(-MAX_HISTORY);
428
- });
429
-
430
- const consoleEntries: ConsoleEntry[] = [];
431
- const startTime = performance.now();
432
- let scopedClientToCleanUp: { ws?: { disconnect(): void } } | null = null;
433
-
434
- // Capture console methods
435
- const originalConsole = {
436
- log: console.log,
437
- warn: console.warn,
438
- error: console.error,
439
- info: console.info
440
- };
441
-
442
- const captureConsole = (type: ConsoleEntry["type"]) => (...args: unknown[]) => {
443
- consoleEntries.push({ type,
444
- args,
445
- timestamp: Date.now() });
446
- originalConsole[type](...args);
447
- };
448
-
449
- try {
450
- console.log = captureConsole("log");
451
- console.warn = captureConsole("warn");
452
- console.error = captureConsole("error");
453
- console.info = captureConsole("info");
454
-
455
- // Build an authenticated client
456
- const { client, isScoped } = await buildClient();
457
- if (isScoped) {
458
- scopedClientToCleanUp = client;
459
- }
460
-
461
- // Build context object with useful info about selected user
462
- const context = {
463
- user: selectedUser ?? (currentUser ? {
464
- uid: currentUser.uid,
465
- displayName: currentUser.displayName,
466
- email: currentUser.email,
467
- roles: currentUser.roles
468
- } : null),
469
- collections: collectionInfos
470
- };
471
-
472
- // Create async function with `client` and `context` injected
473
-
474
- const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
475
- const fn = new AsyncFunction("client", "context", code);
476
-
477
- const value = await fn(client, context);
478
- const duration = performance.now() - startTime;
479
-
480
- setResult({
481
- value,
482
- console: consoleEntries,
483
- duration,
484
- timestamp: Date.now()
485
- });
486
-
487
- // Auto-detect best view
488
- const resultObj = value as Record<string, unknown> | undefined;
489
- if (resultObj?.data && Array.isArray(resultObj.data)) {
490
- setResultView("table");
491
- } else if (consoleEntries.length > 0 && value === undefined) {
492
- setResultView("console");
493
- } else {
494
- setResultView("json");
495
- }
496
- } catch (err: unknown) {
497
- const duration = performance.now() - startTime;
498
- setResult({
499
- value: undefined,
500
- console: consoleEntries,
501
- duration,
502
- error: err instanceof Error ? err.message : String(err),
503
- timestamp: Date.now()
504
- });
505
- setResultView("json");
506
- } finally {
507
- console.log = originalConsole.log;
508
- console.warn = originalConsole.warn;
509
- console.error = originalConsole.error;
510
- console.info = originalConsole.info;
511
- setIsRunning(false);
512
-
513
- if (scopedClientToCleanUp) {
514
- // Ensure we disconnect WebSockets to prevent leaking connections
515
- scopedClientToCleanUp.ws?.disconnect();
516
- }
517
- }
518
- }, [activeTab?.code, buildClient, selectedUser, currentUser, collectionInfos]);
519
-
520
- // ─── Snippet management ──────────────────────────────────────
521
-
522
- const saveSnippet = useCallback(() => {
523
- if (!snippetName.trim() || !activeTab?.code.trim()) return;
524
- const snippet: JSSnippet = {
525
- id: String(Date.now()),
526
- name: snippetName.trim(),
527
- code: activeTab.code,
528
- createdAt: Date.now()
529
- };
530
- setSnippets(prev => [snippet, ...prev]);
531
- setShowSaveDialog(false);
532
- setSnippetName("");
533
- snackbar.open({ type: "success",
534
- message: "Snippet saved" });
535
- }, [snippetName, activeTab?.code, snackbar]);
536
-
537
- const deleteSnippet = useCallback((id: string) => {
538
- setSnippets(prev => prev.filter(s => s.id !== id));
539
- }, []);
540
-
541
- // ─── Export ──────────────────────────────────────────────────
542
-
543
- const exportResult = useCallback(() => {
544
- if (!result?.value) return;
545
- const blob = new Blob([formatJSON(result.value)], { type: "application/json" });
546
- const url = URL.createObjectURL(blob);
547
- const a = document.createElement("a");
548
- a.href = url;
549
- a.download = `rebase-result-${Date.now()}.json`;
550
- a.click();
551
- URL.revokeObjectURL(url);
552
- }, [result]);
553
-
554
- // ─── Table columns for array data ────────────────────────────
555
-
556
- const tableData = useMemo(() => {
557
- if (!result?.value) return { columns: [] as VirtualTableColumn[],
558
- data: [] as Record<string, unknown>[] };
559
-
560
- let rows: Record<string, unknown>[] = [];
561
- const val = result.value as Record<string, unknown>;
562
- if (val?.data && Array.isArray(val.data)) {
563
- rows = (val.data as Record<string, unknown>[]).map((entity) => ({
564
- id: entity.id,
565
- ...(entity.values as Record<string, unknown> ?? {}),
566
- ...(entity.values ? {} : entity)
567
- }));
568
- } else if (Array.isArray(result.value)) {
569
- rows = result.value;
570
- }
571
-
572
- if (rows.length === 0) return { columns: [] as VirtualTableColumn[],
573
- data: [] as Record<string, unknown>[] };
574
-
575
- const keys = new Set<string>();
576
- rows.slice(0, 20).forEach(row => {
577
- if (row && typeof row === "object") {
578
- Object.keys(row).forEach(k => keys.add(k));
579
- }
580
- });
581
-
582
- const columns: VirtualTableColumn[] = Array.from(keys).map(key => ({
583
- key,
584
- title: key,
585
- width: key === "id" ? 100 : 200
586
- }));
587
-
588
- return { columns,
589
- data: rows };
590
- }, [result]);
591
-
592
- // ─── Matched collections for entity actions ──────────────────
593
-
594
- const matchedCollections = useMemo(() => {
595
- if (!result?.value || result.error) return [];
596
- return detectCollectionsInResult(
597
- activeTab?.code ?? "",
598
- result.value,
599
- collectionRegistry?.collections ?? []
600
- );
601
- }, [result, activeTab?.code, collectionRegistry?.collections]);
602
-
603
- const getRowEntityActions = useCallback((rowData: Record<string, unknown>): { collection: MatchedJSCollection; entityId: string | number }[] => {
604
- if (!rowData || matchedCollections.length === 0) return [];
605
- return matchedCollections
606
- .filter(mc => rowData[mc.pkColumn] != null)
607
- .map(mc => ({
608
- collection: mc,
609
- entityId: rowData[mc.pkColumn] as string | number
610
- }));
611
- }, [matchedCollections]);
612
-
613
- // ─── Export handlers ─────────────────────────────────────────
614
-
615
- const handleExportCSV = useCallback(() => {
616
- if (tableData.data.length === 0) return;
617
- const headers = tableData.columns.map(c => c.key).join(",");
618
- const rows = tableData.data.map(row =>
619
- tableData.columns.map(c => {
620
- const val = row[c.key];
621
- const str = val === null || val === undefined ? "" : String(val);
622
- return str.includes(",") ? `"${str}"` : str;
623
- }).join(",")
624
- );
625
- const csv = [headers, ...rows].join("\n");
626
- const blob = new Blob([csv], { type: "text/csv" });
627
- const url = URL.createObjectURL(blob);
628
- const a = document.createElement("a");
629
- a.href = url;
630
- a.download = `js_results_${new Date().toISOString().slice(0, 19)}.csv`;
631
- a.click();
632
- URL.revokeObjectURL(url);
633
- }, [tableData]);
634
-
635
- const handleExportMarkdown = useCallback(() => {
636
- if (tableData.data.length === 0) return;
637
- const headers = tableData.columns.map(c => c.key);
638
- const headerRow = `| ${headers.join(" | ")} |`;
639
- const dividerRow = `| ${headers.map(() => "---").join(" | ")} |`;
640
- const dataRows = tableData.data.map(row =>
641
- `| ${headers.map(h => {
642
- const val = row[h];
643
- if (val === null || val === undefined) return "";
644
- return String(val).replace(/\|/g, "\\|").replace(/\n/g, " ");
645
- }).join(" | ")} |`
646
- );
647
- const markdown = [headerRow, dividerRow, ...dataRows].join("\n");
648
- navigator.clipboard.writeText(markdown).then(() => {
649
- snackbar.open({ type: "success",
650
- message: t("studio_sql_markdown_copied") });
651
- }).catch(() => {
652
- snackbar.open({ type: "error",
653
- message: t("studio_sql_markdown_copy_failed") });
654
- });
655
- }, [tableData, snackbar, t]);
656
-
657
- // ─── Render ──────────────────────────────────────────────────
658
-
659
- return (
660
- <div className="flex flex-col h-full w-full">
661
- {/* Main content */}
662
- <div className="flex-grow overflow-hidden">
663
- <ResizablePanels
664
- orientation="horizontal"
665
- panelSizePercent={sidebarSize}
666
- onPanelSizeChange={setSidebarSize}
667
- minPanelSizePx={200}
668
- firstPanel={
669
- <JSEditorSidebar
670
- collections={collectionInfos}
671
- snippets={snippets}
672
- history={history}
673
- onSelectSnippet={(code) => updateActiveCode(code)}
674
- onDeleteSnippet={deleteSnippet}
675
- onInsertCode={(code) => updateActiveCode(code)}
676
- />
677
- }
678
- secondPanel={
679
- <div className="flex flex-col h-full overflow-hidden">
680
- {/* Toolbar: matching SQL Editor layout */}
681
- <div className={cls("flex items-center justify-between pr-2 border-b bg-white dark:bg-surface-950", defaultBorderMixin)}>
682
- <div className="flex items-center flex-grow overflow-hidden mr-4">
683
- <div className="flex items-center no-scrollbar overflow-x-auto min-w-0">
684
- <Tabs value={activeTabId} onValueChange={setActiveTabId} variant="boxy" className="w-[unset] flex-shrink-0" innerClassName="bg-white dark:bg-surface-950">
685
- {tabs.map(tab => (
686
- <Tab key={tab.id} value={tab.id} className="flex items-center justify-between group max-w-[200px]">
687
- <TerminalIcon size={iconSize.smallest} className="text-amber-500 mr-1.5 flex-shrink-0"/>
688
- <span className="truncate">{tab.name}</span>
689
- {tabs.length > 1 && (
690
- <IconButton
691
- size="smallest"
692
- onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
693
- className="ml-1 !p-0.5 opacity-0 group-hover:opacity-100 hover:text-red-500 transition-opacity"
694
- >
695
- <XIcon size={iconSize.smallest}/>
696
- </IconButton>
697
- )}
698
- </Tab>
699
- ))}
700
- </Tabs>
701
- <IconButton
702
- size="small"
703
- onClick={addTab}
704
- className="ml-2 flex-shrink-0"
705
- >
706
- <PlusIcon size={iconSize.smallest}/>
707
- </IconButton>
708
- </div>
709
- </div>
710
- <div className="flex shrink-0 items-center justify-end gap-1.5">
711
- <Tooltip title="Save as snippet">
712
- <IconButton
713
- size="small"
714
- onClick={() => {
715
- setSnippetName("");
716
- setShowSaveDialog(true);
717
- }}
718
- disabled={!activeTab?.code.trim()}
719
- >
720
- <SaveIcon size={iconSize.smallest}/>
721
- </IconButton>
722
- </Tooltip>
723
-
724
- {result?.value != null && (
725
- <Tooltip title="Export result as JSON">
726
- <IconButton size="small" onClick={exportResult}>
727
- <DownloadIcon size={iconSize.smallest}/>
728
- </IconButton>
729
- </Tooltip>
730
- )}
731
-
732
- <Button
733
- size="small"
734
- color="primary"
735
- disabled={isRunning || !activeTab?.code.trim()}
736
- onClick={() => executeCode()}
737
- >
738
- {isRunning ? <CircularProgress size="smallest" className="mr-2"/> : <PlayIcon size={iconSize.smallest} className="mr-2"/>}
739
- Run
740
- </Button>
741
- </div>
742
- </div>
743
-
744
- {/* Editor + Results split */}
745
- <div className="flex-grow overflow-hidden">
746
- <ResizablePanels
747
- orientation="vertical"
748
- panelSizePercent={editorHeight}
749
- onPanelSizeChange={setEditorHeight}
750
- minPanelSizePx={100}
751
- firstPanel={
752
- <div className="h-full w-full overflow-hidden flex flex-col">
753
- {/* Auth Simulation UI */}
754
- <div className="p-2 px-3 border-b border-surface-200 dark:border-surface-950 bg-surface-50 dark:bg-surface-900 flex items-center shrink-0">
755
- <AuthSimulationSelector
756
- authMode={authMode}
757
- setAuthMode={setAuthMode}
758
- selectedUser={selectedUser}
759
- setSelectedUser={setSelectedUser}
760
- users={users}
761
- loading={false}
762
- currentUser={currentSelectableUser}
763
- />
764
- </div>
765
- <div className="flex-1 min-h-0">
766
- <JSMonacoEditor
767
- value={activeTab?.code ?? ""}
768
- onChange={updateActiveCode}
769
- onRun={(selectedText) => executeCode(selectedText)}
770
- collectionSlugs={collectionSlugs}
771
- collections={collectionInfos}
772
- autoFocus
773
- />
774
- </div>
775
- </div>
776
- }
777
- secondPanel={
778
- <div className="h-full w-full flex flex-col bg-surface-50 dark:bg-surface-950 overflow-hidden min-h-0">
779
- {/* Result header — matches SQL editor */}
780
- <div className={cls("p-2 px-4 bg-surface-100 dark:bg-surface-900 border-b shrink-0 flex items-center", defaultBorderMixin)}>
781
- <Typography variant="caption" className="font-semibold text-text-disabled dark:text-text-disabled-dark uppercase tracking-widest text-[10px]">
782
- {t("studio_sql_query_results")}
783
- </Typography>
784
-
785
- {result && (
786
- <>
787
- <div className="flex-grow"/>
788
-
789
- <Tabs value={resultView} onValueChange={(val) => setResultView(val as "json" | "table" | "console")} variant="pill" className="w-[unset] mr-2">
790
- <Tab value="json">JSON</Tab>
791
- {tableData.data.length > 0 && <Tab value="table">Table</Tab>}
792
- {result.console.length > 0 && <Tab value="console">Console ({result.console.length})</Tab>}
793
- </Tabs>
794
-
795
- <Chip size="smallest" colorScheme={result.error ? "redDarker" : "greenDarker"}>
796
- {result.error ? "Error" : `${result.duration.toFixed(0)}ms`}
797
- </Chip>
798
- </>
799
- )}
800
- </div>
801
-
802
- {/* Result content */}
803
- <div className="flex-grow flex flex-col min-h-0 overflow-hidden">
804
- {isRunning && (
805
- <div className="flex-grow flex items-center justify-center">
806
- <div className="text-center">
807
- <CircularProgress size="medium"/>
808
- <Typography variant="body2" className="mt-4 text-text-secondary dark:text-text-secondary-dark font-mono tracking-tight animate-pulse">
809
- EXECUTING SCRIPT...
810
- </Typography>
811
- </div>
812
- </div>
813
- )}
814
-
815
- {!isRunning && !result && (
816
- <div className="flex-grow flex items-center justify-center text-text-disabled dark:text-text-disabled-dark">
817
- <div className="text-center">
818
- <svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
819
- <Typography variant="body2">Write JavaScript and press <kbd className="px-1.5 py-0.5 rounded bg-surface-200 dark:bg-surface-700 text-[11px] font-mono">⌘ Enter</kbd> to run</Typography>
820
- </div>
821
- </div>
822
- )}
823
-
824
- {!isRunning && result?.error && (
825
- <div className="flex-grow flex items-center justify-center p-6 overflow-auto">
826
- <ErrorView
827
- title="Execution Error"
828
- error={result.error}
829
- />
830
- </div>
831
- )}
832
-
833
- {!isRunning && result && !result.error && resultView === "json" && (
834
- <pre className="flex-grow overflow-auto p-4 text-[13px] font-mono leading-relaxed text-text-primary dark:text-text-primary-dark whitespace-pre-wrap break-words">
835
- {result.value === undefined ? (
836
- <span className="text-text-disabled italic">undefined (no return value)</span>
837
- ) : (
838
- <JSONHighlight value={result.value}/>
839
- )}
840
- </pre>
841
- )}
842
-
843
- {!isRunning && result && !result.error && resultView === "table" && tableData.data.length > 0 && (
844
- <div className="flex-grow flex flex-col overflow-hidden min-h-0">
845
- {/* Collection badges bar — matching SQL editor */}
846
- {matchedCollections.length > 0 && (
847
- <div className={cls("px-4 py-1.5 border-b flex items-center gap-2 shrink-0 bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
848
- <Tooltip title={t("studio_sql_admin_collections_tooltip")}>
849
- <Typography variant="caption" className="text-[10px] font-semibold uppercase tracking-widest text-text-disabled dark:text-text-disabled-dark mr-1 shrink-0 cursor-help">{t("studio_sql_collections_label")}</Typography>
850
- </Tooltip>
851
- <div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
852
- {matchedCollections.map(mc => (
853
- <Tooltip key={mc.collectionSlug} title={`${mc.collection.name} (${mc.collectionSlug})`}>
854
- <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 dark:bg-primary-dark/15 text-primary dark:text-primary-dark whitespace-nowrap border border-primary/20 dark:border-primary-dark/20">
855
- {typeof mc.collection.icon === "string" && (
856
- <IconForView collectionOrView={{ icon: mc.collection.icon } as never} className="text-[12px]"/>
857
- )}
858
- {mc.collection.name}
859
- </span>
860
- </Tooltip>
861
- ))}
862
- </div>
863
- </div>
864
- )}
865
- <div className="flex-grow relative h-full min-h-0 min-w-0">
866
- <VirtualTable
867
- data={tableData.data}
868
- columns={
869
- matchedCollections.length > 0
870
- ? [{ key: "__entity_action__",
871
- title: "",
872
- width: 36,
873
- sortable: false,
874
- resizable: false }, ...tableData.columns]
875
- : tableData.columns
876
- }
877
- rowHeight={32}
878
- headerHeight={32}
879
- cellRenderer={({ rowData, column, rowIndex }: CellRendererParams<Record<string, unknown>>) => {
880
- // Entity action column
881
- if (column.key === "__entity_action__") {
882
- const rowActions = getRowEntityActions(rowData ?? {});
883
- if (rowActions.length === 0) return <div className="h-full w-full"/>;
884
- if (rowActions.length === 1) {
885
- const ra = rowActions[0];
886
- return (
887
- <div className="h-full flex items-center justify-center">
888
- <Tooltip title={t("studio_sql_edit_entity", { name: ra.collection.collection.name,
889
- id: String(ra.entityId) })}>
890
- <IconButton
891
- size="small"
892
- className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300"
893
- onClick={(e) => {
894
- e.stopPropagation();
895
- sidePanelController.open({
896
- path: ra.collection.collectionSlug,
897
- entityId: ra.entityId,
898
- collection: ra.collection.collection,
899
- updateUrl: false
900
- });
901
- }}
902
- >
903
- <PencilIcon size={iconSize.smallest}/>
904
- </IconButton>
905
- </Tooltip>
906
- </div>
907
- );
908
- }
909
- // Multiple matched collections
910
- return (
911
- <div className="h-full flex items-center justify-center">
912
- <Menu
913
- trigger={
914
- <IconButton
915
- size={"small"}
916
- className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300"
917
- onClick={(e) => e.stopPropagation()}
918
- >
919
- <MoreVerticalIcon size={iconSize.smallest}/>
920
- </IconButton>
921
- }
922
- >
923
- {rowActions.map(ra => (
924
- <MenuItem
925
- key={ra.collection.collectionSlug}
926
- dense
927
- onClick={() => {
928
- sidePanelController.open({
929
- path: ra.collection.collectionSlug,
930
- entityId: ra.entityId,
931
- collection: ra.collection.collection,
932
- updateUrl: false
933
- });
934
- }}
935
- >
936
- {t("studio_sql_edit_entity", { name: ra.collection.collection.name,
937
- id: String(ra.entityId) })}
938
- </MenuItem>
939
- ))}
940
- </Menu>
941
- </div>
942
- );
943
- }
944
-
945
- // Regular data cell
946
- if (!rowData) return null;
947
- const val = rowData[column.key];
948
- const displayValue = typeof val === "object" && val !== null ? JSON.stringify(val) : String(val ?? "");
949
- return (
950
- <div className="px-4 py-1.5 h-full flex items-center whitespace-nowrap text-[13px] text-text-primary dark:text-text-primary-dark font-mono">
951
- <div className="truncate flex-grow" title={displayValue}>
952
- {displayValue === "" ? <span className="text-text-disabled dark:text-text-disabled-dark italic text-[11px]">NULL</span> : displayValue}
953
- </div>
954
- </div>
955
- );
956
- }}
957
- />
958
- </div>
959
- </div>
960
- )}
961
-
962
- {!isRunning && result && resultView === "console" && (
963
- <div className="flex-grow overflow-auto p-2 space-y-1 font-mono text-[12px]">
964
- {result.console.length === 0 ? (
965
- <Typography variant="caption" className="text-text-disabled p-2">No console output</Typography>
966
- ) : (
967
- result.console.map((entry, i) => (
968
- <div
969
- key={i}
970
- className={cls(
971
- "px-2 py-1 rounded flex items-start gap-2",
972
- entry.type === "error" && "bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400",
973
- entry.type === "warn" && "bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400",
974
- entry.type === "log" && "text-text-primary dark:text-text-primary-dark",
975
- entry.type === "info" && "text-blue-700 dark:text-blue-400"
976
- )}
977
- >
978
- <span className="text-[10px] opacity-50 flex-shrink-0 mt-0.5">
979
- {entry.type === "error" ? "❌" : entry.type === "warn" ? "⚠️" : entry.type === "info" ? "ℹ️" : "›"}
980
- </span>
981
- <span className="whitespace-pre-wrap break-words">
982
- {entry.args.map(a => typeof a === "object" ? JSON.stringify(a, null, 2) : String(a)).join(" ")}
983
- </span>
984
- </div>
985
- ))
986
- )}
987
-
988
- {result.value !== undefined && (
989
- <div className={cls("px-2 py-1 mt-2 border-t pt-2", defaultBorderMixin)}>
990
- <Typography variant="caption" className="text-text-disabled text-[10px] uppercase tracking-wider mb-1 block">Return Value</Typography>
991
- <pre className="text-text-primary dark:text-text-primary-dark whitespace-pre-wrap break-words text-[12px]">
992
- {formatJSON(result.value)}
993
- </pre>
994
- </div>
995
- )}
996
- </div>
997
- )}
998
- </div>
999
-
1000
- {/* Footer bar — matching SQL editor */}
1001
- {!isRunning && result && !result.error && resultView === "table" && tableData.data.length > 0 && (
1002
- <div className={cls("p-2 px-4 border-t bg-surface-50 dark:bg-surface-900 flex justify-between items-center shrink-0", defaultBorderMixin)}>
1003
- <div className="flex space-x-4">
1004
- <div className="flex items-center text-[11px]">
1005
- <span className="font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_rows")}</span>
1006
- <span className="font-mono text-text-secondary dark:text-text-secondary-dark">{tableData.data.length}</span>
1007
- </div>
1008
- <div className="flex items-center text-[11px]">
1009
- <span className="font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter">{t("studio_sql_time")}</span>
1010
- <span className="font-mono text-text-secondary dark:text-text-secondary-dark">{result.duration.toFixed(0)}ms</span>
1011
- </div>
1012
- </div>
1013
- <div className="flex gap-2 overflow-x-auto no-scrollbar items-center px-2">
1014
- <Button
1015
- size="small"
1016
- variant="text"
1017
- className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1018
- onClick={handleExportMarkdown}
1019
- >
1020
- {t("studio_sql_copy_markdown")}
1021
- </Button>
1022
- <Button
1023
- size="small"
1024
- variant="text"
1025
- className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1026
- onClick={exportResult}
1027
- >
1028
- {t("studio_sql_export_json")}
1029
- </Button>
1030
- <Button
1031
- size="small"
1032
- variant="text"
1033
- className="text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap"
1034
- onClick={handleExportCSV}
1035
- >
1036
- {t("studio_sql_export_csv")}
1037
- </Button>
1038
- </div>
1039
- </div>
1040
- )}
1041
- </div>
1042
- }
1043
- />
1044
- </div>
1045
- </div>
1046
- }
1047
- />
1048
- </div>
1049
-
1050
- {/* Save snippet dialog */}
1051
- <Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
1052
- <DialogTitle>Save Snippet</DialogTitle>
1053
- <DialogContent>
1054
- <TextField
1055
- label="Snippet name"
1056
- value={snippetName}
1057
- onChange={(e) => setSnippetName(e.target.value)}
1058
- placeholder="e.g. List all products"
1059
- autoFocus
1060
- onKeyDown={(e) => {
1061
- if (e.key === "Enter") saveSnippet();
1062
- }}
1063
- />
1064
- </DialogContent>
1065
- <DialogActions>
1066
- <Button variant="text" onClick={() => setShowSaveDialog(false)}>Cancel</Button>
1067
- <Button variant="filled" color="primary" onClick={saveSnippet} disabled={!snippetName.trim()}>Save</Button>
1068
- </DialogActions>
1069
- </Dialog>
1070
- </div>
1071
- );
1072
- }
1073
-
1074
- // ─── JSON Syntax Highlighting ────────────────────────────────────────
1075
-
1076
- function JSONHighlight({ value }: { value: unknown }) {
1077
- const json = formatJSON(value);
1078
- const { mode } = useModeController();
1079
-
1080
- return (
1081
- <Highlight
1082
- theme={mode === "dark" ? themes.vsDark : themes.github}
1083
- code={json}
1084
- language="json"
1085
- >
1086
- {({ style, tokens, getLineProps, getTokenProps }) => (
1087
- <span style={{ ...style,
1088
- backgroundColor: "transparent" }}>
1089
- {tokens.map((line, i) => (
1090
- <div key={i} {...getLineProps({ line })}>
1091
- {line.map((token, key) => (
1092
- <span key={key} {...getTokenProps({ token })}/>
1093
- ))}
1094
- </div>
1095
- ))}
1096
- </span>
1097
- )}
1098
- </Highlight>
1099
- );
1100
- }