mbeditor 0.12.1 → 0.12.3
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 +77 -0
- data/README.md +38 -8
- data/app/assets/javascripts/mbeditor/collaboration_service.js +62 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +78 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +89 -0
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +233 -59
- data/app/assets/javascripts/mbeditor/file_service.js +9 -0
- data/app/assets/javascripts/mbeditor/websocket_service.js +17 -0
- data/app/assets/stylesheets/mbeditor/editor.css +88 -0
- data/app/channels/mbeditor/channel_authentication.rb +31 -2
- data/app/controllers/mbeditor/editors_controller.rb +13 -0
- data/app/services/mbeditor/model_graph_service.rb +13 -3
- data/app/services/mbeditor/route_service.rb +79 -0
- data/lib/mbeditor/configuration.rb +15 -2
- data/lib/mbeditor/engine.rb +17 -3
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/version.rb +1 -1
- metadata +2 -1
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// carried no information at all. Only a layered layout derives position from
|
|
15
15
|
// the associations themselves.
|
|
16
16
|
var ModelGraph = (function () {
|
|
17
|
-
|
|
17
|
+
// Boxes are sized to their contents rather than all being one width. A fixed
|
|
18
|
+
// 188px meant a model with a long name or long column names had its labels
|
|
19
|
+
// truncated while a model called Tag wasted most of its box. Clamped at both
|
|
20
|
+
// ends so one pathological name cannot stretch a whole layer.
|
|
21
|
+
var NODE_W_MIN = 168;
|
|
22
|
+
var NODE_W_MAX = 320;
|
|
23
|
+
var NODE_W = NODE_W_MIN; // fallback for anything without a measured model
|
|
18
24
|
var HEADER_H = 34; // model name + table name
|
|
19
25
|
var FIELD_H = 15;
|
|
20
26
|
var MAX_FIELDS = 8; // the rest are one click away in the schema modal
|
|
@@ -22,14 +28,79 @@ var ModelGraph = (function () {
|
|
|
22
28
|
var LAYER_GAP = 110; // horizontal space between layers
|
|
23
29
|
var ORDER_PASSES = 4; // median sweeps for crossing reduction
|
|
24
30
|
var STRAIGHTEN_PASSES = 3;
|
|
25
|
-
var MAX_ASSOC_LISTED = 12;
|
|
31
|
+
var MAX_ASSOC_LISTED = 12;
|
|
32
|
+
var MAX_SEARCH_RESULTS = 50; // the card stays readable; the rest are counted
|
|
26
33
|
var BLOCK_PAD = 26; // breathing room inside a cluster's boundary
|
|
27
34
|
var BLOCK_GAP = 56; // space between neighbouring clusters
|
|
28
35
|
|
|
36
|
+
// SVG text neither wraps nor ellipsises: a long model or column name simply
|
|
37
|
+
// draws straight out past the edge of its box and over whatever is beside it.
|
|
38
|
+
// Measure with a cached canvas context and cut to fit — accurate for whatever
|
|
39
|
+
// font the theme actually resolves, unlike a characters-per-pixel guess.
|
|
40
|
+
var _measureCtx = null;
|
|
41
|
+
var _measureCache = {};
|
|
42
|
+
|
|
43
|
+
function textWidth(text, font) {
|
|
44
|
+
var key = font + '\u0000' + text;
|
|
45
|
+
if (_measureCache[key] !== undefined) return _measureCache[key];
|
|
46
|
+
if (!_measureCtx) {
|
|
47
|
+
if (typeof document === 'undefined') return text.length * 6;
|
|
48
|
+
_measureCtx = document.createElement('canvas').getContext('2d');
|
|
49
|
+
}
|
|
50
|
+
_measureCtx.font = font;
|
|
51
|
+
var w = _measureCtx.measureText(text).width;
|
|
52
|
+
// Unbounded growth would be a leak on a large schema; the cache only needs
|
|
53
|
+
// to cover one render's worth of labels.
|
|
54
|
+
if (Object.keys(_measureCache).length > 4000) _measureCache = {};
|
|
55
|
+
_measureCache[key] = w;
|
|
56
|
+
return w;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fitText(text, maxWidth, font) {
|
|
60
|
+
text = String(text == null ? '' : text);
|
|
61
|
+
if (!text || textWidth(text, font) <= maxWidth) return text;
|
|
62
|
+
var lo = 0, hi = text.length;
|
|
63
|
+
while (lo < hi) {
|
|
64
|
+
var mid = Math.ceil((lo + hi) / 2);
|
|
65
|
+
if (textWidth(text.slice(0, mid) + '…', font) <= maxWidth) lo = mid; else hi = mid - 1;
|
|
66
|
+
}
|
|
67
|
+
return lo > 0 ? text.slice(0, lo) + '…' : '…';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Must match the .mg-name / .mg-table / .mg-field rules in editor.css.
|
|
71
|
+
var FONT_NAME = '600 12px ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
72
|
+
var FONT_SMALL = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
73
|
+
var FONT_FIELD = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
74
|
+
|
|
75
|
+
function nodeWidth(model) {
|
|
76
|
+
if (model && model.__mgWidth) return model.__mgWidth;
|
|
77
|
+
if (!model) return NODE_W;
|
|
78
|
+
|
|
79
|
+
// Header: name plus the open-schema button, and the table/count line.
|
|
80
|
+
var need = textWidth(String(model.name || ''), FONT_NAME) + 20 + 26;
|
|
81
|
+
need = Math.max(need, textWidth(
|
|
82
|
+
(model.table || '—') + (model.columnCount ? ' · ' + model.columnCount + ' cols' : ''),
|
|
83
|
+
FONT_SMALL) + 20 + 26);
|
|
84
|
+
|
|
85
|
+
(model.columns || []).slice(0, MAX_FIELDS).forEach(function (c) {
|
|
86
|
+
// name on the left, type right-aligned, with a gap between them
|
|
87
|
+
need = Math.max(need,
|
|
88
|
+
textWidth(String(c.name || ''), FONT_FIELD) +
|
|
89
|
+
textWidth(String(c.type || ''), FONT_SMALL) + 32);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
var w = Math.round(Math.min(NODE_W_MAX, Math.max(NODE_W_MIN, need)));
|
|
93
|
+
try { model.__mgWidth = w; } catch (e) { /* frozen payload — recompute */ }
|
|
94
|
+
return w;
|
|
95
|
+
}
|
|
96
|
+
|
|
29
97
|
function nodeHeight(model) {
|
|
30
98
|
var shown = Math.min((model.columns || []).length, MAX_FIELDS);
|
|
31
99
|
var more = (model.columnCount || 0) > shown ? FIELD_H : 0;
|
|
32
|
-
|
|
100
|
+
// With no columns the box still draws one line — "no database connection" —
|
|
101
|
+
// and not counting it left that text hanging below the box it belongs to.
|
|
102
|
+
var placeholder = shown === 0 ? FIELD_H : 0;
|
|
103
|
+
return HEADER_H + shown * FIELD_H + more + placeholder + 8;
|
|
33
104
|
}
|
|
34
105
|
|
|
35
106
|
// Undirected adjacency: for placement, "A belongs_to B" and "B has_many A"
|
|
@@ -224,15 +295,27 @@ var ModelGraph = (function () {
|
|
|
224
295
|
Object.keys(y).forEach(function (n) { minY = Math.min(minY, y[n]); });
|
|
225
296
|
if (!isFinite(minY)) minY = 0;
|
|
226
297
|
|
|
298
|
+
// Layers are as wide as their widest box, and each starts where the previous
|
|
299
|
+
// one ended. A fixed stride assumed every box was NODE_W and would overlap
|
|
300
|
+
// the next layer as soon as one of them grew.
|
|
301
|
+
var layerX = [];
|
|
302
|
+
var cursorX = 0;
|
|
303
|
+
layers.forEach(function (names, li) {
|
|
304
|
+
layerX[li] = cursorX;
|
|
305
|
+
var widest = 0;
|
|
306
|
+
(names || []).forEach(function (n) { widest = Math.max(widest, nodeWidth(byName[n])); });
|
|
307
|
+
cursorX += (widest || NODE_W_MIN) + LAYER_GAP;
|
|
308
|
+
});
|
|
309
|
+
|
|
227
310
|
var positions = {};
|
|
228
311
|
var maxX = 0, maxY = 0;
|
|
229
312
|
layers.forEach(function (names, li) {
|
|
230
313
|
if (!names) return;
|
|
231
314
|
names.forEach(function (n) {
|
|
232
|
-
var x = li
|
|
315
|
+
var x = layerX[li];
|
|
233
316
|
var yy = y[n] - minY;
|
|
234
317
|
positions[n] = { x: x, y: yy, model: byName[n] };
|
|
235
|
-
maxX = Math.max(maxX, x +
|
|
318
|
+
maxX = Math.max(maxX, x + nodeWidth(byName[n]));
|
|
236
319
|
maxY = Math.max(maxY, yy + nodeHeight(byName[n]));
|
|
237
320
|
});
|
|
238
321
|
});
|
|
@@ -273,10 +356,13 @@ var ModelGraph = (function () {
|
|
|
273
356
|
var positions = {};
|
|
274
357
|
var regions = [];
|
|
275
358
|
var pad = 60;
|
|
276
|
-
var cursorY = pad;
|
|
277
|
-
var maxRight = pad;
|
|
278
359
|
|
|
279
|
-
|
|
360
|
+
// Lay each component out first, then pack the finished blocks. Blocks used
|
|
361
|
+
// to be stacked in a single column, so a schema with a big connected core
|
|
362
|
+
// and a handful of one-model islands ran off the bottom of the canvas with
|
|
363
|
+
// the whole right-hand side empty. Shelf packing wraps them instead, which
|
|
364
|
+
// is both more compact and closer to how the eye scans a page.
|
|
365
|
+
var blocks = components(allNames, adj).map(function (group) {
|
|
280
366
|
var inGroup = {};
|
|
281
367
|
group.forEach(function (n) { inGroup[n] = true; });
|
|
282
368
|
var groupEdges = dir.filter(function (e) { return inGroup[e.from] && inGroup[e.to]; });
|
|
@@ -299,39 +385,67 @@ var ModelGraph = (function () {
|
|
|
299
385
|
orderLayers(layers, adjIn, adjOut);
|
|
300
386
|
var laid = assignCoords(layers, byName, adjIn, adjOut);
|
|
301
387
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
388
|
+
return {
|
|
389
|
+
laid: laid,
|
|
390
|
+
group: group,
|
|
391
|
+
w: laid.width + BLOCK_PAD * 2,
|
|
392
|
+
h: laid.height + BLOCK_PAD * 2
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Wrap at whichever is wider: a roughly square canvas, or the widest single
|
|
397
|
+
// block (which can never be split).
|
|
398
|
+
var totalArea = blocks.reduce(function (a, b) { return a + b.w * b.h; }, 0);
|
|
399
|
+
var widest = blocks.reduce(function (a, b) { return Math.max(a, b.w); }, 0);
|
|
400
|
+
var targetW = Math.max(widest, Math.sqrt(totalArea * 1.6));
|
|
401
|
+
|
|
402
|
+
var shelfX = pad, shelfY = pad, shelfH = 0, maxRight = pad;
|
|
403
|
+
|
|
404
|
+
blocks.forEach(function (b) {
|
|
405
|
+
if (shelfX > pad && shelfX + b.w > pad + targetW) {
|
|
406
|
+
shelfX = pad;
|
|
407
|
+
shelfY += shelfH + BLOCK_GAP;
|
|
408
|
+
shelfH = 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
Object.keys(b.laid.positions).forEach(function (n) {
|
|
412
|
+
var q = b.laid.positions[n];
|
|
413
|
+
positions[n] = {
|
|
414
|
+
x: shelfX + BLOCK_PAD + q.x,
|
|
415
|
+
y: shelfY + BLOCK_PAD + q.y,
|
|
416
|
+
model: q.model
|
|
417
|
+
};
|
|
305
418
|
});
|
|
306
419
|
|
|
307
420
|
regions.push({
|
|
308
|
-
x:
|
|
309
|
-
|
|
310
|
-
label: group.length > 1 ? group.length + ' related models' : group[0]
|
|
421
|
+
x: shelfX, y: shelfY, w: b.w, h: b.h,
|
|
422
|
+
label: b.group.length > 1 ? b.group.length + ' related models' : b.group[0]
|
|
311
423
|
});
|
|
312
424
|
|
|
313
|
-
maxRight = Math.max(maxRight,
|
|
314
|
-
|
|
425
|
+
maxRight = Math.max(maxRight, shelfX + b.w);
|
|
426
|
+
shelfH = Math.max(shelfH, b.h);
|
|
427
|
+
shelfX += b.w + BLOCK_GAP;
|
|
315
428
|
});
|
|
316
429
|
|
|
317
430
|
return {
|
|
318
431
|
positions: positions,
|
|
319
432
|
regions: regions,
|
|
320
433
|
width: maxRight + pad,
|
|
321
|
-
height:
|
|
434
|
+
height: shelfY + shelfH + pad
|
|
322
435
|
};
|
|
323
436
|
}
|
|
324
437
|
|
|
325
438
|
function anchors(a, b) {
|
|
326
439
|
var ah = nodeHeight(a.model), bh = nodeHeight(b.model);
|
|
327
|
-
var
|
|
328
|
-
var
|
|
440
|
+
var aw = nodeWidth(a.model), bw = nodeWidth(b.model);
|
|
441
|
+
var acx = a.x + aw / 2, acy = a.y + ah / 2;
|
|
442
|
+
var bcx = b.x + bw / 2, bcy = b.y + bh / 2;
|
|
329
443
|
var horizontal = Math.abs(bcx - acx) > Math.abs(bcy - acy);
|
|
330
444
|
|
|
331
445
|
if (horizontal) {
|
|
332
446
|
return bcx > acx
|
|
333
|
-
? { x1: a.x +
|
|
334
|
-
: { x1: a.x, y1: acy, x2: b.x +
|
|
447
|
+
? { x1: a.x + aw, y1: acy, x2: b.x, y2: bcy, h: true }
|
|
448
|
+
: { x1: a.x, y1: acy, x2: b.x + bw, y2: bcy, h: true };
|
|
335
449
|
}
|
|
336
450
|
return bcy > acy
|
|
337
451
|
? { x1: acx, y1: a.y + ah, x2: bcx, y2: b.y, h: false }
|
|
@@ -409,6 +523,10 @@ var ModelGraph = (function () {
|
|
|
409
523
|
|
|
410
524
|
var _search = React.useState('');
|
|
411
525
|
var search = _search[0], setSearch = _search[1];
|
|
526
|
+
var _searchOpen = React.useState(false);
|
|
527
|
+
var searchOpen = _searchOpen[0], setSearchOpen = _searchOpen[1];
|
|
528
|
+
var _highlight = React.useState(0);
|
|
529
|
+
var highlight = _highlight[0], setHighlight = _highlight[1];
|
|
412
530
|
var _focused = React.useState(null);
|
|
413
531
|
var focused = _focused[0], setFocused = _focused[1];
|
|
414
532
|
var _hovered = React.useState(null);
|
|
@@ -439,6 +557,21 @@ var ModelGraph = (function () {
|
|
|
439
557
|
return byModel;
|
|
440
558
|
}, [graph]);
|
|
441
559
|
|
|
560
|
+
// Prefix matches first, then anything containing the term — the ordering a
|
|
561
|
+
// name-completion list is expected to have. Capped so a large schema cannot
|
|
562
|
+
// render a list taller than the window.
|
|
563
|
+
var searchMatches = React.useMemo(function () {
|
|
564
|
+
var all = (graph && graph.models) || [];
|
|
565
|
+
var q = search.trim().toLowerCase();
|
|
566
|
+
if (!q) return all.slice(0, MAX_SEARCH_RESULTS);
|
|
567
|
+
var starts = [], contains = [];
|
|
568
|
+
all.forEach(function (m) {
|
|
569
|
+
var i = m.name.toLowerCase().indexOf(q);
|
|
570
|
+
if (i === 0) starts.push(m); else if (i > 0) contains.push(m);
|
|
571
|
+
});
|
|
572
|
+
return starts.concat(contains).slice(0, MAX_SEARCH_RESULTS);
|
|
573
|
+
}, [graph, search]);
|
|
574
|
+
|
|
442
575
|
var hoverCardRef = React.useRef(null);
|
|
443
576
|
var _modelHover = React.useState(null);
|
|
444
577
|
var modelHover = _modelHover[0], setModelHover = _modelHover[1];
|
|
@@ -535,7 +668,7 @@ var ModelGraph = (function () {
|
|
|
535
668
|
var k = v.k >= 1 ? v.k : Math.min(MAX_ZOOM, v.k + (1 - v.k) / 2);
|
|
536
669
|
return {
|
|
537
670
|
k: k,
|
|
538
|
-
x: rect.width / 2 - (pos.x +
|
|
671
|
+
x: rect.width / 2 - (pos.x + nodeWidth(pos.model) / 2) * k,
|
|
539
672
|
y: rect.height / 2 - (pos.y + h / 2) * k
|
|
540
673
|
};
|
|
541
674
|
});
|
|
@@ -661,6 +794,7 @@ var ModelGraph = (function () {
|
|
|
661
794
|
var fields = (m.columns || []).slice(0, MAX_FIELDS);
|
|
662
795
|
var hidden = (m.columnCount || 0) - fields.length;
|
|
663
796
|
var h = nodeHeight(m);
|
|
797
|
+
var w = nodeWidth(m);
|
|
664
798
|
return React.createElement(
|
|
665
799
|
'g',
|
|
666
800
|
{
|
|
@@ -679,11 +813,14 @@ var ModelGraph = (function () {
|
|
|
679
813
|
centreRef.current(m.name);
|
|
680
814
|
}
|
|
681
815
|
},
|
|
682
|
-
React.createElement('rect', { className: 'mg-box', width:
|
|
683
|
-
React.createElement('rect', { className: 'mg-box-header', width:
|
|
684
|
-
|
|
816
|
+
React.createElement('rect', { className: 'mg-box', width: w, height: h, rx: 4 }),
|
|
817
|
+
React.createElement('rect', { className: 'mg-box-header', width: w, height: HEADER_H, rx: 4 }),
|
|
818
|
+
// Budget stops short of the header button, which sits at w - 26.
|
|
819
|
+
React.createElement('text', { className: 'mg-name', x: 10, y: 15 },
|
|
820
|
+
fitText(m.name, w - 44, FONT_NAME)),
|
|
685
821
|
React.createElement('text', { className: 'mg-table', x: 10, y: 27 },
|
|
686
|
-
(m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : '')
|
|
822
|
+
fitText((m.table || '—') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : ''),
|
|
823
|
+
w - 44, FONT_SMALL)),
|
|
687
824
|
// Opening the full schema is now an explicit target rather than
|
|
688
825
|
// anything-anywhere on the box, so clicking around to navigate
|
|
689
826
|
// cannot keep throwing a modal at you.
|
|
@@ -699,20 +836,25 @@ var ModelGraph = (function () {
|
|
|
699
836
|
},
|
|
700
837
|
React.createElement('title', null, 'Open the full schema for ' + m.name),
|
|
701
838
|
React.createElement('rect', {
|
|
702
|
-
className: 'mg-open-btn-bg', x:
|
|
839
|
+
className: 'mg-open-btn-bg', x: w - 26, y: 7, width: 19, height: 19, rx: 3
|
|
703
840
|
}),
|
|
704
841
|
// Three stacked bars — a table, matching what the button opens.
|
|
705
|
-
React.createElement('rect', { className: 'mg-open-btn-bar', x:
|
|
706
|
-
React.createElement('rect', { className: 'mg-open-btn-bar', x:
|
|
707
|
-
React.createElement('rect', { className: 'mg-open-btn-bar', x:
|
|
842
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 11, width: 11, height: 2 }),
|
|
843
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 15.5, width: 11, height: 2 }),
|
|
844
|
+
React.createElement('rect', { className: 'mg-open-btn-bar', x: w - 22, y: 20, width: 11, height: 2 })
|
|
708
845
|
),
|
|
709
846
|
fields.map(function (c, i) {
|
|
710
847
|
var y = HEADER_H + 11 + i * FIELD_H;
|
|
848
|
+
var typeW = Math.min(64, textWidth(String(c.type == null ? '' : c.type), FONT_SMALL));
|
|
711
849
|
return React.createElement(
|
|
712
850
|
React.Fragment,
|
|
713
851
|
{ key: c.name },
|
|
714
|
-
|
|
715
|
-
|
|
852
|
+
// The type is right-aligned, so the name's budget is whatever
|
|
853
|
+
// the type does not claim — otherwise the two met in the middle.
|
|
854
|
+
React.createElement('text', { className: 'mg-field', x: 10, y: y },
|
|
855
|
+
fitText(c.name, w - 28 - typeW, FONT_FIELD)),
|
|
856
|
+
React.createElement('text', { className: 'mg-field-type', x: w - 10, y: y, textAnchor: 'end' },
|
|
857
|
+
fitText(c.type, 64, FONT_SMALL))
|
|
716
858
|
);
|
|
717
859
|
}),
|
|
718
860
|
hidden > 0 && React.createElement('text', {
|
|
@@ -781,34 +923,66 @@ var ModelGraph = (function () {
|
|
|
781
923
|
React.createElement(
|
|
782
924
|
'div',
|
|
783
925
|
{ className: 'ide-model-graph-actions' },
|
|
784
|
-
// A
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
placeholder: 'Centre on a model…',
|
|
791
|
-
value: search,
|
|
792
|
-
onChange: function (e) {
|
|
793
|
-
setSearch(e.target.value);
|
|
794
|
-
// Picking from the datalist fires change with the full name, so
|
|
795
|
-
// an exact hit centres immediately rather than needing Enter.
|
|
796
|
-
if (placed.positions[e.target.value]) centreOn(e.target.value);
|
|
797
|
-
},
|
|
798
|
-
onKeyDown: function (e) {
|
|
799
|
-
if (e.key !== 'Enter') return;
|
|
800
|
-
var match = models.filter(function (m) {
|
|
801
|
-
return m.name.toLowerCase().indexOf(e.target.value.trim().toLowerCase()) === 0;
|
|
802
|
-
})[0];
|
|
803
|
-
if (match) { setSearch(match.name); centreOn(match.name); }
|
|
804
|
-
}
|
|
805
|
-
}),
|
|
926
|
+
// A real dropdown rather than a native <datalist>. The datalist was
|
|
927
|
+
// chosen to get filtering and keyboard handling for free, but the
|
|
928
|
+
// browser renders it as an unstyleable list in the platform's own
|
|
929
|
+
// chrome — wrong font, wrong colours, and no way to show which model
|
|
930
|
+
// is in which cluster. This is a few more lines and looks like the
|
|
931
|
+
// rest of the editor.
|
|
806
932
|
React.createElement(
|
|
807
|
-
'
|
|
808
|
-
{
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
933
|
+
'div',
|
|
934
|
+
{ className: 'mg-search-wrap' },
|
|
935
|
+
React.createElement('input', {
|
|
936
|
+
className: 'ide-model-graph-search',
|
|
937
|
+
type: 'text',
|
|
938
|
+
placeholder: 'Centre on a model…',
|
|
939
|
+
value: search,
|
|
940
|
+
onChange: function (e) { setSearch(e.target.value); setSearchOpen(true); setHighlight(0); },
|
|
941
|
+
onFocus: function () { setSearchOpen(true); },
|
|
942
|
+
// A click on an option would otherwise be lost: blur fires first
|
|
943
|
+
// and unmounts the list before mousedown lands.
|
|
944
|
+
onBlur: function () { window.setTimeout(function () { setSearchOpen(false); }, 120); },
|
|
945
|
+
onKeyDown: function (e) {
|
|
946
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
947
|
+
e.preventDefault();
|
|
948
|
+
setSearchOpen(true);
|
|
949
|
+
setHighlight(function (h) {
|
|
950
|
+
var next = h + (e.key === 'ArrowDown' ? 1 : -1);
|
|
951
|
+
if (next < 0) return searchMatches.length - 1;
|
|
952
|
+
if (next >= searchMatches.length) return 0;
|
|
953
|
+
return next;
|
|
954
|
+
});
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (e.key === 'Escape') { setSearchOpen(false); return; }
|
|
958
|
+
if (e.key !== 'Enter') return;
|
|
959
|
+
var pick = searchMatches[highlight] || searchMatches[0];
|
|
960
|
+
if (pick) { setSearch(pick.name); setSearchOpen(false); centreOn(pick.name); }
|
|
961
|
+
}
|
|
962
|
+
}),
|
|
963
|
+
searchOpen && searchMatches.length > 0 && React.createElement(
|
|
964
|
+
'ul',
|
|
965
|
+
{ className: 'mg-search-list' },
|
|
966
|
+
searchMatches.map(function (m, i) {
|
|
967
|
+
return React.createElement(
|
|
968
|
+
'li',
|
|
969
|
+
{
|
|
970
|
+
key: m.name,
|
|
971
|
+
className: 'mg-search-option' + (i === highlight ? ' mg-search-option-active' : ''),
|
|
972
|
+
onMouseEnter: function () { setHighlight(i); },
|
|
973
|
+
onMouseDown: function (ev) {
|
|
974
|
+
ev.preventDefault(); // keep focus so blur cannot beat us
|
|
975
|
+
setSearch(m.name);
|
|
976
|
+
setSearchOpen(false);
|
|
977
|
+
centreOn(m.name);
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
React.createElement('span', { className: 'mg-search-option-name' }, m.name),
|
|
981
|
+
React.createElement('span', { className: 'mg-search-option-meta' },
|
|
982
|
+
(m.table || '') + (m.columnCount ? ' · ' + m.columnCount + ' cols' : ''))
|
|
983
|
+
);
|
|
984
|
+
})
|
|
985
|
+
)
|
|
812
986
|
),
|
|
813
987
|
React.createElement('button', {
|
|
814
988
|
type: 'button', className: 'ide-model-graph-btn', title: 'Fit the whole graph',
|
|
@@ -117,6 +117,14 @@ var FileService = (function () {
|
|
|
117
117
|
return axios.get(window.mbeditorBasePath() + '/ping', { timeout: 4000 }).then(function(res) { return res.data; });
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Which routes reach each action in a controller. Returns { controller, actions }
|
|
121
|
+
// with an empty actions map for anything that is not a controller, so the
|
|
122
|
+
// caller does not have to know the naming convention.
|
|
123
|
+
function getRoutes(path) {
|
|
124
|
+
return axios.get(window.mbeditorBasePath() + '/routes?path=' + encodeURIComponent(path))
|
|
125
|
+
.then(function (res) { return res.data; });
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
function getState() {
|
|
121
129
|
return axios.get(window.mbeditorBasePath() + '/state').then(function(res) { return res.data; });
|
|
122
130
|
}
|
|
@@ -336,6 +344,7 @@ var FileService = (function () {
|
|
|
336
344
|
formatFile: formatFile,
|
|
337
345
|
runTests: runTests,
|
|
338
346
|
ping: ping,
|
|
347
|
+
getRoutes: getRoutes,
|
|
339
348
|
getState: getState,
|
|
340
349
|
saveState: saveState,
|
|
341
350
|
getBranchState: getBranchState,
|
|
@@ -10,6 +10,11 @@ var WebSocketService = (function () {
|
|
|
10
10
|
var _consumer = null;
|
|
11
11
|
var _subscription = null;
|
|
12
12
|
var _connected = false;
|
|
13
|
+
// Why collaboration is or is not working, so the editor can say so instead of
|
|
14
|
+
// failing silently. A rejected subscription used to just schedule a reconnect
|
|
15
|
+
// and tell nobody, which made a blocked cable indistinguishable from an empty
|
|
16
|
+
// room.
|
|
17
|
+
var _status = 'idle'; // idle | unsupported | connecting | connected | rejected | dropped
|
|
13
18
|
var _filesChangedCallbacks = [];
|
|
14
19
|
var _fileSavedCallbacks = [];
|
|
15
20
|
var _presenceCallbacks = [];
|
|
@@ -98,6 +103,7 @@ var WebSocketService = (function () {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
_lastCableAttemptAt = Date.now();
|
|
106
|
+
if (_status !== 'rejected') _status = 'connecting';
|
|
101
107
|
|
|
102
108
|
try {
|
|
103
109
|
_consumer = _getConsumer();
|
|
@@ -106,12 +112,15 @@ var WebSocketService = (function () {
|
|
|
106
112
|
{
|
|
107
113
|
connected: function () {
|
|
108
114
|
_connected = true;
|
|
115
|
+
_status = 'connected';
|
|
109
116
|
},
|
|
110
117
|
disconnected: function () {
|
|
118
|
+
_status = _status === 'rejected' ? 'rejected' : 'dropped';
|
|
111
119
|
_cleanupConsumer();
|
|
112
120
|
_scheduleReconnect();
|
|
113
121
|
},
|
|
114
122
|
rejected: function () {
|
|
123
|
+
_status = 'rejected';
|
|
115
124
|
_cleanupConsumer();
|
|
116
125
|
_scheduleReconnect();
|
|
117
126
|
},
|
|
@@ -180,6 +189,7 @@ var WebSocketService = (function () {
|
|
|
180
189
|
function connect(serverSupportsWs) {
|
|
181
190
|
_serverSupportsWs = !!serverSupportsWs;
|
|
182
191
|
if (!_serverSupportsWs || !_isActionCableAvailable()) {
|
|
192
|
+
_status = 'unsupported';
|
|
183
193
|
return; // polling remains the only refresh mechanism
|
|
184
194
|
}
|
|
185
195
|
_installUnhandledRejectionGuard();
|
|
@@ -201,6 +211,12 @@ var WebSocketService = (function () {
|
|
|
201
211
|
return _connected;
|
|
202
212
|
}
|
|
203
213
|
|
|
214
|
+
// Coarse state of the editor's own cable subscription, for the collaboration
|
|
215
|
+
// diagnostics panel.
|
|
216
|
+
function cableStatus() {
|
|
217
|
+
return _status;
|
|
218
|
+
}
|
|
219
|
+
|
|
204
220
|
// Returns true when ActionCable is loaded and the server advertised cable
|
|
205
221
|
// support. Collaboration uses this as its up-front "is the feature available?"
|
|
206
222
|
// gate, independent of whether the EditorChannel handshake has completed yet.
|
|
@@ -290,6 +306,7 @@ var WebSocketService = (function () {
|
|
|
290
306
|
connect: connect,
|
|
291
307
|
disconnect: disconnect,
|
|
292
308
|
isConnected: isConnected,
|
|
309
|
+
cableStatus: cableStatus,
|
|
293
310
|
isCableAvailable: isCableAvailable,
|
|
294
311
|
subscribeCollaboration: subscribeCollaboration,
|
|
295
312
|
perform: perform,
|
|
@@ -61,6 +61,17 @@ html, body, #mbeditor-root {
|
|
|
61
61
|
font-size: 13px;
|
|
62
62
|
color: var(--ide-text);
|
|
63
63
|
font-weight: 500;
|
|
64
|
+
/* Never wrap: the titlebar is a fixed 32px row, so a second line pushed the
|
|
65
|
+
toolbar buttons out of it. */
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Below the width where the toolbar drops its button labels, drop the title
|
|
70
|
+
too. The layers icon beside it already identifies the editor, and the host
|
|
71
|
+
and port are in the browser's own address bar. */
|
|
72
|
+
@media (max-width: 1180px) {
|
|
73
|
+
.ide-titlebar-title { display: none; }
|
|
74
|
+
.ide-titlebar-icon { margin-right: 0; }
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/* Fills the gap between the title and the button cluster. min-width: 0 lets it
|
|
@@ -3335,3 +3346,80 @@ input.ide-model-graph-search[type="search"] {
|
|
|
3335
3346
|
background-image: none;
|
|
3336
3347
|
padding-inline-start: 8px;
|
|
3337
3348
|
}
|
|
3349
|
+
|
|
3350
|
+
/* Model-graph search dropdown. Replaces a native <datalist>, which the browser
|
|
3351
|
+
renders in its own chrome — unstyleable, and unable to show the table name
|
|
3352
|
+
beside each model. */
|
|
3353
|
+
.mg-search-wrap { position: relative; display: inline-block; }
|
|
3354
|
+
.mg-search-list {
|
|
3355
|
+
position: absolute;
|
|
3356
|
+
top: calc(100% + 4px);
|
|
3357
|
+
right: 0;
|
|
3358
|
+
z-index: 40;
|
|
3359
|
+
min-width: 220px;
|
|
3360
|
+
max-height: 280px;
|
|
3361
|
+
overflow-y: auto;
|
|
3362
|
+
margin: 0;
|
|
3363
|
+
padding: 4px 0;
|
|
3364
|
+
list-style: none;
|
|
3365
|
+
background: var(--ide-panel-bg, #252526);
|
|
3366
|
+
border: 1px solid var(--ide-border, #3c3c3c);
|
|
3367
|
+
border-radius: 6px;
|
|
3368
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
|
3369
|
+
}
|
|
3370
|
+
.mg-search-option {
|
|
3371
|
+
display: flex;
|
|
3372
|
+
align-items: baseline;
|
|
3373
|
+
justify-content: space-between;
|
|
3374
|
+
gap: 10px;
|
|
3375
|
+
padding: 4px 10px;
|
|
3376
|
+
font-size: 11px;
|
|
3377
|
+
color: var(--ide-text, #d4d4d4);
|
|
3378
|
+
cursor: pointer;
|
|
3379
|
+
white-space: nowrap;
|
|
3380
|
+
}
|
|
3381
|
+
.mg-search-option-active { background: var(--ide-accent, #3794ff); color: #fff; }
|
|
3382
|
+
.mg-search-option-name { font-family: var(--ide-mono, monospace); }
|
|
3383
|
+
.mg-search-option-meta { color: var(--ide-text-muted, #858585); font-size: 10px; }
|
|
3384
|
+
.mg-search-option-active .mg-search-option-meta { color: rgba(255, 255, 255, 0.75); }
|
|
3385
|
+
|
|
3386
|
+
/* Inline route hints beside controller actions. Muted and italic so they read
|
|
3387
|
+
as annotation rather than code — they sit on the same line as the `def`. */
|
|
3388
|
+
.mbeditor-route-hint {
|
|
3389
|
+
color: var(--ide-text-muted, #858585) !important;
|
|
3390
|
+
font-style: italic;
|
|
3391
|
+
opacity: 0.85;
|
|
3392
|
+
}
|
|
3393
|
+
.mbeditor-route-hint-none {
|
|
3394
|
+
color: var(--ide-warning, #cca700) !important;
|
|
3395
|
+
font-style: italic;
|
|
3396
|
+
opacity: 0.8;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
/* Pair-programming diagnostics. The chip only appears when a condition pairing
|
|
3400
|
+
needs has actually failed — an empty room is silence, not a warning. */
|
|
3401
|
+
.mbeditor-collab-trouble { color: var(--ide-warning, #cca700); }
|
|
3402
|
+
.mbeditor-modal-backdrop {
|
|
3403
|
+
position: fixed; inset: 0; z-index: 5000;
|
|
3404
|
+
background: rgba(0, 0, 0, 0.45);
|
|
3405
|
+
display: flex; align-items: center; justify-content: center;
|
|
3406
|
+
}
|
|
3407
|
+
.mbeditor-collab-diag {
|
|
3408
|
+
width: min(560px, 92vw);
|
|
3409
|
+
max-height: 80vh; overflow-y: auto;
|
|
3410
|
+
padding: 18px 20px;
|
|
3411
|
+
background: var(--ide-panel-bg, #252526);
|
|
3412
|
+
border: 1px solid var(--ide-border, #3c3c3c);
|
|
3413
|
+
border-radius: 8px;
|
|
3414
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
|
3415
|
+
color: var(--ide-text, #d4d4d4);
|
|
3416
|
+
font-size: 12px;
|
|
3417
|
+
}
|
|
3418
|
+
.mbeditor-collab-diag-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
|
3419
|
+
.mbeditor-collab-diag-list { list-style: none; margin: 0 0 12px; padding: 0; }
|
|
3420
|
+
.mbeditor-collab-diag-row { display: flex; gap: 9px; align-items: flex-start; padding: 6px 0; }
|
|
3421
|
+
.mbeditor-collab-diag-row i { margin-top: 2px; color: var(--ide-success, #4ec9b0); }
|
|
3422
|
+
.mbeditor-collab-diag-row.is-bad i { color: var(--ide-danger, #f14c4c); }
|
|
3423
|
+
.mbeditor-collab-diag-label { font-weight: 500; }
|
|
3424
|
+
.mbeditor-collab-diag-detail { color: var(--ide-text-muted, #858585); margin-top: 3px; line-height: 1.5; }
|
|
3425
|
+
.mbeditor-collab-diag-foot { color: var(--ide-text-muted, #858585); margin-bottom: 12px; }
|
|
@@ -19,20 +19,49 @@ module Mbeditor
|
|
|
19
19
|
# True when the connection is allowed (or no hook is configured); otherwise
|
|
20
20
|
# rejects the subscription and returns false.
|
|
21
21
|
def mbeditor_authenticated?
|
|
22
|
-
hook =
|
|
22
|
+
hook = mbeditor_auth_hook
|
|
23
23
|
return true unless hook
|
|
24
24
|
|
|
25
25
|
probe = AuthProbe.new(mbeditor_connection_env)
|
|
26
26
|
probe.instance_exec(&hook)
|
|
27
27
|
return true unless probe.denied?
|
|
28
28
|
|
|
29
|
+
# Both denial paths used to be completely silent, which is what makes this
|
|
30
|
+
# so hard to diagnose: pairing simply never works and nothing anywhere says
|
|
31
|
+
# why. The commonest cause is a hook that reads state a controller filter
|
|
32
|
+
# populates — Current.user, an Authlogic session — because a WebSocket
|
|
33
|
+
# subscribe runs no controller, so that state is nil or raises here while
|
|
34
|
+
# working perfectly over HTTP.
|
|
35
|
+
mbeditor_log_denial("the authenticate_with hook denied the connection")
|
|
29
36
|
mbeditor_reject_subscription
|
|
30
37
|
false
|
|
31
|
-
rescue StandardError
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
mbeditor_log_denial("the authenticate_with hook raised #{e.class}: #{e.message}")
|
|
32
40
|
mbeditor_reject_subscription
|
|
33
41
|
false
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
# `cable_authenticate_with` when set, otherwise the HTTP hook. A hook that
|
|
45
|
+
# depends on controller filters cannot work here at all, and asking people to
|
|
46
|
+
# write one proc that straddles both contexts is worse than letting them
|
|
47
|
+
# supply the cable one explicitly.
|
|
48
|
+
def mbeditor_auth_hook
|
|
49
|
+
Mbeditor.configuration.cable_authenticate_with ||
|
|
50
|
+
Mbeditor.configuration.authenticate_with
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mbeditor_log_denial(reason)
|
|
54
|
+
Rails.logger&.warn(
|
|
55
|
+
"[mbeditor] WebSocket subscription rejected: #{reason}. " \
|
|
56
|
+
"Realtime collaboration will not work. A WebSocket subscribe runs no " \
|
|
57
|
+
"controller, so Current.*, Authlogic sessions and other request-scoped " \
|
|
58
|
+
"state set by before_actions are unavailable here — resolve the user " \
|
|
59
|
+
"from `session` instead, or set config.cable_authenticate_with."
|
|
60
|
+
)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
# Logging must never be the thing that breaks the socket.
|
|
63
|
+
end
|
|
64
|
+
|
|
36
65
|
private
|
|
37
66
|
|
|
38
67
|
def mbeditor_connection_env
|