mbeditor 0.11.0 → 0.12.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +465 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -0,0 +1,565 @@
1
+ 'use strict';
2
+
3
+ // ModelGraph — an SVG entity diagram of the host app's ActiveRecord models.
4
+ //
5
+ // Laid out radially rather than left-to-right: the model with the most
6
+ // associations sits at the centre and everything else fans out in rings by how
7
+ // many hops away it is. A Rails schema is usually a hub with satellites, and a
8
+ // column layout turns that into one very wide, very short strip.
9
+ var ModelGraph = (function () {
10
+ var NODE_W = 188;
11
+ var HEADER_H = 34; // model name + table name
12
+ var FIELD_H = 15;
13
+ var MAX_FIELDS = 8; // the rest are one click away in the schema modal
14
+ var RING_GAP = 150;
15
+ var MIN_RADIUS = 210;
16
+ var NODE_GAP = 56; // clear space between neighbours on the same ring
17
+ var BARYCENTRE_PASSES = 4;
18
+
19
+ function nodeHeight(model) {
20
+ var shown = Math.min((model.columns || []).length, MAX_FIELDS);
21
+ var more = (model.columnCount || 0) > shown ? FIELD_H : 0;
22
+ return HEADER_H + shown * FIELD_H + more + 8;
23
+ }
24
+
25
+ // Undirected adjacency: for placement, "A belongs_to B" and "B has_many A"
26
+ // are the same tie and should pull the two together once.
27
+ function adjacency(models, edges) {
28
+ var adj = {};
29
+ models.forEach(function (m) { adj[m.name] = {}; });
30
+ edges.forEach(function (e) {
31
+ if (!adj[e.from] || !adj[e.to] || e.from === e.to) return;
32
+ adj[e.from][e.to] = true;
33
+ adj[e.to][e.from] = true;
34
+ });
35
+ return adj;
36
+ }
37
+
38
+ function degreeOf(adj, name) { return Object.keys(adj[name] || {}).length; }
39
+
40
+ // Rings by hop count from the busiest model. Disconnected islands are picked
41
+ // up afterwards by their own local hub, so nothing is silently dropped.
42
+ function assignRings(models, adj) {
43
+ var remaining = {};
44
+ models.forEach(function (m) { remaining[m.name] = true; });
45
+
46
+ var ring = {};
47
+ var order = [];
48
+
49
+ while (Object.keys(remaining).length > 0) {
50
+ var hub = Object.keys(remaining).sort(function (a, b) {
51
+ var d = degreeOf(adj, b) - degreeOf(adj, a);
52
+ return d !== 0 ? d : (a < b ? -1 : 1);
53
+ })[0];
54
+
55
+ var queue = [hub];
56
+ ring[hub] = order.length === 0 ? 0 : 1;
57
+ delete remaining[hub];
58
+ order.push(hub);
59
+
60
+ while (queue.length) {
61
+ var current = queue.shift();
62
+ Object.keys(adj[current] || {}).sort().forEach(function (next) {
63
+ if (!remaining[next]) return;
64
+ delete remaining[next];
65
+ ring[next] = ring[current] + 1;
66
+ order.push(next);
67
+ queue.push(next);
68
+ });
69
+ }
70
+ }
71
+ return ring;
72
+ }
73
+
74
+ // Ordering within a ring is what decides how many lines cross. Repeatedly
75
+ // move each node to the average angle of its already-placed neighbours
76
+ // (a barycentre sweep) — cheap, and it untangles most of the crossings a
77
+ // naive alphabetical ring would create.
78
+ function orderRing(names, angleOf, adj, ringOf, thisRing) {
79
+ var ordered = names.slice();
80
+ for (var pass = 0; pass < BARYCENTRE_PASSES; pass++) {
81
+ var target = {};
82
+ ordered.forEach(function (name) {
83
+ var xs = 0, ys = 0, n = 0;
84
+ Object.keys(adj[name] || {}).forEach(function (nb) {
85
+ // Only anchor to neighbours already pinned by an inner ring;
86
+ // same-ring ties would just chase each other.
87
+ if (ringOf[nb] >= thisRing) return;
88
+ var a = angleOf[nb];
89
+ if (a === undefined) return;
90
+ xs += Math.cos(a); ys += Math.sin(a); n++;
91
+ });
92
+ target[name] = n === 0 ? null : Math.atan2(ys, xs);
93
+ });
94
+
95
+ var anchored = ordered.filter(function (n) { return target[n] !== null; });
96
+ var floating = ordered.filter(function (n) { return target[n] === null; });
97
+ anchored.sort(function (a, b) { return target[a] - target[b]; });
98
+ ordered = anchored.concat(floating);
99
+ }
100
+ return ordered;
101
+ }
102
+
103
+ function layout(models, edges) {
104
+ var byName = {};
105
+ models.forEach(function (m) { byName[m.name] = m; });
106
+
107
+ var adj = adjacency(models, edges);
108
+ var ringOf = assignRings(models, adj);
109
+
110
+ var rings = [];
111
+ models.forEach(function (m) {
112
+ var r = ringOf[m.name] || 0;
113
+ (rings[r] = rings[r] || []).push(m.name);
114
+ });
115
+
116
+ var positions = {};
117
+ var angleOf = {};
118
+ var maxExtent = 0;
119
+ // Radius and half-footprint of the ring inside this one, so each ring is
120
+ // pushed clear of it. Without this an inner ring with many nodes gets a
121
+ // large circumference-derived radius and the next ring lands inside it.
122
+ var prevRadius = 0;
123
+ var prevHalf = 0;
124
+
125
+ rings.forEach(function (names, ringIndex) {
126
+ if (!names) return;
127
+
128
+ if (ringIndex === 0) {
129
+ names.forEach(function (name) {
130
+ // Centred on the origin, like every other node. Placing the hub by
131
+ // its top-left instead shifts it half a box down and right, straight
132
+ // into the ring around it.
133
+ var h = nodeHeight(byName[name]);
134
+ positions[name] = { x: -NODE_W / 2, y: -h / 2, model: byName[name] };
135
+ angleOf[name] = 0;
136
+ prevHalf = Math.max(prevHalf, Math.max(NODE_W, h) / 2);
137
+ });
138
+ return;
139
+ }
140
+
141
+ var ordered = orderRing(names, angleOf, adj, ringOf, ringIndex);
142
+
143
+ // Boxes vary in height with their field count, so each one claims arc
144
+ // proportional to its own footprint. Spacing them evenly by count is what
145
+ // makes a tall box collide with its neighbours.
146
+ var extents = ordered.map(function (name) {
147
+ return Math.max(NODE_W, nodeHeight(byName[name])) + NODE_GAP;
148
+ });
149
+ var totalExtent = extents.reduce(function (a, b) { return a + b; }, 0);
150
+ var thisHalf = Math.max.apply(null, extents) / 2;
151
+ var radius = Math.max(
152
+ MIN_RADIUS,
153
+ totalExtent / (2 * Math.PI), // wide enough that neighbours clear
154
+ prevRadius + prevHalf + thisHalf + 40 // and outside the ring within
155
+ );
156
+ prevRadius = radius;
157
+ prevHalf = thisHalf;
158
+
159
+ var cumulative = 0;
160
+ ordered.forEach(function (name, i) {
161
+ var angle = (2 * Math.PI * (cumulative + extents[i] / 2)) / totalExtent - Math.PI / 2;
162
+ cumulative += extents[i];
163
+ angleOf[name] = angle;
164
+ var h = nodeHeight(byName[name]);
165
+ positions[name] = {
166
+ x: Math.cos(angle) * radius - NODE_W / 2,
167
+ y: Math.sin(angle) * radius - h / 2,
168
+ model: byName[name]
169
+ };
170
+ maxExtent = Math.max(maxExtent, radius + NODE_W, radius + h);
171
+ });
172
+ });
173
+
174
+ // Shift everything positive and size the canvas to what was actually drawn.
175
+ var pad = 60;
176
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
177
+ Object.keys(positions).forEach(function (name) {
178
+ var p = positions[name];
179
+ var h = nodeHeight(p.model);
180
+ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
181
+ maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + h);
182
+ });
183
+ if (!isFinite(minX)) { minX = 0; minY = 0; maxX = NODE_W; maxY = HEADER_H; }
184
+
185
+ Object.keys(positions).forEach(function (name) {
186
+ positions[name].x += pad - minX;
187
+ positions[name].y += pad - minY;
188
+ });
189
+
190
+ return {
191
+ positions: positions,
192
+ width: (maxX - minX) + pad * 2,
193
+ height: (maxY - minY) + pad * 2
194
+ };
195
+ }
196
+
197
+ // Anchor on whichever side of each box faces the other, so lines leave and
198
+ // arrive at the near edge instead of cutting through their own node.
199
+ function anchors(a, b) {
200
+ var ah = nodeHeight(a.model), bh = nodeHeight(b.model);
201
+ var acx = a.x + NODE_W / 2, acy = a.y + ah / 2;
202
+ var bcx = b.x + NODE_W / 2, bcy = b.y + bh / 2;
203
+ var horizontal = Math.abs(bcx - acx) > Math.abs(bcy - acy);
204
+
205
+ if (horizontal) {
206
+ return bcx > acx
207
+ ? { x1: a.x + NODE_W, y1: acy, x2: b.x, y2: bcy, h: true }
208
+ : { x1: a.x, y1: acy, x2: b.x + NODE_W, y2: bcy, h: true };
209
+ }
210
+ return bcy > acy
211
+ ? { x1: acx, y1: a.y + ah, x2: bcx, y2: b.y, h: false }
212
+ : { x1: acx, y1: a.y, x2: bcx, y2: b.y + bh, h: false };
213
+ }
214
+
215
+ function edgePath(a, b) {
216
+ var p = anchors(a, b);
217
+ var dx = Math.abs(p.x2 - p.x1), dy = Math.abs(p.y2 - p.y1);
218
+ if (p.h) {
219
+ var cx = Math.max(40, dx / 2);
220
+ return 'M' + p.x1 + ',' + p.y1 +
221
+ ' C' + (p.x1 + (p.x2 > p.x1 ? cx : -cx)) + ',' + p.y1 +
222
+ ' ' + (p.x2 + (p.x2 > p.x1 ? -cx : cx)) + ',' + p.y2 +
223
+ ' ' + p.x2 + ',' + p.y2;
224
+ }
225
+ var cy = Math.max(40, dy / 2);
226
+ return 'M' + p.x1 + ',' + p.y1 +
227
+ ' C' + p.x1 + ',' + (p.y1 + (p.y2 > p.y1 ? cy : -cy)) +
228
+ ' ' + p.x2 + ',' + (p.y2 + (p.y2 > p.y1 ? -cy : cy)) +
229
+ ' ' + p.x2 + ',' + p.y2;
230
+ }
231
+
232
+ var MACRO_CLASS = {
233
+ belongs_to: 'mg-edge-belongs',
234
+ has_one: 'mg-edge-has-one',
235
+ has_many: 'mg-edge-has-many',
236
+ has_and_belongs_to_many: 'mg-edge-habtm'
237
+ };
238
+
239
+ // What the association means in cardinality terms, since the arrow alone
240
+ // only shows direction.
241
+ var MACRO_CARDINALITY = {
242
+ belongs_to: 'many → one',
243
+ has_one: 'one → one',
244
+ has_many: 'one → many',
245
+ has_and_belongs_to_many: 'many ↔ many'
246
+ };
247
+
248
+ var MIN_ZOOM = 0.2;
249
+ var MAX_ZOOM = 2.5;
250
+
251
+ return function ModelGraphComponent(_ref) {
252
+ var graph = _ref.graph;
253
+ var onOpenModel = _ref.onOpenModel;
254
+ var onRefresh = _ref.onRefresh;
255
+ var loading = _ref.loading;
256
+
257
+ var _view = React.useState({ k: 1, x: 0, y: 0 });
258
+ var view = _view[0], setView = _view[1];
259
+ var _search = React.useState('');
260
+ var search = _search[0], setSearch = _search[1];
261
+ var _focused = React.useState(null);
262
+ var focused = _focused[0], setFocused = _focused[1];
263
+ var _hovered = React.useState(null);
264
+ var hovered = _hovered[0], setHovered = _hovered[1];
265
+ var _pointer = React.useState({ x: 0, y: 0 });
266
+ var pointer = _pointer[0], setPointer = _pointer[1];
267
+ var dragRef = React.useRef(null);
268
+ var svgRef = React.useRef(null);
269
+
270
+ // Laid out before the early returns so the fit effect below can see it.
271
+ var placed = (graph && graph.ok && (graph.models || []).length)
272
+ ? layout(graph.models, graph.edges || [])
273
+ : null;
274
+
275
+ // Frame the whole graph on load. Without this the view starts at the
276
+ // top-left of a canvas much larger than the pane and the diagram looks
277
+ // empty until you go looking for it.
278
+ var fitToPane = React.useCallback(function () {
279
+ var el = svgRef.current;
280
+ if (!el || !placed) return;
281
+ var rect = el.getBoundingClientRect();
282
+ if (!rect.width || !rect.height) return;
283
+
284
+ var k = Math.min(1, Math.min(rect.width / placed.width, rect.height / placed.height));
285
+ setView({
286
+ k: k,
287
+ x: (rect.width - placed.width * k) / 2,
288
+ y: (rect.height - placed.height * k) / 2
289
+ });
290
+ }, [placed && placed.width, placed && placed.height]);
291
+
292
+ React.useEffect(function () { fitToPane(); }, [fitToPane]);
293
+
294
+ // Bring one model to the middle of the pane at the current zoom, and mark
295
+ // it so it's findable in a dense graph once it gets there.
296
+ var centreOn = React.useCallback(function (name) {
297
+ var el = svgRef.current;
298
+ var pos = placed && placed.positions[name];
299
+ if (!el || !pos) return;
300
+ var rect = el.getBoundingClientRect();
301
+ var h = nodeHeight(pos.model);
302
+ setFocused(name);
303
+ setView(function (v) {
304
+ // Close half the distance to actual size, so a model found while
305
+ // fitted (often ~0.3) lands readable. Never zooms out: if you were
306
+ // already past 1:1 you meant to be there.
307
+ var k = v.k >= 1 ? v.k : Math.min(MAX_ZOOM, v.k + (1 - v.k) / 2);
308
+ return {
309
+ k: k,
310
+ x: rect.width / 2 - (pos.x + NODE_W / 2) * k,
311
+ y: rect.height / 2 - (pos.y + h / 2) * k
312
+ };
313
+ });
314
+ }, [placed]);
315
+
316
+ // Same mounting problem as the wheel listener: the fit effect can run
317
+ // before the SVG exists and not again afterwards. Kept current so the
318
+ // callback ref can fit as soon as the element is really there.
319
+ var fitRef = React.useRef(fitToPane);
320
+ fitRef.current = fitToPane;
321
+
322
+ // A callback ref rather than useRef + useEffect. The SVG mounts on a
323
+ // render where `graph` has not changed — loading flips false separately
324
+ // from the data arriving — so an effect keyed on the data never re-runs
325
+ // once the element finally exists, and the listener is never attached.
326
+ // A callback ref fires exactly when the node appears.
327
+ //
328
+ // The listener is manual and non-passive: React's onWheel is passive, so
329
+ // preventDefault is ignored there and the editor scrolls instead of the
330
+ // diagram zooming.
331
+ var wheelCleanup = React.useRef(null);
332
+ var attachSvg = React.useCallback(function (el) {
333
+ if (wheelCleanup.current) { wheelCleanup.current(); wheelCleanup.current = null; }
334
+ svgRef.current = el;
335
+ if (!el) return;
336
+
337
+ var onWheel = function (e) {
338
+ e.preventDefault();
339
+ var rect = el.getBoundingClientRect();
340
+ var px = e.clientX - rect.left;
341
+ var py = e.clientY - rect.top;
342
+ setView(function (v) {
343
+ var k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.k * (e.deltaY < 0 ? 1.12 : 1 / 1.12)));
344
+ // Keep the point under the cursor pinned while scaling.
345
+ return { k: k, x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k) };
346
+ });
347
+ };
348
+ el.addEventListener('wheel', onWheel, { passive: false });
349
+ wheelCleanup.current = function () { el.removeEventListener('wheel', onWheel); };
350
+
351
+ // After layout, so the pane has a measurable size to fit into.
352
+ window.requestAnimationFrame(function () { fitRef.current(); });
353
+ }, []);
354
+
355
+ if (loading) {
356
+ return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Building the model graph…');
357
+ }
358
+ if (!graph) {
359
+ return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Loading…');
360
+ }
361
+ if (!graph.ok) {
362
+ return React.createElement(
363
+ 'div',
364
+ { className: 'ide-model-graph-empty' },
365
+ React.createElement('div', null, graph.error || 'No model graph available.'),
366
+ React.createElement('button', {
367
+ type: 'button', className: 'ide-model-graph-btn', onClick: onRefresh
368
+ }, 'Try again')
369
+ );
370
+ }
371
+
372
+ var models = graph.models || [];
373
+ var edges = graph.edges || [];
374
+ if (models.length === 0 || !placed) {
375
+ return React.createElement('div', { className: 'ide-model-graph-empty' }, 'No ActiveRecord models found.');
376
+ }
377
+
378
+ var onMouseDown = function (e) {
379
+ if (e.button !== 0) return;
380
+ dragRef.current = { sx: e.clientX, sy: e.clientY, ox: view.x, oy: view.y, moved: false };
381
+ };
382
+ var onMouseMove = function (e) {
383
+ var d = dragRef.current;
384
+ if (!d) return;
385
+ var dx = e.clientX - d.sx, dy = e.clientY - d.sy;
386
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true;
387
+ setView(function (v) { return { k: v.k, x: d.ox + dx, y: d.oy + dy }; });
388
+ };
389
+ var endDrag = function () { dragRef.current = null; };
390
+
391
+ return React.createElement(
392
+ 'div',
393
+ { className: 'ide-model-graph' },
394
+ React.createElement(
395
+ 'div',
396
+ { className: 'ide-model-graph-toolbar' },
397
+ React.createElement('span', null, models.length + ' models, ' + edges.length + ' associations'),
398
+ graph.truncated && React.createElement('span', { className: 'ide-model-graph-warn' }, ' (truncated)'),
399
+ React.createElement('span', { className: 'ide-model-graph-hint' }, 'drag to pan · scroll to zoom · click a model for its schema'),
400
+ React.createElement(
401
+ 'div',
402
+ { className: 'ide-model-graph-actions' },
403
+ // A native datalist rather than a bespoke dropdown: the browser gives
404
+ // us the filtering and keyboard handling for free.
405
+ React.createElement('input', {
406
+ className: 'ide-model-graph-search',
407
+ type: 'search',
408
+ list: 'mg-model-names',
409
+ placeholder: 'Centre on a model…',
410
+ value: search,
411
+ onChange: function (e) {
412
+ setSearch(e.target.value);
413
+ // Picking from the datalist fires change with the full name, so
414
+ // an exact hit centres immediately rather than needing Enter.
415
+ if (placed.positions[e.target.value]) centreOn(e.target.value);
416
+ },
417
+ onKeyDown: function (e) {
418
+ if (e.key !== 'Enter') return;
419
+ var match = models.filter(function (m) {
420
+ return m.name.toLowerCase().indexOf(e.target.value.trim().toLowerCase()) === 0;
421
+ })[0];
422
+ if (match) { setSearch(match.name); centreOn(match.name); }
423
+ }
424
+ }),
425
+ React.createElement(
426
+ 'datalist',
427
+ { id: 'mg-model-names' },
428
+ models.map(function (m) {
429
+ return React.createElement('option', { key: m.name, value: m.name });
430
+ })
431
+ ),
432
+ React.createElement('button', {
433
+ type: 'button', className: 'ide-model-graph-btn', title: 'Fit the whole graph',
434
+ onClick: fitToPane
435
+ }, React.createElement('i', { className: 'fas fa-compress-arrows-alt' })),
436
+ React.createElement('button', {
437
+ type: 'button', className: 'ide-model-graph-btn',
438
+ title: 'Rebuild from the current code', onClick: onRefresh
439
+ }, React.createElement('i', { className: 'fas fa-sync' }))
440
+ )
441
+ ),
442
+ hovered && React.createElement(
443
+ 'div',
444
+ {
445
+ className: 'mg-tooltip',
446
+ // Offset from the cursor, and flipped left near the right edge so
447
+ // the tooltip never runs off the pane.
448
+ style: {
449
+ left: pointer.x + (svgRef.current && pointer.x > svgRef.current.clientWidth - 260 ? -240 : 14) + 'px',
450
+ top: (pointer.y + 14) + 'px'
451
+ }
452
+ },
453
+ React.createElement('div', { className: 'mg-tooltip-title' },
454
+ hovered.edge.from + ' → ' + hovered.edge.to),
455
+ React.createElement('div', { className: 'mg-tooltip-macro' },
456
+ hovered.edge.macro + ' :' + hovered.edge.name),
457
+ React.createElement('div', { className: 'mg-tooltip-meta' },
458
+ MACRO_CARDINALITY[hovered.edge.macro] || hovered.edge.macro),
459
+ hovered.edge.through && React.createElement('div', { className: 'mg-tooltip-meta' },
460
+ 'through :' + hovered.edge.through)
461
+ ),
462
+ React.createElement(
463
+ 'svg',
464
+ {
465
+ ref: attachSvg,
466
+ className: 'ide-model-graph-svg' + (dragRef.current ? ' mg-dragging' : ''),
467
+ onMouseDown: onMouseDown,
468
+ onMouseMove: onMouseMove,
469
+ onMouseUp: endDrag,
470
+ onMouseLeave: endDrag
471
+ },
472
+ React.createElement(
473
+ 'defs',
474
+ null,
475
+ React.createElement(
476
+ 'marker',
477
+ {
478
+ id: 'mg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5',
479
+ markerWidth: '5', markerHeight: '5', orient: 'auto-start-reverse'
480
+ },
481
+ React.createElement('path', { d: 'M 0 0 L 10 5 L 0 10 z', className: 'mg-arrow-head' })
482
+ )
483
+ ),
484
+ React.createElement(
485
+ 'g',
486
+ { transform: 'translate(' + view.x + ',' + view.y + ') scale(' + view.k + ')' },
487
+ edges.map(function (e, i) {
488
+ var a = placed.positions[e.from];
489
+ var b = placed.positions[e.to];
490
+ if (!a || !b || e.from === e.to) return null;
491
+ var d = edgePath(a, b);
492
+ var isHovered = hovered && hovered.index === i;
493
+ return React.createElement(
494
+ 'g',
495
+ { key: 'e' + i },
496
+ // A transparent, much thicker copy of the line under the real
497
+ // one. A 1.2px stroke is its own hit area, which makes hovering
498
+ // an association essentially impossible without this.
499
+ React.createElement('path', {
500
+ d: d,
501
+ className: 'mg-edge-hit',
502
+ onMouseEnter: function () { setHovered({ index: i, edge: e }); },
503
+ onMouseMove: function (ev) {
504
+ var rect = svgRef.current.getBoundingClientRect();
505
+ setPointer({ x: ev.clientX - rect.left, y: ev.clientY - rect.top });
506
+ },
507
+ onMouseLeave: function () { setHovered(null); }
508
+ }),
509
+ React.createElement('path', {
510
+ d: d,
511
+ className: 'mg-edge ' + (MACRO_CLASS[e.macro] || '') + (isHovered ? ' mg-edge-hovered' : ''),
512
+ markerEnd: 'url(#mg-arrow)'
513
+ })
514
+ );
515
+ }),
516
+ models.map(function (m) {
517
+ var pos = placed.positions[m.name];
518
+ if (!pos) return null;
519
+ var fields = (m.columns || []).slice(0, MAX_FIELDS);
520
+ var hidden = (m.columnCount || 0) - fields.length;
521
+ var h = nodeHeight(m);
522
+ return React.createElement(
523
+ 'g',
524
+ {
525
+ key: m.name,
526
+ className: 'mg-node' + (focused === m.name ? ' mg-focused' : ''),
527
+ transform: 'translate(' + pos.x + ',' + pos.y + ')',
528
+ onClick: function () {
529
+ // A drag that ends over a box must not also open it.
530
+ if (dragRef.current && dragRef.current.moved) return;
531
+ if (onOpenModel) onOpenModel(m);
532
+ }
533
+ },
534
+ React.createElement('title', null, m.name + ' — click for the full schema'),
535
+ React.createElement('rect', { className: 'mg-box', width: NODE_W, height: h, rx: 4 }),
536
+ React.createElement('rect', { className: 'mg-box-header', width: NODE_W, height: HEADER_H, rx: 4 }),
537
+ React.createElement('text', { className: 'mg-name', x: 10, y: 15 }, m.name),
538
+ React.createElement('text', { className: 'mg-table', x: 10, y: 27 },
539
+ (m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : '')),
540
+ fields.map(function (c, i) {
541
+ var y = HEADER_H + 11 + i * FIELD_H;
542
+ return React.createElement(
543
+ React.Fragment,
544
+ { key: c.name },
545
+ React.createElement('text', { className: 'mg-field', x: 10, y: y }, c.name),
546
+ React.createElement('text', { className: 'mg-field-type', x: NODE_W - 10, y: y, textAnchor: 'end' }, c.type)
547
+ );
548
+ }),
549
+ hidden > 0 && React.createElement('text', {
550
+ className: 'mg-field-more', x: 10, y: HEADER_H + 11 + fields.length * FIELD_H
551
+ }, '+' + hidden + ' more…'),
552
+ // No connection means no columns to show; say so rather than
553
+ // rendering an empty box that looks like a model with no fields.
554
+ fields.length === 0 && React.createElement('text', {
555
+ className: 'mg-field-more', x: 10, y: HEADER_H + 11
556
+ }, 'no database connection')
557
+ );
558
+ })
559
+ )
560
+ )
561
+ );
562
+ };
563
+ })();
564
+
565
+ window.ModelGraph = ModelGraph;