@rebasepro/studio 0.24.0 → 0.24.1-canary.gcb918fc

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 (48) hide show
  1. package/dist/{ApiExplorer-CCtczj3F.js → ApiExplorer-Bzt7Ye49.js} +5 -3
  2. package/dist/ApiExplorer-Bzt7Ye49.js.map +1 -0
  3. package/dist/{ApiKeysView-B66TdzhH.js → ApiKeysView-Bqf6XYR1.js} +2 -1
  4. package/dist/{ApiKeysView-B66TdzhH.js.map → ApiKeysView-Bqf6XYR1.js.map} +1 -1
  5. package/dist/{BackupsView-DmSMzAbT.js → BackupsView-BhOHB6iP.js} +6 -20
  6. package/dist/BackupsView-BhOHB6iP.js.map +1 -0
  7. package/dist/{BranchesView-DP1a87E-.js → BranchesView-BHfwGTYc.js} +5 -3
  8. package/dist/BranchesView-BHfwGTYc.js.map +1 -0
  9. package/dist/{CronJobsView-TDErHTuK.js → CronJobsView-CMNK7eWO.js} +52 -59
  10. package/dist/CronJobsView-CMNK7eWO.js.map +1 -0
  11. package/dist/{JSEditor-B59pwg4c.js → JSEditor-DIO2Mw8q.js} +115 -96
  12. package/dist/JSEditor-DIO2Mw8q.js.map +1 -0
  13. package/dist/{LogsExplorer-BKM4yfP8.js → LogsExplorer-C-bwDFIl.js} +19 -23
  14. package/dist/LogsExplorer-C-bwDFIl.js.map +1 -0
  15. package/dist/{RLSEditor-BlLLV6xs.js → RLSEditor-jFD_d06f.js} +10 -8
  16. package/dist/RLSEditor-jFD_d06f.js.map +1 -0
  17. package/dist/{SQLEditor-1An7w0j6.js → SQLEditor-D5Ci4pyW.js} +372 -545
  18. package/dist/SQLEditor-D5Ci4pyW.js.map +1 -0
  19. package/dist/{SchemaVisualizer-DCLVfA5t.js → SchemaVisualizer-D-rAqwul.js} +479 -206
  20. package/dist/SchemaVisualizer-D-rAqwul.js.map +1 -0
  21. package/dist/{StorageView-CbsHuCBf.js → StorageView-CPkDKbsX.js} +498 -241
  22. package/dist/StorageView-CPkDKbsX.js.map +1 -0
  23. package/dist/assets/dagre-layout.worker-BFnhAzuR.js.map +1 -0
  24. package/dist/components/SQLEditor/SQLEditor.d.ts +11 -0
  25. package/dist/components/SchemaVisualizer/dagre-layout.d.ts +32 -0
  26. package/dist/components/SchemaVisualizer/dagre-layout.worker.d.ts +13 -0
  27. package/dist/components/SchemaVisualizer/layout-runner.d.ts +23 -0
  28. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +21 -10
  29. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +38 -1
  30. package/dist/components/StorageView/useVisibleFileMetadata.d.ts +46 -0
  31. package/dist/dagre-layout.worker-j9NbH9Gb.js +27 -0
  32. package/dist/dagre-layout.worker-j9NbH9Gb.js.map +1 -0
  33. package/dist/index.es.js +16 -14
  34. package/dist/index.es.js.map +1 -1
  35. package/dist/sql_utils-BZD31xE3.js +478 -0
  36. package/dist/sql_utils-BZD31xE3.js.map +1 -0
  37. package/dist/utils/sql_utils.d.ts +60 -0
  38. package/package.json +9 -9
  39. package/dist/ApiExplorer-CCtczj3F.js.map +0 -1
  40. package/dist/BackupsView-DmSMzAbT.js.map +0 -1
  41. package/dist/BranchesView-DP1a87E-.js.map +0 -1
  42. package/dist/CronJobsView-TDErHTuK.js.map +0 -1
  43. package/dist/JSEditor-B59pwg4c.js.map +0 -1
  44. package/dist/LogsExplorer-BKM4yfP8.js.map +0 -1
  45. package/dist/RLSEditor-BlLLV6xs.js.map +0 -1
  46. package/dist/SQLEditor-1An7w0j6.js.map +0 -1
  47. package/dist/SchemaVisualizer-DCLVfA5t.js.map +0 -1
  48. package/dist/StorageView-CbsHuCBf.js.map +0 -1
@@ -1,12 +1,101 @@
1
1
  import { IconForView, useRebaseContext, useStudioCollectionRegistry, useTranslation } from "@rebasepro/app";
2
2
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Chip, CircularProgress, IconButton, ResizablePanels, SearchBar, Tooltip, Typography, cls, defaultBorderMixin, useIsDarkMode } from "@rebasepro/ui";
3
+ import { Chip, CircularProgress, IconButton, ResizablePanels, SearchBar, ToggleButtonGroup, Tooltip, Typography, cls, defaultBorderMixin, useIsDarkMode } from "@rebasepro/ui";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { isPostgresCollectionConfig } from "@rebasepro/types";
6
6
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
7
- import { Background, BackgroundVariant, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Position, ReactFlow, ReactFlowProvider, applyEdgeChanges, applyNodeChanges, getSmoothStepPath, useNodesInitialized, useReactFlow } from "@xyflow/react";
7
+ import { Background, BackgroundVariant, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Position, ReactFlow, ReactFlowProvider, applyEdgeChanges, applyNodeChanges, getSmoothStepPath, useReactFlow } from "@xyflow/react";
8
8
  import "@xyflow/react/dist/style.css";
9
9
  import dagre from "dagre";
10
+ //#region src/components/SchemaVisualizer/dagre-layout.ts
11
+ /**
12
+ * Place the graph with dagre, top to bottom.
13
+ *
14
+ * An ERD read as a column of tables fits the tall, narrow canvas the
15
+ * visualizer is given, and it is the only layout the visualizer offers.
16
+ * Deterministic: the same input always gives the same positions.
17
+ *
18
+ * The cost is in dagre's ordering phase and grows fast with cross-linked
19
+ * graphs — 5 s at 200 tables with two relations each, 90 s at 500 — which is
20
+ * why the visualizer runs this in a worker (`dagre-layout.worker.ts`).
21
+ */
22
+ function runDagre(input) {
23
+ const g = new dagre.graphlib.Graph();
24
+ g.setGraph({
25
+ rankdir: "TB",
26
+ nodesep: 100,
27
+ ranksep: 180,
28
+ edgesep: 60,
29
+ marginx: 60,
30
+ marginy: 60
31
+ });
32
+ g.setDefaultEdgeLabel(() => ({}));
33
+ for (const node of input.nodes) g.setNode(node.id, {
34
+ width: node.width,
35
+ height: node.height
36
+ });
37
+ for (const edge of input.edges) g.setEdge(edge.source, edge.target);
38
+ dagre.layout(g);
39
+ const positions = {};
40
+ for (const node of input.nodes) {
41
+ const placed = g.node(node.id);
42
+ positions[node.id] = {
43
+ x: placed.x - node.width / 2,
44
+ y: placed.y - node.height / 2
45
+ };
46
+ }
47
+ return positions;
48
+ }
49
+ //#endregion
50
+ //#region src/components/SchemaVisualizer/layout-runner.ts
51
+ /**
52
+ * Whether this environment can start the layout worker.
53
+ *
54
+ * No in tests (jsdom has no `Worker`) and on a server render; there the
55
+ * layout runs where it is asked for, as it always did.
56
+ */
57
+ var canLayOutOffThread = () => typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function";
58
+ /**
59
+ * Lay a graph out in a worker of its own, which ends with the layout.
60
+ *
61
+ * The worker is inlined into the bundle (`?worker&inline`) rather than emitted
62
+ * as a file beside it: `@rebasepro/studio` is a library, and a worker file
63
+ * would be found only by an app bundler that rewrites
64
+ * `new URL(…, import.meta.url)` inside dependencies, which Vite's dependency
65
+ * pre-bundling does not. Imported on first use, so a page that never lays a
66
+ * graph out never decodes it.
67
+ *
68
+ * A worker that cannot be started or loaded — a Content-Security-Policy
69
+ * without `blob:` in `worker-src`, say — is not a broken diagram: the layout
70
+ * runs on the main thread instead, as it did before there was a worker.
71
+ */
72
+ async function layOutOffThread(input) {
73
+ const { default: LayoutWorker } = await import("./dagre-layout.worker-j9NbH9Gb.js");
74
+ let worker;
75
+ try {
76
+ worker = new LayoutWorker({ name: "rebase-schema-layout" });
77
+ } catch {
78
+ return runDagre(input);
79
+ }
80
+ return new Promise((resolve, reject) => {
81
+ worker.addEventListener("message", (event) => {
82
+ worker.terminate();
83
+ const reply = event.data;
84
+ if ("positions" in reply) resolve(reply.positions);
85
+ else reject(new Error(reply.error));
86
+ });
87
+ worker.addEventListener("error", (event) => {
88
+ event.preventDefault();
89
+ worker.terminate();
90
+ try {
91
+ resolve(runDagre(input));
92
+ } catch (error) {
93
+ reject(error);
94
+ }
95
+ });
96
+ worker.postMessage(input);
97
+ });
98
+ }
10
99
  /** Header with a single line of text (junction tables or tableName === collectionName). */
11
100
  var HEADER_HEIGHT_SINGLE = 33;
12
101
  /** Header with two lines (name + subtitle when collectionName !== tableName). */
@@ -29,57 +118,82 @@ var estimateNodeHeight = (columnCount, headerHeight = HEADER_HEIGHT_DOUBLE) => h
29
118
  */
30
119
  var getColumnRowY = (rowIndex, headerHeight = HEADER_HEIGHT_DOUBLE) => headerHeight + rowIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
31
120
  /**
32
- * Compute node positions using the dagre graph layout engine.
33
- * Returns a new array of nodes with updated `position`.
34
- *
35
- * The graph always ranks top to bottom: an ERD read as a column of tables
36
- * fits the tall, narrow canvas the visualizer is given, and it is the only
37
- * layout the visualizer offers.
121
+ * What dagre will be given for these nodes and edges: each node's id and
122
+ * box, each edge's ends, in order. The signature is all of it, so a changed
123
+ * schema — a table, a column count, a relation — is a different key.
38
124
  */
39
- var getLayoutedElements = (nodes, edges) => {
40
- const g = new dagre.graphlib.Graph();
41
- g.setGraph({
42
- rankdir: "TB",
43
- nodesep: 100,
44
- ranksep: 180,
45
- edgesep: 60,
46
- marginx: 60,
47
- marginy: 60
48
- });
49
- g.setDefaultEdgeLabel(() => ({}));
50
- const nodeHeights = /* @__PURE__ */ new Map();
51
- nodes.forEach((node) => {
52
- const data = node.data;
53
- const h = estimateNodeHeight(data.columns?.length ?? 3, getHeaderHeight({
54
- isJunction: Boolean(data.isJunction),
55
- collectionName: data.collectionName ?? "",
56
- tableName: data.tableName ?? ""
57
- }));
58
- nodeHeights.set(node.id, h);
59
- g.setNode(node.id, {
60
- width: 280,
61
- height: h
62
- });
63
- });
64
- edges.forEach((edge) => {
65
- g.setEdge(edge.source, edge.target);
66
- });
67
- dagre.layout(g);
68
- return {
125
+ var planLayout = (nodes, edges) => {
126
+ const input = {
69
127
  nodes: nodes.map((node) => {
70
- const nodeWithPosition = g.node(node.id);
71
- const h = nodeHeights.get(node.id) ?? estimateNodeHeight(3);
128
+ const data = node.data;
129
+ const headerH = getHeaderHeight({
130
+ isJunction: Boolean(data.isJunction),
131
+ collectionName: data.collectionName ?? "",
132
+ tableName: data.tableName ?? ""
133
+ });
72
134
  return {
73
- ...node,
74
- position: {
75
- x: nodeWithPosition.x - 280 / 2,
76
- y: nodeWithPosition.y - h / 2
77
- }
135
+ id: node.id,
136
+ width: 280,
137
+ height: estimateNodeHeight(data.columns?.length ?? 3, headerH)
78
138
  };
79
139
  }),
80
- edges
140
+ edges: edges.map((edge) => ({
141
+ source: edge.source,
142
+ target: edge.target
143
+ }))
144
+ };
145
+ return {
146
+ input,
147
+ signature: `${input.nodes.map((node) => `${node.id}:${node.height}`).join("|")}#${input.edges.map((edge) => `${edge.source}>${edge.target}`).join("|")}`
81
148
  };
82
149
  };
150
+ /**
151
+ * Positions of the graphs laid out in this page's life, by
152
+ * {@link LayoutPlan.signature}: the most recent few.
153
+ *
154
+ * Dagre takes 4.5–5 s at 200 cross-linked tables and 90 s at 500, so a graph
155
+ * is laid out once: not again when the live RLS answer arrives (a badge, not
156
+ * the graph), nor when the view is reopened. The layout is deterministic, so
157
+ * an entry never goes stale. Holds geometry only, nothing read from the
158
+ * database.
159
+ */
160
+ var layoutCache = /* @__PURE__ */ new Map();
161
+ var LAYOUT_CACHE_SIZE = 16;
162
+ var rememberLayout = (signature, positions) => {
163
+ layoutCache.set(signature, positions);
164
+ if (layoutCache.size > LAYOUT_CACHE_SIZE) {
165
+ const oldest = layoutCache.keys().next().value;
166
+ if (oldest !== void 0) layoutCache.delete(oldest);
167
+ }
168
+ return positions;
169
+ };
170
+ /** The positions for this plan, if it has been laid out before. */
171
+ var cachedLayout = (plan) => layoutCache.get(plan.signature);
172
+ /** Lay the plan out here, on the calling thread. */
173
+ var layOutNow = (plan) => cachedLayout(plan) ?? rememberLayout(plan.signature, runDagre(plan.input));
174
+ /** Layouts running in a worker, by signature, so a second ask waits for the first. */
175
+ var layoutsInFlight = /* @__PURE__ */ new Map();
176
+ /**
177
+ * Lay the plan out in a worker; the main thread stays free while it runs.
178
+ * See {@link layOutOffThread}.
179
+ */
180
+ var layOutInBackground = (plan) => {
181
+ const cached = cachedLayout(plan);
182
+ if (cached) return Promise.resolve(cached);
183
+ const running = layoutsInFlight.get(plan.signature);
184
+ if (running) return running;
185
+ const job = layOutOffThread(plan.input).then((positions) => rememberLayout(plan.signature, positions)).finally(() => layoutsInFlight.delete(plan.signature));
186
+ layoutsInFlight.set(plan.signature, job);
187
+ return job;
188
+ };
189
+ /** The nodes at their laid-out positions, as new objects. */
190
+ var placeNodes = (nodes, positions) => nodes.map((node) => ({
191
+ ...node,
192
+ position: { ...positions[node.id] ?? {
193
+ x: 0,
194
+ y: 0
195
+ } }
196
+ }));
83
197
  var getCardinalityLabel = (cardinality, direction) => {
84
198
  if (cardinality === "many") return "M:N";
85
199
  if (direction === "inverse") return "1:1 ←";
@@ -307,52 +421,151 @@ var buildGraph = (collections, liveRls) => {
307
421
  });
308
422
  }
309
423
  }
310
- const layoutResult = getLayoutedElements(nodes, edges);
311
- const nodePositions = /* @__PURE__ */ new Map();
312
- for (const node of layoutResult.nodes) nodePositions.set(node.id, { x: node.position.x });
313
- for (const edge of layoutResult.edges) {
314
- const srcPos = nodePositions.get(edge.source);
315
- const tgtPos = nodePositions.get(edge.target);
316
- const srcBaseHandle = edge.sourceHandle ?? "source-default";
317
- const tgtBaseHandle = edge.targetHandle ?? "target-default";
318
- if (srcPos && tgtPos) {
424
+ return {
425
+ nodes,
426
+ edges
427
+ };
428
+ };
429
+ /**
430
+ * The graph at its laid-out positions, with each edge's handles on the sides
431
+ * that face each other: a source left of its target exits right and enters
432
+ * the target from the left, and the other way round.
433
+ */
434
+ var placeGraph = (graph, positions) => {
435
+ return {
436
+ nodes: placeNodes(graph.nodes, positions),
437
+ edges: graph.edges.map((edge) => {
438
+ const srcPos = positions[edge.source];
439
+ const tgtPos = positions[edge.target];
440
+ const srcBaseHandle = edge.sourceHandle ?? "source-default";
441
+ const tgtBaseHandle = edge.targetHandle ?? "target-default";
442
+ if (!srcPos || !tgtPos) return {
443
+ ...edge,
444
+ sourceHandle: `${srcBaseHandle}-right`,
445
+ targetHandle: `${tgtBaseHandle}-left`
446
+ };
319
447
  const sourceIsLeft = srcPos.x + 280 / 2 <= tgtPos.x + 280 / 2;
320
- edge.sourceHandle = `${srcBaseHandle}-${sourceIsLeft ? "right" : "left"}`;
321
- edge.targetHandle = `${tgtBaseHandle}-${sourceIsLeft ? "left" : "right"}`;
322
- } else {
323
- edge.sourceHandle = `${srcBaseHandle}-right`;
324
- edge.targetHandle = `${tgtBaseHandle}-left`;
448
+ return {
449
+ ...edge,
450
+ sourceHandle: `${srcBaseHandle}-${sourceIsLeft ? "right" : "left"}`,
451
+ targetHandle: `${tgtBaseHandle}-${sourceIsLeft ? "left" : "right"}`
452
+ };
453
+ })
454
+ };
455
+ };
456
+ var isJunctionNode = (node) => Boolean(node.data.isJunction);
457
+ /**
458
+ * The tables within `depth` relations of `focus`, and every relation between
459
+ * them.
460
+ *
461
+ * A junction table is not a step of its own: a many-to-many is one relation,
462
+ * drawn through its junction. So distances are counted in half steps — an edge
463
+ * that touches a junction is one, a foreign key between two tables is two.
464
+ */
465
+ var neighbourhoodOf = (graph, focus, depth) => {
466
+ const junctions = new Set(graph.nodes.filter(isJunctionNode).map((node) => node.id));
467
+ const adjacent = /* @__PURE__ */ new Map();
468
+ for (const edge of graph.edges) {
469
+ adjacent.set(edge.source, [...adjacent.get(edge.source) ?? [], edge.target]);
470
+ adjacent.set(edge.target, [...adjacent.get(edge.target) ?? [], edge.source]);
471
+ }
472
+ const limit = depth * 2;
473
+ const distance = /* @__PURE__ */ new Map([[focus, 0]]);
474
+ const byDistance = Array.from({ length: limit + 1 }, () => []);
475
+ byDistance[0].push(focus);
476
+ for (let reached = 0; reached <= limit; reached++) for (const id of byDistance[reached]) {
477
+ if (distance.get(id) !== reached) continue;
478
+ for (const next of adjacent.get(id) ?? []) {
479
+ const step = junctions.has(id) || junctions.has(next) ? 1 : 2;
480
+ const nextDistance = reached + step;
481
+ if (nextDistance > limit || (distance.get(next) ?? Infinity) <= nextDistance) continue;
482
+ distance.set(next, nextDistance);
483
+ byDistance[nextDistance].push(next);
325
484
  }
326
485
  }
327
- return layoutResult;
486
+ return {
487
+ nodes: graph.nodes.filter((node) => distance.has(node.id)),
488
+ edges: graph.edges.filter((edge) => distance.has(edge.source) && distance.has(edge.target))
489
+ };
490
+ };
491
+ /** The table with the most relations: where a large schema opens. */
492
+ var mostConnectedTable = (graph) => {
493
+ const degree = /* @__PURE__ */ new Map();
494
+ for (const edge of graph.edges) {
495
+ degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
496
+ degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
497
+ }
498
+ let best;
499
+ for (const node of graph.nodes) {
500
+ if (isJunctionNode(node)) continue;
501
+ if (!best || (degree.get(node.id) ?? 0) > (degree.get(best.id) ?? 0)) best = node;
502
+ }
503
+ return best?.id;
504
+ };
505
+ var EMPTY_GRAPH = {
506
+ nodes: [],
507
+ edges: []
328
508
  };
329
- var useSchemaGraph = (collections, liveRls = null) => {
509
+ var NO_POSITIONS = {};
510
+ var useSchemaGraph = (collections, liveRls = null, focus = null, depth = 1) => {
330
511
  const [version, setVersion] = useState(0);
331
512
  const relayout = useCallback(() => setVersion((v) => v + 1), []);
332
- const { nodes, edges, tableCount, relationCount } = useMemo(() => {
333
- if (!collections || collections.length === 0) return {
334
- nodes: [],
335
- edges: [],
336
- tableCount: 0,
337
- relationCount: 0
338
- };
339
- const result = buildGraph(collections, liveRls);
340
- return {
341
- ...result,
342
- tableCount: result.nodes.length,
343
- relationCount: result.edges.length
513
+ const graph = useMemo(() => collections && collections.length > 0 ? buildGraph(collections, liveRls) : EMPTY_GRAPH, [collections, liveRls]);
514
+ const tables = graph.nodes.filter((node) => !isJunctionNode(node)).length;
515
+ const focused = tables > 300 ? (focus && graph.nodes.some((node) => node.id === focus) ? focus : mostConnectedTable(graph)) ?? null : null;
516
+ const drawn = useMemo(() => focused ? neighbourhoodOf(graph, focused, depth) : graph, [
517
+ graph,
518
+ focused,
519
+ depth
520
+ ]);
521
+ const plan = useMemo(() => planLayout(drawn.nodes, drawn.edges), [drawn]);
522
+ /**
523
+ * Where the layout runs. Off the main thread wherever a worker can start;
524
+ * in tests and on a server, here, as the visualizer always did.
525
+ */
526
+ const offThread = canLayOutOffThread();
527
+ const [laidOut, setLaidOut] = useState(null);
528
+ const [layoutError, setLayoutError] = useState(null);
529
+ const positions = drawn.nodes.length === 0 ? NO_POSITIONS : cachedLayout(plan) ?? (laidOut?.signature === plan.signature ? laidOut.positions : void 0) ?? (offThread ? void 0 : layOutNow(plan));
530
+ useEffect(() => {
531
+ if (!offThread || drawn.nodes.length === 0 || cachedLayout(plan)) return;
532
+ let current = true;
533
+ layOutInBackground(plan).then((result) => {
534
+ if (current) setLaidOut({
535
+ signature: plan.signature,
536
+ positions: result
537
+ });
538
+ }, (error) => {
539
+ if (current) setLayoutError(error);
540
+ });
541
+ return () => {
542
+ current = false;
344
543
  };
345
544
  }, [
346
- collections,
347
- version,
348
- liveRls
545
+ plan,
546
+ offThread,
547
+ drawn
548
+ ]);
549
+ const placed = useMemo(() => positions ? placeGraph(drawn, positions) : EMPTY_GRAPH, [
550
+ drawn,
551
+ positions,
552
+ version
349
553
  ]);
554
+ if (layoutError) throw layoutError;
350
555
  return {
351
- nodes,
352
- edges,
556
+ nodes: placed.nodes,
557
+ edges: placed.edges,
558
+ allNodes: graph.nodes,
353
559
  relayout,
354
- tableCount,
355
- relationCount
560
+ tableCount: graph.nodes.length,
561
+ relationCount: graph.edges.length,
562
+ layingOut: positions === void 0,
563
+ neighbourhood: focused ? {
564
+ focus: focused,
565
+ depth,
566
+ shown: drawn.nodes.length,
567
+ total: tables
568
+ } : null
356
569
  };
357
570
  };
358
571
  //#endregion
@@ -699,43 +912,44 @@ function SchemaVisualizerCanvas({ collections }) {
699
912
  const reactFlowInstance = useReactFlow();
700
913
  const liveRls = useLiveRlsTables();
701
914
  const isDark = useIsDarkMode();
702
- const { nodes: layoutedNodes, edges: layoutedEdges, relayout, tableCount, relationCount } = useSchemaGraph(collections, liveRls);
915
+ const [focus, setFocus] = useState(null);
916
+ const [depth, setDepth] = useState(1);
917
+ const { nodes: layoutedNodes, edges: layoutedEdges, allNodes, relayout, tableCount, relationCount, layingOut, neighbourhood } = useSchemaGraph(collections, liveRls, focus, depth);
703
918
  const [nodes, setNodes] = useState([]);
704
919
  const [edges, setEdges] = useState([]);
705
920
  const [selectedTable, setSelectedTable] = useState(null);
706
921
  const [searchQuery, setSearchQuery] = useState("");
707
- const initialFitDone = useRef(false);
708
922
  useEffect(() => {
923
+ if (layingOut) return;
709
924
  setNodes(layoutedNodes);
710
925
  setEdges(layoutedEdges);
711
- }, [layoutedNodes, layoutedEdges]);
926
+ }, [
927
+ layoutedNodes,
928
+ layoutedEdges,
929
+ layingOut
930
+ ]);
712
931
  const onNodesChange = useCallback((changes) => setNodes((nds) => applyNodeChanges(changes, nds)), []);
713
932
  const onEdgesChange = useCallback((changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), []);
714
933
  /**
715
- * Fit the graph into view, once, as soon as it can be measured.
934
+ * Fit the graph into view when it is first drawn, and again whenever a
935
+ * different neighbourhood replaces it.
716
936
  *
717
- * This used to fire on a 200ms timer. `fitView` needs every node's real
718
- * width and height, which React Flow only knows after it has laid them out
719
- * and measured them — with a dozen tables, a cold Monaco-sized bundle and
720
- * a slow first paint, 200ms is often too early. The fit then ran against
721
- * zero-sized nodes and produced a viewport nothing lines up with, and
722
- * `initialFitDone` made sure it was never retried: the ERD opened as a
723
- * scatter of specks in a corner and stayed there until you hit "fit" by
724
- * hand. `useNodesInitialized` is the event this was approximating.
937
+ * `fitView` needs every node's real width and height, which React Flow
938
+ * only knows once it has rendered and measured them; a fit made before
939
+ * that leaves the ERD a scatter of specks in a corner. React Flow's own
940
+ * `fitView` waits for the measurement, so it is asked for in the commit
941
+ * that hands it the new nodes — after its store has them, which a
942
+ * child's effect does before this one runs.
725
943
  */
726
- const nodesInitialized = useNodesInitialized();
944
+ const fitPending = useRef(true);
727
945
  useEffect(() => {
728
- if (!nodesInitialized || nodes.length === 0 || initialFitDone.current) return;
729
- initialFitDone.current = true;
946
+ if (!fitPending.current || nodes.length === 0) return;
947
+ fitPending.current = false;
730
948
  reactFlowInstance.fitView({
731
949
  padding: .15,
732
950
  duration: 400
733
951
  });
734
- }, [
735
- nodesInitialized,
736
- nodes.length,
737
- reactFlowInstance
738
- ]);
952
+ }, [nodes, reactFlowInstance]);
739
953
  const handleFitView = useCallback(() => {
740
954
  reactFlowInstance.fitView({
741
955
  padding: .15,
@@ -750,12 +964,27 @@ function SchemaVisualizerCanvas({ collections }) {
750
964
  }, []);
751
965
  const handleTableSelect = useCallback((nodeId) => {
752
966
  setSelectedTable(nodeId);
967
+ if (neighbourhood) {
968
+ if (neighbourhood.focus !== nodeId) {
969
+ fitPending.current = true;
970
+ setFocus(nodeId);
971
+ }
972
+ return;
973
+ }
753
974
  const node = nodes.find((n) => n.id === nodeId);
754
975
  if (node) reactFlowInstance.setCenter(node.position.x + 140, node.position.y + 60, {
755
976
  zoom: 1.2,
756
977
  duration: 400
757
978
  });
758
- }, [nodes, reactFlowInstance]);
979
+ }, [
980
+ nodes,
981
+ reactFlowInstance,
982
+ neighbourhood
983
+ ]);
984
+ const handleDepthChange = useCallback((value) => {
985
+ fitPending.current = true;
986
+ setDepth(value === "2" ? 2 : 1);
987
+ }, []);
759
988
  const [sidebarSize, setSidebarSize] = useState(() => {
760
989
  try {
761
990
  const saved = localStorage.getItem("rebase_schema_viz_sidebar_size");
@@ -775,30 +1004,34 @@ function SchemaVisualizerCanvas({ collections }) {
775
1004
  const q = searchQuery.toLowerCase();
776
1005
  return postgresCollections.filter((c) => c.name.toLowerCase().includes(q) || getTableName(c).toLowerCase().includes(q) || c.slug?.toLowerCase().includes(q));
777
1006
  }, [postgresCollections, searchQuery]);
778
- const junctionNodes = useMemo(() => nodes.filter((n) => n.data.isJunction), [nodes]);
1007
+ const junctionNodes = useMemo(() => allNodes.filter((n) => n.data.isJunction), [allNodes]);
779
1008
  /**
780
1009
  * The RLS answer for one collection, from the same place the graph used.
781
1010
  *
782
- * `nodes` already carries it, so look it up there rather than re-deriving —
783
- * that is what kept the sidebar dots, the node badges and the total from
784
- * agreeing.
1011
+ * The graph's nodes already carry it, so look it up there rather than
1012
+ * re-deriving — that is what kept the sidebar dots, the node badges and the
1013
+ * total from agreeing. Every node, not only the drawn ones: a large schema
1014
+ * draws a neighbourhood, and the list shows every table.
785
1015
  */
786
- const rlsEnabledFor = useCallback((collection) => {
787
- const node = nodes.find((n) => n.id === `table-${getTableName(collection)}`);
788
- return Boolean((node?.data)?.rlsEnabled);
789
- }, [nodes]);
1016
+ const rlsByNodeId = useMemo(() => new Map(allNodes.map((n) => [n.id, Boolean(n.data.rlsEnabled)])), [allNodes]);
1017
+ const rlsEnabledFor = useCallback((collection) => rlsByNodeId.get(`table-${getTableName(collection)}`) ?? false, [rlsByNodeId]);
1018
+ const focusName = useMemo(() => {
1019
+ const node = neighbourhood ? allNodes.find((n) => n.id === neighbourhood.focus) : void 0;
1020
+ return node ? node.data.collectionName : "";
1021
+ }, [allNodes, neighbourhood]);
790
1022
  const stats = useMemo(() => ({
791
1023
  tables: tableCount,
792
1024
  relations: relationCount,
793
1025
  junctions: junctionNodes.length,
794
- withRls: nodes.filter((n) => n.data?.rlsEnabled).length
1026
+ withRls: allNodes.filter((n) => n.data?.rlsEnabled).length
795
1027
  }), [
796
1028
  tableCount,
797
1029
  relationCount,
798
1030
  junctionNodes.length,
799
- nodes
1031
+ allNodes
800
1032
  ]);
801
1033
  return /* @__PURE__ */ jsx(ResizablePanels, {
1034
+ dividerAriaLabel: t("resize_sidebar"),
802
1035
  orientation: "horizontal",
803
1036
  panelSizePercent: sidebarSize,
804
1037
  onPanelSizeChange: setSidebarSize,
@@ -1013,104 +1246,144 @@ function SchemaVisualizerCanvas({ collections }) {
1013
1246
  })]
1014
1247
  }), /* @__PURE__ */ jsxs("div", {
1015
1248
  className: "flex-grow relative",
1016
- children: [/* @__PURE__ */ jsxs(ReactFlow, {
1017
- nodes,
1018
- edges,
1019
- onNodesChange,
1020
- onEdgesChange,
1021
- onNodeClick: handleNodeClick,
1022
- onPaneClick: handlePaneClick,
1023
- nodeTypes,
1024
- edgeTypes,
1025
- fitView: true,
1026
- fitViewOptions: { padding: .15 },
1027
- minZoom: .1,
1028
- maxZoom: 2,
1029
- proOptions: { hideAttribution: true },
1030
- colorMode: isDark ? "dark" : "light",
1031
- className: "bg-surface-well",
1032
- children: [
1033
- /* @__PURE__ */ jsx(Background, {
1034
- variant: BackgroundVariant.Dots,
1035
- gap: 20,
1036
- size: 1,
1037
- className: "!bg-surface-well",
1038
- color: isDark ? "#2a2a2a" : "#d9d9dc"
1039
- }),
1040
- /* @__PURE__ */ jsx(Controls, {
1041
- showInteractive: false,
1042
- className: "!mb-16 !bg-surface-card !border !border-hairline !shadow-sm !rounded-lg [--xy-controls-button-background-color:var(--color-surface-card)] [--xy-controls-button-background-color-hover:var(--color-surface-card-hover)] [--xy-controls-button-color:var(--color-text-secondary)] dark:[--xy-controls-button-color:var(--color-text-secondary-dark)] [--xy-controls-button-color-hover:var(--color-text-primary)] dark:[--xy-controls-button-color-hover:var(--color-text-primary-dark)] [--xy-controls-button-border-color:var(--color-hairline)]"
1043
- }),
1044
- /* @__PURE__ */ jsx(MiniMap, {
1045
- nodeStrokeColor: (n) => {
1046
- const d = n.data;
1047
- if (d.isJunction) return "#a78bfa";
1048
- if (d.rlsEnabled) return "#22c55e";
1049
- return "#6366f1";
1050
- },
1051
- nodeColor: (n) => {
1052
- if (n.data.isJunction) return isDark ? "#3b2f6b" : "#ede9fe";
1053
- return isDark ? "#2b2d4a" : "#eef2ff";
1054
- },
1055
- maskColor: isDark ? "rgba(0,0,0,0.35)" : "rgba(0,0,0,0.08)",
1056
- className: "!bg-surface-card !border !border-hairline !shadow-sm !rounded-lg"
1057
- })
1058
- ]
1059
- }), /* @__PURE__ */ jsxs("div", {
1060
- className: "absolute bottom-4 left-4 flex items-center gap-3 px-3 py-2 bg-surface-card/90 backdrop-blur-sm rounded-lg border border-hairline shadow-sm",
1061
- children: [
1062
- /* @__PURE__ */ jsxs("div", {
1063
- className: "flex items-center gap-1.5",
1064
- children: [/* @__PURE__ */ jsx("div", { className: "w-6 h-0.5 bg-indigo-500 rounded" }), /* @__PURE__ */ jsx(Typography, {
1065
- variant: "caption",
1066
- className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1067
- children: "Owning"
1068
- })]
1069
- }),
1070
- /* @__PURE__ */ jsxs("div", {
1071
- className: "flex items-center gap-1.5",
1072
- children: [/* @__PURE__ */ jsx("div", { className: "w-6 h-0.5 bg-violet-500 rounded" }), /* @__PURE__ */ jsx(Typography, {
1073
- variant: "caption",
1074
- className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1075
- children: "M:N"
1076
- })]
1077
- }),
1078
- /* @__PURE__ */ jsxs("div", {
1079
- className: "flex items-center gap-1.5",
1080
- children: [/* @__PURE__ */ jsx("div", {
1081
- className: "w-6 h-0.5 rounded",
1082
- style: { backgroundImage: "repeating-linear-gradient(90deg, #94a3b8, #94a3b8 4px, transparent 4px, transparent 7px)" }
1083
- }), /* @__PURE__ */ jsx(Typography, {
1084
- variant: "caption",
1085
- className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1086
- children: "Inverse"
1087
- })]
1088
- }),
1089
- /* @__PURE__ */ jsx("div", { className: "h-3 w-px bg-surface-raised" }),
1090
- /* @__PURE__ */ jsxs("div", {
1091
- className: "flex items-center gap-1",
1092
- children: [/* @__PURE__ */ jsx("span", {
1093
- className: "text-[9px]",
1094
- children: "🔑"
1095
- }), /* @__PURE__ */ jsx(Typography, {
1249
+ children: [
1250
+ /* @__PURE__ */ jsxs(ReactFlow, {
1251
+ nodes,
1252
+ edges,
1253
+ onNodesChange,
1254
+ onEdgesChange,
1255
+ onNodeClick: handleNodeClick,
1256
+ onPaneClick: handlePaneClick,
1257
+ nodeTypes,
1258
+ edgeTypes,
1259
+ fitView: true,
1260
+ fitViewOptions: { padding: .15 },
1261
+ minZoom: .1,
1262
+ maxZoom: 2,
1263
+ proOptions: { hideAttribution: true },
1264
+ colorMode: isDark ? "dark" : "light",
1265
+ className: "bg-surface-well",
1266
+ children: [
1267
+ /* @__PURE__ */ jsx(Background, {
1268
+ variant: BackgroundVariant.Dots,
1269
+ gap: 20,
1270
+ size: 1,
1271
+ className: "!bg-surface-well",
1272
+ color: isDark ? "#2a2a2a" : "#d9d9dc"
1273
+ }),
1274
+ /* @__PURE__ */ jsx(Controls, {
1275
+ showInteractive: false,
1276
+ className: "!mb-16 !bg-surface-card !border !border-hairline !shadow-sm !rounded-lg [--xy-controls-button-background-color:var(--color-surface-card)] [--xy-controls-button-background-color-hover:var(--color-surface-card-hover)] [--xy-controls-button-color:var(--color-text-secondary)] dark:[--xy-controls-button-color:var(--color-text-secondary-dark)] [--xy-controls-button-color-hover:var(--color-text-primary)] dark:[--xy-controls-button-color-hover:var(--color-text-primary-dark)] [--xy-controls-button-border-color:var(--color-hairline)]"
1277
+ }),
1278
+ /* @__PURE__ */ jsx(MiniMap, {
1279
+ nodeStrokeColor: (n) => {
1280
+ const d = n.data;
1281
+ if (d.isJunction) return "#a78bfa";
1282
+ if (d.rlsEnabled) return "#22c55e";
1283
+ return "#6366f1";
1284
+ },
1285
+ nodeColor: (n) => {
1286
+ if (n.data.isJunction) return isDark ? "#3b2f6b" : "#ede9fe";
1287
+ return isDark ? "#2b2d4a" : "#eef2ff";
1288
+ },
1289
+ maskColor: isDark ? "rgba(0,0,0,0.35)" : "rgba(0,0,0,0.08)",
1290
+ className: "!bg-surface-card !border !border-hairline !shadow-sm !rounded-lg"
1291
+ })
1292
+ ]
1293
+ }),
1294
+ neighbourhood && /* @__PURE__ */ jsx("div", {
1295
+ className: "absolute top-4 left-4 right-4 flex justify-start pointer-events-none",
1296
+ children: /* @__PURE__ */ jsxs("div", {
1297
+ className: "flex flex-wrap items-center gap-2 max-w-full px-3 py-2 bg-surface-card/90 backdrop-blur-sm rounded-lg border border-hairline shadow-sm pointer-events-auto",
1298
+ children: [/* @__PURE__ */ jsx(Typography, {
1096
1299
  variant: "caption",
1097
- className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1098
- children: "PK"
1300
+ className: "text-text-secondary dark:text-text-secondary-dark",
1301
+ children: t("studio_schema_neighbourhood", {
1302
+ shown: neighbourhood.shown,
1303
+ total: neighbourhood.total,
1304
+ table: focusName
1305
+ })
1306
+ }), /* @__PURE__ */ jsx(ToggleButtonGroup, {
1307
+ value: String(neighbourhood.depth),
1308
+ onValueChange: handleDepthChange,
1309
+ options: [{
1310
+ value: "1",
1311
+ label: t("studio_schema_neighbourhood_depth_1")
1312
+ }, {
1313
+ value: "2",
1314
+ label: t("studio_schema_neighbourhood_depth_2")
1315
+ }]
1099
1316
  })]
1100
- }),
1101
- /* @__PURE__ */ jsxs("div", {
1102
- className: "flex items-center gap-1",
1103
- children: [/* @__PURE__ */ jsx("span", {
1104
- className: "text-[9px]",
1105
- children: "🔗"
1106
- }), /* @__PURE__ */ jsx(Typography, {
1317
+ })
1318
+ }),
1319
+ layingOut && /* @__PURE__ */ jsx("div", {
1320
+ role: "status",
1321
+ className: "absolute inset-0 flex items-center justify-center pointer-events-none",
1322
+ children: /* @__PURE__ */ jsxs("div", {
1323
+ className: "flex items-center gap-2 px-3 py-2 bg-surface-card/90 backdrop-blur-sm rounded-lg border border-hairline shadow-sm",
1324
+ children: [/* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }), /* @__PURE__ */ jsx(Typography, {
1107
1325
  variant: "caption",
1108
- className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1109
- children: "FK"
1326
+ className: "text-text-secondary dark:text-text-secondary-dark",
1327
+ children: t("studio_schema_laying_out")
1110
1328
  })]
1111
1329
  })
1112
- ]
1113
- })]
1330
+ }),
1331
+ /* @__PURE__ */ jsxs("div", {
1332
+ className: "absolute bottom-4 left-4 flex items-center gap-3 px-3 py-2 bg-surface-card/90 backdrop-blur-sm rounded-lg border border-hairline shadow-sm",
1333
+ children: [
1334
+ /* @__PURE__ */ jsxs("div", {
1335
+ className: "flex items-center gap-1.5",
1336
+ children: [/* @__PURE__ */ jsx("div", { className: "w-6 h-0.5 bg-indigo-500 rounded" }), /* @__PURE__ */ jsx(Typography, {
1337
+ variant: "caption",
1338
+ className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1339
+ children: "Owning"
1340
+ })]
1341
+ }),
1342
+ /* @__PURE__ */ jsxs("div", {
1343
+ className: "flex items-center gap-1.5",
1344
+ children: [/* @__PURE__ */ jsx("div", { className: "w-6 h-0.5 bg-violet-500 rounded" }), /* @__PURE__ */ jsx(Typography, {
1345
+ variant: "caption",
1346
+ className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1347
+ children: "M:N"
1348
+ })]
1349
+ }),
1350
+ /* @__PURE__ */ jsxs("div", {
1351
+ className: "flex items-center gap-1.5",
1352
+ children: [/* @__PURE__ */ jsx("div", {
1353
+ className: "w-6 h-0.5 rounded",
1354
+ style: { backgroundImage: "repeating-linear-gradient(90deg, #94a3b8, #94a3b8 4px, transparent 4px, transparent 7px)" }
1355
+ }), /* @__PURE__ */ jsx(Typography, {
1356
+ variant: "caption",
1357
+ className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1358
+ children: "Inverse"
1359
+ })]
1360
+ }),
1361
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-px bg-surface-raised" }),
1362
+ /* @__PURE__ */ jsxs("div", {
1363
+ className: "flex items-center gap-1",
1364
+ children: [/* @__PURE__ */ jsx("span", {
1365
+ className: "text-[9px]",
1366
+ children: "🔑"
1367
+ }), /* @__PURE__ */ jsx(Typography, {
1368
+ variant: "caption",
1369
+ className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1370
+ children: "PK"
1371
+ })]
1372
+ }),
1373
+ /* @__PURE__ */ jsxs("div", {
1374
+ className: "flex items-center gap-1",
1375
+ children: [/* @__PURE__ */ jsx("span", {
1376
+ className: "text-[9px]",
1377
+ children: "🔗"
1378
+ }), /* @__PURE__ */ jsx(Typography, {
1379
+ variant: "caption",
1380
+ className: "text-[10px] text-text-disabled dark:text-text-disabled-dark",
1381
+ children: "FK"
1382
+ })]
1383
+ })
1384
+ ]
1385
+ })
1386
+ ]
1114
1387
  })]
1115
1388
  })
1116
1389
  });
@@ -1173,4 +1446,4 @@ var SchemaVisualizer = () => {
1173
1446
  //#endregion
1174
1447
  export { SchemaVisualizer };
1175
1448
 
1176
- //# sourceMappingURL=SchemaVisualizer-DCLVfA5t.js.map
1449
+ //# sourceMappingURL=SchemaVisualizer-D-rAqwul.js.map