mbeditor 0.13.1 → 0.14.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. metadata +12 -2
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
 
3
- var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; })();
4
3
 
5
4
  var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
6
5
 
@@ -25,14 +24,23 @@ var useMemo = _React.useMemo;
25
24
  // the re-render happened anyway, and the quick-open index was never rebuilt.
26
25
  // JSON.stringify over the whole tree measures 0.33 ms for ~1600 nodes, well
27
26
  // under the render it saves.
28
- function _treeUpdater(newData) {
27
+ // newSignature lets a caller that has already stringified newData hand the
28
+ // result in. The tree poll needs its own changed/unchanged answer and cannot
29
+ // read one out of a functional setState updater, which React invokes during a
30
+ // later render and which has to stay pure. Without this the poll stringified
31
+ // the whole tree a second time on every tick, forever.
32
+ function _treeUpdater(newData, newSignature) {
29
33
  return function (prevData) {
30
- if (JSON.stringify(newData) === JSON.stringify(prevData)) return prevData;
34
+ var signature = newSignature != null ? newSignature : JSON.stringify(newData);
35
+ if (signature === JSON.stringify(prevData)) return prevData;
31
36
  SearchService.buildIndex(newData);
32
37
  return newData;
33
38
  };
34
39
  }
35
40
 
41
+ // The tree poll's own view of the last tree it saw.
42
+ var _lastPolledTree = null;
43
+
36
44
  var SIDEBAR_MIN_WIDTH = 280;
37
45
  var SIDEBAR_MAX_WIDTH = 560;
38
46
  var EDITOR_MIN_WIDTH = 320;
@@ -40,6 +48,7 @@ var GIT_PANEL_MIN_WIDTH = 280;
40
48
  var PANE_MIN_WIDTH_PERCENT = 20;
41
49
  var PANE_MAX_WIDTH_PERCENT = 80;
42
50
  var SIDEBAR_COLLAPSED_WIDTH = 48;
51
+ var DRAWER_MIN_HEIGHT = 120;
43
52
  // Extension -> Prettier parser. Limited to what the vendored plugins actually
44
53
  // parse (babel, estree, html, postcss, markdown); anything outside this map
45
54
  // falls through to Monaco's re-indent.
@@ -61,6 +70,27 @@ function prettierParserFor(path) {
61
70
  return PRETTIER_PARSERS[String(path || '').split('.').pop().toLowerCase()] || null;
62
71
  }
63
72
 
73
+ // The audit ring takes numbers only, so a path travels as the index of its
74
+ // extension in the legend and can never be reconstructed from the log.
75
+ function _auditExt(path) {
76
+ var audit = window.MbeditorAudit;
77
+ return audit ? audit.code('ext', String(path || '').split('.').pop().toLowerCase()) : 0;
78
+ }
79
+
80
+ // Named rather than passed an EV code so callers need no audit reference.
81
+ function _recErr(evName) {
82
+ var audit = window.MbeditorAudit;
83
+ if (audit) audit.rec(audit.EV.ERR, audit.EV[evName]);
84
+ }
85
+
86
+ // null means no formatter covers this file type, which is not a round trip.
87
+ function _recFormat(path, startedAt, formatted) {
88
+ var audit = window.MbeditorAudit;
89
+ if (audit && formatted != null) {
90
+ audit.rec(audit.EV.FORMAT, _auditExt(path), Date.now() - startedAt, formatted.length);
91
+ }
92
+ }
93
+
64
94
  // Indentation comes from the editor's own tabSize/insertSpaces, so formatting
65
95
  // produces what the user set up. `insertSpaces !== true` mirrors how
66
96
  // EditorPanel configures Monaco: anything but an explicit true means tabs.
@@ -116,7 +146,8 @@ var DEFAULT_EDITOR_PREFS = {
116
146
  wordBasedSuggestions: 'matchingDocuments',
117
147
  acceptSuggestionOnEnter: 'on',
118
148
  autoRevealInExplorer: true,
119
- toolbarIconOnly: false,
149
+ toolbarLabels: false,
150
+ glass: false,
120
151
  rubocopLintEnabled: true,
121
152
  routeHints: true,
122
153
  prettierPrintWidth: 80,
@@ -130,7 +161,8 @@ var DEFAULT_EDITOR_PREFS = {
130
161
  tabDisplayMode: 'scroll',
131
162
  persistFindState: true,
132
163
  showDotFiles: false,
133
- branchStateRestore: true
164
+ branchStateRestore: true,
165
+ auditLog: true
134
166
  };
135
167
 
136
168
  // Indentation of formatted output is the formatter's job, not a post-pass:
@@ -139,6 +171,52 @@ var DEFAULT_EDITOR_PREFS = {
139
171
  // "Convert Indentation to Tabs / to Spaces" commands (F1) cover converting a
140
172
  // file that is already open, using its own indentation guesser.
141
173
 
174
+ // Drops both rings — the browser's and the server's — so the next download is
175
+ // a trace of one session and nothing else.
176
+ function clearAuditLog() {
177
+ if (window.MbeditorAudit) window.MbeditorAudit.clear();
178
+ fetch(window.mbeditorBasePath() + '/audit_log', {
179
+ method: 'DELETE',
180
+ headers: { 'X-Mbeditor-Client': '1' }
181
+ }).then(function () {
182
+ EditorStore.setStatus('Audit log cleared', 'info');
183
+ })["catch"](function () {
184
+ EditorStore.setStatus('Could not clear the audit log', 'error');
185
+ });
186
+ }
187
+
188
+ // Flush first, so the entries recorded in this session are on the server
189
+ // before it builds the payload.
190
+ function downloadAuditLog() {
191
+ var fetchLog = function () {
192
+ return fetch(window.mbeditorBasePath() + '/audit_log').then(function (resp) {
193
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
194
+ return resp.json();
195
+ }).then(function (payload) {
196
+ // The legend lives only in the browser bundle, so it is stitched on here
197
+ // rather than uploaded — see audit_log.js.
198
+ if (payload.client && window.MbeditorAudit) payload.client.legend = window.MbeditorAudit.LEGEND;
199
+ var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
200
+ var objectUrl = URL.createObjectURL(blob);
201
+ var link = document.createElement('a');
202
+ link.href = objectUrl;
203
+ link.download = 'mbeditor-audit-' + Date.now() + '.json';
204
+ link.rel = 'noopener';
205
+ document.body.appendChild(link);
206
+ link.click();
207
+ document.body.removeChild(link);
208
+ // Revoking in the same tick can free the blob before the browser has
209
+ // started reading it, which cancels the download instead of saving it.
210
+ setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 60000);
211
+ EditorStore.setStatus('Downloading audit log...', 'info');
212
+ });
213
+ };
214
+ var flushed = window.MbeditorAudit ? window.MbeditorAudit.flush() : Promise.resolve();
215
+ flushed.then(fetchLog, fetchLog)["catch"](function (err) {
216
+ EditorStore.setStatus('Audit log download failed: ' + (err && err.message || 'network error'), 'error');
217
+ });
218
+ }
219
+
142
220
  function diffLines(oldLines, newLines) {
143
221
  var n = oldLines.length, m = newLines.length;
144
222
  var dp = [];
@@ -157,6 +235,46 @@ function diffLines(oldLines, newLines) {
157
235
  return changed;
158
236
  }
159
237
 
238
+ // Cursor line/column for the status bar.
239
+ //
240
+ // Its own component holding its own state, deliberately: the cursor moves on
241
+ // every keystroke and every click, and MbeditorApp subscribes to the whole
242
+ // store — reading the position there would re-render the entire app, tree and
243
+ // all, on each keypress. Here a move re-renders one span.
244
+ var CursorPosition = function CursorPosition() {
245
+ var _pos = React.useState(null);
246
+ var pos = _pos[0];
247
+ var setPos = _pos[1];
248
+
249
+ React.useEffect(function () {
250
+ var disposable = null;
251
+ var attach = function () {
252
+ if (disposable) { disposable.dispose(); disposable = null; }
253
+ var editor = window.__mbeditorActiveEditor;
254
+ if (!editor) { setPos(null); return; }
255
+ var read = function () {
256
+ var p = editor.getPosition();
257
+ setPos(p ? { line: p.lineNumber, col: p.column } : null);
258
+ };
259
+ read();
260
+ disposable = editor.onDidChangeCursorPosition(read);
261
+ };
262
+ attach();
263
+ window.addEventListener('mbeditor:active-editor', attach);
264
+ return function () {
265
+ window.removeEventListener('mbeditor:active-editor', attach);
266
+ if (disposable) disposable.dispose();
267
+ };
268
+ }, []);
269
+
270
+ if (!pos) return null;
271
+ return React.createElement(
272
+ 'span',
273
+ { className: 'statusbar-cursor', title: 'Line ' + pos.line + ', column ' + pos.col },
274
+ 'Ln ' + pos.line + ', Col ' + pos.col
275
+ );
276
+ };
277
+
160
278
  var SidebarActionButton = function SidebarActionButton(_ref) {
161
279
  var title = _ref.title;
162
280
  var iconClass = _ref.iconClass;
@@ -191,7 +309,13 @@ var SidebarActionButton = function SidebarActionButton(_ref) {
191
309
  className: "project-action-btn" + (danger ? " danger" : ""),
192
310
  "aria-label": ariaLabel || title,
193
311
  "aria-busy": !!ariaBusy,
194
- onClick: onClick,
312
+ onClick: function (e) {
313
+ if (onClick) onClick(e);
314
+ // Clicking leaves the button focused, which keeps the :focus-within
315
+ // tooltip open until the user clicks elsewhere. Blur to drop it
316
+ // immediately; a Tab-focused button still shows it on keyboard focus.
317
+ e.currentTarget.blur();
318
+ },
195
319
  disabled: !!disabled
196
320
  },
197
321
  !ariaBusy && React.createElement("i", { className: iconClass })
@@ -271,107 +395,126 @@ function FileReloadBanner(_ref) {
271
395
  );
272
396
  }
273
397
 
398
+ // ── Test-result cache (localStorage) ────────────────────────────────────
399
+ // One entry per test file, capped so an unbounded suite can't fill the
400
+ // origin's quota and take the unsaved-draft backup down with it (#84).
401
+ var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
402
+ var TEST_CACHE_MAX_ENTRIES = 50;
403
+
404
+ // ponytail: full localStorage scan per save, fine at a 50-entry cap.
405
+ function _trimTestResultCache(maxEntries) {
406
+ var entries = [];
407
+ for (var i = 0; i < localStorage.length; i++) {
408
+ var key = localStorage.key(i);
409
+ if (key && key.indexOf(TEST_CACHE_PREFIX) === 0) {
410
+ var ts = 0;
411
+ try { ts = (JSON.parse(localStorage.getItem(key)) || {}).ts || 0; } catch (e) {}
412
+ entries.push({ key: key, ts: ts });
413
+ }
414
+ }
415
+ entries.sort(function (a, b) { return a.ts - b.ts; });
416
+ while (entries.length > maxEntries) localStorage.removeItem(entries.shift().key);
417
+ }
418
+
419
+ function saveCachedTestResult(filePath, result) {
420
+ var payload = JSON.stringify(_extends({}, result, { ts: Date.now() }));
421
+ try {
422
+ localStorage.setItem(TEST_CACHE_PREFIX + filePath, payload);
423
+ } catch (e) {
424
+ _trimTestResultCache(Math.floor(TEST_CACHE_MAX_ENTRIES / 2));
425
+ try { localStorage.setItem(TEST_CACHE_PREFIX + filePath, payload); } catch (e2) { return; }
426
+ }
427
+ _trimTestResultCache(TEST_CACHE_MAX_ENTRIES);
428
+ }
429
+
274
430
  var MbeditorApp = function MbeditorApp() {
275
431
  var _useState = useState(EditorStore.getState());
276
432
 
277
- var _useState2 = _slicedToArray(_useState, 2);
278
433
 
279
- var state = _useState2[0];
280
- var setState = _useState2[1];
434
+ var state = _useState[0];
435
+ var setState = _useState[1];
281
436
 
282
437
  var _useState21 = useState(null);
283
- var _useState22 = _slicedToArray(_useState21, 2);
284
- var historyPanelPath = _useState22[0];
285
- var setHistoryPanelPath = _useState22[1];
438
+ var historyPanelPath = _useState21[0];
439
+ var setHistoryPanelPath = _useState21[1];
286
440
 
287
441
  var _useState23 = useState(false);
288
- var _useState24 = _slicedToArray(_useState23, 2);
289
- var isNavigating = _useState24[0];
290
- var setIsNavigating = _useState24[1];
442
+ var isNavigating = _useState23[0];
443
+ var setIsNavigating = _useState23[1];
291
444
 
292
445
  var _useState25 = useState(false);
293
- var _useState26 = _slicedToArray(_useState25, 2);
294
- var isReviewOpen = _useState26[0];
295
- var setIsReviewOpen = _useState26[1];
446
+ var isReviewOpen = _useState25[0];
447
+ var setIsReviewOpen = _useState25[1];
296
448
 
297
449
  var _useState27 = useState(null);
298
- var _useState28 = _slicedToArray(_useState27, 2);
299
- var selectedCommit = _useState28[0];
300
- var setSelectedCommit = _useState28[1];
450
+ var selectedCommit = _useState27[0];
451
+ var setSelectedCommit = _useState27[1];
301
452
 
302
453
  var _useState29 = useState(null);
303
- var _useState30 = _slicedToArray(_useState29, 2);
304
- var commitDetailFiles = _useState30[0];
305
- var setCommitDetailFiles = _useState30[1];
454
+ var commitDetailFiles = _useState29[0];
455
+ var setCommitDetailFiles = _useState29[1];
456
+
457
+ var _useStateCFI = useState(0);
458
+ var conflictFileIndex = _useStateCFI[0];
459
+ var setConflictFileIndex = _useStateCFI[1];
306
460
 
307
461
  var _useState4 = useState([]);
308
462
 
309
- var _useState42 = _slicedToArray(_useState4, 2);
310
463
 
311
- var treeData = _useState42[0];
312
- var setTreeData = _useState42[1];
464
+ var treeData = _useState4[0];
465
+ var setTreeData = _useState4[1];
313
466
 
314
467
  var _useState5 = useState("");
315
468
 
316
- var _useState52 = _slicedToArray(_useState5, 2);
317
469
 
318
- var projectRootName = _useState52[0];
319
- var setProjectRootName = _useState52[1];
470
+ var projectRootName = _useState5[0];
471
+ var setProjectRootName = _useState5[1];
320
472
 
321
473
  var _useState6 = useState(null);
322
474
 
323
- var _useState62 = _slicedToArray(_useState6, 2);
324
475
 
325
- var selectedTreeNode = _useState62[0];
326
- var setSelectedTreeNode = _useState62[1];
476
+ var selectedTreeNode = _useState6[0];
477
+ var setSelectedTreeNode = _useState6[1];
327
478
 
328
479
  var _useStateSP = useState(new Set());
329
- var _useStateSP2 = _slicedToArray(_useStateSP, 2);
330
- var selectedPaths = _useStateSP2[0];
331
- var setSelectedPaths = _useStateSP2[1];
480
+ var selectedPaths = _useStateSP[0];
481
+ var setSelectedPaths = _useStateSP[1];
332
482
 
333
483
  var _useState7 = useState("");
334
484
 
335
- var _useState72 = _slicedToArray(_useState7, 2);
336
485
 
337
- var searchQuery = _useState72[0];
338
- var setSearchQuery = _useState72[1];
486
+ var searchQuery = _useState7[0];
487
+ var setSearchQuery = _useState7[1];
339
488
 
340
489
  var _useState33 = useState(false);
341
- var _useState332 = _slicedToArray(_useState33, 2);
342
- var searchLoading = _useState332[0];
343
- var setSearchLoading = _useState332[1];
490
+ var searchLoading = _useState33[0];
491
+ var setSearchLoading = _useState33[1];
344
492
 
345
493
  var searchRequestIdRef = useRef(0);
346
494
 
347
495
  var _useState33h = useState(false);
348
- var _useState33h2 = _slicedToArray(_useState33h, 2);
349
- var searchHasMore = _useState33h2[0];
350
- var setSearchHasMore = _useState33h2[1];
496
+ var searchHasMore = _useState33h[0];
497
+ var setSearchHasMore = _useState33h[1];
351
498
 
352
499
  var _useState33tc = useState(0);
353
- var _useState33tc2 = _slicedToArray(_useState33tc, 2);
354
- var searchTotalCount = _useState33tc2[0];
355
- var setSearchTotalCount = _useState33tc2[1];
500
+ var searchTotalCount = _useState33tc[0];
501
+ var setSearchTotalCount = _useState33tc[1];
356
502
 
357
503
  var searchHasMoreRef = useRef(false);
358
504
  var searchOffsetRef = useRef(0);
359
505
  var searchLoadingMoreRef = useRef(false);
360
506
 
361
507
  var _useStateRx = useState(false);
362
- var _useStateRx2 = _slicedToArray(_useStateRx, 2);
363
- var searchUseRegex = _useStateRx2[0];
364
- var setSearchUseRegex = _useStateRx2[1];
508
+ var searchUseRegex = _useStateRx[0];
509
+ var setSearchUseRegex = _useStateRx[1];
365
510
 
366
511
  var _useStateMC = useState(false);
367
- var _useStateMC2 = _slicedToArray(_useStateMC, 2);
368
- var searchMatchCase = _useStateMC2[0];
369
- var setSearchMatchCase = _useStateMC2[1];
512
+ var searchMatchCase = _useStateMC[0];
513
+ var setSearchMatchCase = _useStateMC[1];
370
514
 
371
515
  var _useStateWW = useState(false);
372
- var _useStateWW2 = _slicedToArray(_useStateWW, 2);
373
- var searchWholeWord = _useStateWW2[0];
374
- var setSearchWholeWord = _useStateWW2[1];
516
+ var searchWholeWord = _useStateWW[0];
517
+ var setSearchWholeWord = _useStateWW[1];
375
518
 
376
519
  var searchQueryRef = useRef('');
377
520
  var searchUseRegexRef = useRef(false);
@@ -395,9 +538,8 @@ var MbeditorApp = function MbeditorApp() {
395
538
  // File paths whose match list is folded away. Keyed by path, so a file that
396
539
  // scrolls out of the window keeps its state.
397
540
  var _useStateSC = useState({});
398
- var _useStateSC2 = _slicedToArray(_useStateSC, 2);
399
- var searchCollapsedFiles = _useStateSC2[0];
400
- var setSearchCollapsedFiles = _useStateSC2[1];
541
+ var searchCollapsedFiles = _useStateSC[0];
542
+ var setSearchCollapsedFiles = _useStateSC[1];
401
543
  var toggleSearchFile = function toggleSearchFile(file) {
402
544
  setSearchCollapsedFiles(function (prev) {
403
545
  var next = Object.assign({}, prev);
@@ -406,9 +548,8 @@ var MbeditorApp = function MbeditorApp() {
406
548
  });
407
549
  };
408
550
  var _useStateSV = useState({ scrollTop: 0, height: 0 });
409
- var _useStateSV2 = _slicedToArray(_useStateSV, 2);
410
- var searchViewport = _useStateSV2[0];
411
- var setSearchViewport = _useStateSV2[1];
551
+ var searchViewport = _useStateSV[0];
552
+ var setSearchViewport = _useStateSV[1];
412
553
 
413
554
  // The container's height is only known once it is on screen, and a viewport
414
555
  // of 0 would render a single row and never grow — nothing would scroll, so
@@ -441,31 +582,26 @@ var MbeditorApp = function MbeditorApp() {
441
582
  }).current;
442
583
 
443
584
  var _useStateRM = useState(false);
444
- var _useStateRM2 = _slicedToArray(_useStateRM, 2);
445
- var replaceMode = _useStateRM2[0];
446
- var setReplaceMode = _useStateRM2[1];
585
+ var replaceMode = _useStateRM[0];
586
+ var setReplaceMode = _useStateRM[1];
447
587
 
448
588
  var _useStateRQ = useState('');
449
- var _useStateRQ2 = _slicedToArray(_useStateRQ, 2);
450
- var replaceQuery = _useStateRQ2[0];
451
- var setReplaceQuery = _useStateRQ2[1];
589
+ var replaceQuery = _useStateRQ[0];
590
+ var setReplaceQuery = _useStateRQ[1];
452
591
 
453
592
  var _useStateRL = useState(false);
454
- var _useStateRL2 = _slicedToArray(_useStateRL, 2);
455
- var replaceLoading = _useStateRL2[0];
456
- var setReplaceLoading = _useStateRL2[1];
593
+ var replaceLoading = _useStateRL[0];
594
+ var setReplaceLoading = _useStateRL[1];
457
595
 
458
596
  var _useState8 = useState("explorer");
459
597
 
460
- var _useState82 = _slicedToArray(_useState8, 2);
461
598
 
462
- var activeSidebarTab = _useState82[0];
463
- var setActiveSidebarTab = _useState82[1];
599
+ var activeSidebarTab = _useState8[0];
600
+ var setActiveSidebarTab = _useState8[1];
464
601
 
465
602
  var _useStateSC = useState(false);
466
- var _useStateSC2 = _slicedToArray(_useStateSC, 2);
467
- var sidebarCollapsed = _useStateSC2[0];
468
- var setSidebarCollapsed = _useStateSC2[1];
603
+ var sidebarCollapsed = _useStateSC[0];
604
+ var setSidebarCollapsed = _useStateSC[1];
469
605
 
470
606
  // Ref mirrors for use inside long-lived event handlers registered with
471
607
  // empty-dependency effects (live search refresh on files_changed).
@@ -477,130 +613,110 @@ var MbeditorApp = function MbeditorApp() {
477
613
  var jsSyntaxCheckAvailableRef = useRef(false);
478
614
 
479
615
  var _useStateRFMap = useState({});
480
- var _useStateRFMap2 = _slicedToArray(_useStateRFMap, 2);
481
- var railsFilesMap = _useStateRFMap2[0];
482
- var setRailsFilesMap = _useStateRFMap2[1];
616
+ var railsFilesMap = _useStateRFMap[0];
617
+ var setRailsFilesMap = _useStateRFMap[1];
483
618
 
484
619
  var _useStateRFC = useState({});
485
- var _useStateRFC2 = _slicedToArray(_useStateRFC, 2);
486
- var railsGroupsCollapsed = _useStateRFC2[0];
487
- var setRailsGroupsCollapsed = _useStateRFC2[1];
620
+ var railsGroupsCollapsed = _useStateRFC[0];
621
+ var setRailsGroupsCollapsed = _useStateRFC[1];
488
622
 
489
623
  var _useStateChangelog = useState(null); // null | { content, loading, error }
490
- var _useStateChangelog2 = _slicedToArray(_useStateChangelog, 2);
491
- var changelogState = _useStateChangelog2[0];
492
- var setChangelogState = _useStateChangelog2[1];
624
+ var changelogState = _useStateChangelog[0];
625
+ var setChangelogState = _useStateChangelog[1];
493
626
 
494
627
  var _useStateSchemaModal = useState(null);
495
- var _useStateSchemaModal2 = _slicedToArray(_useStateSchemaModal, 2);
496
- var schemaModal = _useStateSchemaModal2[0];
497
- var setSchemaModal = _useStateSchemaModal2[1];
628
+ var schemaModal = _useStateSchemaModal[0];
629
+ var setSchemaModal = _useStateSchemaModal[1];
498
630
 
499
631
  var _useStateImportConflict = useState(null);
500
- var _useStateImportConflict2 = _slicedToArray(_useStateImportConflict, 2);
501
- var importConflict = _useStateImportConflict2[0];
502
- var setImportConflict = _useStateImportConflict2[1];
632
+ var importConflict = _useStateImportConflict[0];
633
+ var setImportConflict = _useStateImportConflict[1];
503
634
 
504
635
  // { initialFolder } while the upload dialog is open, null otherwise.
505
636
  var _useStateImportDialog = useState(null);
506
- var _useStateImportDialog2 = _slicedToArray(_useStateImportDialog, 2);
507
- var importDialog = _useStateImportDialog2[0];
508
- var setImportDialog = _useStateImportDialog2[1];
637
+ var importDialog = _useStateImportDialog[0];
638
+ var setImportDialog = _useStateImportDialog[1];
509
639
 
510
640
  var _useStateSchemaLoading = useState(null);
511
- var _useStateSchemaLoading2 = _slicedToArray(_useStateSchemaLoading, 2);
512
- var schemaLoadingLabel = _useStateSchemaLoading2[0];
513
- var setSchemaLoadingLabel = _useStateSchemaLoading2[1];
641
+ var schemaLoadingLabel = _useStateSchemaLoading[0];
642
+ var setSchemaLoadingLabel = _useStateSchemaLoading[1];
514
643
 
515
644
  var _useState9 = useState({});
516
645
 
517
- var _useState92 = _slicedToArray(_useState9, 2);
518
646
 
519
- var markers = _useState92[0];
520
- var setMarkers = _useState92[1];
647
+ var markers = _useState9[0];
648
+ var setMarkers = _useState9[1];
521
649
  // { tabId: [] }
522
650
 
523
651
  var _useState10 = useState({});
524
652
 
525
- var _useState102 = _slicedToArray(_useState10, 2);
526
653
 
527
- var loading = _useState102[0];
528
- var setLoading = _useState102[1];
654
+ var loading = _useState10[0];
655
+ var setLoading = _useState10[1];
529
656
 
530
657
  var _useState11 = useState(null);
531
658
 
532
- var _useState112 = _slicedToArray(_useState11, 2);
533
659
 
534
- var closingTabId = _useState112[0];
535
- var setClosingTabId = _useState112[1];
660
+ var closingTabId = _useState11[0];
661
+ var setClosingTabId = _useState11[1];
536
662
 
537
663
  var _useState12 = useState(null);
538
664
 
539
- var _useState122 = _slicedToArray(_useState12, 2);
540
665
 
541
- var closingPaneId = _useState122[0];
542
- var setClosingPaneId = _useState122[1];
666
+ var closingPaneId = _useState12[0];
667
+ var setClosingPaneId = _useState12[1];
543
668
 
544
669
  var _useState13 = useState(SIDEBAR_MIN_WIDTH);
545
670
 
546
- var _useState132 = _slicedToArray(_useState13, 2);
547
671
 
548
- var sidebarWidth = _useState132[0];
549
- var setSidebarWidth = _useState132[1];
672
+ var sidebarWidth = _useState13[0];
673
+ var setSidebarWidth = _useState13[1];
550
674
 
551
675
  var _useState14 = useState(50);
552
676
 
553
- var _useState142 = _slicedToArray(_useState14, 2);
554
677
 
555
- var pane1Width = _useState142[0];
556
- var setPane1Width = _useState142[1];
678
+ var pane1Width = _useState14[0];
679
+ var setPane1Width = _useState14[1];
557
680
  // percentage
558
681
 
559
682
  var dragSplitWidthRef = useRef(pane1Width);
560
683
 
561
684
  var _useState15 = useState(null);
562
685
 
563
- var _useState152 = _slicedToArray(_useState15, 2);
564
686
 
565
- var activeResizeMode = _useState152[0];
566
- var setActiveResizeMode = _useState152[1];
687
+ var activeResizeMode = _useState15[0];
688
+ var setActiveResizeMode = _useState15[1];
567
689
 
568
690
  var _useState16 = useState(null);
569
691
 
570
- var _useState162 = _slicedToArray(_useState16, 2);
571
692
 
572
- var draggedTab = _useState162[0];
573
- var setDraggedTab = _useState162[1];
693
+ var draggedTab = _useState16[0];
694
+ var setDraggedTab = _useState16[1];
574
695
 
575
696
  var _useState17 = useState(null);
576
697
 
577
- var _useState172 = _slicedToArray(_useState17, 2);
578
698
 
579
- var dragOverPaneId = _useState172[0];
580
- var setDragOverPaneId = _useState172[1];
699
+ var dragOverPaneId = _useState17[0];
700
+ var setDragOverPaneId = _useState17[1];
581
701
 
582
702
  var _useState18 = useState(false);
583
- var _useState182 = _slicedToArray(_useState18, 2);
584
- var showGitPanel = _useState182[0];
585
- var setShowGitPanel = _useState182[1];
703
+ var showGitPanel = _useState18[0];
704
+ var setShowGitPanel = _useState18[1];
586
705
  var showGitPanelRef = useRef(showGitPanel);
587
706
  showGitPanelRef.current = showGitPanel;
588
707
 
589
708
  var _useStateLog = useState(false);
590
- var _useStateLog2 = _slicedToArray(_useStateLog, 2);
591
- var showLogPanel = _useStateLog2[0];
592
- var setShowLogPanel = _useStateLog2[1];
709
+ var showLogPanel = _useStateLog[0];
710
+ var setShowLogPanel = _useStateLog[1];
593
711
 
594
712
  var _useStateProblems = useState(false);
595
- var _useStateProblems2 = _slicedToArray(_useStateProblems, 2);
596
- var showProblemsPanel = _useStateProblems2[0];
597
- var setShowProblemsPanel = _useStateProblems2[1];
713
+ var showProblemsPanel = _useStateProblems[0];
714
+ var setShowProblemsPanel = _useStateProblems[1];
598
715
 
599
716
  // Error/warning tallies across the open tabs, mirrored into the status bar.
600
717
  var _useStateProblemCounts = useState({ errors: 0, warnings: 0 });
601
- var _useStateProblemCounts2 = _slicedToArray(_useStateProblemCounts, 2);
602
- var problemCounts = _useStateProblemCounts2[0];
603
- var setProblemCounts = _useStateProblemCounts2[1];
718
+ var problemCounts = _useStateProblemCounts[0];
719
+ var setProblemCounts = _useStateProblemCounts[1];
604
720
 
605
721
  // Below this the toolbar's labelled buttons no longer fit beside the title
606
722
  // and the file search, and start pushing each other out of the bar.
@@ -612,9 +728,8 @@ var MbeditorApp = function MbeditorApp() {
612
728
  return typeof window.matchMedia === 'function' &&
613
729
  window.matchMedia('(max-width: ' + TOOLBAR_LABEL_MIN_WIDTH + 'px)').matches;
614
730
  });
615
- var _useStateNarrow2 = _slicedToArray(_useStateNarrow, 2);
616
- var narrowToolbar = _useStateNarrow2[0];
617
- var setNarrowToolbar = _useStateNarrow2[1];
731
+ var narrowToolbar = _useStateNarrow[0];
732
+ var setNarrowToolbar = _useStateNarrow[1];
618
733
 
619
734
  useEffect(function () {
620
735
  if (typeof window.matchMedia !== 'function') return;
@@ -634,146 +749,183 @@ var MbeditorApp = function MbeditorApp() {
634
749
  // Model graph. Built lazily — generating it eager-loads the host app — and
635
750
  // only when the Models tab is opened.
636
751
  var _useStateModelGraph = useState(null);
637
- var _useStateModelGraph2 = _slicedToArray(_useStateModelGraph, 2);
638
- var modelGraph = _useStateModelGraph2[0];
639
- var setModelGraph = _useStateModelGraph2[1];
752
+ var modelGraph = _useStateModelGraph[0];
753
+ var setModelGraph = _useStateModelGraph[1];
640
754
 
641
755
  var _useStateModelGraphLoading = useState(false);
642
- var _useStateModelGraphLoading2 = _slicedToArray(_useStateModelGraphLoading, 2);
643
- var modelGraphLoading = _useStateModelGraphLoading2[0];
644
- var setModelGraphLoading = _useStateModelGraphLoading2[1];
756
+ var modelGraphLoading = _useStateModelGraphLoading[0];
757
+ var setModelGraphLoading = _useStateModelGraphLoading[1];
645
758
 
646
759
  var _useState18g = useState(320);
647
- var _useState18g2 = _slicedToArray(_useState18g, 2);
648
- var gitPanelWidth = _useState18g2[0];
649
- var setGitPanelWidth = _useState18g2[1];
760
+ var gitPanelWidth = _useState18g[0];
761
+ var setGitPanelWidth = _useState18g[1];
650
762
  var gitPanelWidthRef = useRef(gitPanelWidth);
651
763
  gitPanelWidthRef.current = gitPanelWidth;
652
764
 
653
765
  var _useState18h = useState(false);
654
766
 
655
- var _useState18h2 = _slicedToArray(_useState18h, 2);
656
767
 
657
- var showHelp = _useState18h2[0];
658
- var setShowHelp = _useState18h2[1];
768
+ var showHelp = _useState18h[0];
769
+ var setShowHelp = _useState18h[1];
770
+
771
+ var _useState18s = useState(false);
772
+ var showSettings = _useState18s[0];
773
+ var setShowSettings = _useState18s[1];
659
774
 
660
775
  var _useStatePwa = useState(null);
661
- var _useStatePwa2 = _slicedToArray(_useStatePwa, 2);
662
- var pwaInstallPrompt = _useStatePwa2[0];
663
- var setPwaInstallPrompt = _useStatePwa2[1];
776
+ var pwaInstallPrompt = _useStatePwa[0];
777
+ var setPwaInstallPrompt = _useStatePwa[1];
664
778
 
665
779
  var _useState18b = useState(true);
666
780
 
667
- var _useState18b2 = _slicedToArray(_useState18b, 2);
668
781
 
669
- var serverOnline = _useState18b2[0];
670
- var setServerOnline = _useState18b2[1];
782
+ var serverOnline = _useState18b[0];
783
+ var setServerOnline = _useState18b[1];
784
+
785
+ // Pending migrations. file_service.js owns the truth (it is what sees the
786
+ // response header) and publishes it as a <body> class plus an event; this
787
+ // only mirrors it into the status bar.
788
+ var _useStateMig = useState(function () {
789
+ return document.body.classList.contains('mbeditor-migration-pending');
790
+ });
791
+ var migrationsPending = _useStateMig[0];
792
+ var setMigrationsPending = _useStateMig[1];
793
+
794
+ useEffect(function () {
795
+ function onChange(e) { setMigrationsPending(!!e.detail); }
796
+ window.addEventListener('mbeditor:pending-migration', onChange);
797
+ return function () { window.removeEventListener('mbeditor:pending-migration', onChange); };
798
+ }, []);
671
799
 
672
800
  var _useState18c = useState(false);
673
801
 
674
- var _useState18c2 = _slicedToArray(_useState18c, 2);
675
802
 
676
- var rubocopAvailable = _useState18c2[0];
677
- var setRubocopAvailable = _useState18c2[1];
803
+ var rubocopAvailable = _useState18c[0];
804
+ var setRubocopAvailable = _useState18c[1];
678
805
 
679
806
  var _useState18d = useState(false);
680
807
 
681
- var _useState18d2 = _slicedToArray(_useState18d, 2);
682
808
 
683
- var hamlLintAvailable = _useState18d2[0];
684
- var setHamlLintAvailable = _useState18d2[1];
809
+ var hamlLintAvailable = _useState18d[0];
810
+ var setHamlLintAvailable = _useState18d[1];
685
811
 
686
812
  var _useState18e = useState(false);
687
- var _useState18e2 = _slicedToArray(_useState18e, 2);
688
- var gitAvailable = _useState18e2[0];
689
- var setGitAvailable = _useState18e2[1];
813
+ var gitAvailable = _useState18e[0];
814
+ var setGitAvailable = _useState18e[1];
690
815
 
691
816
  var _useState18f = useState(false);
692
- var _useState18f2 = _slicedToArray(_useState18f, 2);
693
- var redmineEnabled = _useState18f2[0];
694
- var setRedmineEnabled = _useState18f2[1];
817
+ var redmineEnabled = _useState18f[0];
818
+ var setRedmineEnabled = _useState18f[1];
695
819
 
696
820
  var _useState18rc = useState(null);
697
- var _useState18rc2 = _slicedToArray(_useState18rc, 2);
698
- var rubocopConfigPath = _useState18rc2[0];
699
- var setRubocopConfigPath = _useState18rc2[1];
821
+ var rubocopConfigPath = _useState18rc[0];
822
+ var setRubocopConfigPath = _useState18rc[1];
700
823
 
701
824
  var _useState18u = useState(null);
702
- var _useState18u2 = _slicedToArray(_useState18u, 2);
703
- var testResult = _useState18u2[0];
704
- var setTestResult = _useState18u2[1];
825
+ var testResult = _useState18u[0];
826
+ var setTestResult = _useState18u[1];
705
827
 
706
828
  var _useState18v = useState(false);
707
- var _useState18v2 = _slicedToArray(_useState18v, 2);
708
- var testLoading = _useState18v2[0];
709
- var setTestLoading = _useState18v2[1];
829
+ var testLoading = _useState18v[0];
830
+ var setTestLoading = _useState18v[1];
710
831
 
711
832
  var _useState18w = useState(true);
712
- var _useState18w2 = _slicedToArray(_useState18w, 2);
713
- var testInlineVisible = _useState18w2[0];
714
- var setTestInlineVisible = _useState18w2[1];
833
+ var testInlineVisible = _useState18w[0];
834
+ var setTestInlineVisible = _useState18w[1];
715
835
 
716
836
  var _useState18x = useState(null);
717
- var _useState18x2 = _slicedToArray(_useState18x, 2);
718
- var testPanelFile = _useState18x2[0];
719
- var setTestPanelFile = _useState18x2[1];
837
+ var testPanelFile = _useState18x[0];
838
+ var setTestPanelFile = _useState18x[1];
720
839
 
721
840
  var _useState18y = useState(false);
722
- var _useState18y2 = _slicedToArray(_useState18y, 2);
723
- var testPanelOpen = _useState18y2[0];
724
- var setTestPanelOpen = _useState18y2[1];
841
+ var testPanelOpen = _useState18y[0];
842
+ var setTestPanelOpen = _useState18y[1];
725
843
 
726
844
  var _useState18p = useState(DEFAULT_EDITOR_PREFS);
727
- var _useState18p2 = _slicedToArray(_useState18p, 2);
728
- var editorPrefs = _useState18p2[0];
729
- var setEditorPrefs = _useState18p2[1];
730
-
731
- // Icon-only toolbar: on by preference, or automatically once the window is
732
- // too narrow for the labels to fit beside the title and file search.
733
- var toolbarIconOnly = editorPrefs.toolbarIconOnly || narrowToolbar;
845
+ var editorPrefs = _useState18p[0];
846
+ var setEditorPrefs = _useState18p[1];
847
+
848
+ // Icon-only toolbar: the default, since every button carries its name in a
849
+ // title attribute. Labels are opt-in, and are dropped regardless once the
850
+ // window is too narrow for them to fit beside the title and file search.
851
+ var toolbarIconOnly = !editorPrefs.toolbarLabels || narrowToolbar;
852
+
853
+ // The audit ring never reads preferences itself; enablement is pushed in.
854
+ // BOOT is recorded on the first enable rather than at mount, because the
855
+ // stored preference arrives from /state after the default has already been
856
+ // applied and switching off discards anything buffered before it.
857
+ var auditBooted = useRef(false);
858
+ useEffect(function () {
859
+ var audit = window.MbeditorAudit;
860
+ if (!audit) return;
861
+ var on = editorPrefs.auditLog !== false;
862
+ audit.setEnabled(on);
863
+ if (!on || auditBooted.current) return;
864
+ auditBooted.current = true;
865
+ // Ready is when Monaco is up and the file can be typed in, not when React
866
+ // mounted: the gap between the two is most of the boot.
867
+ Promise.resolve(window.__monacoReady).then(function () {
868
+ audit.rec(audit.EV.BOOT, performance.now() | 0);
869
+ });
870
+ }, [editorPrefs.auditLog]);
734
871
 
735
872
  var _useState19 = useState({
736
873
  openEditors: false,
737
874
  projects: false
738
875
  });
739
876
 
740
- var _useState192 = _slicedToArray(_useState19, 2);
741
877
 
742
- var collapsedSections = _useState192[0];
743
- var setCollapsedSections = _useState192[1];
878
+ var collapsedSections = _useState19[0];
879
+ var setCollapsedSections = _useState19[1];
744
880
 
745
881
  var _useState20 = useState({});
746
882
 
747
- var _useState202 = _slicedToArray(_useState20, 2);
748
883
 
749
- var expandedDirs = _useState202[0];
750
- var setExpandedDirs = _useState202[1];
884
+ var expandedDirs = _useState20[0];
885
+ var setExpandedDirs = _useState20[1];
751
886
 
752
887
  var _useState21 = useState(null);
753
888
 
754
- var _useState212 = _slicedToArray(_useState21, 2);
755
889
 
756
- var pendingCreate = _useState212[0];
757
- var setPendingCreate = _useState212[1];
890
+ var pendingCreate = _useState21[0];
891
+ var setPendingCreate = _useState21[1];
758
892
 
759
893
  var _useState22 = useState(null);
760
894
 
761
- var _useState222 = _slicedToArray(_useState22, 2);
762
895
 
763
- var pendingRename = _useState222[0];
764
- var setPendingRename = _useState222[1];
896
+ var pendingRename = _useState22[0];
897
+ var setPendingRename = _useState22[1];
765
898
 
766
899
  var _useState23 = useState(null);
767
900
 
768
- var _useState232 = _slicedToArray(_useState23, 2);
769
901
 
770
- var contextMenu = _useState232[0];
771
- var setContextMenu = _useState232[1];
902
+ var contextMenu = _useState23[0];
903
+ var setContextMenu = _useState23[1];
904
+ var contextMenuRef = useRef(null);
772
905
 
773
906
  var _useState24 = useState(140);
774
- var _useState242 = _slicedToArray(_useState24, 2);
775
- var openEditorsHeight = _useState242[0];
776
- var setOpenEditorsHeight = _useState242[1];
907
+ var openEditorsHeight = _useState24[0];
908
+ var setOpenEditorsHeight = _useState24[1];
909
+
910
+ // Both bottom drawers are resized by a gutter that sits between the editor
911
+ // card and the drawer, so the height lives here rather than in the drawer —
912
+ // a panel cannot own the handle that is no longer inside it.
913
+ var _drawerHeight = function (key) {
914
+ var saved = parseInt(window.localStorage.getItem(key), 10);
915
+ return (saved && saved >= DRAWER_MIN_HEIGHT) ? saved : 240;
916
+ };
917
+ var _useStateLogH = useState(function () { return _drawerHeight('mbeditorLogHeight'); });
918
+ var logHeight = _useStateLogH[0];
919
+ var setLogHeight = _useStateLogH[1];
920
+ var _useStateProblemsH = useState(function () { return _drawerHeight('mbeditorProblemsHeight'); });
921
+ var problemsHeight = _useStateProblemsH[0];
922
+ var setProblemsHeight = _useStateProblemsH[1];
923
+
924
+ // One-shot target for post-operation scroll reveal (create/rename/delete).
925
+ // A fresh object each time so the effect re-fires even for a repeated path.
926
+ var _useState25 = useState(null);
927
+ var revealTarget = _useState25[0];
928
+ var setRevealTarget = _useState25[1];
777
929
 
778
930
  var resizeSessionRef = useRef(null);
779
931
  var resizeRafRef = useRef(null);
@@ -783,9 +935,8 @@ var MbeditorApp = function MbeditorApp() {
783
935
  var ctrlWPendingRef = useRef(false);
784
936
  var ctrlWTimeoutRef = useRef(null);
785
937
  var _useStateCP = useState([]);
786
- var _useStateCP2 = _slicedToArray(_useStateCP, 2);
787
- var customPaths = _useStateCP2[0];
788
- var setCustomPaths = _useStateCP2[1];
938
+ var customPaths = _useStateCP[0];
939
+ var setCustomPaths = _useStateCP[1];
789
940
  var customPathsRef = useRef([]);
790
941
  customPathsRef.current = customPaths;
791
942
  // Whether to show the presence chips at all. Read at render rather than held in
@@ -798,33 +949,29 @@ var MbeditorApp = function MbeditorApp() {
798
949
  var _useStateIdent = useState(
799
950
  typeof CollaborationIdentity !== 'undefined' ? CollaborationIdentity.get() : null
800
951
  );
801
- var _useStateIdent2 = _slicedToArray(_useStateIdent, 2);
802
- var collabIdentity = _useStateIdent2[0];
803
- var setCollabIdentity = _useStateIdent2[1];
952
+ var collabIdentity = _useStateIdent[0];
953
+ var setCollabIdentity = _useStateIdent[1];
804
954
  // Presence roster: other connected participants, keyed by client_id →
805
955
  // { name, colour, current_file }. Fed by the global presence stream; rendered
806
956
  // as click-to-jump chips in the status bar.
807
957
  var _useStateRoster = useState({});
808
- var _useStateRoster2 = _slicedToArray(_useStateRoster, 2);
809
- var collabRoster = _useStateRoster2[0];
810
- var setCollabRoster = _useStateRoster2[1];
958
+ var collabRoster = _useStateRoster[0];
959
+ var setCollabRoster = _useStateRoster[1];
811
960
 
812
961
  // Follow mode (slice 8): the presence client_id of the participant whose file +
813
962
  // viewport we're tracking, or null when navigating independently. Toggled from a
814
963
  // roster chip. The file-open is driven by the effect below; the scroll-tracking
815
964
  // lives in CollaborationService.setFollow().
816
965
  var _useStateFollow = useState(null);
817
- var _useStateFollow2 = _slicedToArray(_useStateFollow, 2);
818
- var followedClientId = _useStateFollow2[0];
819
- var setFollowedClientId = _useStateFollow2[1];
966
+ var followedClientId = _useStateFollow[0];
967
+ var setFollowedClientId = _useStateFollow[1];
820
968
  var recentSavesRef = useRef({});
821
969
  var isSavingRef = useRef(false);
822
970
  // True once the saved session has finished loading into the panes. Anything
823
971
  // that opens a tab on startup must wait for this, or the restore overwrites it.
824
972
  var _useStateSR = useState(false);
825
- var _useStateSR2 = _slicedToArray(_useStateSR, 2);
826
- var sessionRestored = _useStateSR2[0];
827
- var setSessionRestored = _useStateSR2[1];
973
+ var sessionRestored = _useStateSR[0];
974
+ var setSessionRestored = _useStateSR[1];
828
975
  var pendingChangelogRef = useRef(false);
829
976
  // path -> the file's content as last seen ON DISK (newline-normalised).
830
977
  // External-change detection compares disk-to-disk; comparing disk to the
@@ -853,15 +1000,37 @@ var MbeditorApp = function MbeditorApp() {
853
1000
 
854
1001
  // ── Draft backup helpers ─────────────────────────────────────────────────
855
1002
  var draftWriteTimerRef = useRef({});
1003
+ var draftWriteWarnedRef = useRef({});
856
1004
  var serverOnlineRef = useRef(true);
857
1005
 
858
1006
  var _draftKey = function _draftKey(path) {
859
1007
  var base = typeof window.mbeditorBasePath === 'function' ? window.mbeditorBasePath() : '';
860
1008
  return 'mbeditor_draft\x00' + base + '\x00' + path;
861
1009
  };
1010
+ // localStorage is UTF-16 and most browsers cap it near 5MB, so a draft for a
1011
+ // file past this can never fit: every debounced write failed, warned once, and
1012
+ // then kept stringifying + attempting the whole buffer every 500ms of typing.
1013
+ // Skip it outright; an explicit save is the safety net for huge files.
1014
+ var MAX_DRAFT_CHARS = 1000000;
862
1015
  var _saveDraftNow = function _saveDraftNow(path, content) {
1016
+ if (typeof content === 'string' && content.length > MAX_DRAFT_CHARS) {
1017
+ if (!draftWriteWarnedRef.current[path]) {
1018
+ draftWriteWarnedRef.current[path] = true;
1019
+ EditorStore.setStatus('Crash-recovery backup skipped for ' + path + ' (file too large)', 'info');
1020
+ }
1021
+ _clearDraft(path);
1022
+ return;
1023
+ }
863
1024
  var doWrite = function() {
864
- try { localStorage.setItem(_draftKey(path), JSON.stringify({ content: content, ts: Date.now() })); } catch (e) {}
1025
+ try {
1026
+ localStorage.setItem(_draftKey(path), JSON.stringify({ content: content, ts: Date.now() }));
1027
+ } catch (e) {
1028
+ // Once per path: this fires on every debounced keystroke otherwise.
1029
+ if (!draftWriteWarnedRef.current[path]) {
1030
+ draftWriteWarnedRef.current[path] = true;
1031
+ EditorStore.setStatus('Crash-recovery backup failed for ' + path + ' (storage full)', 'error');
1032
+ }
1033
+ }
865
1034
  };
866
1035
  if (typeof requestIdleCallback !== 'undefined') {
867
1036
  requestIdleCallback(doWrite, { timeout: 2000 });
@@ -884,24 +1053,25 @@ var MbeditorApp = function MbeditorApp() {
884
1053
  };
885
1054
 
886
1055
  var _useState_dro = useState(null);
887
- var _useState_dro2 = _slicedToArray(_useState_dro, 2);
888
- var draftRestoreOffer = _useState_dro2[0];
889
- var setDraftRestoreOffer = _useState_dro2[1];
1056
+ var draftRestoreOffer = _useState_dro[0];
1057
+ var setDraftRestoreOffer = _useState_dro[1];
890
1058
 
891
1059
  var _useStateMR = useState(false);
892
- var _useStateMR2 = _slicedToArray(_useStateMR, 2);
893
- var monacoReady = _useStateMR2[0];
894
- var setMonacoReady = _useStateMR2[1];
1060
+ var monacoReady = _useStateMR[0];
1061
+ var setMonacoReady = _useStateMR[1];
895
1062
 
896
1063
  var _useStateZen = useState(false);
897
- var _useStateZen2 = _slicedToArray(_useStateZen, 2);
898
- var zenMode = _useStateZen2[0];
899
- var setZenMode = _useStateZen2[1];
1064
+ var zenMode = _useStateZen[0];
1065
+ var setZenMode = _useStateZen[1];
1066
+
1067
+ // Transient, like zen mode — never persisted, always starts closed.
1068
+ var _useStateMGV = useState(false);
1069
+ var showModelGraphView = _useStateMGV[0];
1070
+ var setShowModelGraphView = _useStateMGV[1];
900
1071
 
901
1072
  var _useStateSB = useState(false);
902
- var _useStateSB2 = _slicedToArray(_useStateSB, 2);
903
- var isSwitchingBranch = _useStateSB2[0];
904
- var setIsSwitchingBranch = _useStateSB2[1];
1073
+ var isSwitchingBranch = _useStateSB[0];
1074
+ var setIsSwitchingBranch = _useStateSB[1];
905
1075
 
906
1076
  var clamp = function clamp(value, min, max) {
907
1077
  return Math.min(max, Math.max(min, value));
@@ -955,6 +1125,48 @@ var MbeditorApp = function MbeditorApp() {
955
1125
  });
956
1126
  };
957
1127
 
1128
+ // Finds the children array of parentPath within tree ('' means the root).
1129
+ var findTreeChildren = function findTreeChildren(tree, parentPath) {
1130
+ if (!parentPath) return tree;
1131
+ for (var i = 0; i < tree.length; i++) {
1132
+ var item = tree[i];
1133
+ if (item.path === parentPath && item.type === 'folder') return item.children || [];
1134
+ if (item.type === 'folder' && item.children) {
1135
+ var found = findTreeChildren(item.children, parentPath);
1136
+ if (found) return found;
1137
+ }
1138
+ }
1139
+ return null;
1140
+ };
1141
+
1142
+ // Row to reveal after a delete: the next surviving sibling in display
1143
+ // order, else the previous one, else the parent directory. Sorted the same
1144
+ // way FileTree sorts (folders first, then name) so it matches what the
1145
+ // user actually sees.
1146
+ var computeDeleteRevealTarget = function computeDeleteRevealTarget(tree, deletedPaths) {
1147
+ var anchor = deletedPaths[0];
1148
+ var parent = parentDir(anchor);
1149
+ var siblings = findTreeChildren(tree, parent);
1150
+ if (siblings) {
1151
+ var sorted = siblings.concat().sort(function (a, b) {
1152
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
1153
+ return a.name.localeCompare(b.name);
1154
+ });
1155
+ var idx = sorted.findIndex(function (n) { return n.path === anchor; });
1156
+ if (idx >= 0) {
1157
+ var deletedSet = new Set(deletedPaths);
1158
+ var j, k;
1159
+ for (j = idx + 1; j < sorted.length; j++) {
1160
+ if (!deletedSet.has(sorted[j].path)) return sorted[j].path;
1161
+ }
1162
+ for (k = idx - 1; k >= 0; k--) {
1163
+ if (!deletedSet.has(sorted[k].path)) return sorted[k].path;
1164
+ }
1165
+ }
1166
+ }
1167
+ return parent || null;
1168
+ };
1169
+
958
1170
  // Every structural mutation (create, delete, rename, import) funnels through
959
1171
  // here, so this is where the project-search cache has to be dropped. Saves
960
1172
  // invalidated it at their own call sites, which is why editing a file
@@ -1085,6 +1297,8 @@ var MbeditorApp = function MbeditorApp() {
1085
1297
  EditorStore.setStatus('Linting...', 'info');
1086
1298
  }
1087
1299
 
1300
+ var startedAt = Date.now();
1301
+
1088
1302
  // ruby-lsp answers diagnostics for Ruby files when it's available: same
1089
1303
  // markers, plus Prism syntax errors, without a per-keystroke rubocop boot.
1090
1304
  // Anything short of a usable answer falls through to the HTTP lint for
@@ -1107,6 +1321,8 @@ var MbeditorApp = function MbeditorApp() {
1107
1321
 
1108
1322
  return lintRequest.then(function (res) {
1109
1323
  var nextMarkers = res.markers || [];
1324
+ var audit = window.MbeditorAudit;
1325
+ if (audit) audit.rec(audit.EV.LINT, _auditExt(tab.path), Date.now() - startedAt, nextMarkers.length);
1110
1326
  applyMarkersForTab(tab.id, nextMarkers);
1111
1327
 
1112
1328
  if (options.showStatus) {
@@ -1116,6 +1332,7 @@ var MbeditorApp = function MbeditorApp() {
1116
1332
 
1117
1333
  return res;
1118
1334
  })["catch"](function (err) {
1335
+ _recErr('LINT');
1119
1336
  if (options.showStatus) {
1120
1337
  EditorStore.setStatus('Lint failed: ' + err.message, 'error');
1121
1338
  }
@@ -1191,9 +1408,7 @@ var MbeditorApp = function MbeditorApp() {
1191
1408
  Promise.all([FileService.getWorkspace()["catch"](function () {
1192
1409
  return null;
1193
1410
  }), refreshProjectTree()]).then(function (_ref) {
1194
- var _ref2 = _slicedToArray(_ref, 1);
1195
-
1196
- var workspace = _ref2[0];
1411
+ var workspace = _ref[0];
1197
1412
 
1198
1413
  if (workspace && workspace.rootName) {
1199
1414
  setProjectRootName(workspace.rootName);
@@ -1214,6 +1429,9 @@ var MbeditorApp = function MbeditorApp() {
1214
1429
  if (workspace && typeof workspace.rubyLspAvailable === 'boolean') {
1215
1430
  window.MBEDITOR_RUBY_LSP_AVAILABLE = workspace.rubyLspAvailable;
1216
1431
  }
1432
+ if (workspace && workspace.searchBackend) {
1433
+ window.MBEDITOR_SEARCH_BACKEND = workspace.searchBackend;
1434
+ }
1217
1435
  if (workspace && typeof workspace.gitAvailable === 'boolean') {
1218
1436
  setGitAvailable(workspace.gitAvailable);
1219
1437
  }
@@ -1231,11 +1449,15 @@ var MbeditorApp = function MbeditorApp() {
1231
1449
  // Helper: load tab content for a set of panes and restore them into EditorStore
1232
1450
  function loadPaneState(panesToLoad, focusedPaneId) {
1233
1451
  if (!panesToLoad || panesToLoad.length === 0) return Promise.resolve();
1452
+ // Settings is a modal now; a state saved when it was a tab must not
1453
+ // restore one.
1454
+ panesToLoad = panesToLoad.map(function (p) {
1455
+ return _extends({}, p, {
1456
+ tabs: p.tabs.filter(function (t) { return !t.isSettings && t.path !== '__settings__'; })
1457
+ });
1458
+ });
1234
1459
  var allTabs = panesToLoad.flatMap(function (p) { return p.tabs; });
1235
1460
  return Promise.all(allTabs.map(function (t) {
1236
- if (t.isSettings || t.path === '__settings__') {
1237
- return Promise.resolve({ content: '' });
1238
- }
1239
1461
  if (t.isChangelog || t.path === 'mbeditor://changelog') {
1240
1462
  return Promise.resolve({ content: '' });
1241
1463
  }
@@ -1247,9 +1469,6 @@ var MbeditorApp = function MbeditorApp() {
1247
1469
  if (t.isCombinedDiff || (t.path || '').startsWith('combined-diff://') || (t.path || '').startsWith('diff://')) {
1248
1470
  return Promise.resolve({ content: '' });
1249
1471
  }
1250
- if (t.isModelGraph || t.path === 'mbeditor://model-graph') {
1251
- return Promise.resolve({ content: '' });
1252
- }
1253
1472
  var sourcePath = t.isPreview || /::preview$/.test(t.path || '') ? t.previewFor || (t.path || '').replace(/::preview$/, '') : t.path;
1254
1473
  return FileService.getFile(sourcePath, { allowMissing: true }).then(function (data) {
1255
1474
  return {
@@ -1272,8 +1491,8 @@ var MbeditorApp = function MbeditorApp() {
1272
1491
  var tabs = [];
1273
1492
  p.tabs.forEach(function (t) {
1274
1493
  var res = results[resIdx++];
1275
- var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff && !t.isSettings &&
1276
- !t.isChangelog && !t.isPreview && !t.isModelGraph &&
1494
+ var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff &&
1495
+ !t.isChangelog && !t.isPreview &&
1277
1496
  !/^(diff|combined-diff):\/\//.test(t.path) && !/::preview$/.test(t.path);
1278
1497
  if (isPlainFile) {
1279
1498
  if (seenPaths[t.path]) return;
@@ -1283,9 +1502,6 @@ var MbeditorApp = function MbeditorApp() {
1283
1502
  content: res.content,
1284
1503
  externalContentVersion: (t.externalContentVersion || 0) + 1
1285
1504
  },
1286
- // A state saved before this tab type existed carries the path but
1287
- // not the flag, and would restore as a missing file.
1288
- t.path === 'mbeditor://model-graph' ? { isModelGraph: true } : {},
1289
1505
  res._isDiffResult ? { diffOriginal: res.diffOriginal, diffModified: res.diffModified } : {},
1290
1506
  typeof res.fileNotFound === 'boolean' ? { fileNotFound: res.fileNotFound, dirty: res.fileNotFound ? false : t.dirty } : {},
1291
1507
  res.image === true ? { isImage: true } : {}));
@@ -1397,10 +1613,10 @@ var MbeditorApp = function MbeditorApp() {
1397
1613
  return {
1398
1614
  id: p.id,
1399
1615
  activeTabId: p.activeTabId,
1400
- tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
1616
+ tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isUntitled; }).map(function (t) {
1401
1617
  return {
1402
1618
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1403
- isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
1619
+ isPreview: !!t.isPreview, previewFor: t.previewFor || null,
1404
1620
  isDiff: !!t.isDiff, diffBaseSha: t.diffBaseSha || null, diffHeadSha: t.diffHeadSha || null,
1405
1621
  repoPath: t.repoPath || null, isChangelog: !!t.isChangelog
1406
1622
  };
@@ -1483,6 +1699,7 @@ var MbeditorApp = function MbeditorApp() {
1483
1699
  if (e.key === 'Escape') {
1484
1700
  setContextMenu(null);
1485
1701
  setShowHelp(false);
1702
+ setCollabOverflowOpen(false);
1486
1703
  }
1487
1704
  };
1488
1705
 
@@ -1516,6 +1733,9 @@ var MbeditorApp = function MbeditorApp() {
1516
1733
  var reservedRight = EDITOR_MIN_WIDTH + (showGitPanelRef.current ? gitPanelWidthRef.current : 0);
1517
1734
  var maxSidebarWidth = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, rect.width - reservedRight));
1518
1735
  var nextWidth = clientX - rect.left - SIDEBAR_COLLAPSED_WIDTH;
1736
+ // Remembered for mouseup: dragged well under the minimum, the
1737
+ // explorer snaps shut instead of pinning at SIDEBAR_MIN_WIDTH.
1738
+ s.snapClose = nextWidth < SIDEBAR_MIN_WIDTH - 80;
1519
1739
  setSidebarWidth(clamp(nextWidth, SIDEBAR_MIN_WIDTH, maxSidebarWidth));
1520
1740
  }
1521
1741
 
@@ -1533,11 +1753,26 @@ var MbeditorApp = function MbeditorApp() {
1533
1753
  var nextHeight = Math.max(60, Math.min(400, s.startHeight + delta));
1534
1754
  setOpenEditorsHeight(nextHeight);
1535
1755
  }
1756
+
1757
+ // Bottom drawers. Delta-based and inverted: dragging the gutter up
1758
+ // grows the drawer. Independent of where the drawer is anchored and
1759
+ // survives a zero/unknown viewport height.
1760
+ if (s.mode === 'log' || s.mode === 'problems') {
1761
+ var vh = window.innerHeight || document.documentElement.clientHeight || 0;
1762
+ var maxH = vh > 0 ? Math.round(vh * 0.85) : Infinity;
1763
+ s.lastHeight = Math.min(maxH, Math.max(DRAWER_MIN_HEIGHT, s.startHeight + (s.startY - clientY)));
1764
+ (s.mode === 'log' ? setLogHeight : setProblemsHeight)(s.lastHeight);
1765
+ }
1536
1766
  });
1537
1767
  };
1538
1768
 
1539
1769
  var handleMouseUp = function handleMouseUp() {
1540
1770
  if (!resizeSessionRef.current) return;
1771
+ if (resizeSessionRef.current.snapClose) setSidebarCollapsed(true);
1772
+ var s = resizeSessionRef.current;
1773
+ if (s.storeKey && s.lastHeight) {
1774
+ try { window.localStorage.setItem(s.storeKey, String(s.lastHeight)); } catch (err) {}
1775
+ }
1541
1776
 
1542
1777
  if (resizeRafRef.current) {
1543
1778
  cancelAnimationFrame(resizeRafRef.current);
@@ -1850,7 +2085,10 @@ var MbeditorApp = function MbeditorApp() {
1850
2085
  function handleFileSaved(data) {
1851
2086
  var path = data && data.path;
1852
2087
  if (!path) return;
1853
- if (typeof CollaborationService === 'undefined' || !CollaborationService.isBound(path)) return;
2088
+ // Only an attached (CRDT-bound) tab has the peer's content. A room that is
2089
+ // still deferring, or degraded to local, holds its own buffer — a peer save
2090
+ // is an external change for it, handled by the files_changed path.
2091
+ if (typeof CollaborationService === 'undefined' || !CollaborationService.isAttached(path)) return;
1854
2092
 
1855
2093
  var st = EditorStore.getState();
1856
2094
  var changed = false;
@@ -1911,7 +2149,6 @@ var MbeditorApp = function MbeditorApp() {
1911
2149
  !path.startsWith('combined-diff://') &&
1912
2150
  !path.startsWith('untitled://') &&
1913
2151
  !pt.tab.isCombinedDiff &&
1914
- !pt.tab.isSettings &&
1915
2152
  !pt.tab.isImage &&
1916
2153
  !pt.tab.isDiff &&
1917
2154
  typeof pt.tab.content === 'string';
@@ -2018,8 +2255,18 @@ var MbeditorApp = function MbeditorApp() {
2018
2255
  useEffect(function () {
2019
2256
  var intervalId = setInterval(function () {
2020
2257
  if (document.hidden) return;
2258
+ var startedAt = Date.now();
2021
2259
  FileService.getTree({ background: true }).then(function (data) {
2022
- setTreeData(_treeUpdater(data || []));
2260
+ var next = data || [];
2261
+ var audit = window.MbeditorAudit;
2262
+ // Stringified once and reused by the updater, so instrumenting the poll
2263
+ // costs no extra pass over the tree.
2264
+ var signature = audit ? JSON.stringify(next) : null;
2265
+ setTreeData(_treeUpdater(next, signature));
2266
+ if (!audit) return;
2267
+ var changed = signature !== _lastPolledTree;
2268
+ _lastPolledTree = signature;
2269
+ audit.rec(audit.EV.TREE_POLL, Date.now() - startedAt, changed ? 1 : 0);
2023
2270
  }).catch(function () {}); // silently ignore auto-refresh errors
2024
2271
  }, 10000);
2025
2272
  return function () { clearInterval(intervalId); };
@@ -2086,6 +2333,10 @@ var MbeditorApp = function MbeditorApp() {
2086
2333
  }, [monacoReady]);
2087
2334
 
2088
2335
  var handleSelectFile = function handleSelectFile(path, name, line, col, endCol) {
2336
+ // The one place every reachable "open a file" action funnels through while
2337
+ // the model graph view is up (quick open is the only such surface left —
2338
+ // explorer, search, rails and git panel are all hidden with it).
2339
+ setShowModelGraphView(false);
2089
2340
  TabManager.openTab(path, name, line, null, false, col, endCol);
2090
2341
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
2091
2342
  setQuickOpen(false);
@@ -2141,6 +2392,17 @@ var MbeditorApp = function MbeditorApp() {
2141
2392
  });
2142
2393
  };
2143
2394
 
2395
+ // Drop any pending-reload prompt whose tab is no longer open in any pane.
2396
+ var _prunePendingReloads = function _prunePendingReloads() {
2397
+ EditorStore.setState({
2398
+ pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2399
+ return EditorStore.getState().panes.some(function (p) {
2400
+ return p.tabs.some(function (t) { return t.id === r.tabId; });
2401
+ });
2402
+ })
2403
+ });
2404
+ };
2405
+
2144
2406
  var requestCloseTab = function requestCloseTab(paneId, id) {
2145
2407
  var pane = state.panes.find(function (p) {
2146
2408
  return p.id === paneId;
@@ -2153,13 +2415,7 @@ var MbeditorApp = function MbeditorApp() {
2153
2415
  setClosingTabId(id);
2154
2416
  } else {
2155
2417
  TabManager.closeTab(paneId, id);
2156
- EditorStore.setState({
2157
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2158
- return EditorStore.getState().panes.some(function (p) {
2159
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2160
- });
2161
- })
2162
- });
2418
+ _prunePendingReloads();
2163
2419
  forgetClosedTabs();
2164
2420
  }
2165
2421
  };
@@ -2178,6 +2434,7 @@ var MbeditorApp = function MbeditorApp() {
2178
2434
  }
2179
2435
 
2180
2436
  if (save) {
2437
+ tab = _freshTab(closingPaneId, tab);
2181
2438
  if (tab.isUntitled) {
2182
2439
  // Save-as converts the scratch tab to a real one (closing the scratch
2183
2440
  // tab in the process); a cancelled prompt keeps the tab open.
@@ -2191,49 +2448,20 @@ var MbeditorApp = function MbeditorApp() {
2191
2448
  });
2192
2449
  return;
2193
2450
  }
2194
- setLoading(function (prev) {
2195
- return _extends({}, prev, { save: true });
2196
- });
2197
- EditorStore.setStatus("Saving " + tab.name + "...", "info");
2198
- isSavingRef.current = true;
2199
- FileService.saveFile(tab.path, tab.content).then(function () {
2200
- noteLocalSave(tab.path, tab.content);
2451
+ _runSave(tab, function () {
2201
2452
  EditorStore.setStatus("Saved", "success");
2202
2453
  SearchService.invalidate();
2203
2454
  GitService.fetchStatusLite({ background: true });
2204
- // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
2205
- var _closeEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
2206
- if (_closeEntry && _closeEntry.model && !_closeEntry.model.isDisposed()) {
2207
- _closeEntry.cleanVersionId = _closeEntry.model.getAlternativeVersionId();
2208
- }
2209
2455
  TabManager.closeTab(closingPaneId, tab.id);
2210
- EditorStore.setState({
2211
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2212
- return EditorStore.getState().panes.some(function (p) {
2213
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2214
- });
2215
- })
2216
- });
2456
+ _prunePendingReloads();
2217
2457
  forgetClosedTabs();
2218
- })["catch"](function (err) {
2219
- EditorStore.setStatus("Save failed: " + err.message, "error");
2220
- })["finally"](function () {
2221
- isSavingRef.current = false;
2222
- setLoading(function (prev) {
2223
- return _extends({}, prev, { save: false });
2224
- });
2458
+ }, function () {
2225
2459
  setClosingTabId(null);
2226
2460
  setClosingPaneId(null);
2227
2461
  });
2228
2462
  } else {
2229
2463
  TabManager.closeTab(closingPaneId, tab.id);
2230
- EditorStore.setState({
2231
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2232
- return EditorStore.getState().panes.some(function (p) {
2233
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2234
- });
2235
- })
2236
- });
2464
+ _prunePendingReloads();
2237
2465
  forgetClosedTabs();
2238
2466
  setClosingTabId(null);
2239
2467
  setClosingPaneId(null);
@@ -2308,7 +2536,10 @@ var MbeditorApp = function MbeditorApp() {
2308
2536
  var input = window.prompt('Save as (path relative to workspace root):', tab.name + '.txt');
2309
2537
  if (!input || !input.trim()) return Promise.reject({ cancelled: true });
2310
2538
  var newPath = input.trim().replace(/^\/+/, '');
2539
+ var startedAt = Date.now();
2311
2540
  return FileService.saveFile(newPath, tab.content).then(function () {
2541
+ var audit = window.MbeditorAudit;
2542
+ if (audit) audit.rec(audit.EV.SAVE, _auditExt(newPath), tab.content.length, Date.now() - startedAt);
2312
2543
  noteLocalSave(newPath, tab.content);
2313
2544
  SearchService.invalidate();
2314
2545
  GitService.fetchStatusLite({ background: true });
@@ -2333,14 +2564,13 @@ var MbeditorApp = function MbeditorApp() {
2333
2564
  return {
2334
2565
  id: p.id,
2335
2566
  activeTabId: p.activeTabId,
2336
- tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
2567
+ tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isUntitled; }).map(function (t) {
2337
2568
  return {
2338
2569
  id: t.id,
2339
2570
  path: t.path,
2340
2571
  name: t.name,
2341
2572
  dirty: t.dirty,
2342
2573
  viewState: t.viewState,
2343
- isSettings: !!t.isSettings,
2344
2574
  isChangelog: !!t.isChangelog,
2345
2575
  isPreview: !!t.isPreview,
2346
2576
  previewFor: t.previewFor || null,
@@ -2367,7 +2597,11 @@ var MbeditorApp = function MbeditorApp() {
2367
2597
 
2368
2598
  useEffect(function() {
2369
2599
  document.documentElement.setAttribute('data-theme', editorPrefs.theme || 'vs-dark');
2370
- }, [editorPrefs.theme]);
2600
+ // Glass is a layer over the active theme, not a theme, so it rides on its
2601
+ // own attribute and glass.css derives every colour from the theme tokens.
2602
+ if (editorPrefs.glass) document.documentElement.dataset.glass = '1';
2603
+ else delete document.documentElement.dataset.glass;
2604
+ }, [editorPrefs.theme, editorPrefs.glass]);
2371
2605
 
2372
2606
  useEffect(function() {
2373
2607
  EditorStore.setState({ editorPrefs: editorPrefs });
@@ -2491,7 +2725,7 @@ var MbeditorApp = function MbeditorApp() {
2491
2725
  });
2492
2726
  tabs.forEach(function(t) {
2493
2727
  if (Object.keys(deps).length >= RAILS_MAX_RESOURCES) return;
2494
- if (!t.path || t.path === '__settings__' || t.path.startsWith('mbeditor://')) return;
2728
+ if (!t.path || t.path.startsWith('mbeditor://')) return;
2495
2729
  var label = resourceLabelFromPath(t.path);
2496
2730
  if (label && !deps[label]) deps[label] = t.path;
2497
2731
  });
@@ -2504,7 +2738,7 @@ var MbeditorApp = function MbeditorApp() {
2504
2738
  var all = {};
2505
2739
  state.panes.forEach(function(p) {
2506
2740
  p.tabs.forEach(function(t) {
2507
- if (!t.path || t.path === '__settings__' || t.path.startsWith('mbeditor://')) return;
2741
+ if (!t.path || t.path.startsWith('mbeditor://')) return;
2508
2742
  var label = resourceLabelFromPath(t.path);
2509
2743
  if (label) all[label] = true;
2510
2744
  });
@@ -2564,14 +2798,14 @@ var MbeditorApp = function MbeditorApp() {
2564
2798
 
2565
2799
  // ── Collaboration presence (slice 7) ──────────────────────────────────────
2566
2800
  // Only real, openable files belong in presence — virtual tabs (diffs, previews,
2567
- // settings/changelog) are reported as "no file" so a peer's chip stays blank
2801
+ // changelog) are reported as "no file" so a peer's chip stays blank
2568
2802
  // rather than pointing at something click-to-jump can't open.
2569
2803
  var _presenceFileFor = function (tab) {
2570
2804
  if (!tab || !tab.path) return null;
2571
2805
  var p = tab.path;
2572
- if (tab.isDiff || tab.isCombinedDiff || tab.isCommitGraph || tab.isPreview || tab.isSettings || tab.isChangelog) return null;
2806
+ if (tab.isDiff || tab.isCombinedDiff || tab.isCommitGraph || tab.isPreview || tab.isChangelog) return null;
2573
2807
  if (p.indexOf('diff://') === 0 || p.indexOf('combined-diff://') === 0 || p.indexOf('mbeditor://') === 0) return null;
2574
- if (p.indexOf('::preview') !== -1 || p === '__settings__') return null;
2808
+ if (p.indexOf('::preview') !== -1) return null;
2575
2809
  return p;
2576
2810
  };
2577
2811
  var presenceFile = _presenceFileFor(activeTab);
@@ -2777,14 +3011,13 @@ var MbeditorApp = function MbeditorApp() {
2777
3011
 
2778
3012
  var collabPeerIds = Object.keys(collabRoster);
2779
3013
 
2780
- // A labelled peer chip costs ~110px (name + filename), and the titlebar button
2781
- // cluster does not shrink or wrap: past three peers it squeezes the search pill
2782
- // to its floor and then pushes Help / Install off the right edge. Drop to bare
2783
- // colour dots instead of hiding peers behind a "+N more" summary — a dot is
2784
- // ~20px, so ten peers still fit, every chip stays clickable to follow, and the
2785
- // solid/hollow ring keeps working. The name and file live in the tooltip.
2786
- var COLLAB_LABEL_LIMIT = 3;
2787
- var collabPeerLabels = !toolbarIconOnly && collabPeerIds.length <= COLLAB_LABEL_LIMIT;
3014
+ // One button per peer stopped working past ~4 people: the titlebar cluster
3015
+ // does not shrink or wrap, so it squeezed the search pill to its floor and
3016
+ // pushed Help / Install off the right edge. Fixed-width stack instead: up to
3017
+ // three overlapping avatars, the rest collapse into one "+N" with a popover.
3018
+ var COLLAB_AVATAR_LIMIT = 3;
3019
+ var collabAvatarIds = collabPeerIds.slice(0, COLLAB_AVATAR_LIMIT);
3020
+ var collabOverflowIds = collabPeerIds.slice(COLLAB_AVATAR_LIMIT);
2788
3021
 
2789
3022
  // Colour is minted from a hash before any peer is known, so it has to be
2790
3023
  // reconciled against the roster once one exists. Runs on every roster change;
@@ -2801,9 +3034,8 @@ var MbeditorApp = function MbeditorApp() {
2801
3034
  // chips sit against the right edge of the titlebar and a left-anchored card
2802
3035
  // would run off screen.
2803
3036
  var _useStateHover = useState(null);
2804
- var _useStateHover2 = _slicedToArray(_useStateHover, 2);
2805
- var collabHover = _useStateHover2[0];
2806
- var setCollabHover = _useStateHover2[1];
3037
+ var collabHover = _useStateHover[0];
3038
+ var setCollabHover = _useStateHover[1];
2807
3039
 
2808
3040
  var openCollabHover = function (cid, e) {
2809
3041
  var r = e.currentTarget.getBoundingClientRect();
@@ -2813,8 +3045,7 @@ var MbeditorApp = function MbeditorApp() {
2813
3045
  // Latency and last-seen only need to tick while the card is actually on screen,
2814
3046
  // so the interval lives and dies with it. Idle cost stays zero.
2815
3047
  var _useStateHoverTick = useState(0);
2816
- var _useStateHoverTick2 = _slicedToArray(_useStateHoverTick, 2);
2817
- var setCollabHoverTick = _useStateHoverTick2[1];
3048
+ var setCollabHoverTick = _useStateHoverTick[1];
2818
3049
  useEffect(function () {
2819
3050
  if (!collabHover) return;
2820
3051
  var id = setInterval(function () { setCollabHoverTick(function (n) { return n + 1; }); }, 1000);
@@ -2845,17 +3076,57 @@ var MbeditorApp = function MbeditorApp() {
2845
3076
  handleSelectFile(followedFile, followedFile.split('/').pop());
2846
3077
  }, [followedClientId, followedFile]);
2847
3078
 
3079
+ // Shared by the avatar stack and the overflow popover so "which peer, what
3080
+ // file, are we following them" is computed once per id instead of twice.
3081
+ var collabPeerInfo = function (cid) {
3082
+ var peer = collabRoster[cid];
3083
+ var file = peer.current_file;
3084
+ return {
3085
+ name: peer.name || 'Anonymous',
3086
+ colour: peer.colour || '#888888',
3087
+ file: file,
3088
+ following: followedClientId === cid,
3089
+ elsewhere: file !== presenceFile
3090
+ };
3091
+ };
3092
+
3093
+ // The "+N" overflow popover. A plain onMouseLeave that closes immediately
3094
+ // makes it uncrossable — the pointer has to travel off the button and onto
3095
+ // the popover — so the close is delayed and cancelled by either one's
3096
+ // onMouseEnter. Click still toggles it directly, which is what makes it
3097
+ // reachable without a mouse at all.
3098
+ // The anchor, not a boolean: the popover renders at the root next to the
3099
+ // hovercard, because an absolutely-positioned child of the title bar is
3100
+ // painted under the editor whatever its z-index.
3101
+ var _useStateOverflow = useState(null);
3102
+ var collabOverflowAnchor = _useStateOverflow[0];
3103
+ var setCollabOverflowAnchor = _useStateOverflow[1];
3104
+ var collabOverflowOpen = !!collabOverflowAnchor;
3105
+ var anchorFrom = function (el) {
3106
+ var r = el.getBoundingClientRect();
3107
+ return { top: r.bottom + 4, right: window.innerWidth - r.right };
3108
+ };
3109
+ var collabOverflowCloseTimerRef = useRef(null);
3110
+ var cancelOverflowClose = function () {
3111
+ if (collabOverflowCloseTimerRef.current) {
3112
+ clearTimeout(collabOverflowCloseTimerRef.current);
3113
+ collabOverflowCloseTimerRef.current = null;
3114
+ }
3115
+ };
3116
+ var scheduleOverflowClose = function () {
3117
+ cancelOverflowClose();
3118
+ collabOverflowCloseTimerRef.current = setTimeout(function () { setCollabOverflowAnchor(null); }, 250);
3119
+ };
3120
+
2848
3121
  // Phase 7: Per-file last-commit info shown in the status bar
2849
3122
  var _useState31 = useState(null);
2850
- var _useState32 = _slicedToArray(_useState31, 2);
2851
- var activeFileCommit = _useState32[0];
2852
- var setActiveFileCommit = _useState32[1];
3123
+ var activeFileCommit = _useState31[0];
3124
+ var setActiveFileCommit = _useState31[1];
2853
3125
 
2854
3126
  // EOL indicator — tracks current line-ending style of the active file
2855
3127
  var _useState31e = useState(null);
2856
- var _useState31e2 = _slicedToArray(_useState31e, 2);
2857
- var activeEOL = _useState31e2[0];
2858
- var setActiveEOL = _useState31e2[1];
3128
+ var activeEOL = _useState31e[0];
3129
+ var setActiveEOL = _useState31e[1];
2859
3130
 
2860
3131
  useEffect(function () {
2861
3132
  if (!gitAvailable || !activeTab || activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || !activeTab.path || activeTab.path.indexOf('://') >= 0) {
@@ -2875,7 +3146,9 @@ var MbeditorApp = function MbeditorApp() {
2875
3146
  });
2876
3147
  }, [activeTab ? activeTab.id : null, gitAvailable]);
2877
3148
 
2878
- // Update EOL indicator whenever active tab or its content changes
3149
+ // Update EOL indicator on external loads, not on every content write. The
3150
+ // content effect fires on the 250ms typing flush, and scanning the whole
3151
+ // buffer for '\r' there was pure waste — typing cannot change the line ending.
2879
3152
  useEffect(function () {
2880
3153
  if (!activeTab || typeof activeTab.content !== 'string' ||
2881
3154
  activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || activeTab.isPreview) {
@@ -2889,7 +3162,7 @@ var MbeditorApp = function MbeditorApp() {
2889
3162
  } else {
2890
3163
  setActiveEOL('LF');
2891
3164
  }
2892
- }, [activeTab ? activeTab.id : null, activeTab ? activeTab.content : null]);
3165
+ }, [activeTab ? activeTab.id : null, activeTab ? activeTab.externalContentVersion : null]);
2893
3166
 
2894
3167
  useEffect(function () {
2895
3168
  if (!activeTab || typeof activeTab.content !== 'string') return;
@@ -2966,15 +3239,35 @@ var MbeditorApp = function MbeditorApp() {
2966
3239
  // by a formatter problem).
2967
3240
  var _formatContentForSave = function _formatContentForSave(tab) {
2968
3241
  var isRubyLang = /\.(rb|rake|gemspec)$/.test(tab.path) || /(?:^|\/)(Rakefile|Gemfile)$/.test(tab.path);
3242
+ var startedAt = Date.now();
2969
3243
  if (isRubyLang) {
2970
- if (!rubocopAvailable) return Promise.resolve(null);
2971
- return FileService.formatFile(tab.path, tab.content)
2972
- .then(function (res) { return (res && res.content) || null; })
2973
- ["catch"](function () { return null; });
3244
+ // Same route as the Format button: formatRubySource tries ruby-lsp and
3245
+ // falls back to /format's `rubocop -A`. Calling /format directly here
3246
+ // meant saving a Ruby file could produce different bytes from formatting
3247
+ // the same file by hand — the two-formatters-disagree trap this file
3248
+ // already documents for JS — and gating on RuboCop alone made
3249
+ // format-on-save silently do nothing when only ruby-lsp was present.
3250
+ if (!rubocopAvailable && !window.MBEDITOR_RUBY_LSP_AVAILABLE) return Promise.resolve(null);
3251
+ return formatRubySource(tab.path, tab.content)
3252
+ .then(function (res) {
3253
+ var out = (res && res.content) || null;
3254
+ _recFormat(tab.path, startedAt, out);
3255
+ return out;
3256
+ })
3257
+ ["catch"](function () {
3258
+ _recErr('FORMAT');
3259
+ return null;
3260
+ });
2974
3261
  }
2975
3262
  var parserName = prettierParserFor(tab.path);
2976
3263
  if (!parserName) return Promise.resolve(null);
2977
- return runPrettier(tab.content, editorPrefs, parserName)["catch"](function () { return null; });
3264
+ return runPrettier(tab.content, editorPrefs, parserName).then(function (out) {
3265
+ _recFormat(tab.path, startedAt, out);
3266
+ return out;
3267
+ })["catch"](function () {
3268
+ _recErr('FORMAT');
3269
+ return null;
3270
+ });
2978
3271
  };
2979
3272
 
2980
3273
  // Re-read a tab from the store after flushing TabManager's throttled content
@@ -3013,6 +3306,40 @@ var MbeditorApp = function MbeditorApp() {
3013
3306
  _doSave(paneId, tab);
3014
3307
  };
3015
3308
 
3309
+ // Shared save-execution flow for a known-on-disk (non-untitled) tab: the
3310
+ // saving status/loading flag, the FileService write, the AVI clean-baseline
3311
+ // reset, and save-failed/cleanup handling. `onSuccess` runs whatever the
3312
+ // caller does after a successful write (before the finally cleanup);
3313
+ // `onFinally` runs any extra caller-specific cleanup after the standard one.
3314
+ var _runSave = function _runSave(tab, onSuccess, onFinally) {
3315
+ setLoading(function (prev) {
3316
+ return _extends({}, prev, { save: true });
3317
+ });
3318
+ EditorStore.setStatus("Saving " + tab.name + "...", "info");
3319
+ isSavingRef.current = true;
3320
+ var startedAt = Date.now();
3321
+ return FileService.saveFile(tab.path, tab.content).then(function () {
3322
+ var audit = window.MbeditorAudit;
3323
+ if (audit) audit.rec(audit.EV.SAVE, _auditExt(tab.path), tab.content.length, Date.now() - startedAt);
3324
+ noteLocalSave(tab.path, tab.content);
3325
+ // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
3326
+ var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
3327
+ if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
3328
+ _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
3329
+ }
3330
+ onSuccess();
3331
+ })["catch"](function (err) {
3332
+ _recErr('SAVE');
3333
+ EditorStore.setStatus("Save failed: " + err.message, "error");
3334
+ })["finally"](function () {
3335
+ isSavingRef.current = false;
3336
+ setLoading(function (prev) {
3337
+ return _extends({}, prev, { save: false });
3338
+ });
3339
+ if (onFinally) onFinally();
3340
+ });
3341
+ };
3342
+
3016
3343
  var _doSave = function _doSave(paneId, tab) {
3017
3344
  if (tab.isUntitled) {
3018
3345
  saveUntitledTab(paneId, tab)["catch"](function (err) {
@@ -3021,13 +3348,7 @@ var MbeditorApp = function MbeditorApp() {
3021
3348
  });
3022
3349
  return;
3023
3350
  }
3024
- setLoading(function (prev) {
3025
- return _extends({}, prev, { save: true });
3026
- });
3027
- EditorStore.setStatus("Saving " + tab.name + "...", "info");
3028
- isSavingRef.current = true;
3029
- FileService.saveFile(tab.path, tab.content).then(function () {
3030
- noteLocalSave(tab.path, tab.content);
3351
+ _runSave(tab, function () {
3031
3352
  var newPanes = EditorStore.getState().panes.map(function (p) {
3032
3353
  if (p.id === paneId) {
3033
3354
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -3037,13 +3358,10 @@ var MbeditorApp = function MbeditorApp() {
3037
3358
  return p;
3038
3359
  });
3039
3360
  EditorStore.setState({ panes: newPanes });
3040
- // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
3041
- var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
3042
- if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
3043
- _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
3044
- }
3045
3361
  // Collab: push a fresh snapshot so the server compacts the buffered deltas.
3046
- if (typeof CollaborationService !== 'undefined' && CollaborationService.isBound(tab.path)) {
3362
+ // Only for an attached tab — a deferred or degraded room pushing its local
3363
+ // buffer as a snapshot could seed a second copy into what a peer holds.
3364
+ if (typeof CollaborationService !== 'undefined' && CollaborationService.isAttached(tab.path)) {
3047
3365
  CollaborationService.pushSnapshot(tab.path);
3048
3366
  }
3049
3367
  EditorStore.setStatus("Saved", "success");
@@ -3062,8 +3380,11 @@ var MbeditorApp = function MbeditorApp() {
3062
3380
  // sprockets/react-rails pipeline can't transform (errors Monaco's TS
3063
3381
  // worker misses). Save only — never per keystroke.
3064
3382
  if (jsSyntaxCheckAvailableRef.current && /\.(js|jsx)$/i.test(tab.path)) {
3383
+ var lintStartedAt = Date.now();
3065
3384
  FileService.lintFile(tab.path, tab.content, 'javascript').then(function (res) {
3066
3385
  var babelMarkers = (res && res.markers) || [];
3386
+ var audit = window.MbeditorAudit;
3387
+ if (audit) audit.rec(audit.EV.LINT, _auditExt(tab.path), Date.now() - lintStartedAt, babelMarkers.length);
3067
3388
  if (babelMarkers.length > 0) {
3068
3389
  applyMarkersForTab(tab.id, babelMarkers);
3069
3390
  EditorStore.setStatus('Saved — babel: ' + babelMarkers[0].message, 'warning');
@@ -3073,7 +3394,9 @@ var MbeditorApp = function MbeditorApp() {
3073
3394
  // applyMarkersForTab keeps the previous map when it is already empty.
3074
3395
  applyMarkersForTab(tab.id, []);
3075
3396
  }
3076
- })["catch"](function () {});
3397
+ })["catch"](function () {
3398
+ _recErr('LINT');
3399
+ });
3077
3400
  }
3078
3401
 
3079
3402
  // The server broadcasts files_changed for this very write, and the
@@ -3084,13 +3407,6 @@ var MbeditorApp = function MbeditorApp() {
3084
3407
  // file every time does by definition, so the duplicate was the expensive
3085
3408
  // one. Without a socket no broadcast arrives, so keep it for that case.
3086
3409
  if (!_socketWillBroadcast()) GitService.fetchStatusLite({ background: true });
3087
- })["catch"](function (err) {
3088
- EditorStore.setStatus("Save failed: " + err.message, "error");
3089
- })["finally"](function () {
3090
- isSavingRef.current = false;
3091
- return setLoading(function (prev) {
3092
- return _extends({}, prev, { save: false });
3093
- });
3094
3410
  });
3095
3411
  };
3096
3412
 
@@ -3179,7 +3495,15 @@ var MbeditorApp = function MbeditorApp() {
3179
3495
  // disk stayed dirty and the only thing on offer was to hit Save All again.
3180
3496
  // Each file is now settled on its own result.
3181
3497
  var promises = dirtyTabs.map(function (tab) {
3182
- return FileService.saveFile(tab.path, tab.content);
3498
+ var startedAt = Date.now();
3499
+ return FileService.saveFile(tab.path, tab.content).then(function (res) {
3500
+ var audit = window.MbeditorAudit;
3501
+ if (audit) audit.rec(audit.EV.SAVE, _auditExt(tab.path), tab.content.length, Date.now() - startedAt);
3502
+ return res;
3503
+ }, function (err) {
3504
+ _recErr('SAVE');
3505
+ throw err;
3506
+ });
3183
3507
  });
3184
3508
  Promise.allSettled(promises).then(function (results) {
3185
3509
  var saved = dirtyTabs.filter(function (tab, i) { return results[i].status === "fulfilled"; });
@@ -3327,6 +3651,7 @@ var MbeditorApp = function MbeditorApp() {
3327
3651
  // rather than the button appearing to do nothing.
3328
3652
  var formatTabContent = function formatTabContent(tab, prefs) {
3329
3653
  prefs = prefs || editorPrefs;
3654
+ var startedAt = Date.now();
3330
3655
  if (isRubyPath(tab.path) || tab.path.endsWith('.rake')) {
3331
3656
  if (!rubocopAvailable) return Promise.reject(new Error("RuboCop is not available for this workspace."));
3332
3657
  // RuboCop's output is taken as-is. Ruby indentation belongs to the
@@ -3335,13 +3660,18 @@ var MbeditorApp = function MbeditorApp() {
3335
3660
  // the same file. The old code tried the opposite — converting the source
3336
3661
  // to tabs *before* handing it over — which RuboCop simply discarded.
3337
3662
  return formatRubySource(tab.path, tab.content).then(function (res) {
3338
- return (res && res.content) || null;
3663
+ var out = (res && res.content) || null;
3664
+ _recFormat(tab.path, startedAt, out);
3665
+ return out;
3339
3666
  });
3340
3667
  }
3341
3668
 
3342
3669
  var parserName = prettierParserFor(tab.path);
3343
3670
  if (!parserName) return Promise.resolve(null);
3344
- return runPrettier(tab.content, prefs, parserName);
3671
+ return runPrettier(tab.content, prefs, parserName).then(function (out) {
3672
+ _recFormat(tab.path, startedAt, out);
3673
+ return out;
3674
+ });
3345
3675
  };
3346
3676
 
3347
3677
  // Markers are per-model and only the mounted editor re-lints, so a document
@@ -3434,6 +3764,7 @@ var MbeditorApp = function MbeditorApp() {
3434
3764
  EditorStore.setStatus(formatted === originalContent ? "Already formatted" : "Formatted (Unsaved)", "success");
3435
3765
  GitService.fetchStatus();
3436
3766
  })["catch"](function (err) {
3767
+ _recErr('FORMAT');
3437
3768
  EditorStore.setStatus("Format failed: " + (err && err.message ? err.message : err), "error");
3438
3769
  })["finally"](function () {
3439
3770
  setLoading(function (prev) { return _extends({}, prev, { format: false }); });
@@ -3451,7 +3782,7 @@ var MbeditorApp = function MbeditorApp() {
3451
3782
  var targets = [];
3452
3783
  EditorStore.getState().panes.forEach(function (p) {
3453
3784
  p.tabs.forEach(function (t) {
3454
- if (t.path && !t.path.startsWith('mbeditor://') && t.path !== '__settings__' && typeof t.content === 'string') {
3785
+ if (t.path && !t.path.startsWith('mbeditor://') && typeof t.content === 'string') {
3455
3786
  targets.push({ paneId: p.id, tab: t });
3456
3787
  }
3457
3788
  });
@@ -3492,14 +3823,6 @@ var MbeditorApp = function MbeditorApp() {
3492
3823
  });
3493
3824
  };
3494
3825
 
3495
- var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
3496
-
3497
- var saveCachedTestResult = function saveCachedTestResult(filePath, result) {
3498
- try {
3499
- localStorage.setItem(TEST_CACHE_PREFIX + filePath, JSON.stringify(result));
3500
- } catch (e) {}
3501
- };
3502
-
3503
3826
  var executeTestRun = function executeTestRun(filePath, line) {
3504
3827
  setTestLoading(true);
3505
3828
  EditorStore.setStatus(line ? 'Running test at line ' + line + '...' : 'Running tests...', 'info');
@@ -3608,6 +3931,15 @@ var MbeditorApp = function MbeditorApp() {
3608
3931
  });
3609
3932
  };
3610
3933
 
3934
+ // Paging is scroll-driven, so a list too short to scroll — few hits, or a
3935
+ // big file group just collapsed — would show "Loading more…" and never load
3936
+ // it. Fill the viewport whenever the list stops being scrollable.
3937
+ useEffect(function () {
3938
+ var el = searchResultsContainerRef.current;
3939
+ if (!searchHasMore || !el) return;
3940
+ if (el.scrollHeight <= el.clientHeight + 200) loadMoreSearchResults();
3941
+ }, [searchHasMore, state.searchResults, searchCollapsedFiles]);
3942
+
3611
3943
  var handleSearchChange = function handleSearchChange(e) {
3612
3944
  var val = e.target.value;
3613
3945
  if (!val) { clearSearch(); return; }
@@ -3850,10 +4182,8 @@ var MbeditorApp = function MbeditorApp() {
3850
4182
  if (imported > 0) {
3851
4183
  // Expand down to what was just written, so the tree actually shows it —
3852
4184
  // importing into a collapsed folder otherwise leaves the explorer looking
3853
- // untouched. Deliberately does not *select* the folder: the tree
3854
- // selection is what the toolbar's Upload button reads for its default
3855
- // destination, and pinning it here would make every later upload default
3856
- // to this import's folder.
4185
+ // untouched. Deliberately does not *select* the folder, which would move
4186
+ // the selection somewhere the user did not put it.
3857
4187
  var landed = parentDir(written[0].path);
3858
4188
  var toExpand = {};
3859
4189
  var bits = landed ? landed.split('/') : [];
@@ -3872,8 +4202,7 @@ var MbeditorApp = function MbeditorApp() {
3872
4202
  // rather than a disaster. Real targets stopPropagation before this runs.
3873
4203
  useEffect(function () {
3874
4204
  var swallow = function (e) {
3875
- var types = (e.dataTransfer && e.dataTransfer.types) || [];
3876
- if (Array.prototype.indexOf.call(types, 'Files') === -1) return;
4205
+ if (!FileImport.hasExternalFiles(e.dataTransfer)) return;
3877
4206
  e.preventDefault();
3878
4207
  // dragover is left alone beyond preventDefault: setting dropEffect here
3879
4208
  // would override the 'copy' cursor the tree sets on a valid folder.
@@ -3901,7 +4230,15 @@ var MbeditorApp = function MbeditorApp() {
3901
4230
  (entries.length === 1 ? '' : 's') + '...', 'info');
3902
4231
  }
3903
4232
 
3904
- return FileService.importFiles(FileImport.buildFormData(entries, targetFolderPath, 'ask'))
4233
+ var where = targetFolderPath || 'the workspace root';
4234
+ var noun = entries.length + ' file' + (entries.length === 1 ? '' : 's');
4235
+ var progress = function (percent) {
4236
+ EditorStore.setStatus(percent === null
4237
+ ? 'Uploaded ' + noun + ' to ' + where + ', writing them...'
4238
+ : 'Uploading ' + noun + ' to ' + where + '... ' + percent + '%', 'info');
4239
+ };
4240
+
4241
+ return FileService.importFiles(FileImport.buildFormData(entries, targetFolderPath, 'ask'), progress)
3905
4242
  .then(function(result) {
3906
4243
  if (result.conflicts && result.conflicts.length > 0) {
3907
4244
  setImportConflict({ result: result, entries: entries, targetFolderPath: targetFolderPath });
@@ -3941,32 +4278,81 @@ var MbeditorApp = function MbeditorApp() {
3941
4278
  });
3942
4279
  };
3943
4280
 
3944
- // Downloads go straight through /raw?download=1 — the browser's own save
3945
- // flow, so nothing is buffered client-side and a 5 MB file costs no memory.
3946
- // A synthetic anchor rather than location.href: navigating away from the
3947
- // editor to an attachment response leaves the page in a half-unloaded state
3948
- // in Safari.
4281
+ // /raw stays a bare anchor for the single-file case — nothing buffered
4282
+ // client-side. /archive can fail with a JSON error body a plain anchor
4283
+ // navigation can't see, so that path goes through fetch instead.
4284
+ // Right-clicking a node inside a multi-selection acts on the whole selection;
4285
+ // right-clicking outside it acts on that node alone. The standard file-manager
4286
+ // rule, and why FileTree's onContextMenu stopped collapsing the set. Every
4287
+ // menu action that can operate on more than one path asks this.
4288
+ var actsOnSelection = function actsOnSelection(node) {
4289
+ return !!(node && selectedPaths && selectedPaths.size > 1 && selectedPaths.has(node.path));
4290
+ };
4291
+
3949
4292
  var handleDownloadFile = function handleDownloadFile(node) {
3950
- if (!node || node.type !== 'file') return;
3951
- var link = document.createElement('a');
3952
- link.href = window.mbeditorBasePath() + '/raw?download=1&path=' + encodeURIComponent(node.path);
3953
- link.download = node.name;
3954
- link.rel = 'noopener';
3955
- document.body.appendChild(link);
3956
- link.click();
3957
- document.body.removeChild(link);
3958
- EditorStore.setStatus('Downloading ' + node.name + '...', 'info');
4293
+ if (!node) return;
4294
+ var isMultiSelect = actsOnSelection(node);
4295
+ if (!isMultiSelect && node.type === 'file') {
4296
+ var link = document.createElement('a');
4297
+ link.href = window.mbeditorBasePath() + '/raw?download=1&path=' + encodeURIComponent(node.path);
4298
+ link.download = node.name;
4299
+ link.rel = 'noopener';
4300
+ document.body.appendChild(link);
4301
+ link.click();
4302
+ document.body.removeChild(link);
4303
+ EditorStore.setStatus('Downloading ' + node.name + '...', 'info');
4304
+ return;
4305
+ }
4306
+ var paths = isMultiSelect ? Array.from(selectedPaths) : [node.path];
4307
+ var url = window.mbeditorBasePath() + '/archive?' + paths.map(function (p) {
4308
+ return 'paths[]=' + encodeURIComponent(p);
4309
+ }).join('&');
4310
+ fetch(url).then(function (resp) {
4311
+ if (!resp.ok) {
4312
+ return resp.json().catch(function () { return {}; }).then(function (body) {
4313
+ var message = (body && body.error) || ('HTTP ' + resp.status);
4314
+ EditorStore.setStatus('Download failed: ' + message, 'error');
4315
+ });
4316
+ }
4317
+ var disposition = resp.headers.get('Content-Disposition');
4318
+ var match = disposition && disposition.match(/filename="([^"]+)"/);
4319
+ var filename = (match && match[1]) || 'archive.tar.gz';
4320
+ return resp.blob().then(function (blob) {
4321
+ var objectUrl = URL.createObjectURL(blob);
4322
+ var archiveLink = document.createElement('a');
4323
+ archiveLink.href = objectUrl;
4324
+ archiveLink.download = filename;
4325
+ archiveLink.rel = 'noopener';
4326
+ document.body.appendChild(archiveLink);
4327
+ archiveLink.click();
4328
+ document.body.removeChild(archiveLink);
4329
+ // Revoking in the same tick can free the blob before the browser has
4330
+ // started reading it, which cancels the download instead of saving it.
4331
+ setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 60000);
4332
+ EditorStore.setStatus('Downloading ' + filename + '...', 'info');
4333
+ });
4334
+ }).catch(function (err) {
4335
+ EditorStore.setStatus('Download failed: ' + (err && err.message || 'network error'), 'error');
4336
+ });
4337
+ };
4338
+
4339
+ var downloadMenuLabel = function downloadMenuLabel(node) {
4340
+ if (!node) return 'Download';
4341
+ if (actsOnSelection(node)) return 'Download ' + selectedPaths.size + ' items';
4342
+ if (node.type === 'folder') return 'Download folder';
4343
+ return 'Download';
4344
+ };
4345
+
4346
+ // Deleting is irreversible, so the item has to say how much it will take —
4347
+ // "Delete" over a 3-item selection would understate it.
4348
+ var deleteMenuLabel = function deleteMenuLabel(node) {
4349
+ return actsOnSelection(node) ? 'Delete ' + selectedPaths.size + ' items' : 'Delete';
3959
4350
  };
3960
4351
 
3961
4352
  // A file node means "upload alongside this file", so the dialog opens on its
3962
4353
  // parent — right-clicking a file to upload next to it is the common gesture.
3963
4354
  var openImportDialog = function openImportDialog(node) {
3964
- var folder = '';
3965
- if (node && node.type === 'folder') {
3966
- folder = node.path;
3967
- } else if (node && node.path) {
3968
- folder = node.path.split('/').slice(0, -1).join('/');
3969
- }
4355
+ var folder = node ? (node.type === 'folder' ? node.path : parentDir(node.path)) : '';
3970
4356
  setImportDialog({ initialFolder: folder });
3971
4357
  };
3972
4358
 
@@ -3977,13 +4363,18 @@ var MbeditorApp = function MbeditorApp() {
3977
4363
 
3978
4364
  var openContextMenu = function openContextMenu(e, node) {
3979
4365
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
3980
- handleNodeSelect(node);
4366
+ if (!actsOnSelection(node)) handleNodeSelect(node);
3981
4367
  };
3982
4368
 
3983
4369
  var closeContextMenu = function closeContextMenu() {
3984
4370
  return setContextMenu(null);
3985
4371
  };
3986
4372
 
4373
+ // Clamp into view whenever the menu opens at a new position.
4374
+ useEffect(function () {
4375
+ if (contextMenu) window.clampMenuIntoView(contextMenuRef.current);
4376
+ }, [contextMenu]);
4377
+
3987
4378
  var handleContextMenuAction = function handleContextMenuAction(action) {
3988
4379
  var node = contextMenu && contextMenu.node;
3989
4380
  closeContextMenu();
@@ -4042,17 +4433,34 @@ var MbeditorApp = function MbeditorApp() {
4042
4433
  document.body.style.userSelect = 'none';
4043
4434
  };
4044
4435
 
4436
+ var startDrawerResize = function startDrawerResize(mode, startHeight, storeKey) {
4437
+ return function (e) {
4438
+ e.preventDefault();
4439
+ resizeSessionRef.current = { mode: mode, startY: e.clientY, startHeight: startHeight, storeKey: storeKey };
4440
+ setActiveResizeMode(mode);
4441
+ document.body.style.cursor = 'row-resize';
4442
+ document.body.style.userSelect = 'none';
4443
+ };
4444
+ };
4445
+
4045
4446
  var handleActivityBarClick = function handleActivityBarClick(tab) {
4046
4447
  if (tab === 'settings') {
4047
- openSettingsTab();
4448
+ setShowModelGraphView(false);
4449
+ setShowSettings(!showSettings);
4048
4450
  return;
4049
4451
  }
4050
4452
  // The model graph is a view, not a panel: it takes over the central area
4051
- // and needs the width. There is no sidebar half to show.
4453
+ // and needs the width. There is no sidebar half to show, so it toggles
4454
+ // instead of behaving like the other sidebar tabs.
4052
4455
  if (tab === 'models') {
4053
- openModelGraphTab();
4456
+ setShowModelGraphView(function (prev) {
4457
+ var next = !prev;
4458
+ if (next) loadModelGraph(false);
4459
+ return next;
4460
+ });
4054
4461
  return;
4055
4462
  }
4463
+ setShowModelGraphView(false);
4056
4464
  if (!sidebarCollapsed && activeSidebarTab === tab) {
4057
4465
  setSidebarCollapsed(true);
4058
4466
  } else {
@@ -4207,6 +4615,7 @@ var MbeditorApp = function MbeditorApp() {
4207
4615
  // Optimistically insert the new node so the tree doesn't flash empty while waiting for the server
4208
4616
  var optimisticNode = { path: path, name: name, type: type === 'folder' ? 'folder' : 'file', children: type === 'folder' ? [] : undefined };
4209
4617
  setTreeData(function (prev) { return insertNodeIntoTree(prev, parentPath, optimisticNode); });
4618
+ setRevealTarget({ path: path });
4210
4619
 
4211
4620
  if (type === 'file') {
4212
4621
  setLoading(function (prev) {
@@ -4337,6 +4746,7 @@ var MbeditorApp = function MbeditorApp() {
4337
4746
  EditorStore.setStatus('Renamed to: ' + renamedPath, 'success');
4338
4747
  return refreshProjectTree().then(function () {
4339
4748
  GitService.fetchStatusLite({ background: true });
4749
+ setRevealTarget({ path: renamedPath });
4340
4750
  });
4341
4751
  })["catch"](function (err) {
4342
4752
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -4353,10 +4763,12 @@ var MbeditorApp = function MbeditorApp() {
4353
4763
  };
4354
4764
 
4355
4765
  var handleDeletePath = function handleDeletePath(targetNode) {
4356
- // If a specific node is provided (e.g. from context menu), delete just that.
4357
- // Otherwise delete all paths in the multi-selection (or fall back to selectedTreeNode).
4766
+ // A node from the context menu deletes just that node — unless it is part
4767
+ // of a live multi-selection, in which case the whole selection goes, the
4768
+ // same rule Download follows. With no node (the toolbar button) it is
4769
+ // always the selection, falling back to selectedTreeNode.
4358
4770
  var pathsToDelete;
4359
- if (targetNode !== undefined && targetNode) {
4771
+ if (targetNode && !actsOnSelection(targetNode)) {
4360
4772
  pathsToDelete = [targetNode.path];
4361
4773
  } else if (selectedPaths && selectedPaths.size > 0) {
4362
4774
  pathsToDelete = Array.from(selectedPaths);
@@ -4380,6 +4792,10 @@ var MbeditorApp = function MbeditorApp() {
4380
4792
  var confirmed = window.confirm('Delete ' + label + '? This cannot be undone.');
4381
4793
  if (!confirmed) return;
4382
4794
 
4795
+ // Computed against the pre-delete tree, since the deleted paths' siblings
4796
+ // are only known before the tree is refreshed.
4797
+ var deleteRevealTarget = computeDeleteRevealTarget(treeData, pathsToDelete);
4798
+
4383
4799
  setLoading(function (prev) {
4384
4800
  return _extends({}, prev, { deletePath: true });
4385
4801
  });
@@ -4398,6 +4814,7 @@ var MbeditorApp = function MbeditorApp() {
4398
4814
  }
4399
4815
  return refreshProjectTree().then(function () {
4400
4816
  GitService.fetchStatusLite({ background: true });
4817
+ if (failures.length === 0 && deleteRevealTarget) setRevealTarget({ path: deleteRevealTarget });
4401
4818
  });
4402
4819
  })["finally"](function () {
4403
4820
  setLoading(function (prev) {
@@ -4415,6 +4832,35 @@ var MbeditorApp = function MbeditorApp() {
4415
4832
  var canLintAndFormat = !!activeTab;
4416
4833
  var hasGitBranch = !!(state.gitBranch && state.gitBranch.trim());
4417
4834
 
4835
+ // Derived at render from state already in the store — not stored via setState,
4836
+ // so the 5s git-status poll (MbeditorApp.js re-render rules) never fires an
4837
+ // extra re-render on an unchanged tick. Only conflictFileIndex is state, and
4838
+ // it changes only on explicit user click, never from a poll.
4839
+ var conflictedFiles = (state.gitInfo && state.gitInfo.workingTree || []).filter(function (f) {
4840
+ return window.MBEDITOR_UNMERGED_STATUSES[(f.status || '').trim()];
4841
+ });
4842
+
4843
+ // Opens the conflicted file at index idx (wrapping), landing the cursor/reveal
4844
+ // on its first `<<<<<<<` marker. Prefers an already-open tab's live content
4845
+ // (may hold unsaved edits) over a fresh disk fetch.
4846
+ var openConflictedFileAt = function openConflictedFileAt(idx) {
4847
+ if (!conflictedFiles.length) return;
4848
+ var i = ((idx % conflictedFiles.length) + conflictedFiles.length) % conflictedFiles.length;
4849
+ var file = conflictedFiles[i];
4850
+ setConflictFileIndex(i);
4851
+ var openTab = state.panes.reduce(function (found, p) {
4852
+ return found || p.tabs.find(function (t) { return t.path === file.path; });
4853
+ }, null);
4854
+ var name = file.path.split('/').pop();
4855
+ var contentPromise = openTab ? Promise.resolve({ content: openTab.content }) : FileService.getFile(file.path);
4856
+ contentPromise.then(function (data) {
4857
+ var content = typeof data.content === 'string' ? data.content : '';
4858
+ var blocks = ConflictParser.parse(content);
4859
+ var line = blocks.length ? blocks[0].startLine + 1 : null;
4860
+ TabManager.openTab(file.path, name, line);
4861
+ });
4862
+ };
4863
+
4418
4864
  var renderTabBar = function renderTabBar(paneId, tabs, activeId) {
4419
4865
  return React.createElement(TabBar, {
4420
4866
  tabs: tabs,
@@ -4427,7 +4873,7 @@ var MbeditorApp = function MbeditorApp() {
4427
4873
  onSelect: function (id) {
4428
4874
  // Sync explorer selection with the newly active tab so there's only one highlight
4429
4875
  var tab = tabs.find(function(t) { return t.id === id; });
4430
- if (tab && tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
4876
+ if (tab && tab.path && !tab.path.startsWith('mbeditor://')) {
4431
4877
  handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
4432
4878
  }
4433
4879
  TabManager.switchTab(paneId, id);
@@ -4464,78 +4910,6 @@ var MbeditorApp = function MbeditorApp() {
4464
4910
  });
4465
4911
  };
4466
4912
 
4467
- function openSettingsTab() {
4468
- var st = EditorStore.getState();
4469
- var foundPaneId = null;
4470
- var foundTab = null;
4471
- st.panes.forEach(function(p) {
4472
- if (!foundTab) {
4473
- var t = p.tabs.find(function(tab) { return tab.path === '__settings__'; });
4474
- if (t) { foundTab = t; foundPaneId = p.id; }
4475
- }
4476
- });
4477
- if (foundTab) {
4478
- var newPanes = st.panes.map(function(p) {
4479
- if (p.id === foundPaneId) return Object.assign({}, p, { activeTabId: '__settings__' });
4480
- return p;
4481
- });
4482
- EditorStore.setState({ panes: newPanes, focusedPaneId: foundPaneId, activeTabId: '__settings__' });
4483
- return;
4484
- }
4485
- var paneId = st.focusedPaneId;
4486
- var pane = st.panes.find(function(p) { return p.id === paneId; }) || st.panes[0];
4487
- if (!pane) return;
4488
- paneId = pane.id;
4489
- var newTab = { id: '__settings__', path: '__settings__', name: 'Settings', dirty: false, content: '', isSettings: true };
4490
- var newPanes2 = st.panes.map(function(p) {
4491
- if (p.id === paneId) return Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: '__settings__' });
4492
- return p;
4493
- });
4494
- EditorStore.setState({ panes: newPanes2, focusedPaneId: paneId, activeTabId: '__settings__' });
4495
- }
4496
-
4497
- // The diagram lives in an editor tab, not the sidebar: a layered graph is
4498
- // inherently wide and a ~300px panel can only ever show its first column.
4499
- // The sidebar tab is the entry point and the searchable model list.
4500
- var MODEL_GRAPH_TAB_ID = 'mbeditor://model-graph';
4501
- function openModelGraphTab() {
4502
- var st = EditorStore.getState();
4503
- var paneId = st.focusedPaneId;
4504
-
4505
- var existing = null;
4506
- st.panes.forEach(function (p) {
4507
- if (!existing && p.tabs.some(function (t) { return t.id === MODEL_GRAPH_TAB_ID; })) {
4508
- existing = p.id;
4509
- }
4510
- });
4511
- if (existing) {
4512
- EditorStore.setState({
4513
- panes: st.panes.map(function (p) {
4514
- return p.id === existing ? Object.assign({}, p, { activeTabId: MODEL_GRAPH_TAB_ID }) : p;
4515
- }),
4516
- focusedPaneId: existing
4517
- });
4518
- return;
4519
- }
4520
-
4521
- var pane = st.panes.find(function (p) { return p.id === paneId; }) || st.panes[0];
4522
- if (!pane) return;
4523
-
4524
- var newTab = {
4525
- id: MODEL_GRAPH_TAB_ID, path: MODEL_GRAPH_TAB_ID, name: 'Model Graph',
4526
- dirty: false, content: '', isModelGraph: true
4527
- };
4528
- EditorStore.setState({
4529
- panes: st.panes.map(function (p) {
4530
- return p.id === pane.id
4531
- ? Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: MODEL_GRAPH_TAB_ID })
4532
- : p;
4533
- }),
4534
- focusedPaneId: pane.id
4535
- });
4536
- loadModelGraph(false);
4537
- }
4538
-
4539
4913
  var CHANGELOG_TAB_ID = 'mbeditor://changelog';
4540
4914
  function openChangelogTab() {
4541
4915
  var st = EditorStore.getState();
@@ -4593,10 +4967,11 @@ var MbeditorApp = function MbeditorApp() {
4593
4967
  { className: "ide-titlebar" },
4594
4968
  React.createElement("i", { className: "fas fa-layer-group ide-titlebar-icon" }),
4595
4969
  React.createElement(
4970
+ // The product name, not the window title: the host and port are in the
4971
+ // browser's own address bar, and the long form crowded the search pill.
4596
4972
  "div",
4597
- { className: "ide-titlebar-title" },
4598
- "Mini Browser Editor — ",
4599
- window.location.host
4973
+ { className: "ide-titlebar-title", title: "Mini Browser Editor — " + window.location.host },
4974
+ "Mbeditor"
4600
4975
  ),
4601
4976
  // The slot claims all the room between the title and the buttons; the
4602
4977
  // search pill then takes 75% of it, centred. Sizing the pill against a
@@ -4686,6 +5061,35 @@ var MbeditorApp = function MbeditorApp() {
4686
5061
  !toolbarIconOnly && " Git"
4687
5062
  )
4688
5063
  ),
5064
+ conflictedFiles.length > 0 && React.createElement(
5065
+ React.Fragment,
5066
+ null,
5067
+ React.createElement("div", { className: "statusbar-sep" }),
5068
+ React.createElement(
5069
+ "button",
5070
+ {
5071
+ type: "button",
5072
+ className: "statusbar-btn mbeditor-conflict-chip",
5073
+ title: "Open the first of " + conflictedFiles.length + " conflicted file" + (conflictedFiles.length === 1 ? "" : "s"),
5074
+ onClick: function () { openConflictedFileAt(0); }
5075
+ },
5076
+ React.createElement("i", { className: "fas fa-exclamation-circle" }),
5077
+ // The count always renders, even icon-only: it is the chip's
5078
+ // content, not a label. A bare warning icon says less than nothing.
5079
+ " " + conflictedFiles.length,
5080
+ !toolbarIconOnly && (conflictedFiles.length === 1 ? " conflict" : " conflicts")
5081
+ ),
5082
+ conflictedFiles.length > 1 && React.createElement(
5083
+ "button",
5084
+ {
5085
+ type: "button",
5086
+ className: "statusbar-btn",
5087
+ title: "Open the next conflicted file",
5088
+ onClick: function () { openConflictedFileAt(conflictFileIndex + 1); }
5089
+ },
5090
+ React.createElement("i", { className: "fas fa-arrow-right" })
5091
+ )
5092
+ ),
4689
5093
  // Collaboration trouble chip. Deliberately not shown when everything is
4690
5094
  // healthy and you are simply alone — that was the whole point of hiding
4691
5095
  // the solo chip. It appears only when a condition pairing needs has
@@ -4737,45 +5141,48 @@ var MbeditorApp = function MbeditorApp() {
4737
5141
  React.Fragment,
4738
5142
  null,
4739
5143
  React.createElement("div", { className: "statusbar-sep" }),
4740
- collabPeerIds.map(function (cid) {
4741
- var peer = collabRoster[cid];
4742
- var file = peer.current_file;
4743
- var name = peer.name || 'Anonymous';
4744
- var colour = peer.colour || '#888888';
4745
- var following = followedClientId === cid;
4746
- // Solid dot: they are in the file you are looking at, so their caret
4747
- // is on screen. Hollow ring: they are somewhere else and there is
4748
- // nothing to see — without this the chip looked identical either way
4749
- // and a peer's caret just vanished with no explanation.
4750
- var elsewhere = file !== presenceFile;
4751
- return React.createElement(
4752
- "button",
4753
- {
4754
- key: cid,
4755
- type: "button",
4756
- className: "statusbar-btn",
4757
- style: following
4758
- ? { background: 'color-mix(in srgb, ' + colour + ' 28%, transparent)' }
4759
- : undefined,
4760
- onMouseEnter: function (e) { openCollabHover(cid, e); },
4761
- onMouseLeave: function () { setCollabHover(null); },
4762
- onClick: function () { toggleFollow(cid); }
4763
- },
4764
- React.createElement("i", {
4765
- className: (following ? "fas fa-eye" : (elsewhere ? "far fa-circle" : "fas fa-circle")) +
4766
- " collab-pulse",
4767
- style: { color: colour, fontSize: "0.7em", marginRight: "2px" }
4768
- }),
4769
- collabPeerLabels && (" " + name),
4770
- // Where they went, when they are not where you are. Basename only —
4771
- // the chip has ~110px to spend and the full path is in the tooltip.
4772
- collabPeerLabels && elsewhere && file && React.createElement(
4773
- "span",
4774
- { style: { opacity: 0.65, marginLeft: "4px" } },
4775
- file.split('/').pop()
4776
- )
4777
- );
4778
- })
5144
+ React.createElement(
5145
+ "div",
5146
+ { className: "collab-avatar-stack" },
5147
+ collabAvatarIds.map(function (cid) {
5148
+ var info = collabPeerInfo(cid);
5149
+ return React.createElement(
5150
+ "button",
5151
+ {
5152
+ key: cid,
5153
+ type: "button",
5154
+ className: "collab-avatar" +
5155
+ (info.following ? " collab-avatar-following" : "") +
5156
+ (info.elsewhere ? " collab-avatar-elsewhere" : ""),
5157
+ style: { background: info.colour },
5158
+ // The dot carries no glyph, so the name has to reach a
5159
+ // screen reader some other way.
5160
+ "aria-label": info.name,
5161
+ onMouseEnter: function (e) { openCollabHover(cid, e); },
5162
+ onMouseLeave: function () { setCollabHover(null); },
5163
+ onClick: function () { toggleFollow(cid); }
5164
+ }
5165
+ );
5166
+ }),
5167
+ collabOverflowIds.length > 0 && React.createElement(
5168
+ "div",
5169
+ { className: "collab-avatar-overflow" },
5170
+ React.createElement(
5171
+ "button",
5172
+ {
5173
+ type: "button",
5174
+ className: "collab-avatar collab-avatar-more",
5175
+ onMouseEnter: function (e) { cancelOverflowClose(); setCollabOverflowAnchor(anchorFrom(e.currentTarget)); },
5176
+ onMouseLeave: scheduleOverflowClose,
5177
+ onClick: function (e) {
5178
+ var el = e.currentTarget;
5179
+ setCollabOverflowAnchor(function (prev) { return prev ? null : anchorFrom(el); });
5180
+ }
5181
+ },
5182
+ "+" + collabOverflowIds.length
5183
+ )
5184
+ )
5185
+ )
4779
5186
  ),
4780
5187
  React.createElement("div", { className: "statusbar-sep" }),
4781
5188
  React.createElement(
@@ -4805,6 +5212,31 @@ var MbeditorApp = function MbeditorApp() {
4805
5212
  )
4806
5213
  )
4807
5214
  ),
5215
+ collabOverflowOpen && React.createElement(
5216
+ "div",
5217
+ {
5218
+ className: "collab-overflow-popover",
5219
+ style: { top: collabOverflowAnchor.top + 'px', right: collabOverflowAnchor.right + 'px' },
5220
+ onMouseEnter: cancelOverflowClose,
5221
+ onMouseLeave: scheduleOverflowClose
5222
+ },
5223
+ collabOverflowIds.map(function (cid) {
5224
+ var info = collabPeerInfo(cid);
5225
+ return React.createElement(
5226
+ "button",
5227
+ {
5228
+ key: cid,
5229
+ type: "button",
5230
+ className: "collab-overflow-row" + (info.following ? " is-following" : ""),
5231
+ onClick: function () { toggleFollow(cid); }
5232
+ },
5233
+ React.createElement("span", { className: "collab-overflow-swatch", style: { background: info.colour } }),
5234
+ React.createElement("span", { className: "collab-overflow-name" }, info.name),
5235
+ React.createElement("span", { className: "collab-overflow-file" }, info.file ? info.file.split('/').pop() : 'No file open'),
5236
+ info.following && React.createElement("i", { className: "fas fa-eye collab-overflow-eye" })
5237
+ );
5238
+ })
5239
+ ),
4808
5240
  collabHover && (function () {
4809
5241
  var isMe = collabHover.cid === '__me__';
4810
5242
  var peer = isMe ? null : collabRoster[collabHover.cid];
@@ -4893,6 +5325,15 @@ var MbeditorApp = function MbeditorApp() {
4893
5325
  );
4894
5326
  })(),
4895
5327
  showHelp && React.createElement(ShortcutHelp, { onClose: function () { return setShowHelp(false); } }),
5328
+ showSettings && React.createElement(window.SettingsModal || SettingsModal, {
5329
+ editorPrefs: editorPrefs,
5330
+ setEditorPrefs: setEditorPrefs,
5331
+ rubocopAvailable: rubocopAvailable,
5332
+ rubocopConfigPath: rubocopConfigPath,
5333
+ onOpenRubocopConfig: function () { handleSelectFile(rubocopConfigPath, rubocopConfigPath.split('/').pop()); },
5334
+ onReset: function () { setEditorPrefs(Object.assign({}, DEFAULT_EDITOR_PREFS)); },
5335
+ onClose: function () { return setShowSettings(false); }
5336
+ }),
4896
5337
  React.createElement(
4897
5338
  "div",
4898
5339
  { className: "ide-body", id: "ide-body-container" },
@@ -4937,7 +5378,7 @@ var MbeditorApp = function MbeditorApp() {
4937
5378
  "button",
4938
5379
  {
4939
5380
  type: "button",
4940
- className: "ide-activity-btn" + (activeTab && activeTab.isModelGraph ? ' active' : ''),
5381
+ className: "ide-activity-btn" + (showModelGraphView ? ' active' : ''),
4941
5382
  title: "Model graph",
4942
5383
  onClick: function() { handleActivityBarClick('models'); }
4943
5384
  },
@@ -4951,16 +5392,16 @@ var MbeditorApp = function MbeditorApp() {
4951
5392
  "button",
4952
5393
  {
4953
5394
  type: "button",
4954
- className: "ide-activity-btn" + (activeTab && activeTab.isSettings ? ' active' : ''),
5395
+ className: "ide-activity-btn" + (showSettings ? ' active' : ''),
4955
5396
  title: "Editor Preferences",
4956
- onClick: openSettingsTab
5397
+ onClick: function() { handleActivityBarClick('settings'); }
4957
5398
  },
4958
5399
  React.createElement("i", { className: "fas fa-cog" })
4959
5400
  )
4960
5401
  )
4961
5402
  ),
4962
- /* Panel content — shown when not collapsed and not in zen mode */
4963
- !sidebarCollapsed && !zenMode && React.createElement(
5403
+ /* Panel content — shown when not collapsed, not in zen mode, and not showing the model graph */
5404
+ !sidebarCollapsed && !zenMode && !showModelGraphView && React.createElement(
4964
5405
  "div",
4965
5406
  { className: "ide-sidebar", style: { width: sidebarWidth + "px" } },
4966
5407
  React.createElement("div", { className: "sidebar-panel-title" },
@@ -4998,108 +5439,126 @@ var MbeditorApp = function MbeditorApp() {
4998
5439
  React.createElement(
4999
5440
  "div",
5000
5441
  { style: { marginBottom: "12px" } },
5001
- state.panes.map(function (pane) {
5002
- if (pane.tabs.length === 0) return null;
5003
- var isPane2 = pane.id === 2;
5004
- return React.createElement(
5005
- "div",
5006
- {
5007
- key: pane.id,
5008
- className: "open-editors-group",
5009
- style: { marginBottom: pane.id === 1 && state.panes[1].tabs.length > 0 ? "10px" : "0" }
5010
- },
5011
- React.createElement(
5442
+ (function () {
5443
+ // VS Code drops the "GROUP N" sub-header when only one group
5444
+ // is populated — it's only useful to tell groups apart.
5445
+ var activePanes = state.panes.filter(function (p) { return p.tabs.length > 0; });
5446
+ var showGroupHeaders = activePanes.length > 1;
5447
+ return state.panes.map(function (pane) {
5448
+ if (pane.tabs.length === 0) return null;
5449
+ var isPane2 = pane.id === 2;
5450
+ return React.createElement(
5012
5451
  "div",
5013
- { className: "ide-sidebar-header open-editors-group-header" },
5014
- React.createElement(
5015
- "span",
5016
- { className: "open-editors-group-title" },
5017
- "GROUP ",
5018
- pane.id
5452
+ {
5453
+ key: pane.id,
5454
+ className: "open-editors-group",
5455
+ style: { marginBottom: pane.id === 1 && state.panes[1].tabs.length > 0 ? "10px" : "0" }
5456
+ },
5457
+ showGroupHeaders && React.createElement(
5458
+ "div",
5459
+ { className: "ide-sidebar-header open-editors-group-header" },
5460
+ React.createElement(
5461
+ "span",
5462
+ { className: "open-editors-group-title" },
5463
+ "GROUP ",
5464
+ pane.id
5465
+ ),
5466
+ React.createElement(
5467
+ SectionActionGroup,
5468
+ { ariaLabel: "Group " + pane.id + " actions", className: "collapsible-actions open-editors-group-actions" },
5469
+ React.createElement(SidebarActionButton, {
5470
+ title: "Close all editors in Group " + pane.id,
5471
+ ariaLabel: "Close all editors in Group " + pane.id,
5472
+ iconClass: "far fa-window-close",
5473
+ onClick: function (e) {
5474
+ e.stopPropagation();handleCloseEditorsInGroup(pane.id);
5475
+ }
5476
+ })
5477
+ )
5019
5478
  ),
5020
5479
  React.createElement(
5021
- SectionActionGroup,
5022
- { ariaLabel: "Group " + pane.id + " actions", className: "collapsible-actions open-editors-group-actions" },
5023
- React.createElement(SidebarActionButton, {
5024
- title: "Close all editors in Group " + pane.id,
5025
- ariaLabel: "Close all editors in Group " + pane.id,
5026
- iconClass: "far fa-window-close",
5027
- onClick: function (e) {
5028
- e.stopPropagation();handleCloseEditorsInGroup(pane.id);
5029
- }
5030
- })
5031
- )
5032
- ),
5033
- React.createElement(
5034
- "div",
5035
- { className: "file-tree" },
5036
- pane.tabs.map(function (tab) {
5037
- return React.createElement(
5038
- "div",
5039
- {
5040
- key: tab.id,
5041
- className: "tree-item " + (pane.activeTabId === tab.id && state.focusedPaneId === pane.id ? "active" : ""),
5042
- // The name ellipsises in a narrow sidebar, so the row
5043
- // carries the full path the way file-tree rows do.
5044
- title: tab.path || tab.name,
5045
- onClick: function () {
5046
- if (tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
5047
- handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
5048
- }
5049
- TabManager.focusPane(pane.id);TabManager.switchTab(pane.id, tab.id);
5050
- }
5051
- },
5052
- React.createElement("i", { className: "tree-item-icon " + (window.getFileIcon ? window.getFileIcon(tab.name) : 'far fa-file-code') + " tree-file-icon" }),
5053
- React.createElement(
5054
- "div",
5055
- // minWidth:0 on both the row's name cell and the label
5056
- // itself: without it a flex item refuses to shrink
5057
- // below its content, so a long filename pushed out
5058
- // under the (formerly absolute) action buttons.
5059
- { className: "tree-item-name", style: { display: 'flex', alignItems: 'center', minWidth: 0 } },
5060
- React.createElement(
5061
- "span",
5062
- { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 } },
5063
- tab.name
5064
- ),
5065
- tab.dirty && React.createElement("i", { className: "fas fa-circle", style: { fontSize: '5px', color: '#e3d286', marginLeft: '6px', marginTop: '1px', flexShrink: 0 } })
5066
- ),
5067
- React.createElement(
5480
+ "div",
5481
+ { className: "file-tree" },
5482
+ pane.tabs.map(function (tab) {
5483
+ var isActive = pane.activeTabId === tab.id && state.focusedPaneId === pane.id;
5484
+ var dir = tab.path ? tab.path.split('/').slice(0, -1).join('/') : '';
5485
+ return React.createElement(
5068
5486
  "div",
5069
- // In flow, not absolute — the buttons now claim their
5070
- // own width so the name truncates instead of running
5071
- // underneath them.
5072
- { className: "tab-actions", style: { display: 'flex', alignItems: 'center', flexShrink: 0, marginLeft: 'auto' } },
5487
+ {
5488
+ key: tab.id,
5489
+ className: "tree-item open-editors-item " + (isActive ? "active" : ""),
5490
+ // The name ellipsises in a narrow sidebar, so the row
5491
+ // carries the full path the way file-tree rows do.
5492
+ title: tab.path || tab.name,
5493
+ onClick: function () {
5494
+ if (tab.path && !tab.path.startsWith('mbeditor://')) {
5495
+ handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
5496
+ }
5497
+ TabManager.focusPane(pane.id);TabManager.switchTab(pane.id, tab.id);
5498
+ }
5499
+ },
5500
+ React.createElement("i", { className: "tree-item-icon " + (window.getFileIcon ? window.getFileIcon(tab.name) : 'far fa-file-code') + " tree-file-icon" }),
5073
5501
  React.createElement(
5074
5502
  "div",
5075
- { className: "tab-split", onClick: function (e) {
5076
- e.stopPropagation();TabManager.moveTabToPane(pane.id, pane.id === 1 ? 2 : 1, tab.id);
5077
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 }, title: "Move to Group " + (pane.id === 1 ? 2 : 1) },
5078
- React.createElement("i", { className: isPane2 ? "fas fa-chevron-left" : "fas fa-chevron-right" })
5503
+ // minWidth:0 on both the row's name cell and the label
5504
+ // itself: without it a flex item refuses to shrink
5505
+ // below its content, so a long filename pushed out
5506
+ // under the (formerly absolute) action buttons.
5507
+ { className: "tree-item-name", style: { display: 'flex', alignItems: 'center', minWidth: 0 } },
5508
+ React.createElement(
5509
+ "span",
5510
+ { className: "open-editors-name", style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flexShrink: 0 } },
5511
+ tab.name
5512
+ ),
5513
+ dir && React.createElement(
5514
+ "span",
5515
+ { className: "open-editors-dir" },
5516
+ dir
5517
+ )
5079
5518
  ),
5080
5519
  React.createElement(
5081
5520
  "div",
5082
- { className: "tab-close", onClick: function (e) {
5083
- e.stopPropagation();requestCloseTab(pane.id, tab.id);
5084
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 },
5085
- // See TabBar: role="button" would pick up Pico's
5086
- // button skin from the host app and square this off.
5087
- title: "Close " + tab.name + (tab.dirty ? " (unsaved changes)" : "") },
5088
- React.createElement("i", { className: "fas fa-times" })
5521
+ // In flow, not absolute — the buttons now claim their
5522
+ // own width so the name truncates instead of running
5523
+ // underneath them.
5524
+ { className: "tab-actions", style: { display: 'flex', alignItems: 'center', flexShrink: 0, marginLeft: 'auto' } },
5525
+ React.createElement(
5526
+ "div",
5527
+ { className: "tab-split", onClick: function (e) {
5528
+ e.stopPropagation();TabManager.moveTabToPane(pane.id, pane.id === 1 ? 2 : 1, tab.id);
5529
+ }, style: { padding: '0 4px', cursor: 'pointer' }, title: "Move to Group " + (pane.id === 1 ? 2 : 1) },
5530
+ React.createElement("i", { className: isPane2 ? "fas fa-chevron-left" : "fas fa-chevron-right" })
5531
+ ),
5532
+ React.createElement(
5533
+ "div",
5534
+ { className: "open-editors-close-slot" },
5535
+ tab.dirty && React.createElement("i", { className: "fas fa-circle open-editors-dirty-dot", 'aria-hidden': 'true' }),
5536
+ React.createElement(
5537
+ "button",
5538
+ { type: "button", className: "tab-close", onClick: function (e) {
5539
+ e.stopPropagation();requestCloseTab(pane.id, tab.id);
5540
+ }, style: { padding: '0 4px' },
5541
+ title: "Close " + tab.name + (tab.dirty ? " (unsaved changes)" : ""),
5542
+ 'aria-label': "Close " + tab.name + (tab.dirty ? " (unsaved changes)" : "") },
5543
+ React.createElement("i", { className: "fas fa-times" })
5544
+ )
5545
+ )
5089
5546
  )
5090
- )
5091
- );
5092
- })
5093
- )
5094
- );
5095
- })
5547
+ );
5548
+ })
5549
+ )
5550
+ );
5551
+ });
5552
+ })()
5096
5553
  )
5097
5554
  )
5098
5555
  ),
5099
- state.panes.flatMap(function (p) { return p.tabs; }).length > 0 && React.createElement(
5100
- "div",
5101
- { className: "open-editors-resize-handle", onMouseDown: startOpenEditorsResize }
5102
- ),
5556
+ state.panes.flatMap(function (p) { return p.tabs; }).length > 0 && React.createElement((window.Gutter || Gutter), {
5557
+ orientation: 'horizontal',
5558
+ label: "Resize open editors list",
5559
+ active: activeResizeMode === 'openeditors',
5560
+ onDragStart: startOpenEditorsResize
5561
+ }),
5103
5562
  React.createElement(
5104
5563
  "div",
5105
5564
  { className: "ide-sidebar-scrollable" },
@@ -5107,10 +5566,7 @@ var MbeditorApp = function MbeditorApp() {
5107
5566
  CollapsibleSection,
5108
5567
  {
5109
5568
  title: projectSectionTitle,
5110
- isCollapsed: collapsedSections.projects,
5111
- onToggle: function (isCollapsed) {
5112
- return handleToggleSection('projects', isCollapsed);
5113
- },
5569
+ collapsible: false,
5114
5570
  actions: React.createElement(
5115
5571
  SectionActionGroup,
5116
5572
  { ariaLabel: "Project actions" },
@@ -5145,10 +5601,15 @@ var MbeditorApp = function MbeditorApp() {
5145
5601
  disabled: !!loading.createDir
5146
5602
  }),
5147
5603
  React.createElement(SidebarActionButton, {
5148
- title: "Upload files",
5604
+ title: "Upload files to the workspace root",
5149
5605
  iconClass: 'fas fa-upload',
5606
+ // Defaults to the workspace root, not whatever happens to be
5607
+ // selected in the tree: an upload that silently follows the
5608
+ // selection lands files somewhere the user was not looking,
5609
+ // and the count reads as a success either way. The context
5610
+ // menu's "Upload Files Here..." is the way to target a folder.
5150
5611
  onClick: function () {
5151
- return openImportDialog(selectedTreeNode);
5612
+ return openImportDialog(null);
5152
5613
  }
5153
5614
  }),
5154
5615
  React.createElement(SidebarActionButton, {
@@ -5178,6 +5639,7 @@ var MbeditorApp = function MbeditorApp() {
5178
5639
  activePath: editorPrefs.autoRevealInExplorer !== false ? (activeTab && activeTab.path) : null,
5179
5640
  selectedPaths: selectedPaths,
5180
5641
  anchorPath: selectedTreePath,
5642
+ revealTarget: revealTarget,
5181
5643
  onNodeSelect: handleNodeSelect,
5182
5644
  onMultiSelect: handleMultiSelect,
5183
5645
  onMove: handleMoveNodes,
@@ -5516,18 +5978,32 @@ var MbeditorApp = function MbeditorApp() {
5516
5978
  })()
5517
5979
  )
5518
5980
  ),
5519
- /* Sidebar resize divider — only when panel is open */
5520
- !sidebarCollapsed && !zenMode && React.createElement("div", {
5521
- className: "panel-divider sidebar-divider " + (activeResizeMode === 'sidebar' ? 'active' : ''),
5522
- onMouseDown: startSidebarResize,
5523
- role: "separator",
5524
- "aria-orientation": "vertical",
5525
- "aria-label": "Resize explorer panel"
5981
+ // Collapsed: the same gutter is a handle. Drag it right and the
5982
+ // explorer snaps open; there is no intermediate width to preview.
5983
+ sidebarCollapsed && !zenMode && !showModelGraphView && React.createElement((window.Gutter || Gutter), {
5984
+ orientation: 'vertical',
5985
+ label: "Drag right to open the explorer",
5986
+ onSnap: function () { setSidebarCollapsed(false); }
5526
5987
  }),
5527
- // Column wrapping the split panes and the bottom drawers. ide-main is a
5528
- // row of panes, so the drawers need a vertical parent to push against;
5529
- // as absolute overlays they covered the editor instead.
5530
- React.createElement(
5988
+ /* Sidebar resize gutter — only when panel is open */
5989
+ !sidebarCollapsed && !zenMode && !showModelGraphView && React.createElement((window.Gutter || Gutter), {
5990
+ orientation: 'vertical',
5991
+ label: "Resize explorer panel",
5992
+ active: activeResizeMode === 'sidebar',
5993
+ onDragStart: startSidebarResize
5994
+ }),
5995
+ // The graph replaces the whole column — no tab bar, no panes, no drawers —
5996
+ // rather than living inside ide-main, so it gets the full center width.
5997
+ showModelGraphView ? React.createElement(
5998
+ "div",
5999
+ { className: "ide-model-graph-view" },
6000
+ React.createElement(ModelGraph, {
6001
+ graph: modelGraph,
6002
+ loading: modelGraphLoading,
6003
+ onRefresh: function () { loadModelGraph(true); },
6004
+ onOpenModel: function (model) { openSchemaModal(model.name); }
6005
+ })
6006
+ ) : React.createElement(
5531
6007
  "div",
5532
6008
  { className: "ide-center-column" },
5533
6009
  React.createElement(
@@ -5625,13 +6101,6 @@ var MbeditorApp = function MbeditorApp() {
5625
6101
  commits: pActiveTab.commits || [],
5626
6102
  onSelectCommit: handleSelectCommit
5627
6103
  });
5628
- } else if (pActiveTab.isModelGraph) {
5629
- content = React.createElement(ModelGraph, {
5630
- graph: modelGraph,
5631
- loading: modelGraphLoading,
5632
- onRefresh: function () { loadModelGraph(true); },
5633
- onOpenModel: function (model) { openSchemaModal(model.name); }
5634
- });
5635
6104
  } else if (pActiveTab.isChangelog) {
5636
6105
  content = React.createElement(ChangelogView, {
5637
6106
  changelogState: changelogState,
@@ -5644,597 +6113,6 @@ var MbeditorApp = function MbeditorApp() {
5644
6113
  }
5645
6114
  }
5646
6115
  });
5647
- } else if (pActiveTab.isSettings) {
5648
- content = React.createElement(
5649
- 'div',
5650
- { className: 'ide-settings-tab-content' },
5651
- React.createElement(
5652
- 'div',
5653
- { className: 'ide-settings-body' },
5654
-
5655
- /* ── Appearance ──────────────────────────────── */
5656
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Appearance'),
5657
- React.createElement(
5658
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Color theme for the editor' },
5659
- React.createElement('span', { className: 'ide-settings-label' }, 'Theme'),
5660
- React.createElement(
5661
- 'select', {
5662
- value: editorPrefs.theme || 'vs-dark',
5663
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { theme: e.target.value }); }); }
5664
- },
5665
- React.createElement('option', { value: 'vs-dark' }, 'Dark'),
5666
- React.createElement('option', { value: 'vs' }, 'Light'),
5667
- React.createElement('option', { value: 'hc-black' }, 'HC Dark'),
5668
- React.createElement('option', { value: 'hc-light' }, 'HC Light'),
5669
- React.createElement('option', { value: 'dracula' }, 'Dracula'),
5670
- React.createElement('option', { value: 'night-owl' }, 'Night Owl'),
5671
- React.createElement('option', { value: 'monokai' }, 'Monokai'),
5672
- React.createElement('option', { value: 'nord' }, 'Nord'),
5673
- React.createElement('option', { value: 'github-dark' }, 'GitHub Dark'),
5674
- React.createElement('option', { value: 'tomorrow-night' }, 'Tomorrow Night'),
5675
- React.createElement('option', { value: 'github-light' }, 'GitHub Light')
5676
- )
5677
- ),
5678
- React.createElement(
5679
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Editor font size in pixels (8–32)' },
5680
- React.createElement('span', { className: 'ide-settings-label' }, 'Font size'),
5681
- React.createElement('input', {
5682
- key: String(editorPrefs.fontSize || 13),
5683
- type: 'number', min: '8', max: '32', step: '1',
5684
- className: 'ide-settings-input',
5685
- defaultValue: editorPrefs.fontSize || 13,
5686
- onChange: function(e) {
5687
- var v = parseInt(e.target.value, 10);
5688
- if (v >= 8 && v <= 32) setEditorPrefs(function(p) { return Object.assign({}, p, { fontSize: v }); });
5689
- },
5690
- onBlur: function(e) {
5691
- var v = parseInt(e.target.value, 10);
5692
- if (isNaN(v) || v < 8 || v > 32) e.target.value = String(editorPrefs.fontSize || 13);
5693
- }
5694
- })
5695
- ),
5696
- React.createElement(
5697
- 'label', { className: 'ide-settings-row-full', title: 'Font stack used in the editor — the first font available on your system is used' },
5698
- React.createElement('span', { className: 'ide-settings-label' }, 'Font family'),
5699
- React.createElement('input', {
5700
- type: 'text',
5701
- className: 'ide-settings-input ide-settings-input-wide',
5702
- value: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
5703
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { fontFamily: e.target.value }); }); }
5704
- })
5705
- ),
5706
- React.createElement(
5707
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Row height in pixels. 0 = auto (roughly font size × 1.5)' },
5708
- React.createElement('span', { className: 'ide-settings-label' }, 'Line height (0=auto)'),
5709
- React.createElement('input', {
5710
- key: String(editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0),
5711
- type: 'number', min: '0', max: '100', step: '1',
5712
- className: 'ide-settings-input',
5713
- defaultValue: editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0,
5714
- onChange: function(e) {
5715
- var v = parseInt(e.target.value, 10);
5716
- if (!isNaN(v) && v >= 0 && v <= 100) setEditorPrefs(function(p) { return Object.assign({}, p, { lineHeight: v }); });
5717
- },
5718
- onBlur: function(e) {
5719
- var v = parseInt(e.target.value, 10);
5720
- if (isNaN(v) || v < 0 || v > 100) e.target.value = String(editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0);
5721
- }
5722
- })
5723
- ),
5724
- React.createElement(
5725
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Extra space between characters in pixels. 0 = default' },
5726
- React.createElement('span', { className: 'ide-settings-label' }, 'Letter spacing (px)'),
5727
- React.createElement('input', {
5728
- key: String(editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0),
5729
- type: 'number', min: '-5', max: '20', step: '0.5',
5730
- className: 'ide-settings-input',
5731
- defaultValue: editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0,
5732
- onChange: function(e) {
5733
- var v = parseFloat(e.target.value);
5734
- if (!isNaN(v) && v >= -5 && v <= 20) setEditorPrefs(function(p) { return Object.assign({}, p, { letterSpacing: v }); });
5735
- },
5736
- onBlur: function(e) {
5737
- var v = parseFloat(e.target.value);
5738
- if (isNaN(v) || v < -5 || v > 20) e.target.value = String(editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0);
5739
- }
5740
- })
5741
- ),
5742
-
5743
- /* ── Indentation (unified editor + Prettier) ── */
5744
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Indentation'),
5745
- React.createElement(
5746
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Number of spaces per indentation level (also sets Prettier tab width)' },
5747
- React.createElement('span', { className: 'ide-settings-label' }, 'Tab size'),
5748
- React.createElement('input', {
5749
- key: String(editorPrefs.tabSize || 4),
5750
- type: 'number', min: '1', max: '8', step: '1',
5751
- className: 'ide-settings-input',
5752
- defaultValue: editorPrefs.tabSize || 4,
5753
- onChange: function(e) {
5754
- var v = parseInt(e.target.value, 10);
5755
- if (v >= 1 && v <= 8) setEditorPrefs(function(p) { return Object.assign({}, p, { tabSize: v }); });
5756
- },
5757
- onBlur: function(e) {
5758
- var v = parseInt(e.target.value, 10);
5759
- if (isNaN(v) || v < 1 || v > 8) e.target.value = String(editorPrefs.tabSize || 4);
5760
- }
5761
- })
5762
- ),
5763
- React.createElement(
5764
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Insert spaces instead of tab characters when pressing Tab' },
5765
- React.createElement('span', { className: 'ide-settings-label' }, 'Use spaces'),
5766
- React.createElement('input', {
5767
- type: 'checkbox',
5768
- className: 'ide-settings-checkbox',
5769
- checked: !!(editorPrefs.insertSpaces),
5770
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { insertSpaces: v }); }); }
5771
- })
5772
- ),
5773
-
5774
- /* ── Editor ──────────────────────────────────── */
5775
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Editor'),
5776
- React.createElement(
5777
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'How long lines are handled — Off: scroll horizontally, On: wrap at viewport width, Column: wrap at a fixed column' },
5778
- React.createElement('span', { className: 'ide-settings-label' }, 'Word wrap'),
5779
- React.createElement(
5780
- 'select', {
5781
- value: editorPrefs.wordWrap || 'off',
5782
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { wordWrap: e.target.value }); }); }
5783
- },
5784
- React.createElement('option', { value: 'off' }, 'Off'),
5785
- React.createElement('option', { value: 'on' }, 'On'),
5786
- React.createElement('option', { value: 'wordWrapColumn' }, 'Column')
5787
- )
5788
- ),
5789
- React.createElement(
5790
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Show line numbers in the gutter — On, Off, or Relative (useful with Vim mode)' },
5791
- React.createElement('span', { className: 'ide-settings-label' }, 'Line numbers'),
5792
- React.createElement(
5793
- 'select', {
5794
- value: editorPrefs.lineNumbers || 'on',
5795
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { lineNumbers: e.target.value }); }); }
5796
- },
5797
- React.createElement('option', { value: 'on' }, 'On'),
5798
- React.createElement('option', { value: 'off' }, 'Off'),
5799
- React.createElement('option', { value: 'relative' }, 'Relative')
5800
- )
5801
- ),
5802
- React.createElement(
5803
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Render whitespace characters visually — None, Selection only, Boundary (leading/trailing), or All' },
5804
- React.createElement('span', { className: 'ide-settings-label' }, 'Whitespace'),
5805
- React.createElement(
5806
- 'select', {
5807
- value: editorPrefs.renderWhitespace || 'none',
5808
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { renderWhitespace: e.target.value }); }); }
5809
- },
5810
- React.createElement('option', { value: 'none' }, 'None'),
5811
- React.createElement('option', { value: 'selection' }, 'Selection'),
5812
- React.createElement('option', { value: 'boundary' }, 'Boundary'),
5813
- React.createElement('option', { value: 'all' }, 'All')
5814
- )
5815
- ),
5816
- React.createElement(
5817
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show a scaled-down overview of the file on the right edge of the editor' },
5818
- React.createElement('span', { className: 'ide-settings-label' }, 'Minimap'),
5819
- React.createElement('input', {
5820
- type: 'checkbox',
5821
- className: 'ide-settings-checkbox',
5822
- checked: !!(editorPrefs.minimap),
5823
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { minimap: v }); }); }
5824
- })
5825
- ),
5826
- React.createElement(
5827
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Allow scrolling past the last line so it can be positioned at the top of the viewport' },
5828
- React.createElement('span', { className: 'ide-settings-label' }, 'Scroll past end'),
5829
- React.createElement('input', {
5830
- type: 'checkbox',
5831
- className: 'ide-settings-checkbox',
5832
- checked: !!(editorPrefs.scrollBeyondLastLine),
5833
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { scrollBeyondLastLine: v }); }); }
5834
- })
5835
- ),
5836
- React.createElement(
5837
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Colorize matching bracket pairs with distinct colors to make nesting easier to read' },
5838
- React.createElement('span', { className: 'ide-settings-label' }, 'Bracket colors'),
5839
- React.createElement('input', {
5840
- type: 'checkbox',
5841
- className: 'ide-settings-checkbox',
5842
- checked: !!(editorPrefs.bracketPairColorization),
5843
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { bracketPairColorization: v }); }); }
5844
- })
5845
- ),
5846
- React.createElement(
5847
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Enable Vim keybindings (Normal/Insert/Visual modes). Press Escape to return to Normal mode.' },
5848
- React.createElement('span', { className: 'ide-settings-label' }, 'Vim mode'),
5849
- React.createElement('input', {
5850
- type: 'checkbox',
5851
- className: 'ide-settings-checkbox',
5852
- checked: !!(editorPrefs.vimMode),
5853
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { vimMode: v }); }); }
5854
- })
5855
- ),
5856
- React.createElement(
5857
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'When to insert a matching closing bracket automatically' },
5858
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto-close brackets'),
5859
- React.createElement(
5860
- 'select', {
5861
- value: editorPrefs.autoClosingBrackets || 'always',
5862
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoClosingBrackets: e.target.value }); }); }
5863
- },
5864
- React.createElement('option', { value: 'always' }, 'Always'),
5865
- React.createElement('option', { value: 'languageDefined' }, 'Per language rules'),
5866
- React.createElement('option', { value: 'beforeWhitespace' }, 'Only before whitespace'),
5867
- React.createElement('option', { value: 'never' }, 'Never')
5868
- )
5869
- ),
5870
- React.createElement(
5871
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'When to insert a matching closing quote automatically' },
5872
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto-close quotes'),
5873
- React.createElement(
5874
- 'select', {
5875
- value: editorPrefs.autoClosingQuotes || 'always',
5876
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoClosingQuotes: e.target.value }); }); }
5877
- },
5878
- React.createElement('option', { value: 'always' }, 'Always'),
5879
- React.createElement('option', { value: 'languageDefined' }, 'Per language rules'),
5880
- React.createElement('option', { value: 'beforeWhitespace' }, 'Only before whitespace'),
5881
- React.createElement('option', { value: 'never' }, 'Never')
5882
- )
5883
- ),
5884
- React.createElement(
5885
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'What to highlight on the current editor line' },
5886
- React.createElement('span', { className: 'ide-settings-label' }, 'Line highlight'),
5887
- React.createElement(
5888
- 'select', {
5889
- value: editorPrefs.renderLineHighlight || 'none',
5890
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { renderLineHighlight: e.target.value }); }); }
5891
- },
5892
- React.createElement('option', { value: 'none' }, 'None'),
5893
- React.createElement('option', { value: 'gutter' }, 'Line number only'),
5894
- React.createElement('option', { value: 'line' }, 'Current line background'),
5895
- React.createElement('option', { value: 'all' }, 'Line number + background')
5896
- )
5897
- ),
5898
- React.createElement(
5899
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Shape of the text cursor in the editor' },
5900
- React.createElement('span', { className: 'ide-settings-label' }, 'Cursor style'),
5901
- React.createElement(
5902
- 'select', {
5903
- value: editorPrefs.cursorStyle || 'line',
5904
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { cursorStyle: e.target.value }); }); }
5905
- },
5906
- React.createElement('option', { value: 'line' }, 'Line (|)'),
5907
- React.createElement('option', { value: 'block' }, 'Block (filled)'),
5908
- React.createElement('option', { value: 'underline' }, 'Underline (_)'),
5909
- React.createElement('option', { value: 'line-thin' }, 'Line thin'),
5910
- React.createElement('option', { value: 'block-outline' }, 'Block outline'),
5911
- React.createElement('option', { value: 'underline-thin' }, 'Underline thin')
5912
- )
5913
- ),
5914
- React.createElement(
5915
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Cursor animation style — Blink (on/off), Smooth (fade), Phase (offset fade), Expand (grow), or Solid (no animation)' },
5916
- React.createElement('span', { className: 'ide-settings-label' }, 'Cursor blinking'),
5917
- React.createElement(
5918
- 'select', {
5919
- value: editorPrefs.cursorBlinking || 'blink',
5920
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { cursorBlinking: e.target.value }); }); }
5921
- },
5922
- React.createElement('option', { value: 'blink' }, 'Blink (on/off)'),
5923
- React.createElement('option', { value: 'smooth' }, 'Smooth (fade)'),
5924
- React.createElement('option', { value: 'phase' }, 'Phase (offset fade)'),
5925
- React.createElement('option', { value: 'expand' }, 'Expand (grow/shrink)'),
5926
- React.createElement('option', { value: 'solid' }, 'Solid (no blink)')
5927
- )
5928
- ),
5929
- React.createElement(
5930
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show collapse arrows next to foldable regions (functions, classes, blocks)' },
5931
- React.createElement('span', { className: 'ide-settings-label' }, 'Code folding'),
5932
- React.createElement('input', {
5933
- type: 'checkbox',
5934
- className: 'ide-settings-checkbox',
5935
- checked: editorPrefs.folding !== false,
5936
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { folding: v }); }); }
5937
- })
5938
- ),
5939
- React.createElement(
5940
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Animate scrolling instead of jumping instantly' },
5941
- React.createElement('span', { className: 'ide-settings-label' }, 'Smooth scrolling'),
5942
- React.createElement('input', {
5943
- type: 'checkbox',
5944
- className: 'ide-settings-checkbox',
5945
- checked: !!(editorPrefs.smoothScrolling),
5946
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { smoothScrolling: v }); }); }
5947
- })
5948
- ),
5949
- React.createElement(
5950
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Hold Ctrl (or Cmd) and scroll the mouse wheel to zoom the font size' },
5951
- React.createElement('span', { className: 'ide-settings-label' }, 'Ctrl+scroll to zoom'),
5952
- React.createElement('input', {
5953
- type: 'checkbox',
5954
- className: 'ide-settings-checkbox',
5955
- checked: !!(editorPrefs.mouseWheelZoom),
5956
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { mouseWheelZoom: v }); }); }
5957
- })
5958
- ),
5959
-
5960
- /* ── Behaviour ───────────────────────────────── */
5961
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Behaviour'),
5962
- React.createElement(
5963
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'How aggressively the editor re-indents lines as you type' },
5964
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto indent'),
5965
- React.createElement(
5966
- 'select', {
5967
- value: editorPrefs.autoIndent || 'full',
5968
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoIndent: e.target.value }); }); }
5969
- },
5970
- React.createElement('option', { value: 'none' }, 'None (disabled)'),
5971
- React.createElement('option', { value: 'keep' }, 'Keep current level'),
5972
- React.createElement('option', { value: 'brackets' }, 'Indent on { and ['),
5973
- React.createElement('option', { value: 'advanced' }, 'Language indent rules'),
5974
- React.createElement('option', { value: 'full' }, 'Full (language grammar)')
5975
- )
5976
- ),
5977
- React.createElement(
5978
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Whether pressing Enter accepts the highlighted autocomplete suggestion' },
5979
- React.createElement('span', { className: 'ide-settings-label' }, 'Accept suggestion on Enter'),
5980
- React.createElement(
5981
- 'select', {
5982
- value: editorPrefs.acceptSuggestionOnEnter || 'on',
5983
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { acceptSuggestionOnEnter: e.target.value }); }); }
5984
- },
5985
- React.createElement('option', { value: 'on' }, 'Always'),
5986
- React.createElement('option', { value: 'smart' }, 'Only when navigated (↑↓)'),
5987
- React.createElement('option', { value: 'off' }, 'Never (Tab only)')
5988
- )
5989
- ),
5990
- React.createElement(
5991
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Suggest completions based on words already present in open files' },
5992
- React.createElement('span', { className: 'ide-settings-label' }, 'Word-based suggestions'),
5993
- React.createElement(
5994
- 'select', {
5995
- value: editorPrefs.wordBasedSuggestions || 'matchingDocuments',
5996
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { wordBasedSuggestions: e.target.value }); }); }
5997
- },
5998
- React.createElement('option', { value: 'off' }, 'Off'),
5999
- React.createElement('option', { value: 'currentDocument' }, 'Current file only'),
6000
- React.createElement('option', { value: 'matchingDocuments' }, 'Same language files'),
6001
- React.createElement('option', { value: 'allDocuments' }, 'All open files')
6002
- )
6003
- ),
6004
- React.createElement(
6005
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Re-indent and auto-close blocks as you type (e.g. after pressing Enter inside {})' },
6006
- React.createElement('span', { className: 'ide-settings-label' }, 'Format on type'),
6007
- React.createElement('input', {
6008
- type: 'checkbox',
6009
- className: 'ide-settings-checkbox',
6010
- checked: editorPrefs.formatOnType === true,
6011
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { formatOnType: v }); }); }
6012
- })
6013
- ),
6014
- React.createElement(
6015
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Format the file before every save — RuboCop -A for Ruby, Prettier for JS/JSX/CSS/HTML/Markdown' },
6016
- React.createElement('span', { className: 'ide-settings-label' }, 'Format on save'),
6017
- React.createElement('input', {
6018
- type: 'checkbox',
6019
- className: 'ide-settings-checkbox',
6020
- checked: editorPrefs.formatOnSave === true,
6021
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { formatOnSave: v }); }); }
6022
- })
6023
- ),
6024
- React.createElement(
6025
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show autocomplete suggestions while typing (not just on trigger characters like .)' },
6026
- React.createElement('span', { className: 'ide-settings-label' }, 'Quick suggestions'),
6027
- React.createElement('input', {
6028
- type: 'checkbox',
6029
- className: 'ide-settings-checkbox',
6030
- checked: editorPrefs.quickSuggestions !== false,
6031
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { quickSuggestions: v }); }); }
6032
- })
6033
- ),
6034
-
6035
- /* ── Formatting (Prettier) ───────────────────── */
6036
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Formatting'),
6037
- React.createElement(
6038
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Prettier: maximum line length before wrapping (40–200)' },
6039
- React.createElement('span', { className: 'ide-settings-label' }, 'Print width'),
6040
- React.createElement('input', {
6041
- key: String(editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80),
6042
- type: 'number', min: '40', max: '200', step: '1',
6043
- className: 'ide-settings-input',
6044
- defaultValue: editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80,
6045
- onChange: function(e) {
6046
- var v = parseInt(e.target.value, 10);
6047
- if (v >= 40 && v <= 200) setEditorPrefs(function(p) { return Object.assign({}, p, { prettierPrintWidth: v }); });
6048
- },
6049
- onBlur: function(e) {
6050
- var v = parseInt(e.target.value, 10);
6051
- if (isNaN(v) || v < 40 || v > 200) e.target.value = String(editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80);
6052
- }
6053
- })
6054
- ),
6055
- React.createElement(
6056
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Prettier: add trailing commas in multi-line expressions — All (ES2017+), ES5 (objects/arrays only), or None' },
6057
- React.createElement('span', { className: 'ide-settings-label' }, 'Trailing commas'),
6058
- React.createElement(
6059
- 'select', {
6060
- value: editorPrefs.prettierTrailingComma || 'all',
6061
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { prettierTrailingComma: e.target.value }); }); }
6062
- },
6063
- React.createElement('option', { value: 'all' }, 'All'),
6064
- React.createElement('option', { value: 'es5' }, 'ES5'),
6065
- React.createElement('option', { value: 'none' }, 'None')
6066
- )
6067
- ),
6068
- React.createElement(
6069
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Prettier: add semicolons at the end of statements' },
6070
- React.createElement('span', { className: 'ide-settings-label' }, 'Semicolons'),
6071
- React.createElement('input', {
6072
- type: 'checkbox',
6073
- className: 'ide-settings-checkbox',
6074
- checked: editorPrefs.prettierSemi !== false,
6075
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierSemi: v }); }); }
6076
- })
6077
- ),
6078
- React.createElement(
6079
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: "Prettier: use single quotes instead of double quotes for strings" },
6080
- React.createElement('span', { className: 'ide-settings-label' }, 'Single quotes'),
6081
- React.createElement('input', {
6082
- type: 'checkbox',
6083
- className: 'ide-settings-checkbox',
6084
- checked: !!editorPrefs.prettierSingleQuote,
6085
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierSingleQuote: v }); }); }
6086
- })
6087
- ),
6088
- React.createElement(
6089
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Prettier: add spaces inside object literal braces, e.g. { a: 1 } vs {a: 1}' },
6090
- React.createElement('span', { className: 'ide-settings-label' }, 'Bracket spacing'),
6091
- React.createElement('input', {
6092
- type: 'checkbox',
6093
- className: 'ide-settings-checkbox',
6094
- checked: editorPrefs.prettierBracketSpacing !== false,
6095
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierBracketSpacing: v }); }); }
6096
- })
6097
- ),
6098
-
6099
- /* ── Interface ───────────────────────────────── */
6100
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Interface'),
6101
- React.createElement(
6102
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Automatically scroll the file explorer to reveal and highlight the file you are editing' },
6103
- React.createElement('span', { className: 'ide-settings-label' }, 'Explorer follows active file'),
6104
- React.createElement('input', {
6105
- type: 'checkbox',
6106
- className: 'ide-settings-checkbox',
6107
- checked: !!(editorPrefs.autoRevealInExplorer),
6108
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { autoRevealInExplorer: v }); }); }
6109
- })
6110
- ),
6111
- React.createElement(
6112
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Jump to a file in the explorer by typing its name when the sidebar is focused' },
6113
- React.createElement('span', { className: 'ide-settings-label' }, 'Explorer type-ahead'),
6114
- React.createElement('input', {
6115
- type: 'checkbox',
6116
- className: 'ide-settings-checkbox',
6117
- checked: !!(editorPrefs.fileTreeTypeahead !== false),
6118
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { fileTreeTypeahead: v }); }); }
6119
- })
6120
- ),
6121
- React.createElement(
6122
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show hidden files and directories (those starting with a dot, e.g. .env, .gitignore) in the file explorer' },
6123
- React.createElement('span', { className: 'ide-settings-label' }, 'Show dotfiles'),
6124
- React.createElement('input', {
6125
- type: 'checkbox',
6126
- className: 'ide-settings-checkbox',
6127
- checked: !!(editorPrefs.showDotFiles),
6128
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { showDotFiles: v }); }); }
6129
- })
6130
- ),
6131
- React.createElement(
6132
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Scroll: tabs overflow horizontally with a scrollbar; Wrap: tabs flow onto multiple rows' },
6133
- React.createElement('span', { className: 'ide-settings-label' }, 'Tab bar layout'),
6134
- React.createElement(
6135
- 'select', {
6136
- value: editorPrefs.tabDisplayMode || 'scroll',
6137
- onChange: function(e) { var v = e.target.value; setEditorPrefs(function(p) { return Object.assign({}, p, { tabDisplayMode: v }); }); }
6138
- },
6139
- React.createElement('option', { value: 'scroll' }, 'Scroll'),
6140
- React.createElement('option', { value: 'wrap' }, 'Wrap (multi-row)')
6141
- )
6142
- ),
6143
- React.createElement(
6144
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Include folder names in the Quick Open picker (Ctrl+P / Cmd+P) results, not just files' },
6145
- React.createElement('span', { className: 'ide-settings-label' }, 'Quick Open: show folders'),
6146
- React.createElement('input', {
6147
- type: 'checkbox',
6148
- className: 'ide-settings-checkbox',
6149
- checked: !!(editorPrefs.quickOpenShowFolders),
6150
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { quickOpenShowFolders: v }); }); }
6151
- })
6152
- ),
6153
- React.createElement(
6154
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Hide toolbar button labels and show only icons, giving more horizontal space' },
6155
- React.createElement('span', { className: 'ide-settings-label' }, 'Toolbar: icons only'),
6156
- React.createElement('input', {
6157
- type: 'checkbox',
6158
- className: 'ide-settings-checkbox',
6159
- // The stored preference, not the derived value: at a
6160
- // narrow width the box would otherwise show as checked
6161
- // and unchecking it would appear to do nothing.
6162
- checked: !!(editorPrefs.toolbarIconOnly),
6163
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { toolbarIconOnly: v }); }); }
6164
- })
6165
- ),
6166
-
6167
- React.createElement(
6168
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Keep the search/replace text when switching between files in the editor' },
6169
- React.createElement('span', { className: 'ide-settings-label' }, 'Persist find state across files'),
6170
- React.createElement('input', {
6171
- type: 'checkbox',
6172
- className: 'ide-settings-checkbox',
6173
- checked: editorPrefs.persistFindState !== false,
6174
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { persistFindState: v }); }); }
6175
- })
6176
- ),
6177
- React.createElement(
6178
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Save which files are open per branch and restore them when switching branches. Disable to always start with a clean slate when switching.' },
6179
- React.createElement('span', { className: 'ide-settings-label' }, 'Restore tabs on branch switch'),
6180
- React.createElement('input', {
6181
- type: 'checkbox',
6182
- className: 'ide-settings-checkbox',
6183
- checked: editorPrefs.branchStateRestore !== false,
6184
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { branchStateRestore: v }); }); }
6185
- })
6186
- ),
6187
-
6188
- React.createElement(
6189
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show the verb and path that route to each controller action after its def line, and mark public actions nothing routes to' },
6190
- React.createElement('span', { className: 'ide-settings-label' }, 'Controller route hints'),
6191
- React.createElement('input', {
6192
- type: 'checkbox',
6193
- className: 'ide-settings-checkbox',
6194
- checked: editorPrefs.routeHints !== false,
6195
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { routeHints: v }); }); }
6196
- })
6197
- ),
6198
-
6199
- /* ── RuboCop ─────────────────────────────────── */
6200
- React.createElement('div', { className: 'ide-settings-section-header' }, 'RuboCop'),
6201
- React.createElement(
6202
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Run RuboCop in the background and show lint warnings/errors as markers in the editor gutter' },
6203
- React.createElement('span', { className: 'ide-settings-label' }, 'Enable RuboCop linting'),
6204
- React.createElement('input', {
6205
- type: 'checkbox',
6206
- className: 'ide-settings-checkbox',
6207
- checked: editorPrefs.rubocopLintEnabled !== false,
6208
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { rubocopLintEnabled: v }); }); }
6209
- })
6210
- ),
6211
- rubocopAvailable && rubocopConfigPath ? React.createElement(
6212
- 'div', { className: 'ide-settings-row ide-settings-row-link' },
6213
- React.createElement('span', { className: 'ide-settings-label' }, 'Config file'),
6214
- React.createElement(
6215
- 'button', {
6216
- type: 'button',
6217
- className: 'ide-settings-config-link',
6218
- title: 'Open ' + rubocopConfigPath,
6219
- onClick: function() { handleSelectFile(rubocopConfigPath, rubocopConfigPath.split('/').pop()); }
6220
- },
6221
- React.createElement('i', { className: 'fas fa-file-alt', style: { marginRight: 5 } }),
6222
- rubocopConfigPath
6223
- )
6224
- ) : null,
6225
- React.createElement(
6226
- 'button',
6227
- {
6228
- className: 'ide-settings-reset-btn',
6229
- type: 'button',
6230
- title: 'Restore every editor preference on this page to its default',
6231
- onClick: function() { setEditorPrefs(Object.assign({}, DEFAULT_EDITOR_PREFS)); }
6232
- },
6233
- React.createElement('i', { className: 'fas fa-undo', style: { marginRight: 6 } }),
6234
- 'Reset to defaults'
6235
- )
6236
- )
6237
- );
6238
6116
  } else if (pActiveTab.isDiff) {
6239
6117
  var _t = editorPrefs.theme || 'vs-dark';
6240
6118
  var isDiffDark = _t !== 'vs' && _t !== 'hc-light' && _t !== 'github-light';
@@ -6283,9 +6161,12 @@ var MbeditorApp = function MbeditorApp() {
6283
6161
  return React.createElement(
6284
6162
  React.Fragment,
6285
6163
  { key: pane.id },
6286
- idx === 1 && isSplit && React.createElement("div", {
6287
- className: "panel-divider pane-divider " + (activeResizeMode === 'pane' ? 'active' : ''),
6288
- onMouseDown: startPaneResize
6164
+ idx === 1 && isSplit && React.createElement((window.Gutter || Gutter), {
6165
+ orientation: 'vertical',
6166
+ className: 'ide-gutter-pane',
6167
+ label: "Resize editor groups",
6168
+ active: activeResizeMode === 'pane',
6169
+ onDragStart: startPaneResize
6289
6170
  }),
6290
6171
  React.createElement(
6291
6172
  "div",
@@ -6293,10 +6174,6 @@ var MbeditorApp = function MbeditorApp() {
6293
6174
  className: "ide-pane " + (isFocused ? 'focused' : '') + " " + (isDropTarget ? 'drop-target' : ''),
6294
6175
  style: { flexBasis: flexBasis, flexShrink: 0, flexGrow: 0, display: 'flex', flexDirection: 'column', minWidth: 0 },
6295
6176
  onClickCapture: function (e) {
6296
- // Do not steal click events from controls inside the Settings tab.
6297
- // Focusing the pane in capture phase can rerender before checkbox
6298
- // change events are processed, making toggles appear stuck.
6299
- if (e.target && e.target.closest && e.target.closest('.ide-settings-tab-content')) return;
6300
6177
  return TabManager.focusPane(pane.id);
6301
6178
  },
6302
6179
  onDragOver: function (e) {
@@ -6438,10 +6315,26 @@ var MbeditorApp = function MbeditorApp() {
6438
6315
  );
6439
6316
  })
6440
6317
  ),
6318
+ showLogPanel && !zenMode && React.createElement((window.Gutter || Gutter), {
6319
+ orientation: 'horizontal',
6320
+ label: "Resize log drawer",
6321
+ active: activeResizeMode === 'log',
6322
+ onDragStart: startDrawerResize('log', logHeight, 'mbeditorLogHeight')
6323
+ }),
6441
6324
  showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
6325
+ height: logHeight,
6442
6326
  onClose: function () { setShowLogPanel(false); }
6443
6327
  }),
6328
+ // With the drawer closed, the gutter above the status bar is a handle:
6329
+ // drag it up to open Problems at the dragged height.
6330
+ showProblemsPanel && !zenMode && React.createElement((window.Gutter || Gutter), {
6331
+ orientation: 'horizontal',
6332
+ label: "Resize problems drawer",
6333
+ active: activeResizeMode === 'problems',
6334
+ onDragStart: startDrawerResize('problems', problemsHeight, 'mbeditorProblemsHeight')
6335
+ }),
6444
6336
  showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
6337
+ height: problemsHeight,
6445
6338
  onClose: function () { setShowProblemsPanel(false); },
6446
6339
  onOpenFile: function (path, line, col) {
6447
6340
  handleSelectFile(path, path.split('/').pop(), line, col);
@@ -6456,14 +6349,20 @@ var MbeditorApp = function MbeditorApp() {
6456
6349
  ),
6457
6350
 
6458
6351
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
6459
- showGitPanel && !zenMode && React.createElement("div", {
6460
- className: "panel-divider gitpanel-divider " + (activeResizeMode === 'gitpanel' ? 'active' : ''),
6461
- onMouseDown: startGitPanelResize,
6462
- role: "separator",
6463
- "aria-orientation": "vertical",
6464
- "aria-label": "Resize git panel"
6352
+ !showGitPanel && !zenMode && !showModelGraphView && React.createElement((window.Gutter || Gutter), {
6353
+ orientation: 'vertical',
6354
+ label: "Drag left to open the git panel",
6355
+ snapDirection: -1,
6356
+ onSnap: function () { setShowGitPanel(true); }
6465
6357
  }),
6466
- showGitPanel && !zenMode && React.createElement(
6358
+ showGitPanel && !zenMode && !showModelGraphView && React.createElement((window.Gutter || Gutter), {
6359
+ orientation: 'vertical',
6360
+ className: 'ide-gutter-gitpanel',
6361
+ label: "Resize git panel",
6362
+ active: activeResizeMode === 'gitpanel',
6363
+ onDragStart: startGitPanelResize
6364
+ }),
6365
+ showGitPanel && !zenMode && !showModelGraphView && React.createElement(
6467
6366
  "div",
6468
6367
  { className: "ide-git-right-panel", style: { width: gitPanelWidth + "px" } },
6469
6368
  React.createElement(window.GitPanel || GitPanel, {
@@ -6479,6 +6378,19 @@ var MbeditorApp = function MbeditorApp() {
6479
6378
  })
6480
6379
  ),
6481
6380
  ),
6381
+ // ponytail: opens on release, no live preview; track the drag if that grates.
6382
+ !showProblemsPanel && !zenMode && !showModelGraphView && React.createElement((window.Gutter || Gutter), {
6383
+ orientation: 'horizontal',
6384
+ className: 'ide-gutter-drawer-open',
6385
+ label: "Drag up to open Problems",
6386
+ snapThreshold: 40,
6387
+ onSnap: function (dragged) {
6388
+ var h = Math.max(DRAWER_MIN_HEIGHT, dragged);
6389
+ try { window.localStorage.setItem('mbeditorProblemsHeight', String(h)); } catch (err) {}
6390
+ setProblemsHeight(h);
6391
+ setShowProblemsPanel(true);
6392
+ }
6393
+ }),
6482
6394
  React.createElement(
6483
6395
  "div",
6484
6396
  { className: "ide-statusbar" },
@@ -6515,6 +6427,15 @@ var MbeditorApp = function MbeditorApp() {
6515
6427
  React.createElement("i", { className: "fas fa-exclamation-triangle statusbar-problems-warning-icon" }),
6516
6428
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
6517
6429
  ),
6430
+ migrationsPending && React.createElement(
6431
+ "div",
6432
+ {
6433
+ className: "statusbar-migration",
6434
+ title: "Pending migrations \u2014 run `rails db:migrate`. Editing still works in the meantime."
6435
+ },
6436
+ React.createElement("i", { className: "fas fa-database" }),
6437
+ " Migrations pending"
6438
+ ),
6518
6439
  !serverOnline && (function () {
6519
6440
  var dirtyCount = state.panes.reduce(function (acc, p) {
6520
6441
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;
@@ -6574,6 +6495,7 @@ var MbeditorApp = function MbeditorApp() {
6574
6495
  },
6575
6496
  React.createElement("i", { className: "fas fa-paragraph" })
6576
6497
  ),
6498
+ React.createElement(CursorPosition, null),
6577
6499
  activeEOL && React.createElement(
6578
6500
  "button",
6579
6501
  {
@@ -6786,6 +6708,7 @@ var MbeditorApp = function MbeditorApp() {
6786
6708
  "div",
6787
6709
  {
6788
6710
  className: "context-menu",
6711
+ ref: contextMenuRef,
6789
6712
  style: { left: contextMenu.x, top: contextMenu.y },
6790
6713
  onClick: function (e) {
6791
6714
  return e.stopPropagation();
@@ -6830,16 +6753,16 @@ var MbeditorApp = function MbeditorApp() {
6830
6753
  return handleContextMenuAction('delete');
6831
6754
  } },
6832
6755
  React.createElement("i", { className: "far fa-trash-alt context-menu-icon" }),
6833
- " Delete"
6756
+ " " + deleteMenuLabel(contextMenu.node)
6834
6757
  ),
6835
6758
  React.createElement("div", { className: "context-menu-divider" }),
6836
- contextMenu.node && contextMenu.node.type === 'file' && React.createElement(
6759
+ contextMenu.node && React.createElement(
6837
6760
  "div",
6838
6761
  { className: "context-menu-item", onClick: function () {
6839
6762
  return handleContextMenuAction('download');
6840
6763
  } },
6841
6764
  React.createElement("i", { className: "fas fa-download context-menu-icon" }),
6842
- " Download"
6765
+ " " + downloadMenuLabel(contextMenu.node)
6843
6766
  ),
6844
6767
  React.createElement(
6845
6768
  "div",
@@ -7033,4 +6956,6 @@ var MbeditorApp = function MbeditorApp() {
7033
6956
  };
7034
6957
 
7035
6958
  window.MbeditorApp = MbeditorApp;
6959
+ // Test-only: lets the system test seed/inspect the test-result cache directly.
6960
+ window.__mbeditorTestCache = { save: saveCachedTestResult, prefix: TEST_CACHE_PREFIX };
7036
6961
  /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */