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.
@@ -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
- var NODE_W = 188;
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; // the card stays readable; the rest are counted
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
- return HEADER_H + shown * FIELD_H + more + 8;
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 * (NODE_W + LAYER_GAP);
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 + NODE_W);
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
- components(allNames, adj).forEach(function (group) {
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
- Object.keys(laid.positions).forEach(function (n) {
303
- var p = laid.positions[n];
304
- positions[n] = { x: pad + p.x, y: cursorY + p.y, model: p.model };
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: pad - BLOCK_PAD, y: cursorY - BLOCK_PAD,
309
- w: laid.width + BLOCK_PAD * 2, h: laid.height + BLOCK_PAD * 2,
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, pad + laid.width);
314
- cursorY += laid.height + BLOCK_GAP;
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: cursorY - BLOCK_GAP + pad
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 acx = a.x + NODE_W / 2, acy = a.y + ah / 2;
328
- var bcx = b.x + NODE_W / 2, bcy = b.y + bh / 2;
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 + NODE_W, y1: acy, x2: b.x, y2: bcy, h: true }
334
- : { x1: a.x, y1: acy, x2: b.x + NODE_W, y2: bcy, h: true };
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 + NODE_W / 2) * k,
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: NODE_W, height: h, rx: 4 }),
683
- React.createElement('rect', { className: 'mg-box-header', width: NODE_W, height: HEADER_H, rx: 4 }),
684
- React.createElement('text', { className: 'mg-name', x: 10, y: 15 }, m.name),
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: NODE_W - 26, y: 7, width: 19, height: 19, rx: 3
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: NODE_W - 22, y: 11, width: 11, height: 2 }),
706
- React.createElement('rect', { className: 'mg-open-btn-bar', x: NODE_W - 22, y: 15.5, width: 11, height: 2 }),
707
- React.createElement('rect', { className: 'mg-open-btn-bar', x: NODE_W - 22, y: 20, width: 11, height: 2 })
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
- React.createElement('text', { className: 'mg-field', x: 10, y: y }, c.name),
715
- React.createElement('text', { className: 'mg-field-type', x: NODE_W - 10, y: y, textAnchor: 'end' }, c.type)
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 native datalist rather than a bespoke dropdown: the browser gives
785
- // us the filtering and keyboard handling for free.
786
- React.createElement('input', {
787
- className: 'ide-model-graph-search',
788
- type: 'search',
789
- list: 'mg-model-names',
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
- 'datalist',
808
- { id: 'mg-model-names' },
809
- models.map(function (m) {
810
- return React.createElement('option', { key: m.name, value: m.name });
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 = Mbeditor.configuration.authenticate_with
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