mbeditor 0.12.9 → 0.13.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +102 -25
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +268 -53
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +31 -5
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
@@ -418,6 +418,8 @@ var TabManager = (function () {
418
418
  }
419
419
 
420
420
  function closeTab(paneId, path) {
421
+ // A throttled content write must not outlive the tab it belongs to.
422
+ flushContent();
421
423
  if (typeof HistoryService !== 'undefined') {
422
424
  HistoryService.flushForPath(path);
423
425
  }
@@ -539,23 +541,73 @@ var TabManager = (function () {
539
541
  EditorStore.setState({ focusedPaneId: paneId, activeTabId: newActive });
540
542
  }
541
543
 
544
+ // Typing calls markDirty/markClean per keystroke, and every store write
545
+ // re-renders the whole app (MbeditorApp subscribes to the entire store). Two
546
+ // rules keep that off the keystroke path:
547
+ //
548
+ // * the dirty flag is only written on a clean<->dirty transition;
549
+ // * `content` is written on a 250 ms trailing edge, so the store sees at
550
+ // most four content updates a second.
551
+ //
552
+ // FLUSH CONTRACT: anything that reads `tab.content` immediately after an edit
553
+ // — save, save-all, format, format-all — must call TabManager.flushContent()
554
+ // first and then re-read the tab from the store, or it works on text up to
555
+ // 250 ms old. Everything else (lint, EOL indicator, markdown preview, the
556
+ // external-change poll) tolerates the lag by design.
557
+ var CONTENT_WRITE_MS = 250;
558
+ var _pendingContent = {}; // "<paneId> <tabId>" -> { paneId, path, content, markdown }
559
+ var _contentTimer = null;
560
+
561
+ function _queueContent(paneId, path, content, updates, markdown) {
562
+ var key = paneId + '' + path;
563
+ if (updates) {
564
+ // A state transition has to land now, and it takes the content with it.
565
+ delete _pendingContent[key];
566
+ updates.content = content;
567
+ _updateTab(paneId, path, updates);
568
+ if (markdown) _syncMarkdownPreviewContent(path, content);
569
+ return;
570
+ }
571
+ _pendingContent[key] = { paneId: paneId, path: path, content: content, markdown: markdown };
572
+ if (_contentTimer === null) {
573
+ _contentTimer = setTimeout(flushContent, CONTENT_WRITE_MS);
574
+ }
575
+ }
576
+
577
+ function flushContent() {
578
+ if (_contentTimer !== null) {
579
+ clearTimeout(_contentTimer);
580
+ _contentTimer = null;
581
+ }
582
+ var pending = _pendingContent;
583
+ _pendingContent = {};
584
+ Object.keys(pending).forEach(function (k) {
585
+ var p = pending[k];
586
+ _updateTab(p.paneId, p.path, { content: p.content });
587
+ if (p.markdown) _syncMarkdownPreviewContent(p.path, p.content);
588
+ });
589
+ }
590
+
542
591
  function markDirty(paneId, path, content) {
543
592
  // Auto-harden any soft-open tab on first edit.
544
593
  var st = EditorStore.getState();
545
594
  var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
546
595
  var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
547
- var updates = { content: content, dirty: true };
596
+ var updates = null;
597
+ if (!existingTab || !existingTab.dirty) updates = { dirty: true };
548
598
  if (existingTab && existingTab.isSoftOpen) {
599
+ updates = updates || {};
549
600
  updates.isSoftOpen = false;
550
601
  }
551
- _updateTab(paneId, path, updates);
552
- if (_isMarkdownPath(path)) {
553
- _syncMarkdownPreviewContent(path, content);
554
- }
602
+ _queueContent(paneId, path, content, updates, _isMarkdownPath(path));
555
603
  }
556
604
 
557
605
  function markClean(paneId, path, content) {
558
- _updateTab(paneId, path, { content: content, dirty: false });
606
+ var st = EditorStore.getState();
607
+ var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
608
+ var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
609
+ var updates = (existingTab && !existingTab.dirty) ? null : { dirty: false };
610
+ _queueContent(paneId, path, content, updates, _isMarkdownPath(path));
559
611
  }
560
612
 
561
613
  function hardenTab(paneId, path) {
@@ -694,6 +746,7 @@ var TabManager = (function () {
694
746
  focusPane: focusPane,
695
747
  markDirty: markDirty,
696
748
  markClean: markClean,
749
+ flushContent: flushContent,
697
750
  hardenTab: hardenTab,
698
751
  saveTabViewState: saveTabViewState,
699
752
  reorderTabInPane: reorderTabInPane,
@@ -8,8 +8,14 @@
8
8
  // otherwise prevent reconnection.
9
9
  var WebSocketService = (function () {
10
10
  var _consumer = null;
11
+ // True only when _consumer is one we created ourselves; a consumer borrowed
12
+ // from the host app (window.App.cable) is not ours to disconnect.
13
+ var _ownsConsumer = false;
11
14
  var _subscription = null;
12
15
  var _connected = false;
16
+ // Distinguishes the first successful handshake from a reconnect, which is the
17
+ // point at which collaboration rooms have to be re-established.
18
+ var _everConnected = false;
13
19
  // Why collaboration is or is not working, so the editor can say so instead of
14
20
  // failing silently. A rejected subscription used to just schedule a reconnect
15
21
  // and tell nobody, which made a blocked cable indistinguishable from an empty
@@ -62,8 +68,10 @@ var WebSocketService = (function () {
62
68
  // Reuse an existing consumer the host app may have already created (App.cable
63
69
  // is the Rails default). Fall back to creating our own.
64
70
  if (typeof window.App !== 'undefined' && window.App.cable) {
71
+ _ownsConsumer = false;
65
72
  return window.App.cable;
66
73
  }
74
+ _ownsConsumer = true;
67
75
  return window.ActionCable.createConsumer(_cableUrl());
68
76
  }
69
77
 
@@ -72,10 +80,14 @@ var WebSocketService = (function () {
72
80
  try { _subscription.unsubscribe(); } catch (e) { /* ignore */ }
73
81
  _subscription = null;
74
82
  }
75
- if (_consumer && typeof _consumer.disconnect === 'function') {
83
+ // Only ever disconnect a consumer we created. When the host app already had
84
+ // one (window.App.cable) it is shared: disconnecting it on any mbeditor
85
+ // cable blip took down the host's own channels with it.
86
+ if (_ownsConsumer && _consumer && typeof _consumer.disconnect === 'function') {
76
87
  try { _consumer.disconnect(); } catch (e) { /* ignore */ }
77
88
  }
78
89
  _consumer = null;
90
+ _ownsConsumer = false;
79
91
  _connected = false;
80
92
  }
81
93
 
@@ -139,11 +151,21 @@ var WebSocketService = (function () {
139
151
  { channel: 'Mbeditor::EditorChannel' },
140
152
  {
141
153
  connected: function () {
154
+ var reconnected = _everConnected;
155
+ _everConnected = true;
142
156
  _connected = true;
143
157
  _status = 'connected';
144
158
  // Back to fast retries for the next blip.
145
159
  _reconnectAttempts = 0;
146
160
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
161
+ // The drop took the old consumer down and every CollaborationChannel
162
+ // subscription made on it with it. Nothing else notices — cable
163
+ // availability is about support, not liveness — so the rooms would
164
+ // stay silently dead until each file was closed and reopened.
165
+ if (reconnected && typeof CollaborationService !== 'undefined' &&
166
+ typeof CollaborationService.rejoinRooms === 'function') {
167
+ try { CollaborationService.rejoinRooms(); } catch (e) { /* ignore */ }
168
+ }
147
169
  },
148
170
  disconnected: function () {
149
171
  _status = _status === 'rejected' ? 'rejected' : 'dropped';
@@ -178,12 +200,71 @@ var WebSocketService = (function () {
178
200
  }
179
201
  }
180
202
 
181
- function _emitFilesChanged(data) {
182
- _filesChangedCallbacks.forEach(function (fn) {
183
- try { fn(data); } catch (e) { /* ignore */ }
203
+ // ── files_changed coalescing ───────────────────────────────────────────────
204
+ //
205
+ // The server broadcasts once per written file, and each subscriber turns a
206
+ // broadcast into HTTP work: the app refreshes the tree and git status, the
207
+ // Monaco plugin refreshes the TypeScript program, every open editor pane
208
+ // re-runs git line-diff. Saving several files in a row — paste, save, next
209
+ // file — therefore multiplied one write into a fan-out per save, and past
210
+ // the point where the dev server could keep up the queue simply grew: saves
211
+ // got slower and slower until requests hit the axios timeout.
212
+ //
213
+ // MbeditorApp already debounced its own handler for exactly this reason, but
214
+ // a debounce there cannot help the other subscribers. Coalescing here fixes
215
+ // every subscriber at once, including any added later.
216
+ var FILES_CHANGED_COALESCE_MS = 150;
217
+ var _filesChangedTimer = null;
218
+ var _filesChangedAcc = null;
219
+
220
+ // Merge a broadcast into the accumulator. Pure so it can be tested directly;
221
+ // the two invariants are easy to get wrong and both have teeth:
222
+ //
223
+ // - A broadcast with no paths means "something changed, re-check
224
+ // everything". It must not be narrowed by paths that happen to land in
225
+ // the same window, or the re-check silently covers only those files.
226
+ // - `structural` is a union: one structural broadcast in the burst makes
227
+ // the whole flush structural, because a tree walk that gets skipped
228
+ // leaves a created or deleted file missing from the explorer.
229
+ function _mergeFilesChanged(acc, data) {
230
+ var next = acc || { paths: [], pathless: false, structural: false };
231
+ if (data && data.paths) next.paths = next.paths.concat(data.paths);
232
+ else next.pathless = true;
233
+ // Absent `structural` means an older server — the conservative read is true.
234
+ if (!data || data.structural !== false) next.structural = true;
235
+ return next;
236
+ }
237
+
238
+ function _coalescedPayload(acc) {
239
+ var payload = { type: 'files_changed', structural: acc.structural };
240
+ if (!acc.pathless && acc.paths.length) {
241
+ var seen = {};
242
+ payload.paths = acc.paths.filter(function (p) {
243
+ if (seen['$' + p]) return false;
244
+ seen['$' + p] = true;
245
+ return true;
246
+ });
247
+ }
248
+ return payload;
249
+ }
250
+
251
+ function _flushFilesChanged() {
252
+ _filesChangedTimer = null;
253
+ var acc = _filesChangedAcc;
254
+ _filesChangedAcc = null;
255
+ if (!acc) return;
256
+ var payload = _coalescedPayload(acc);
257
+ _filesChangedCallbacks.slice().forEach(function (fn) {
258
+ try { fn(payload); } catch (e) { /* ignore */ }
184
259
  });
185
260
  }
186
261
 
262
+ function _emitFilesChanged(data) {
263
+ _filesChangedAcc = _mergeFilesChanged(_filesChangedAcc, data);
264
+ if (_filesChangedTimer) return;
265
+ _filesChangedTimer = setTimeout(_flushFilesChanged, FILES_CHANGED_COALESCE_MS);
266
+ }
267
+
187
268
  function _emitFileSaved(data) {
188
269
  _fileSavedCallbacks.forEach(function (fn) {
189
270
  try { fn(data); } catch (e) { /* ignore */ }
@@ -342,6 +423,9 @@ var WebSocketService = (function () {
342
423
  subscribeCollaboration: subscribeCollaboration,
343
424
  perform: perform,
344
425
  onFilesChanged: onFilesChanged,
426
+ // Exposed for tests — the burst-merge invariants above are worth pinning.
427
+ _mergeFilesChanged: _mergeFilesChanged,
428
+ _coalescedPayload: _coalescedPayload,
345
429
  offFilesChanged: offFilesChanged,
346
430
  onFileSaved: onFileSaved,
347
431
  offFileSaved: offFileSaved,
@@ -8,10 +8,86 @@
8
8
  box-shadow: none !important;
9
9
  }
10
10
 
11
- /* Prevent Pico CSS from applying its primary-colour focus/active state to IDE buttons.
12
- Pico sets --pico-background-color and --pico-border-color to its primary blue on
13
- button:focus, which bleeds through as a blue tint on any button not explicitly
14
- overriding its background with a literal value. */
11
+ /* ── Pico containment ──────────────────────────────────────────────────────
12
+ *
13
+ * The host app's stylesheet loads alongside this one, and when it is Pico (the
14
+ * dummy app's is) it styles bare element and ARIA selectors — `button`,
15
+ * `[role=button]`, `[role=group]`, `[disabled]` — every one of which matches
16
+ * this UI's markup. Two shipped bugs came from exactly that: `[role=button]`
17
+ * painted the tab close buttons as filled primary squares, and
18
+ * `[disabled] { pointer-events: none }` stopped disabled toolbar buttons from
19
+ * receiving hover at all, so their tooltips never appeared.
20
+ *
21
+ * The technique is to zero Pico's own custom properties: every Pico button
22
+ * declaration reads from them, so neutralising the variables disarms the whole
23
+ * ruleset at once, which beats fighting each declaration on specificity. Only
24
+ * the few properties Pico hard-codes need explicit overrides.
25
+ *
26
+ * Two things make this safe to widen, and both depend on where the block sits:
27
+ *
28
+ * - The root is wrapped in :where(), so these rules carry exactly Pico's own
29
+ * specificity, no more. They win over Pico only because the whole of
30
+ * editor.css is bundled after the whole of Pico.
31
+ * - The block lives at the TOP of this file, so every component rule below —
32
+ * .statusbar-btn, .project-actions, .tab-close — is later at equal or
33
+ * greater specificity and still wins.
34
+ *
35
+ * That combination is what makes this a floor rather than a new source of
36
+ * truth: it can only remove Pico's opinions, never override this file's own.
37
+ * Moving it further down would silently invert that.
38
+ *
39
+ * Deliberately NOT reset: Pico's `*` box-sizing and background-repeat rules
40
+ * (this UI assumes border-box, which is what Pico sets), and `position` on
41
+ * [role=group] (Pico's `relative` is the safer value for any absolutely
42
+ * positioned descendant, and nothing here needs it to be static).
43
+ *
44
+ * Monaco is listed separately because its widgets portal out of the shell to
45
+ * document.body (.context-view, .action-widget), so a #mbeditor-root scope
46
+ * would miss them. Monaco renders its own actions as <a role="button">.
47
+ */
48
+ :where(#mbeditor-root) :is(button, [type="button"], [type="submit"], [type="reset"], [role="button"], [role="group"]),
49
+ .monaco-hover [role=button],
50
+ .action-widget [role=button],
51
+ .context-view [role=button],
52
+ .monaco-editor [role=button],
53
+ .monaco-diff-editor [role=button] {
54
+ --pico-background-color: transparent;
55
+ --pico-border-color: transparent;
56
+ --pico-color: inherit;
57
+ --pico-box-shadow: none;
58
+ --pico-form-element-spacing-vertical: 0;
59
+ --pico-form-element-spacing-horizontal: 0;
60
+ }
61
+
62
+ /* Properties Pico hard-codes, which the variable reset above cannot reach. */
63
+
64
+ /* Pico's `[disabled] { pointer-events: none }` means a disabled control never
65
+ * receives hover, so neither a title attribute nor a CSS hover tooltip can
66
+ * fire on it — and a disabled button is exactly when a user most wants to be
67
+ * told what it is. */
68
+ :where(#mbeditor-root) [disabled],
69
+ :where(#mbeditor-root) [aria-disabled="true"] {
70
+ pointer-events: auto;
71
+ }
72
+
73
+ /* Pico lays [role=group] out as a full-width bordered input group. It is used
74
+ * here only to label toolbars for screen readers. */
75
+ :where(#mbeditor-root) [role="group"] {
76
+ display: inline-flex;
77
+ width: auto;
78
+ margin-bottom: 0;
79
+ }
80
+
81
+ /* Pico gives every button a bottom margin to space form controls. */
82
+ :where(#mbeditor-root) :is(button, [type="button"], [type="submit"], [type="reset"]) {
83
+ margin: 0;
84
+ }
85
+
86
+ /* Pico's focus/active state sets its primary blue on --pico-background-color
87
+ and --pico-border-color, which bleeds through as a blue tint on any button
88
+ not overriding its background with a literal value. Kept separate from the
89
+ block above: this one is intentionally more specific than a component rule,
90
+ because no component should be able to opt back into a Pico focus colour. */
15
91
  #mbeditor-root button:not(.pico-btn):focus,
16
92
  #mbeditor-root button:not(.pico-btn):focus-visible,
17
93
  #mbeditor-root button:not(.pico-btn):active {
@@ -129,57 +205,66 @@ html, body, #mbeditor-root {
129
205
 
130
206
  /* ── Activity Bar (VSCode-style, always visible) ───────── */
131
207
  .ide-activity-bar {
132
- width: 48px;
133
- min-width: 48px;
208
+ width: 60px;
209
+ min-width: 60px;
134
210
  flex-shrink: 0;
135
211
  background: var(--ide-panel-bg);
136
212
  border-right: 1px solid var(--ide-border);
137
213
  display: flex;
138
214
  flex-direction: column;
139
- align-items: center;
140
- padding: 4px 0;
215
+ /* stretch, not center: the buttons fill the bar edge to edge so the active
216
+ indicator can sit flush against the left of the screen. Centering them
217
+ left a gutter the indicator was inset by. */
218
+ align-items: stretch;
219
+ padding: 0;
141
220
  z-index: 10;
142
221
  }
143
222
 
144
223
  .ide-activity-bar-top {
145
224
  display: flex;
146
225
  flex-direction: column;
147
- align-items: center;
148
- gap: 2px;
226
+ align-items: stretch;
227
+ gap: 0;
149
228
  flex: 1;
150
229
  }
151
230
 
152
231
  .ide-activity-bar-bottom {
153
232
  display: flex;
154
233
  flex-direction: column;
155
- align-items: center;
156
- gap: 2px;
157
- padding-bottom: 4px;
234
+ align-items: stretch;
235
+ gap: 0;
158
236
  }
159
237
 
160
- .ide-activity-btn {
161
- width: 36px;
162
- height: 36px;
238
+ /* Scoped through the bar so the glyph size outranks the global
239
+ `button:not(.pico-btn) { font-size: 12px }` reset, which is more specific
240
+ than a bare class and was silently winning. */
241
+ .ide-activity-bar .ide-activity-btn {
242
+ width: 100%;
243
+ height: 60px;
163
244
  background: transparent;
164
245
  border: none;
246
+ /* Always reserve the indicator's width so the glyph does not shift sideways
247
+ by a pixel the moment a panel is opened. */
248
+ border-left: 2px solid transparent;
165
249
  border-radius: 0;
166
250
  color: var(--ide-text-muted);
167
251
  display: flex;
168
252
  align-items: center;
169
253
  justify-content: center;
170
254
  cursor: pointer;
171
- font-size: 15px;
172
- transition: background 0.12s ease, color 0.12s ease;
255
+ font-size: 25px;
256
+ transition: color 0.12s ease;
173
257
  }
174
258
 
175
- .ide-activity-btn:hover {
176
- background: var(--ide-hover-bg);
259
+ /* The glyph carries the state, not a filled box behind it. */
260
+ .ide-activity-bar .ide-activity-btn:hover {
261
+ background: transparent;
177
262
  color: var(--ide-text);
178
263
  }
179
264
 
180
- .ide-activity-btn.active {
265
+ .ide-activity-bar .ide-activity-btn.active {
181
266
  color: var(--ide-accent-fg);
182
- border-left: 2px solid var(--ide-accent-fg);
267
+ border-left-color: var(--ide-accent-fg);
183
268
  }
184
269
 
185
270
  .sidebar-panel-title {
@@ -300,25 +385,25 @@ html, body, #mbeditor-root {
300
385
  border: 1px solid var(--ide-accent);
301
386
  border-radius: 3px;
302
387
  color: var(--ide-text);
303
- font-size: 12px;
388
+ font-size: 14px;
304
389
  font-family: inherit;
305
390
  padding: 1px 4px;
306
391
  outline: none;
307
- height: 18px;
308
- line-height: 18px;
392
+ height: 21px;
393
+ line-height: 21px;
309
394
  }
310
395
 
311
396
  .tree-item-icon {
312
- width: 16px;
313
- margin-right: 5px;
314
- font-size: 12px;
397
+ width: 20px;
398
+ margin-right: 6px;
399
+ font-size: 14px;
315
400
  flex-shrink: 0;
316
401
  text-align: center;
317
402
  }
318
403
 
319
404
  .tree-folder-icon { color: #dcb67a; }
320
405
  .tree-file-icon { color: #78b4e0; }
321
- .tree-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
406
+ .tree-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
322
407
 
323
408
  /* ── Custom File Icons ───────────────────────────────────── */
324
409
  .ruby-icon { color: #f26262 !important; }
@@ -1412,7 +1497,90 @@ html, body, #mbeditor-root {
1412
1497
  cursor: pointer;
1413
1498
  }
1414
1499
  .import-conflict-btn:hover { background: var(--ide-hover-bg, #4a4a4a); }
1500
+ .import-conflict-btn:disabled { opacity: 0.45; cursor: default; }
1415
1501
  .import-conflict-btn-danger { border-color: var(--ide-error, #f48771); color: var(--ide-error, #f48771); }
1502
+ .import-conflict-btn-primary {
1503
+ background: var(--ide-accent, #0e639c);
1504
+ border-color: var(--ide-accent, #0e639c);
1505
+ color: #fff;
1506
+ }
1507
+ .import-conflict-btn-primary:hover:not(:disabled) { background: var(--ide-accent-hover, #1177bb); }
1508
+
1509
+ /* Upload dialog — the pointed-at half of drag-and-drop import */
1510
+ .import-dialog { min-width: 460px; max-width: 620px; }
1511
+ .import-dialog .schema-modal-body { padding: 12px 16px; }
1512
+ .import-pick-row { display: flex; gap: 8px; }
1513
+ .import-pick-btn {
1514
+ background: var(--ide-input-bg, #3c3c3c);
1515
+ color: var(--ide-text, #d4d4d4);
1516
+ border: 1px solid var(--ide-border, #3c3c3c);
1517
+ border-radius: 3px;
1518
+ padding: 6px 12px;
1519
+ font-size: 12px;
1520
+ cursor: pointer;
1521
+ }
1522
+ .import-pick-btn:hover { background: var(--ide-hover-bg, #4a4a4a); }
1523
+ .import-pick-btn i { margin-right: 6px; opacity: 0.75; }
1524
+ .import-dialog-empty,
1525
+ .import-dialog-warning {
1526
+ margin-top: 12px;
1527
+ font-size: 12px;
1528
+ color: var(--ide-text-muted, #888);
1529
+ }
1530
+ .import-dialog-warning { color: var(--ide-warning, #cca700); }
1531
+ .import-dialog-section-title {
1532
+ margin: 16px 0 6px;
1533
+ font-size: 10px;
1534
+ font-weight: 600;
1535
+ text-transform: uppercase;
1536
+ letter-spacing: 0.06em;
1537
+ color: var(--ide-text-muted, #888);
1538
+ }
1539
+ .import-dest-options { display: flex; flex-direction: column; gap: 2px; }
1540
+ .import-dest-option {
1541
+ display: flex;
1542
+ align-items: center;
1543
+ gap: 8px;
1544
+ width: 100%;
1545
+ text-align: left;
1546
+ background: none;
1547
+ border: none;
1548
+ border-radius: 3px;
1549
+ padding: 4px 6px;
1550
+ color: var(--ide-text, #d4d4d4);
1551
+ font-size: 12px;
1552
+ cursor: pointer;
1553
+ }
1554
+ .import-dest-option:hover { background: var(--ide-hover, rgba(255,255,255,0.06)); }
1555
+ .import-dest-option.active { background: var(--ide-selection-bg, rgba(14,99,156,0.35)); }
1556
+ .import-dest-radio { opacity: 0.7; font-size: 11px; }
1557
+ .import-dest-label {
1558
+ font-family: var(--ide-mono-font, monospace);
1559
+ overflow: hidden;
1560
+ text-overflow: ellipsis;
1561
+ white-space: nowrap;
1562
+ }
1563
+ .import-dest-hint { color: var(--ide-text-muted, #888); font-size: 11px; margin-left: auto; flex-shrink: 0; }
1564
+ .import-dest-input {
1565
+ width: 100%;
1566
+ margin-top: 8px;
1567
+ box-sizing: border-box;
1568
+ background: var(--ide-input-bg, #3c3c3c);
1569
+ color: var(--ide-text, #d4d4d4);
1570
+ border: 1px solid var(--ide-border, #3c3c3c);
1571
+ border-radius: 3px;
1572
+ padding: 5px 8px;
1573
+ font-family: var(--ide-mono-font, monospace);
1574
+ font-size: 12px;
1575
+ }
1576
+ .import-dest-input:focus { outline: 1px solid var(--ide-accent, #0e639c); }
1577
+ .import-target-replaces {
1578
+ margin-left: 8px;
1579
+ font-size: 10px;
1580
+ text-transform: uppercase;
1581
+ letter-spacing: 0.06em;
1582
+ color: var(--ide-warning, #cca700);
1583
+ }
1416
1584
  .schema-table {
1417
1585
  width: 100%;
1418
1586
  border-collapse: collapse;
@@ -1969,12 +2137,20 @@ html, body, #mbeditor-root {
1969
2137
  flex-shrink: 0;
1970
2138
  }
1971
2139
 
2140
+ /* The title yields the header to the action buttons rather than wrapping.
2141
+ *
2142
+ * It used to break-word onto three lines ("SAMPLE_ / WORKSPA / CE") because a
2143
+ * long workspace name plus six buttons genuinely does not fit a 300px sidebar.
2144
+ * flex-shrink against a `flex-shrink: 0` action group means the buttons always
2145
+ * win: full title, then ellipsis, then gone. No JS measuring, and it adapts on
2146
+ * its own to sections with fewer buttons — the Open Editors header keeps its
2147
+ * title at the same width where Explorer has already dropped it. */
1972
2148
  .collapsible-title {
1973
- flex: 1;
2149
+ flex: 1 1 auto;
1974
2150
  min-width: 0;
1975
- word-wrap: break-word;
1976
- overflow-wrap: break-word;
1977
- word-break: break-word;
2151
+ white-space: nowrap;
2152
+ overflow: hidden;
2153
+ text-overflow: ellipsis;
1978
2154
  }
1979
2155
 
1980
2156
  .collapsible-actions {
@@ -1991,6 +2167,45 @@ html, body, #mbeditor-root {
1991
2167
  gap: 0;
1992
2168
  }
1993
2169
 
2170
+ /* Tooltip for the icon-only sidebar action buttons. See SidebarActionButton
2171
+ * for why this is not a title attribute. Anchored to the button's right edge
2172
+ * because .ide-sidebar-scrollable is overflow-x: hidden — a left-anchored or
2173
+ * centred tooltip on the rightmost button would be clipped by the sidebar. */
2174
+ .sb-tip {
2175
+ position: relative;
2176
+ display: inline-flex;
2177
+ }
2178
+ .sb-tip::after {
2179
+ content: attr(data-tip);
2180
+ position: absolute;
2181
+ top: calc(100% + 4px);
2182
+ right: 0;
2183
+ z-index: 4000;
2184
+ padding: 4px 7px;
2185
+ background: var(--ide-panel-bg, #252526);
2186
+ border: 1px solid var(--ide-border, #3c3c3c);
2187
+ border-radius: 4px;
2188
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
2189
+ color: var(--ide-text, #d4d4d4);
2190
+ /* The section header sets uppercase, 600 weight and letter-spacing; a
2191
+ tooltip inheriting those reads as another heading. */
2192
+ font-size: 11px;
2193
+ font-weight: 400;
2194
+ letter-spacing: 0;
2195
+ text-transform: none;
2196
+ white-space: nowrap;
2197
+ opacity: 0;
2198
+ pointer-events: none;
2199
+ transition: opacity 0.08s ease 0.3s;
2200
+ }
2201
+ .sb-tip:hover::after,
2202
+ .sb-tip:focus-within::after {
2203
+ opacity: 1;
2204
+ }
2205
+ @media (prefers-reduced-motion: reduce) {
2206
+ .sb-tip::after { transition: none; }
2207
+ }
2208
+
1994
2209
  /* Open-editors panel: height is controlled by the --open-editors-height CSS variable
1995
2210
  set inline from React state (default 140px ≈ 5-6 items), drag-resizable via the
1996
2211
  .open-editors-resize-handle strip below. */
@@ -2652,26 +2867,8 @@ button:not(.pico-btn) {
2652
2867
  font-size: 12px;
2653
2868
  }
2654
2869
 
2655
- /* Monaco widget isolationPico's [role=button] selector matches Monaco's
2656
- * internal action elements (View Problem, Quick Fix…, lightbulb actions)
2657
- * because Monaco renders them as <a role="button">. Without this reset, Pico
2658
- * applies its primary-colour background, white text, and large form-element
2659
- * padding, making those buttons huge and unreadable on certain themes.
2660
- * Resetting the Pico CSS variables here is lower-impact than fighting
2661
- * per-property specificity battles with Monaco's --vscode-* rules.
2662
- */
2663
- .monaco-hover [role=button],
2664
- .action-widget [role=button],
2665
- .context-view [role=button],
2666
- .monaco-editor [role=button],
2667
- .monaco-diff-editor [role=button] {
2668
- --pico-background-color: transparent;
2669
- --pico-border-color: transparent;
2670
- --pico-color: inherit;
2671
- --pico-box-shadow: none;
2672
- --pico-form-element-spacing-vertical: 0;
2673
- --pico-form-element-spacing-horizontal: 0;
2674
- }
2870
+ /* Pico containment lives at the top of this file see the block there. It has
2871
+ * to sit above every component rule to stay a floor rather than an override. */
2675
2872
 
2676
2873
  /* Inputs and selects — keep compact but styled */
2677
2874
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
@@ -3049,6 +3246,24 @@ button:not(.pico-btn) { margin-bottom: 0; }
3049
3246
  padding: 2px 6px;
3050
3247
  }
3051
3248
  .ide-problems-btn:hover { color: var(--ide-fg, #fff); }
3249
+ .ide-problems-btn:disabled { opacity: 0.4; cursor: default; }
3250
+ .ide-problems-btn:disabled:hover { color: var(--ide-fg-muted, #ccc); }
3251
+ .ide-problems-actions { display: flex; gap: 2px; }
3252
+ .ide-problems-actions .ide-problems-btn { white-space: nowrap; }
3253
+ .ide-problems-btn.is-on { color: var(--ide-accent, #0a84ff); }
3254
+ .ide-testrun-stat { font-size: 11px; color: var(--ide-text-muted, #858585); }
3255
+ .ide-testrun-pass { color: #4ec9b0; }
3256
+ .ide-testrun-fail { color: #f14c4c; }
3257
+ .ide-testrun-raw {
3258
+ margin: 0;
3259
+ padding: 8px 14px;
3260
+ font-family: var(--ide-mono, monospace);
3261
+ font-size: 11px;
3262
+ line-height: 1.5;
3263
+ white-space: pre;
3264
+ overflow-x: auto;
3265
+ color: var(--ide-fg, #eee);
3266
+ }
3052
3267
  .ide-problems-body { flex: 1; overflow: auto; padding: 4px 0; font-size: 12px; }
3053
3268
  .ide-problems-empty {
3054
3269
  padding: 10px 14px;
@@ -19,6 +19,13 @@ 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
+ # The same gate the controllers apply. Without it the cable channels were
23
+ # reachable in any environment, hook or no hook.
24
+ unless Array(Mbeditor.configuration.allowed_environments).map(&:to_sym).include?(Rails.env.to_sym)
25
+ mbeditor_reject_subscription
26
+ return false
27
+ end
28
+
22
29
  hook = mbeditor_auth_hook
23
30
  return true unless hook
24
31