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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- 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;
|