mbeditor 0.13.0 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  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 +440 -334
  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/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -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 })
@@ -216,6 +340,23 @@ var SectionActionGroup = function SectionActionGroup(_ref2) {
216
340
  );
217
341
  };
218
342
 
343
+ // Split a search hit into [before, match, after] so the match can be tinted
344
+ // and pinned on screen. `col`/`end_col` are 1-based against the RAW line while
345
+ // the row renders the stripped `text`, so `lead` — the characters strip took
346
+ // off the front — is what maps one onto the other. Returns null for the tiers
347
+ // and queries that produce no columns; the row then renders as plain text.
348
+ function searchMatchParts(res) {
349
+ var text = res.text == null ? "" : String(res.text);
350
+ if (!res.col || !res.end_col) return null;
351
+ var start = res.col - 1 - (res.lead || 0);
352
+ var end = Math.min(res.end_col - 1 - (res.lead || 0), text.length);
353
+ if (!(start >= 0 && end > start && start < text.length)) return null;
354
+ // U+200E: a strong LTR character, so the left-ellipsis trick in
355
+ // .search-result-pre (direction: rtl) can never reorder a segment that
356
+ // happens to be all punctuation.
357
+ return ["‎" + text.slice(0, start), text.slice(start, end), text.slice(end)];
358
+ }
359
+
219
360
  function FileReloadBanner(_ref) {
220
361
  var pendingReloads = _ref.pendingReloads;
221
362
  var onSaveAndReload = _ref.onSaveAndReload;
@@ -254,107 +395,126 @@ function FileReloadBanner(_ref) {
254
395
  );
255
396
  }
256
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
+
257
430
  var MbeditorApp = function MbeditorApp() {
258
431
  var _useState = useState(EditorStore.getState());
259
432
 
260
- var _useState2 = _slicedToArray(_useState, 2);
261
433
 
262
- var state = _useState2[0];
263
- var setState = _useState2[1];
434
+ var state = _useState[0];
435
+ var setState = _useState[1];
264
436
 
265
437
  var _useState21 = useState(null);
266
- var _useState22 = _slicedToArray(_useState21, 2);
267
- var historyPanelPath = _useState22[0];
268
- var setHistoryPanelPath = _useState22[1];
438
+ var historyPanelPath = _useState21[0];
439
+ var setHistoryPanelPath = _useState21[1];
269
440
 
270
441
  var _useState23 = useState(false);
271
- var _useState24 = _slicedToArray(_useState23, 2);
272
- var isNavigating = _useState24[0];
273
- var setIsNavigating = _useState24[1];
442
+ var isNavigating = _useState23[0];
443
+ var setIsNavigating = _useState23[1];
274
444
 
275
445
  var _useState25 = useState(false);
276
- var _useState26 = _slicedToArray(_useState25, 2);
277
- var isReviewOpen = _useState26[0];
278
- var setIsReviewOpen = _useState26[1];
446
+ var isReviewOpen = _useState25[0];
447
+ var setIsReviewOpen = _useState25[1];
279
448
 
280
449
  var _useState27 = useState(null);
281
- var _useState28 = _slicedToArray(_useState27, 2);
282
- var selectedCommit = _useState28[0];
283
- var setSelectedCommit = _useState28[1];
450
+ var selectedCommit = _useState27[0];
451
+ var setSelectedCommit = _useState27[1];
284
452
 
285
453
  var _useState29 = useState(null);
286
- var _useState30 = _slicedToArray(_useState29, 2);
287
- var commitDetailFiles = _useState30[0];
288
- 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];
289
460
 
290
461
  var _useState4 = useState([]);
291
462
 
292
- var _useState42 = _slicedToArray(_useState4, 2);
293
463
 
294
- var treeData = _useState42[0];
295
- var setTreeData = _useState42[1];
464
+ var treeData = _useState4[0];
465
+ var setTreeData = _useState4[1];
296
466
 
297
467
  var _useState5 = useState("");
298
468
 
299
- var _useState52 = _slicedToArray(_useState5, 2);
300
469
 
301
- var projectRootName = _useState52[0];
302
- var setProjectRootName = _useState52[1];
470
+ var projectRootName = _useState5[0];
471
+ var setProjectRootName = _useState5[1];
303
472
 
304
473
  var _useState6 = useState(null);
305
474
 
306
- var _useState62 = _slicedToArray(_useState6, 2);
307
475
 
308
- var selectedTreeNode = _useState62[0];
309
- var setSelectedTreeNode = _useState62[1];
476
+ var selectedTreeNode = _useState6[0];
477
+ var setSelectedTreeNode = _useState6[1];
310
478
 
311
479
  var _useStateSP = useState(new Set());
312
- var _useStateSP2 = _slicedToArray(_useStateSP, 2);
313
- var selectedPaths = _useStateSP2[0];
314
- var setSelectedPaths = _useStateSP2[1];
480
+ var selectedPaths = _useStateSP[0];
481
+ var setSelectedPaths = _useStateSP[1];
315
482
 
316
483
  var _useState7 = useState("");
317
484
 
318
- var _useState72 = _slicedToArray(_useState7, 2);
319
485
 
320
- var searchQuery = _useState72[0];
321
- var setSearchQuery = _useState72[1];
486
+ var searchQuery = _useState7[0];
487
+ var setSearchQuery = _useState7[1];
322
488
 
323
489
  var _useState33 = useState(false);
324
- var _useState332 = _slicedToArray(_useState33, 2);
325
- var searchLoading = _useState332[0];
326
- var setSearchLoading = _useState332[1];
490
+ var searchLoading = _useState33[0];
491
+ var setSearchLoading = _useState33[1];
327
492
 
328
493
  var searchRequestIdRef = useRef(0);
329
494
 
330
495
  var _useState33h = useState(false);
331
- var _useState33h2 = _slicedToArray(_useState33h, 2);
332
- var searchHasMore = _useState33h2[0];
333
- var setSearchHasMore = _useState33h2[1];
496
+ var searchHasMore = _useState33h[0];
497
+ var setSearchHasMore = _useState33h[1];
334
498
 
335
499
  var _useState33tc = useState(0);
336
- var _useState33tc2 = _slicedToArray(_useState33tc, 2);
337
- var searchTotalCount = _useState33tc2[0];
338
- var setSearchTotalCount = _useState33tc2[1];
500
+ var searchTotalCount = _useState33tc[0];
501
+ var setSearchTotalCount = _useState33tc[1];
339
502
 
340
503
  var searchHasMoreRef = useRef(false);
341
504
  var searchOffsetRef = useRef(0);
342
505
  var searchLoadingMoreRef = useRef(false);
343
506
 
344
507
  var _useStateRx = useState(false);
345
- var _useStateRx2 = _slicedToArray(_useStateRx, 2);
346
- var searchUseRegex = _useStateRx2[0];
347
- var setSearchUseRegex = _useStateRx2[1];
508
+ var searchUseRegex = _useStateRx[0];
509
+ var setSearchUseRegex = _useStateRx[1];
348
510
 
349
511
  var _useStateMC = useState(false);
350
- var _useStateMC2 = _slicedToArray(_useStateMC, 2);
351
- var searchMatchCase = _useStateMC2[0];
352
- var setSearchMatchCase = _useStateMC2[1];
512
+ var searchMatchCase = _useStateMC[0];
513
+ var setSearchMatchCase = _useStateMC[1];
353
514
 
354
515
  var _useStateWW = useState(false);
355
- var _useStateWW2 = _slicedToArray(_useStateWW, 2);
356
- var searchWholeWord = _useStateWW2[0];
357
- var setSearchWholeWord = _useStateWW2[1];
516
+ var searchWholeWord = _useStateWW[0];
517
+ var setSearchWholeWord = _useStateWW[1];
358
518
 
359
519
  var searchQueryRef = useRef('');
360
520
  var searchUseRegexRef = useRef(false);
@@ -366,14 +526,30 @@ var MbeditorApp = function MbeditorApp() {
366
526
  // SearchReplaceService::MAX_RESULTS (10_000) rows, and every row is five
367
527
  // elements, so rendering the list in full built ~50k nodes — enough to kill
368
528
  // the tab outright, and 92 ms of render for a mere 3_000 rows. Rows are a
369
- // fixed 40px (.search-result-item), which is what makes the arithmetic here
370
- // as simple as the file tree's.
371
- var SEARCH_ROW_HEIGHT = 40;
529
+ // fixed 22px, which is what makes the arithmetic here as simple as the file
530
+ // tree's. The results are a VS Code-style tree — a header row per file, its
531
+ // matches nested under it — but header and match rows are deliberately the
532
+ // *same* height (.search-result-file-row and .search-result-item both pin
533
+ // it), so the flattened row array still windows by plain multiplication.
534
+ // Give the two rows different heights and every offset here is wrong.
535
+ var SEARCH_ROW_HEIGHT = 22;
372
536
  var SEARCH_ROW_BUFFER = 5;
537
+
538
+ // File paths whose match list is folded away. Keyed by path, so a file that
539
+ // scrolls out of the window keeps its state.
540
+ var _useStateSC = useState({});
541
+ var searchCollapsedFiles = _useStateSC[0];
542
+ var setSearchCollapsedFiles = _useStateSC[1];
543
+ var toggleSearchFile = function toggleSearchFile(file) {
544
+ setSearchCollapsedFiles(function (prev) {
545
+ var next = Object.assign({}, prev);
546
+ if (next[file]) delete next[file]; else next[file] = true;
547
+ return next;
548
+ });
549
+ };
373
550
  var _useStateSV = useState({ scrollTop: 0, height: 0 });
374
- var _useStateSV2 = _slicedToArray(_useStateSV, 2);
375
- var searchViewport = _useStateSV2[0];
376
- var setSearchViewport = _useStateSV2[1];
551
+ var searchViewport = _useStateSV[0];
552
+ var setSearchViewport = _useStateSV[1];
377
553
 
378
554
  // The container's height is only known once it is on screen, and a viewport
379
555
  // of 0 would render a single row and never grow — nothing would scroll, so
@@ -406,31 +582,26 @@ var MbeditorApp = function MbeditorApp() {
406
582
  }).current;
407
583
 
408
584
  var _useStateRM = useState(false);
409
- var _useStateRM2 = _slicedToArray(_useStateRM, 2);
410
- var replaceMode = _useStateRM2[0];
411
- var setReplaceMode = _useStateRM2[1];
585
+ var replaceMode = _useStateRM[0];
586
+ var setReplaceMode = _useStateRM[1];
412
587
 
413
588
  var _useStateRQ = useState('');
414
- var _useStateRQ2 = _slicedToArray(_useStateRQ, 2);
415
- var replaceQuery = _useStateRQ2[0];
416
- var setReplaceQuery = _useStateRQ2[1];
589
+ var replaceQuery = _useStateRQ[0];
590
+ var setReplaceQuery = _useStateRQ[1];
417
591
 
418
592
  var _useStateRL = useState(false);
419
- var _useStateRL2 = _slicedToArray(_useStateRL, 2);
420
- var replaceLoading = _useStateRL2[0];
421
- var setReplaceLoading = _useStateRL2[1];
593
+ var replaceLoading = _useStateRL[0];
594
+ var setReplaceLoading = _useStateRL[1];
422
595
 
423
596
  var _useState8 = useState("explorer");
424
597
 
425
- var _useState82 = _slicedToArray(_useState8, 2);
426
598
 
427
- var activeSidebarTab = _useState82[0];
428
- var setActiveSidebarTab = _useState82[1];
599
+ var activeSidebarTab = _useState8[0];
600
+ var setActiveSidebarTab = _useState8[1];
429
601
 
430
602
  var _useStateSC = useState(false);
431
- var _useStateSC2 = _slicedToArray(_useStateSC, 2);
432
- var sidebarCollapsed = _useStateSC2[0];
433
- var setSidebarCollapsed = _useStateSC2[1];
603
+ var sidebarCollapsed = _useStateSC[0];
604
+ var setSidebarCollapsed = _useStateSC[1];
434
605
 
435
606
  // Ref mirrors for use inside long-lived event handlers registered with
436
607
  // empty-dependency effects (live search refresh on files_changed).
@@ -442,135 +613,110 @@ var MbeditorApp = function MbeditorApp() {
442
613
  var jsSyntaxCheckAvailableRef = useRef(false);
443
614
 
444
615
  var _useStateRFMap = useState({});
445
- var _useStateRFMap2 = _slicedToArray(_useStateRFMap, 2);
446
- var railsFilesMap = _useStateRFMap2[0];
447
- var setRailsFilesMap = _useStateRFMap2[1];
616
+ var railsFilesMap = _useStateRFMap[0];
617
+ var setRailsFilesMap = _useStateRFMap[1];
448
618
 
449
619
  var _useStateRFC = useState({});
450
- var _useStateRFC2 = _slicedToArray(_useStateRFC, 2);
451
- var railsGroupsCollapsed = _useStateRFC2[0];
452
- var setRailsGroupsCollapsed = _useStateRFC2[1];
620
+ var railsGroupsCollapsed = _useStateRFC[0];
621
+ var setRailsGroupsCollapsed = _useStateRFC[1];
453
622
 
454
623
  var _useStateChangelog = useState(null); // null | { content, loading, error }
455
- var _useStateChangelog2 = _slicedToArray(_useStateChangelog, 2);
456
- var changelogState = _useStateChangelog2[0];
457
- var setChangelogState = _useStateChangelog2[1];
624
+ var changelogState = _useStateChangelog[0];
625
+ var setChangelogState = _useStateChangelog[1];
458
626
 
459
627
  var _useStateSchemaModal = useState(null);
460
- var _useStateSchemaModal2 = _slicedToArray(_useStateSchemaModal, 2);
461
- var schemaModal = _useStateSchemaModal2[0];
462
- var setSchemaModal = _useStateSchemaModal2[1];
628
+ var schemaModal = _useStateSchemaModal[0];
629
+ var setSchemaModal = _useStateSchemaModal[1];
463
630
 
464
631
  var _useStateImportConflict = useState(null);
465
- var _useStateImportConflict2 = _slicedToArray(_useStateImportConflict, 2);
466
- var importConflict = _useStateImportConflict2[0];
467
- var setImportConflict = _useStateImportConflict2[1];
632
+ var importConflict = _useStateImportConflict[0];
633
+ var setImportConflict = _useStateImportConflict[1];
468
634
 
469
635
  // { initialFolder } while the upload dialog is open, null otherwise.
470
636
  var _useStateImportDialog = useState(null);
471
- var _useStateImportDialog2 = _slicedToArray(_useStateImportDialog, 2);
472
- var importDialog = _useStateImportDialog2[0];
473
- var setImportDialog = _useStateImportDialog2[1];
637
+ var importDialog = _useStateImportDialog[0];
638
+ var setImportDialog = _useStateImportDialog[1];
474
639
 
475
640
  var _useStateSchemaLoading = useState(null);
476
- var _useStateSchemaLoading2 = _slicedToArray(_useStateSchemaLoading, 2);
477
- var schemaLoadingLabel = _useStateSchemaLoading2[0];
478
- var setSchemaLoadingLabel = _useStateSchemaLoading2[1];
641
+ var schemaLoadingLabel = _useStateSchemaLoading[0];
642
+ var setSchemaLoadingLabel = _useStateSchemaLoading[1];
479
643
 
480
644
  var _useState9 = useState({});
481
645
 
482
- var _useState92 = _slicedToArray(_useState9, 2);
483
646
 
484
- var markers = _useState92[0];
485
- var setMarkers = _useState92[1];
647
+ var markers = _useState9[0];
648
+ var setMarkers = _useState9[1];
486
649
  // { tabId: [] }
487
650
 
488
651
  var _useState10 = useState({});
489
652
 
490
- var _useState102 = _slicedToArray(_useState10, 2);
491
653
 
492
- var loading = _useState102[0];
493
- var setLoading = _useState102[1];
654
+ var loading = _useState10[0];
655
+ var setLoading = _useState10[1];
494
656
 
495
657
  var _useState11 = useState(null);
496
658
 
497
- var _useState112 = _slicedToArray(_useState11, 2);
498
659
 
499
- var closingTabId = _useState112[0];
500
- var setClosingTabId = _useState112[1];
660
+ var closingTabId = _useState11[0];
661
+ var setClosingTabId = _useState11[1];
501
662
 
502
663
  var _useState12 = useState(null);
503
664
 
504
- var _useState122 = _slicedToArray(_useState12, 2);
505
665
 
506
- var closingPaneId = _useState122[0];
507
- var setClosingPaneId = _useState122[1];
666
+ var closingPaneId = _useState12[0];
667
+ var setClosingPaneId = _useState12[1];
508
668
 
509
669
  var _useState13 = useState(SIDEBAR_MIN_WIDTH);
510
670
 
511
- var _useState132 = _slicedToArray(_useState13, 2);
512
671
 
513
- var sidebarWidth = _useState132[0];
514
- var setSidebarWidth = _useState132[1];
672
+ var sidebarWidth = _useState13[0];
673
+ var setSidebarWidth = _useState13[1];
515
674
 
516
675
  var _useState14 = useState(50);
517
676
 
518
- var _useState142 = _slicedToArray(_useState14, 2);
519
677
 
520
- var pane1Width = _useState142[0];
521
- var setPane1Width = _useState142[1];
678
+ var pane1Width = _useState14[0];
679
+ var setPane1Width = _useState14[1];
522
680
  // percentage
523
681
 
524
682
  var dragSplitWidthRef = useRef(pane1Width);
525
683
 
526
684
  var _useState15 = useState(null);
527
685
 
528
- var _useState152 = _slicedToArray(_useState15, 2);
529
686
 
530
- var activeResizeMode = _useState152[0];
531
- var setActiveResizeMode = _useState152[1];
687
+ var activeResizeMode = _useState15[0];
688
+ var setActiveResizeMode = _useState15[1];
532
689
 
533
690
  var _useState16 = useState(null);
534
691
 
535
- var _useState162 = _slicedToArray(_useState16, 2);
536
692
 
537
- var draggedTab = _useState162[0];
538
- var setDraggedTab = _useState162[1];
693
+ var draggedTab = _useState16[0];
694
+ var setDraggedTab = _useState16[1];
539
695
 
540
696
  var _useState17 = useState(null);
541
697
 
542
- var _useState172 = _slicedToArray(_useState17, 2);
543
698
 
544
- var dragOverPaneId = _useState172[0];
545
- var setDragOverPaneId = _useState172[1];
699
+ var dragOverPaneId = _useState17[0];
700
+ var setDragOverPaneId = _useState17[1];
546
701
 
547
702
  var _useState18 = useState(false);
548
- var _useState182 = _slicedToArray(_useState18, 2);
549
- var showGitPanel = _useState182[0];
550
- var setShowGitPanel = _useState182[1];
703
+ var showGitPanel = _useState18[0];
704
+ var setShowGitPanel = _useState18[1];
551
705
  var showGitPanelRef = useRef(showGitPanel);
552
706
  showGitPanelRef.current = showGitPanel;
553
707
 
554
708
  var _useStateLog = useState(false);
555
- var _useStateLog2 = _slicedToArray(_useStateLog, 2);
556
- var showLogPanel = _useStateLog2[0];
557
- var setShowLogPanel = _useStateLog2[1];
709
+ var showLogPanel = _useStateLog[0];
710
+ var setShowLogPanel = _useStateLog[1];
558
711
 
559
712
  var _useStateProblems = useState(false);
560
- var _useStateProblems2 = _slicedToArray(_useStateProblems, 2);
561
- var showProblemsPanel = _useStateProblems2[0];
562
- var setShowProblemsPanel = _useStateProblems2[1];
563
-
564
- var _useStateTestRun = useState(false);
565
- var _useStateTestRun2 = _slicedToArray(_useStateTestRun, 2);
566
- var showTestRunPanel = _useStateTestRun2[0];
567
- var setShowTestRunPanel = _useStateTestRun2[1];
713
+ var showProblemsPanel = _useStateProblems[0];
714
+ var setShowProblemsPanel = _useStateProblems[1];
568
715
 
569
716
  // Error/warning tallies across the open tabs, mirrored into the status bar.
570
717
  var _useStateProblemCounts = useState({ errors: 0, warnings: 0 });
571
- var _useStateProblemCounts2 = _slicedToArray(_useStateProblemCounts, 2);
572
- var problemCounts = _useStateProblemCounts2[0];
573
- var setProblemCounts = _useStateProblemCounts2[1];
718
+ var problemCounts = _useStateProblemCounts[0];
719
+ var setProblemCounts = _useStateProblemCounts[1];
574
720
 
575
721
  // Below this the toolbar's labelled buttons no longer fit beside the title
576
722
  // and the file search, and start pushing each other out of the bar.
@@ -582,9 +728,8 @@ var MbeditorApp = function MbeditorApp() {
582
728
  return typeof window.matchMedia === 'function' &&
583
729
  window.matchMedia('(max-width: ' + TOOLBAR_LABEL_MIN_WIDTH + 'px)').matches;
584
730
  });
585
- var _useStateNarrow2 = _slicedToArray(_useStateNarrow, 2);
586
- var narrowToolbar = _useStateNarrow2[0];
587
- var setNarrowToolbar = _useStateNarrow2[1];
731
+ var narrowToolbar = _useStateNarrow[0];
732
+ var setNarrowToolbar = _useStateNarrow[1];
588
733
 
589
734
  useEffect(function () {
590
735
  if (typeof window.matchMedia !== 'function') return;
@@ -604,158 +749,183 @@ var MbeditorApp = function MbeditorApp() {
604
749
  // Model graph. Built lazily — generating it eager-loads the host app — and
605
750
  // only when the Models tab is opened.
606
751
  var _useStateModelGraph = useState(null);
607
- var _useStateModelGraph2 = _slicedToArray(_useStateModelGraph, 2);
608
- var modelGraph = _useStateModelGraph2[0];
609
- var setModelGraph = _useStateModelGraph2[1];
752
+ var modelGraph = _useStateModelGraph[0];
753
+ var setModelGraph = _useStateModelGraph[1];
610
754
 
611
755
  var _useStateModelGraphLoading = useState(false);
612
- var _useStateModelGraphLoading2 = _slicedToArray(_useStateModelGraphLoading, 2);
613
- var modelGraphLoading = _useStateModelGraphLoading2[0];
614
- var setModelGraphLoading = _useStateModelGraphLoading2[1];
615
-
616
- // ruby-lsp status for the status-bar chip. 'off' means never available here,
617
- // 'degraded' means we backed off after a failure, 'ok' means it's answering.
618
- var _useStateLspHealth = useState({ status: 'off', reason: null });
619
- var _useStateLspHealth2 = _slicedToArray(_useStateLspHealth, 2);
620
- var lspHealth = _useStateLspHealth2[0];
621
- var setLspHealth = _useStateLspHealth2[1];
756
+ var modelGraphLoading = _useStateModelGraphLoading[0];
757
+ var setModelGraphLoading = _useStateModelGraphLoading[1];
622
758
 
623
759
  var _useState18g = useState(320);
624
- var _useState18g2 = _slicedToArray(_useState18g, 2);
625
- var gitPanelWidth = _useState18g2[0];
626
- var setGitPanelWidth = _useState18g2[1];
760
+ var gitPanelWidth = _useState18g[0];
761
+ var setGitPanelWidth = _useState18g[1];
627
762
  var gitPanelWidthRef = useRef(gitPanelWidth);
628
763
  gitPanelWidthRef.current = gitPanelWidth;
629
764
 
630
765
  var _useState18h = useState(false);
631
766
 
632
- var _useState18h2 = _slicedToArray(_useState18h, 2);
633
767
 
634
- var showHelp = _useState18h2[0];
635
- 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];
636
774
 
637
775
  var _useStatePwa = useState(null);
638
- var _useStatePwa2 = _slicedToArray(_useStatePwa, 2);
639
- var pwaInstallPrompt = _useStatePwa2[0];
640
- var setPwaInstallPrompt = _useStatePwa2[1];
776
+ var pwaInstallPrompt = _useStatePwa[0];
777
+ var setPwaInstallPrompt = _useStatePwa[1];
641
778
 
642
779
  var _useState18b = useState(true);
643
780
 
644
- var _useState18b2 = _slicedToArray(_useState18b, 2);
645
781
 
646
- var serverOnline = _useState18b2[0];
647
- 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
+ }, []);
648
799
 
649
800
  var _useState18c = useState(false);
650
801
 
651
- var _useState18c2 = _slicedToArray(_useState18c, 2);
652
802
 
653
- var rubocopAvailable = _useState18c2[0];
654
- var setRubocopAvailable = _useState18c2[1];
803
+ var rubocopAvailable = _useState18c[0];
804
+ var setRubocopAvailable = _useState18c[1];
655
805
 
656
806
  var _useState18d = useState(false);
657
807
 
658
- var _useState18d2 = _slicedToArray(_useState18d, 2);
659
808
 
660
- var hamlLintAvailable = _useState18d2[0];
661
- var setHamlLintAvailable = _useState18d2[1];
809
+ var hamlLintAvailable = _useState18d[0];
810
+ var setHamlLintAvailable = _useState18d[1];
662
811
 
663
812
  var _useState18e = useState(false);
664
- var _useState18e2 = _slicedToArray(_useState18e, 2);
665
- var gitAvailable = _useState18e2[0];
666
- var setGitAvailable = _useState18e2[1];
813
+ var gitAvailable = _useState18e[0];
814
+ var setGitAvailable = _useState18e[1];
667
815
 
668
816
  var _useState18f = useState(false);
669
- var _useState18f2 = _slicedToArray(_useState18f, 2);
670
- var redmineEnabled = _useState18f2[0];
671
- var setRedmineEnabled = _useState18f2[1];
817
+ var redmineEnabled = _useState18f[0];
818
+ var setRedmineEnabled = _useState18f[1];
672
819
 
673
820
  var _useState18rc = useState(null);
674
- var _useState18rc2 = _slicedToArray(_useState18rc, 2);
675
- var rubocopConfigPath = _useState18rc2[0];
676
- var setRubocopConfigPath = _useState18rc2[1];
677
-
678
- var _useState18t = useState(false);
679
- var _useState18t2 = _slicedToArray(_useState18t, 2);
680
- var testAvailable = _useState18t2[0];
681
- var setTestAvailable = _useState18t2[1];
821
+ var rubocopConfigPath = _useState18rc[0];
822
+ var setRubocopConfigPath = _useState18rc[1];
682
823
 
683
824
  var _useState18u = useState(null);
684
- var _useState18u2 = _slicedToArray(_useState18u, 2);
685
- var testResult = _useState18u2[0];
686
- var setTestResult = _useState18u2[1];
825
+ var testResult = _useState18u[0];
826
+ var setTestResult = _useState18u[1];
687
827
 
688
828
  var _useState18v = useState(false);
689
- var _useState18v2 = _slicedToArray(_useState18v, 2);
690
- var testLoading = _useState18v2[0];
691
- var setTestLoading = _useState18v2[1];
829
+ var testLoading = _useState18v[0];
830
+ var setTestLoading = _useState18v[1];
692
831
 
693
832
  var _useState18w = useState(true);
694
- var _useState18w2 = _slicedToArray(_useState18w, 2);
695
- var testInlineVisible = _useState18w2[0];
696
- var setTestInlineVisible = _useState18w2[1];
833
+ var testInlineVisible = _useState18w[0];
834
+ var setTestInlineVisible = _useState18w[1];
697
835
 
698
836
  var _useState18x = useState(null);
699
- var _useState18x2 = _slicedToArray(_useState18x, 2);
700
- var testPanelFile = _useState18x2[0];
701
- var setTestPanelFile = _useState18x2[1];
837
+ var testPanelFile = _useState18x[0];
838
+ var setTestPanelFile = _useState18x[1];
702
839
 
703
840
  var _useState18y = useState(false);
704
- var _useState18y2 = _slicedToArray(_useState18y, 2);
705
- var testPanelOpen = _useState18y2[0];
706
- var setTestPanelOpen = _useState18y2[1];
841
+ var testPanelOpen = _useState18y[0];
842
+ var setTestPanelOpen = _useState18y[1];
707
843
 
708
844
  var _useState18p = useState(DEFAULT_EDITOR_PREFS);
709
- var _useState18p2 = _slicedToArray(_useState18p, 2);
710
- var editorPrefs = _useState18p2[0];
711
- var setEditorPrefs = _useState18p2[1];
712
-
713
- // Icon-only toolbar: on by preference, or automatically once the window is
714
- // too narrow for the labels to fit beside the title and file search.
715
- 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]);
716
871
 
717
872
  var _useState19 = useState({
718
873
  openEditors: false,
719
874
  projects: false
720
875
  });
721
876
 
722
- var _useState192 = _slicedToArray(_useState19, 2);
723
877
 
724
- var collapsedSections = _useState192[0];
725
- var setCollapsedSections = _useState192[1];
878
+ var collapsedSections = _useState19[0];
879
+ var setCollapsedSections = _useState19[1];
726
880
 
727
881
  var _useState20 = useState({});
728
882
 
729
- var _useState202 = _slicedToArray(_useState20, 2);
730
883
 
731
- var expandedDirs = _useState202[0];
732
- var setExpandedDirs = _useState202[1];
884
+ var expandedDirs = _useState20[0];
885
+ var setExpandedDirs = _useState20[1];
733
886
 
734
887
  var _useState21 = useState(null);
735
888
 
736
- var _useState212 = _slicedToArray(_useState21, 2);
737
889
 
738
- var pendingCreate = _useState212[0];
739
- var setPendingCreate = _useState212[1];
890
+ var pendingCreate = _useState21[0];
891
+ var setPendingCreate = _useState21[1];
740
892
 
741
893
  var _useState22 = useState(null);
742
894
 
743
- var _useState222 = _slicedToArray(_useState22, 2);
744
895
 
745
- var pendingRename = _useState222[0];
746
- var setPendingRename = _useState222[1];
896
+ var pendingRename = _useState22[0];
897
+ var setPendingRename = _useState22[1];
747
898
 
748
899
  var _useState23 = useState(null);
749
900
 
750
- var _useState232 = _slicedToArray(_useState23, 2);
751
901
 
752
- var contextMenu = _useState232[0];
753
- var setContextMenu = _useState232[1];
902
+ var contextMenu = _useState23[0];
903
+ var setContextMenu = _useState23[1];
904
+ var contextMenuRef = useRef(null);
754
905
 
755
906
  var _useState24 = useState(140);
756
- var _useState242 = _slicedToArray(_useState24, 2);
757
- var openEditorsHeight = _useState242[0];
758
- 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];
759
929
 
760
930
  var resizeSessionRef = useRef(null);
761
931
  var resizeRafRef = useRef(null);
@@ -765,9 +935,8 @@ var MbeditorApp = function MbeditorApp() {
765
935
  var ctrlWPendingRef = useRef(false);
766
936
  var ctrlWTimeoutRef = useRef(null);
767
937
  var _useStateCP = useState([]);
768
- var _useStateCP2 = _slicedToArray(_useStateCP, 2);
769
- var customPaths = _useStateCP2[0];
770
- var setCustomPaths = _useStateCP2[1];
938
+ var customPaths = _useStateCP[0];
939
+ var setCustomPaths = _useStateCP[1];
771
940
  var customPathsRef = useRef([]);
772
941
  customPathsRef.current = customPaths;
773
942
  // Whether to show the presence chips at all. Read at render rather than held in
@@ -780,33 +949,29 @@ var MbeditorApp = function MbeditorApp() {
780
949
  var _useStateIdent = useState(
781
950
  typeof CollaborationIdentity !== 'undefined' ? CollaborationIdentity.get() : null
782
951
  );
783
- var _useStateIdent2 = _slicedToArray(_useStateIdent, 2);
784
- var collabIdentity = _useStateIdent2[0];
785
- var setCollabIdentity = _useStateIdent2[1];
952
+ var collabIdentity = _useStateIdent[0];
953
+ var setCollabIdentity = _useStateIdent[1];
786
954
  // Presence roster: other connected participants, keyed by client_id →
787
955
  // { name, colour, current_file }. Fed by the global presence stream; rendered
788
956
  // as click-to-jump chips in the status bar.
789
957
  var _useStateRoster = useState({});
790
- var _useStateRoster2 = _slicedToArray(_useStateRoster, 2);
791
- var collabRoster = _useStateRoster2[0];
792
- var setCollabRoster = _useStateRoster2[1];
958
+ var collabRoster = _useStateRoster[0];
959
+ var setCollabRoster = _useStateRoster[1];
793
960
 
794
961
  // Follow mode (slice 8): the presence client_id of the participant whose file +
795
962
  // viewport we're tracking, or null when navigating independently. Toggled from a
796
963
  // roster chip. The file-open is driven by the effect below; the scroll-tracking
797
964
  // lives in CollaborationService.setFollow().
798
965
  var _useStateFollow = useState(null);
799
- var _useStateFollow2 = _slicedToArray(_useStateFollow, 2);
800
- var followedClientId = _useStateFollow2[0];
801
- var setFollowedClientId = _useStateFollow2[1];
966
+ var followedClientId = _useStateFollow[0];
967
+ var setFollowedClientId = _useStateFollow[1];
802
968
  var recentSavesRef = useRef({});
803
969
  var isSavingRef = useRef(false);
804
970
  // True once the saved session has finished loading into the panes. Anything
805
971
  // that opens a tab on startup must wait for this, or the restore overwrites it.
806
972
  var _useStateSR = useState(false);
807
- var _useStateSR2 = _slicedToArray(_useStateSR, 2);
808
- var sessionRestored = _useStateSR2[0];
809
- var setSessionRestored = _useStateSR2[1];
973
+ var sessionRestored = _useStateSR[0];
974
+ var setSessionRestored = _useStateSR[1];
810
975
  var pendingChangelogRef = useRef(false);
811
976
  // path -> the file's content as last seen ON DISK (newline-normalised).
812
977
  // External-change detection compares disk-to-disk; comparing disk to the
@@ -835,15 +1000,37 @@ var MbeditorApp = function MbeditorApp() {
835
1000
 
836
1001
  // ── Draft backup helpers ─────────────────────────────────────────────────
837
1002
  var draftWriteTimerRef = useRef({});
1003
+ var draftWriteWarnedRef = useRef({});
838
1004
  var serverOnlineRef = useRef(true);
839
1005
 
840
1006
  var _draftKey = function _draftKey(path) {
841
1007
  var base = typeof window.mbeditorBasePath === 'function' ? window.mbeditorBasePath() : '';
842
1008
  return 'mbeditor_draft\x00' + base + '\x00' + path;
843
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;
844
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
+ }
845
1024
  var doWrite = function() {
846
- 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
+ }
847
1034
  };
848
1035
  if (typeof requestIdleCallback !== 'undefined') {
849
1036
  requestIdleCallback(doWrite, { timeout: 2000 });
@@ -866,24 +1053,25 @@ var MbeditorApp = function MbeditorApp() {
866
1053
  };
867
1054
 
868
1055
  var _useState_dro = useState(null);
869
- var _useState_dro2 = _slicedToArray(_useState_dro, 2);
870
- var draftRestoreOffer = _useState_dro2[0];
871
- var setDraftRestoreOffer = _useState_dro2[1];
1056
+ var draftRestoreOffer = _useState_dro[0];
1057
+ var setDraftRestoreOffer = _useState_dro[1];
872
1058
 
873
1059
  var _useStateMR = useState(false);
874
- var _useStateMR2 = _slicedToArray(_useStateMR, 2);
875
- var monacoReady = _useStateMR2[0];
876
- var setMonacoReady = _useStateMR2[1];
1060
+ var monacoReady = _useStateMR[0];
1061
+ var setMonacoReady = _useStateMR[1];
877
1062
 
878
1063
  var _useStateZen = useState(false);
879
- var _useStateZen2 = _slicedToArray(_useStateZen, 2);
880
- var zenMode = _useStateZen2[0];
881
- 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];
882
1071
 
883
1072
  var _useStateSB = useState(false);
884
- var _useStateSB2 = _slicedToArray(_useStateSB, 2);
885
- var isSwitchingBranch = _useStateSB2[0];
886
- var setIsSwitchingBranch = _useStateSB2[1];
1073
+ var isSwitchingBranch = _useStateSB[0];
1074
+ var setIsSwitchingBranch = _useStateSB[1];
887
1075
 
888
1076
  var clamp = function clamp(value, min, max) {
889
1077
  return Math.min(max, Math.max(min, value));
@@ -937,6 +1125,48 @@ var MbeditorApp = function MbeditorApp() {
937
1125
  });
938
1126
  };
939
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
+
940
1170
  // Every structural mutation (create, delete, rename, import) funnels through
941
1171
  // here, so this is where the project-search cache has to be dropped. Saves
942
1172
  // invalidated it at their own call sites, which is why editing a file
@@ -1023,64 +1253,6 @@ var MbeditorApp = function MbeditorApp() {
1023
1253
  loadModelGraph(false);
1024
1254
  }, [activeSidebarTab, sidebarCollapsed]);
1025
1255
 
1026
- var readLspHealth = function readLspHealth() {
1027
- if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) {
1028
- return { status: 'off', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
1029
- }
1030
- if (window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff()) {
1031
- return { status: 'degraded', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
1032
- }
1033
- return { status: 'ok', reason: null };
1034
- };
1035
-
1036
- // The backoff expires on a wall-clock deadline rather than a timer, so the
1037
- // chip also re-reads on a slow interval — otherwise it would sit on
1038
- // 'degraded' until the next failure or restart click.
1039
- //
1040
- // readLspHealth() builds a fresh object every call, so handing it straight to
1041
- // setLspHealth re-rendered the whole app every 10 seconds whether or not the
1042
- // health had changed — React bails on Object.is, and two object literals are
1043
- // never identical. Compare the fields and keep the previous object when they
1044
- // match. (Same shape of bug as the file-tree poll; see _treeUpdater.)
1045
- useEffect(function () {
1046
- var sync = function () {
1047
- setLspHealth(function (prev) {
1048
- var next = readLspHealth();
1049
- if (prev && prev.status === next.status && prev.reason === next.reason) return prev;
1050
- return next;
1051
- });
1052
- };
1053
- sync();
1054
- window.addEventListener('mbeditor:lsp-health', sync);
1055
- var tick = setInterval(sync, 10000);
1056
- return function () {
1057
- window.removeEventListener('mbeditor:lsp-health', sync);
1058
- clearInterval(tick);
1059
- };
1060
- }, []);
1061
-
1062
- var restartRubyLsp = function restartRubyLsp() {
1063
- if (!FileService.rubyLspRequest) return;
1064
- EditorStore.setStatus('Restarting ruby-lsp…', 'info');
1065
- FileService.rubyLspRequest('restart', '', '', 1, 1).then(function (data) {
1066
- var ok = data && data.available && data.state !== 'failed';
1067
- if (ok) {
1068
- window.MBEDITOR_RUBY_LSP_AVAILABLE = true;
1069
- window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL = 0;
1070
- window.MBEDITOR_RUBY_LSP_REASON = null;
1071
- } else {
1072
- window.MBEDITOR_RUBY_LSP_REASON =
1073
- (data && (data.reason || data.error)) || 'ruby-lsp did not come back';
1074
- }
1075
- EditorStore.setStatus(ok ? 'ruby-lsp restarted' : 'ruby-lsp unavailable', ok ? 'success' : 'warning');
1076
- setLspHealth(readLspHealth());
1077
- })["catch"](function (err) {
1078
- noteLspFailure(err);
1079
- EditorStore.setStatus('Could not restart ruby-lsp', 'error');
1080
- setLspHealth(readLspHealth());
1081
- });
1082
- };
1083
-
1084
1256
  // The one writer for the markers map. Two things it must not do:
1085
1257
  //
1086
1258
  // * write a fresh map when nothing changed — the auto-lint fires per
@@ -1125,6 +1297,8 @@ var MbeditorApp = function MbeditorApp() {
1125
1297
  EditorStore.setStatus('Linting...', 'info');
1126
1298
  }
1127
1299
 
1300
+ var startedAt = Date.now();
1301
+
1128
1302
  // ruby-lsp answers diagnostics for Ruby files when it's available: same
1129
1303
  // markers, plus Prism syntax errors, without a per-keystroke rubocop boot.
1130
1304
  // Anything short of a usable answer falls through to the HTTP lint for
@@ -1147,6 +1321,8 @@ var MbeditorApp = function MbeditorApp() {
1147
1321
 
1148
1322
  return lintRequest.then(function (res) {
1149
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);
1150
1326
  applyMarkersForTab(tab.id, nextMarkers);
1151
1327
 
1152
1328
  if (options.showStatus) {
@@ -1156,6 +1332,7 @@ var MbeditorApp = function MbeditorApp() {
1156
1332
 
1157
1333
  return res;
1158
1334
  })["catch"](function (err) {
1335
+ _recErr('LINT');
1159
1336
  if (options.showStatus) {
1160
1337
  EditorStore.setStatus('Lint failed: ' + err.message, 'error');
1161
1338
  }
@@ -1231,9 +1408,7 @@ var MbeditorApp = function MbeditorApp() {
1231
1408
  Promise.all([FileService.getWorkspace()["catch"](function () {
1232
1409
  return null;
1233
1410
  }), refreshProjectTree()]).then(function (_ref) {
1234
- var _ref2 = _slicedToArray(_ref, 1);
1235
-
1236
- var workspace = _ref2[0];
1411
+ var workspace = _ref[0];
1237
1412
 
1238
1413
  if (workspace && workspace.rootName) {
1239
1414
  setProjectRootName(workspace.rootName);
@@ -1254,20 +1429,17 @@ var MbeditorApp = function MbeditorApp() {
1254
1429
  if (workspace && typeof workspace.rubyLspAvailable === 'boolean') {
1255
1430
  window.MBEDITOR_RUBY_LSP_AVAILABLE = workspace.rubyLspAvailable;
1256
1431
  }
1432
+ if (workspace && workspace.searchBackend) {
1433
+ window.MBEDITOR_SEARCH_BACKEND = workspace.searchBackend;
1434
+ }
1257
1435
  if (workspace && typeof workspace.gitAvailable === 'boolean') {
1258
1436
  setGitAvailable(workspace.gitAvailable);
1259
1437
  }
1260
1438
  if (workspace && typeof workspace.redmineEnabled === 'boolean') {
1261
1439
  setRedmineEnabled(workspace.redmineEnabled);
1262
1440
  }
1263
- if (workspace && (workspace.testTimeout || workspace.testAllTimeout)) {
1264
- FileService.setTestTimeouts({
1265
- test: workspace.testTimeout,
1266
- testAll: workspace.testAllTimeout
1267
- });
1268
- }
1269
- if (workspace && typeof workspace.testAvailable === 'boolean') {
1270
- setTestAvailable(workspace.testAvailable);
1441
+ if (workspace && workspace.testTimeout) {
1442
+ FileService.setTestTimeout(workspace.testTimeout);
1271
1443
  }
1272
1444
  if (workspace && typeof workspace.actionCableEnabled === 'boolean') {
1273
1445
  WebSocketService.connect(workspace.actionCableEnabled);
@@ -1277,11 +1449,15 @@ var MbeditorApp = function MbeditorApp() {
1277
1449
  // Helper: load tab content for a set of panes and restore them into EditorStore
1278
1450
  function loadPaneState(panesToLoad, focusedPaneId) {
1279
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
+ });
1280
1459
  var allTabs = panesToLoad.flatMap(function (p) { return p.tabs; });
1281
1460
  return Promise.all(allTabs.map(function (t) {
1282
- if (t.isSettings || t.path === '__settings__') {
1283
- return Promise.resolve({ content: '' });
1284
- }
1285
1461
  if (t.isChangelog || t.path === 'mbeditor://changelog') {
1286
1462
  return Promise.resolve({ content: '' });
1287
1463
  }
@@ -1293,9 +1469,6 @@ var MbeditorApp = function MbeditorApp() {
1293
1469
  if (t.isCombinedDiff || (t.path || '').startsWith('combined-diff://') || (t.path || '').startsWith('diff://')) {
1294
1470
  return Promise.resolve({ content: '' });
1295
1471
  }
1296
- if (t.isModelGraph || t.path === 'mbeditor://model-graph') {
1297
- return Promise.resolve({ content: '' });
1298
- }
1299
1472
  var sourcePath = t.isPreview || /::preview$/.test(t.path || '') ? t.previewFor || (t.path || '').replace(/::preview$/, '') : t.path;
1300
1473
  return FileService.getFile(sourcePath, { allowMissing: true }).then(function (data) {
1301
1474
  return {
@@ -1318,8 +1491,8 @@ var MbeditorApp = function MbeditorApp() {
1318
1491
  var tabs = [];
1319
1492
  p.tabs.forEach(function (t) {
1320
1493
  var res = results[resIdx++];
1321
- var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff && !t.isSettings &&
1322
- !t.isChangelog && !t.isPreview && !t.isModelGraph &&
1494
+ var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff &&
1495
+ !t.isChangelog && !t.isPreview &&
1323
1496
  !/^(diff|combined-diff):\/\//.test(t.path) && !/::preview$/.test(t.path);
1324
1497
  if (isPlainFile) {
1325
1498
  if (seenPaths[t.path]) return;
@@ -1329,9 +1502,6 @@ var MbeditorApp = function MbeditorApp() {
1329
1502
  content: res.content,
1330
1503
  externalContentVersion: (t.externalContentVersion || 0) + 1
1331
1504
  },
1332
- // A state saved before this tab type existed carries the path but
1333
- // not the flag, and would restore as a missing file.
1334
- t.path === 'mbeditor://model-graph' ? { isModelGraph: true } : {},
1335
1505
  res._isDiffResult ? { diffOriginal: res.diffOriginal, diffModified: res.diffModified } : {},
1336
1506
  typeof res.fileNotFound === 'boolean' ? { fileNotFound: res.fileNotFound, dirty: res.fileNotFound ? false : t.dirty } : {},
1337
1507
  res.image === true ? { isImage: true } : {}));
@@ -1443,10 +1613,10 @@ var MbeditorApp = function MbeditorApp() {
1443
1613
  return {
1444
1614
  id: p.id,
1445
1615
  activeTabId: p.activeTabId,
1446
- 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) {
1447
1617
  return {
1448
1618
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1449
- isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
1619
+ isPreview: !!t.isPreview, previewFor: t.previewFor || null,
1450
1620
  isDiff: !!t.isDiff, diffBaseSha: t.diffBaseSha || null, diffHeadSha: t.diffHeadSha || null,
1451
1621
  repoPath: t.repoPath || null, isChangelog: !!t.isChangelog
1452
1622
  };
@@ -1529,6 +1699,7 @@ var MbeditorApp = function MbeditorApp() {
1529
1699
  if (e.key === 'Escape') {
1530
1700
  setContextMenu(null);
1531
1701
  setShowHelp(false);
1702
+ setCollabOverflowOpen(false);
1532
1703
  }
1533
1704
  };
1534
1705
 
@@ -1562,6 +1733,9 @@ var MbeditorApp = function MbeditorApp() {
1562
1733
  var reservedRight = EDITOR_MIN_WIDTH + (showGitPanelRef.current ? gitPanelWidthRef.current : 0);
1563
1734
  var maxSidebarWidth = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, rect.width - reservedRight));
1564
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;
1565
1739
  setSidebarWidth(clamp(nextWidth, SIDEBAR_MIN_WIDTH, maxSidebarWidth));
1566
1740
  }
1567
1741
 
@@ -1579,11 +1753,26 @@ var MbeditorApp = function MbeditorApp() {
1579
1753
  var nextHeight = Math.max(60, Math.min(400, s.startHeight + delta));
1580
1754
  setOpenEditorsHeight(nextHeight);
1581
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
+ }
1582
1766
  });
1583
1767
  };
1584
1768
 
1585
1769
  var handleMouseUp = function handleMouseUp() {
1586
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
+ }
1587
1776
 
1588
1777
  if (resizeRafRef.current) {
1589
1778
  cancelAnimationFrame(resizeRafRef.current);
@@ -1896,7 +2085,10 @@ var MbeditorApp = function MbeditorApp() {
1896
2085
  function handleFileSaved(data) {
1897
2086
  var path = data && data.path;
1898
2087
  if (!path) return;
1899
- 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;
1900
2092
 
1901
2093
  var st = EditorStore.getState();
1902
2094
  var changed = false;
@@ -1957,7 +2149,6 @@ var MbeditorApp = function MbeditorApp() {
1957
2149
  !path.startsWith('combined-diff://') &&
1958
2150
  !path.startsWith('untitled://') &&
1959
2151
  !pt.tab.isCombinedDiff &&
1960
- !pt.tab.isSettings &&
1961
2152
  !pt.tab.isImage &&
1962
2153
  !pt.tab.isDiff &&
1963
2154
  typeof pt.tab.content === 'string';
@@ -2019,6 +2210,12 @@ var MbeditorApp = function MbeditorApp() {
2019
2210
  });
2020
2211
  })
2021
2212
  });
2213
+ // The text just moved under the diagnostics. Only the mounted editor
2214
+ // re-lints, so without this a background tab kept reporting offenses
2215
+ // at line numbers the external write had shifted — and the Problems
2216
+ // panel and status-bar tallies reported them too. Dropping them says
2217
+ // "not known yet", which is true: the file re-lints when you open it.
2218
+ discardStaleMarkers(pt.tab.path);
2022
2219
  } else {
2023
2220
  // Re-verify the tab still exists before queuing
2024
2221
  var currentState = EditorStore.getState();
@@ -2058,8 +2255,18 @@ var MbeditorApp = function MbeditorApp() {
2058
2255
  useEffect(function () {
2059
2256
  var intervalId = setInterval(function () {
2060
2257
  if (document.hidden) return;
2258
+ var startedAt = Date.now();
2061
2259
  FileService.getTree({ background: true }).then(function (data) {
2062
- 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);
2063
2270
  }).catch(function () {}); // silently ignore auto-refresh errors
2064
2271
  }, 10000);
2065
2272
  return function () { clearInterval(intervalId); };
@@ -2125,8 +2332,12 @@ var MbeditorApp = function MbeditorApp() {
2125
2332
  };
2126
2333
  }, [monacoReady]);
2127
2334
 
2128
- var handleSelectFile = function handleSelectFile(path, name, line, col) {
2129
- TabManager.openTab(path, name, line, null, false, col);
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);
2340
+ TabManager.openTab(path, name, line, null, false, col, endCol);
2130
2341
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
2131
2342
  setQuickOpen(false);
2132
2343
  };
@@ -2181,6 +2392,17 @@ var MbeditorApp = function MbeditorApp() {
2181
2392
  });
2182
2393
  };
2183
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
+
2184
2406
  var requestCloseTab = function requestCloseTab(paneId, id) {
2185
2407
  var pane = state.panes.find(function (p) {
2186
2408
  return p.id === paneId;
@@ -2193,13 +2415,7 @@ var MbeditorApp = function MbeditorApp() {
2193
2415
  setClosingTabId(id);
2194
2416
  } else {
2195
2417
  TabManager.closeTab(paneId, id);
2196
- EditorStore.setState({
2197
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2198
- return EditorStore.getState().panes.some(function (p) {
2199
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2200
- });
2201
- })
2202
- });
2418
+ _prunePendingReloads();
2203
2419
  forgetClosedTabs();
2204
2420
  }
2205
2421
  };
@@ -2218,6 +2434,7 @@ var MbeditorApp = function MbeditorApp() {
2218
2434
  }
2219
2435
 
2220
2436
  if (save) {
2437
+ tab = _freshTab(closingPaneId, tab);
2221
2438
  if (tab.isUntitled) {
2222
2439
  // Save-as converts the scratch tab to a real one (closing the scratch
2223
2440
  // tab in the process); a cancelled prompt keeps the tab open.
@@ -2231,49 +2448,20 @@ var MbeditorApp = function MbeditorApp() {
2231
2448
  });
2232
2449
  return;
2233
2450
  }
2234
- setLoading(function (prev) {
2235
- return _extends({}, prev, { save: true });
2236
- });
2237
- EditorStore.setStatus("Saving " + tab.name + "...", "info");
2238
- isSavingRef.current = true;
2239
- FileService.saveFile(tab.path, tab.content).then(function () {
2240
- noteLocalSave(tab.path, tab.content);
2451
+ _runSave(tab, function () {
2241
2452
  EditorStore.setStatus("Saved", "success");
2242
2453
  SearchService.invalidate();
2243
2454
  GitService.fetchStatusLite({ background: true });
2244
- // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
2245
- var _closeEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
2246
- if (_closeEntry && _closeEntry.model && !_closeEntry.model.isDisposed()) {
2247
- _closeEntry.cleanVersionId = _closeEntry.model.getAlternativeVersionId();
2248
- }
2249
2455
  TabManager.closeTab(closingPaneId, tab.id);
2250
- EditorStore.setState({
2251
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2252
- return EditorStore.getState().panes.some(function (p) {
2253
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2254
- });
2255
- })
2256
- });
2456
+ _prunePendingReloads();
2257
2457
  forgetClosedTabs();
2258
- })["catch"](function (err) {
2259
- EditorStore.setStatus("Save failed: " + err.message, "error");
2260
- })["finally"](function () {
2261
- isSavingRef.current = false;
2262
- setLoading(function (prev) {
2263
- return _extends({}, prev, { save: false });
2264
- });
2458
+ }, function () {
2265
2459
  setClosingTabId(null);
2266
2460
  setClosingPaneId(null);
2267
2461
  });
2268
2462
  } else {
2269
2463
  TabManager.closeTab(closingPaneId, tab.id);
2270
- EditorStore.setState({
2271
- pendingReloads: EditorStore.getState().pendingReloads.filter(function (r) {
2272
- return EditorStore.getState().panes.some(function (p) {
2273
- return p.tabs.some(function (t) { return t.id === r.tabId; });
2274
- });
2275
- })
2276
- });
2464
+ _prunePendingReloads();
2277
2465
  forgetClosedTabs();
2278
2466
  setClosingTabId(null);
2279
2467
  setClosingPaneId(null);
@@ -2348,7 +2536,10 @@ var MbeditorApp = function MbeditorApp() {
2348
2536
  var input = window.prompt('Save as (path relative to workspace root):', tab.name + '.txt');
2349
2537
  if (!input || !input.trim()) return Promise.reject({ cancelled: true });
2350
2538
  var newPath = input.trim().replace(/^\/+/, '');
2539
+ var startedAt = Date.now();
2351
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);
2352
2543
  noteLocalSave(newPath, tab.content);
2353
2544
  SearchService.invalidate();
2354
2545
  GitService.fetchStatusLite({ background: true });
@@ -2373,14 +2564,13 @@ var MbeditorApp = function MbeditorApp() {
2373
2564
  return {
2374
2565
  id: p.id,
2375
2566
  activeTabId: p.activeTabId,
2376
- 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) {
2377
2568
  return {
2378
2569
  id: t.id,
2379
2570
  path: t.path,
2380
2571
  name: t.name,
2381
2572
  dirty: t.dirty,
2382
2573
  viewState: t.viewState,
2383
- isSettings: !!t.isSettings,
2384
2574
  isChangelog: !!t.isChangelog,
2385
2575
  isPreview: !!t.isPreview,
2386
2576
  previewFor: t.previewFor || null,
@@ -2407,7 +2597,11 @@ var MbeditorApp = function MbeditorApp() {
2407
2597
 
2408
2598
  useEffect(function() {
2409
2599
  document.documentElement.setAttribute('data-theme', editorPrefs.theme || 'vs-dark');
2410
- }, [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]);
2411
2605
 
2412
2606
  useEffect(function() {
2413
2607
  EditorStore.setState({ editorPrefs: editorPrefs });
@@ -2531,7 +2725,7 @@ var MbeditorApp = function MbeditorApp() {
2531
2725
  });
2532
2726
  tabs.forEach(function(t) {
2533
2727
  if (Object.keys(deps).length >= RAILS_MAX_RESOURCES) return;
2534
- if (!t.path || t.path === '__settings__' || t.path.startsWith('mbeditor://')) return;
2728
+ if (!t.path || t.path.startsWith('mbeditor://')) return;
2535
2729
  var label = resourceLabelFromPath(t.path);
2536
2730
  if (label && !deps[label]) deps[label] = t.path;
2537
2731
  });
@@ -2544,7 +2738,7 @@ var MbeditorApp = function MbeditorApp() {
2544
2738
  var all = {};
2545
2739
  state.panes.forEach(function(p) {
2546
2740
  p.tabs.forEach(function(t) {
2547
- if (!t.path || t.path === '__settings__' || t.path.startsWith('mbeditor://')) return;
2741
+ if (!t.path || t.path.startsWith('mbeditor://')) return;
2548
2742
  var label = resourceLabelFromPath(t.path);
2549
2743
  if (label) all[label] = true;
2550
2744
  });
@@ -2604,14 +2798,14 @@ var MbeditorApp = function MbeditorApp() {
2604
2798
 
2605
2799
  // ── Collaboration presence (slice 7) ──────────────────────────────────────
2606
2800
  // Only real, openable files belong in presence — virtual tabs (diffs, previews,
2607
- // 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
2608
2802
  // rather than pointing at something click-to-jump can't open.
2609
2803
  var _presenceFileFor = function (tab) {
2610
2804
  if (!tab || !tab.path) return null;
2611
2805
  var p = tab.path;
2612
- 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;
2613
2807
  if (p.indexOf('diff://') === 0 || p.indexOf('combined-diff://') === 0 || p.indexOf('mbeditor://') === 0) return null;
2614
- if (p.indexOf('::preview') !== -1 || p === '__settings__') return null;
2808
+ if (p.indexOf('::preview') !== -1) return null;
2615
2809
  return p;
2616
2810
  };
2617
2811
  var presenceFile = _presenceFileFor(activeTab);
@@ -2817,14 +3011,13 @@ var MbeditorApp = function MbeditorApp() {
2817
3011
 
2818
3012
  var collabPeerIds = Object.keys(collabRoster);
2819
3013
 
2820
- // A labelled peer chip costs ~110px (name + filename), and the titlebar button
2821
- // cluster does not shrink or wrap: past three peers it squeezes the search pill
2822
- // to its floor and then pushes Help / Install off the right edge. Drop to bare
2823
- // colour dots instead of hiding peers behind a "+N more" summary — a dot is
2824
- // ~20px, so ten peers still fit, every chip stays clickable to follow, and the
2825
- // solid/hollow ring keeps working. The name and file live in the tooltip.
2826
- var COLLAB_LABEL_LIMIT = 3;
2827
- 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);
2828
3021
 
2829
3022
  // Colour is minted from a hash before any peer is known, so it has to be
2830
3023
  // reconciled against the roster once one exists. Runs on every roster change;
@@ -2841,9 +3034,8 @@ var MbeditorApp = function MbeditorApp() {
2841
3034
  // chips sit against the right edge of the titlebar and a left-anchored card
2842
3035
  // would run off screen.
2843
3036
  var _useStateHover = useState(null);
2844
- var _useStateHover2 = _slicedToArray(_useStateHover, 2);
2845
- var collabHover = _useStateHover2[0];
2846
- var setCollabHover = _useStateHover2[1];
3037
+ var collabHover = _useStateHover[0];
3038
+ var setCollabHover = _useStateHover[1];
2847
3039
 
2848
3040
  var openCollabHover = function (cid, e) {
2849
3041
  var r = e.currentTarget.getBoundingClientRect();
@@ -2853,8 +3045,7 @@ var MbeditorApp = function MbeditorApp() {
2853
3045
  // Latency and last-seen only need to tick while the card is actually on screen,
2854
3046
  // so the interval lives and dies with it. Idle cost stays zero.
2855
3047
  var _useStateHoverTick = useState(0);
2856
- var _useStateHoverTick2 = _slicedToArray(_useStateHoverTick, 2);
2857
- var setCollabHoverTick = _useStateHoverTick2[1];
3048
+ var setCollabHoverTick = _useStateHoverTick[1];
2858
3049
  useEffect(function () {
2859
3050
  if (!collabHover) return;
2860
3051
  var id = setInterval(function () { setCollabHoverTick(function (n) { return n + 1; }); }, 1000);
@@ -2885,17 +3076,57 @@ var MbeditorApp = function MbeditorApp() {
2885
3076
  handleSelectFile(followedFile, followedFile.split('/').pop());
2886
3077
  }, [followedClientId, followedFile]);
2887
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
+
2888
3121
  // Phase 7: Per-file last-commit info shown in the status bar
2889
3122
  var _useState31 = useState(null);
2890
- var _useState32 = _slicedToArray(_useState31, 2);
2891
- var activeFileCommit = _useState32[0];
2892
- var setActiveFileCommit = _useState32[1];
3123
+ var activeFileCommit = _useState31[0];
3124
+ var setActiveFileCommit = _useState31[1];
2893
3125
 
2894
3126
  // EOL indicator — tracks current line-ending style of the active file
2895
3127
  var _useState31e = useState(null);
2896
- var _useState31e2 = _slicedToArray(_useState31e, 2);
2897
- var activeEOL = _useState31e2[0];
2898
- var setActiveEOL = _useState31e2[1];
3128
+ var activeEOL = _useState31e[0];
3129
+ var setActiveEOL = _useState31e[1];
2899
3130
 
2900
3131
  useEffect(function () {
2901
3132
  if (!gitAvailable || !activeTab || activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || !activeTab.path || activeTab.path.indexOf('://') >= 0) {
@@ -2915,7 +3146,9 @@ var MbeditorApp = function MbeditorApp() {
2915
3146
  });
2916
3147
  }, [activeTab ? activeTab.id : null, gitAvailable]);
2917
3148
 
2918
- // 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.
2919
3152
  useEffect(function () {
2920
3153
  if (!activeTab || typeof activeTab.content !== 'string' ||
2921
3154
  activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || activeTab.isPreview) {
@@ -2929,7 +3162,7 @@ var MbeditorApp = function MbeditorApp() {
2929
3162
  } else {
2930
3163
  setActiveEOL('LF');
2931
3164
  }
2932
- }, [activeTab ? activeTab.id : null, activeTab ? activeTab.content : null]);
3165
+ }, [activeTab ? activeTab.id : null, activeTab ? activeTab.externalContentVersion : null]);
2933
3166
 
2934
3167
  useEffect(function () {
2935
3168
  if (!activeTab || typeof activeTab.content !== 'string') return;
@@ -3006,15 +3239,35 @@ var MbeditorApp = function MbeditorApp() {
3006
3239
  // by a formatter problem).
3007
3240
  var _formatContentForSave = function _formatContentForSave(tab) {
3008
3241
  var isRubyLang = /\.(rb|rake|gemspec)$/.test(tab.path) || /(?:^|\/)(Rakefile|Gemfile)$/.test(tab.path);
3242
+ var startedAt = Date.now();
3009
3243
  if (isRubyLang) {
3010
- if (!rubocopAvailable) return Promise.resolve(null);
3011
- return FileService.formatFile(tab.path, tab.content)
3012
- .then(function (res) { return (res && res.content) || null; })
3013
- ["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
+ });
3014
3261
  }
3015
3262
  var parserName = prettierParserFor(tab.path);
3016
3263
  if (!parserName) return Promise.resolve(null);
3017
- 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
+ });
3018
3271
  };
3019
3272
 
3020
3273
  // Re-read a tab from the store after flushing TabManager's throttled content
@@ -3053,6 +3306,40 @@ var MbeditorApp = function MbeditorApp() {
3053
3306
  _doSave(paneId, tab);
3054
3307
  };
3055
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
+
3056
3343
  var _doSave = function _doSave(paneId, tab) {
3057
3344
  if (tab.isUntitled) {
3058
3345
  saveUntitledTab(paneId, tab)["catch"](function (err) {
@@ -3061,13 +3348,7 @@ var MbeditorApp = function MbeditorApp() {
3061
3348
  });
3062
3349
  return;
3063
3350
  }
3064
- setLoading(function (prev) {
3065
- return _extends({}, prev, { save: true });
3066
- });
3067
- EditorStore.setStatus("Saving " + tab.name + "...", "info");
3068
- isSavingRef.current = true;
3069
- FileService.saveFile(tab.path, tab.content).then(function () {
3070
- noteLocalSave(tab.path, tab.content);
3351
+ _runSave(tab, function () {
3071
3352
  var newPanes = EditorStore.getState().panes.map(function (p) {
3072
3353
  if (p.id === paneId) {
3073
3354
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -3077,13 +3358,10 @@ var MbeditorApp = function MbeditorApp() {
3077
3358
  return p;
3078
3359
  });
3079
3360
  EditorStore.setState({ panes: newPanes });
3080
- // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
3081
- var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
3082
- if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
3083
- _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
3084
- }
3085
3361
  // Collab: push a fresh snapshot so the server compacts the buffered deltas.
3086
- 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)) {
3087
3365
  CollaborationService.pushSnapshot(tab.path);
3088
3366
  }
3089
3367
  EditorStore.setStatus("Saved", "success");
@@ -3102,8 +3380,11 @@ var MbeditorApp = function MbeditorApp() {
3102
3380
  // sprockets/react-rails pipeline can't transform (errors Monaco's TS
3103
3381
  // worker misses). Save only — never per keystroke.
3104
3382
  if (jsSyntaxCheckAvailableRef.current && /\.(js|jsx)$/i.test(tab.path)) {
3383
+ var lintStartedAt = Date.now();
3105
3384
  FileService.lintFile(tab.path, tab.content, 'javascript').then(function (res) {
3106
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);
3107
3388
  if (babelMarkers.length > 0) {
3108
3389
  applyMarkersForTab(tab.id, babelMarkers);
3109
3390
  EditorStore.setStatus('Saved — babel: ' + babelMarkers[0].message, 'warning');
@@ -3113,7 +3394,9 @@ var MbeditorApp = function MbeditorApp() {
3113
3394
  // applyMarkersForTab keeps the previous map when it is already empty.
3114
3395
  applyMarkersForTab(tab.id, []);
3115
3396
  }
3116
- })["catch"](function () {});
3397
+ })["catch"](function () {
3398
+ _recErr('LINT');
3399
+ });
3117
3400
  }
3118
3401
 
3119
3402
  // The server broadcasts files_changed for this very write, and the
@@ -3124,13 +3407,6 @@ var MbeditorApp = function MbeditorApp() {
3124
3407
  // file every time does by definition, so the duplicate was the expensive
3125
3408
  // one. Without a socket no broadcast arrives, so keep it for that case.
3126
3409
  if (!_socketWillBroadcast()) GitService.fetchStatusLite({ background: true });
3127
- })["catch"](function (err) {
3128
- EditorStore.setStatus("Save failed: " + err.message, "error");
3129
- })["finally"](function () {
3130
- isSavingRef.current = false;
3131
- return setLoading(function (prev) {
3132
- return _extends({}, prev, { save: false });
3133
- });
3134
3410
  });
3135
3411
  };
3136
3412
 
@@ -3219,7 +3495,15 @@ var MbeditorApp = function MbeditorApp() {
3219
3495
  // disk stayed dirty and the only thing on offer was to hit Save All again.
3220
3496
  // Each file is now settled on its own result.
3221
3497
  var promises = dirtyTabs.map(function (tab) {
3222
- 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
+ });
3223
3507
  });
3224
3508
  Promise.allSettled(promises).then(function (results) {
3225
3509
  var saved = dirtyTabs.filter(function (tab, i) { return results[i].status === "fulfilled"; });
@@ -3367,6 +3651,7 @@ var MbeditorApp = function MbeditorApp() {
3367
3651
  // rather than the button appearing to do nothing.
3368
3652
  var formatTabContent = function formatTabContent(tab, prefs) {
3369
3653
  prefs = prefs || editorPrefs;
3654
+ var startedAt = Date.now();
3370
3655
  if (isRubyPath(tab.path) || tab.path.endsWith('.rake')) {
3371
3656
  if (!rubocopAvailable) return Promise.reject(new Error("RuboCop is not available for this workspace."));
3372
3657
  // RuboCop's output is taken as-is. Ruby indentation belongs to the
@@ -3375,13 +3660,18 @@ var MbeditorApp = function MbeditorApp() {
3375
3660
  // the same file. The old code tried the opposite — converting the source
3376
3661
  // to tabs *before* handing it over — which RuboCop simply discarded.
3377
3662
  return formatRubySource(tab.path, tab.content).then(function (res) {
3378
- return (res && res.content) || null;
3663
+ var out = (res && res.content) || null;
3664
+ _recFormat(tab.path, startedAt, out);
3665
+ return out;
3379
3666
  });
3380
3667
  }
3381
3668
 
3382
3669
  var parserName = prettierParserFor(tab.path);
3383
3670
  if (!parserName) return Promise.resolve(null);
3384
- 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
+ });
3385
3675
  };
3386
3676
 
3387
3677
  // Markers are per-model and only the mounted editor re-lints, so a document
@@ -3391,7 +3681,19 @@ var MbeditorApp = function MbeditorApp() {
3391
3681
  // counts because only the visible tab was re-checked. Dropping them says
3392
3682
  // "not known yet", which is true: the file re-lints when you open it.
3393
3683
  var discardStaleMarkers = function discardStaleMarkers(path) {
3394
- if (!path || !window.monaco || !window.monaco.editor) return;
3684
+ if (!path) return;
3685
+
3686
+ // The React map has to go too, not just Monaco's copy. It is what TabBar
3687
+ // counts, and EditorPanel re-applies it to the model the next time that tab
3688
+ // mounts — so clearing only Monaco left every background tab primed to put
3689
+ // its stale squiggles straight back on the next tab switch.
3690
+ EditorStore.getState().panes.forEach(function (p) {
3691
+ p.tabs.forEach(function (t) {
3692
+ if (t.path === path) applyMarkersForTab(t.id, []);
3693
+ });
3694
+ });
3695
+
3696
+ if (!window.monaco || !window.monaco.editor) return;
3395
3697
  var entry = window.__mbeditorModels && window.__mbeditorModels[path];
3396
3698
  if (!entry || !entry.model || entry.model.isDisposed()) return;
3397
3699
 
@@ -3462,6 +3764,7 @@ var MbeditorApp = function MbeditorApp() {
3462
3764
  EditorStore.setStatus(formatted === originalContent ? "Already formatted" : "Formatted (Unsaved)", "success");
3463
3765
  GitService.fetchStatus();
3464
3766
  })["catch"](function (err) {
3767
+ _recErr('FORMAT');
3465
3768
  EditorStore.setStatus("Format failed: " + (err && err.message ? err.message : err), "error");
3466
3769
  })["finally"](function () {
3467
3770
  setLoading(function (prev) { return _extends({}, prev, { format: false }); });
@@ -3479,7 +3782,7 @@ var MbeditorApp = function MbeditorApp() {
3479
3782
  var targets = [];
3480
3783
  EditorStore.getState().panes.forEach(function (p) {
3481
3784
  p.tabs.forEach(function (t) {
3482
- 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') {
3483
3786
  targets.push({ paneId: p.id, tab: t });
3484
3787
  }
3485
3788
  });
@@ -3520,23 +3823,6 @@ var MbeditorApp = function MbeditorApp() {
3520
3823
  });
3521
3824
  };
3522
3825
 
3523
- var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
3524
-
3525
- var loadCachedTestResult = function loadCachedTestResult(filePath) {
3526
- try {
3527
- var stored = localStorage.getItem(TEST_CACHE_PREFIX + filePath);
3528
- return stored ? JSON.parse(stored) : null;
3529
- } catch (e) {
3530
- return null;
3531
- }
3532
- };
3533
-
3534
- var saveCachedTestResult = function saveCachedTestResult(filePath, result) {
3535
- try {
3536
- localStorage.setItem(TEST_CACHE_PREFIX + filePath, JSON.stringify(result));
3537
- } catch (e) {}
3538
- };
3539
-
3540
3826
  var executeTestRun = function executeTestRun(filePath, line) {
3541
3827
  setTestLoading(true);
3542
3828
  EditorStore.setStatus(line ? 'Running test at line ' + line + '...' : 'Running tests...', 'info');
@@ -3572,21 +3858,6 @@ var MbeditorApp = function MbeditorApp() {
3572
3858
  });
3573
3859
  };
3574
3860
 
3575
- var handleRunTest = function handleRunTest() {
3576
- if (!activeTab || !activeTab.path) return;
3577
- if (testLoading) return;
3578
-
3579
- var cached = loadCachedTestResult(activeTab.path);
3580
- if (cached && !testPanelOpen) {
3581
- setTestResult(cached);
3582
- setTestPanelFile(cached.testFile || activeTab.path);
3583
- setTestPanelOpen(true);
3584
- return;
3585
- }
3586
-
3587
- executeTestRun(activeTab.path);
3588
- };
3589
-
3590
3861
  var handleRerunTest = function handleRerunTest() {
3591
3862
  if (!activeTab || !activeTab.path) return;
3592
3863
  if (testLoading) return;
@@ -3627,6 +3898,7 @@ var MbeditorApp = function MbeditorApp() {
3627
3898
  searchOffsetRef.current = 0;
3628
3899
  searchLoadingMoreRef.current = false;
3629
3900
  searchQueryRef.current = q;
3901
+ setSearchCollapsedFiles({});
3630
3902
  EditorStore.setState({ searchResults: [], searchHasMore: false });
3631
3903
  EditorStore.setStatus("Searching project...", "info");
3632
3904
  SearchService.projectSearch(q, 0, SearchService.PAGE_SIZE, { regex: searchUseRegexRef.current, matchCase: searchMatchCaseRef.current, wholeWord: searchWholeWordRef.current }).then(function (res) {
@@ -3659,6 +3931,15 @@ var MbeditorApp = function MbeditorApp() {
3659
3931
  });
3660
3932
  };
3661
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
+
3662
3943
  var handleSearchChange = function handleSearchChange(e) {
3663
3944
  var val = e.target.value;
3664
3945
  if (!val) { clearSearch(); return; }
@@ -3795,10 +4076,6 @@ var MbeditorApp = function MbeditorApp() {
3795
4076
  setShowLogPanel(function (prev) { return !prev; });
3796
4077
  };
3797
4078
 
3798
- var toggleTestRunPanel = function toggleTestRunPanel() {
3799
- setShowTestRunPanel(function (prev) { return !prev; });
3800
- };
3801
-
3802
4079
  var toggleProblemsPanel = function toggleProblemsPanel() {
3803
4080
  setShowProblemsPanel(function (prev) { return !prev; });
3804
4081
  };
@@ -3885,12 +4162,17 @@ var MbeditorApp = function MbeditorApp() {
3885
4162
  });
3886
4163
  };
3887
4164
 
3888
- var finishImport = function finishImport(result) {
3889
- var imported = (result.imported || []).length;
4165
+ var finishImport = function finishImport(result, destFolder) {
4166
+ var written = result.imported || [];
4167
+ var imported = written.length;
3890
4168
  var skipped = (result.conflicts || []).length;
3891
4169
  var failed = (result.errors || []).length;
3892
4170
 
3893
- var parts = [imported + ' file' + (imported === 1 ? '' : 's') + ' imported'];
4171
+ // Say where the files went. A bulk upload that reports only a count looks
4172
+ // the same whether it landed where you meant it to or in the workspace
4173
+ // root, and the destination is the whole question a folder import raises.
4174
+ var where = destFolder ? ' to ' + destFolder : ' to the workspace root';
4175
+ var parts = [imported + ' file' + (imported === 1 ? '' : 's') + ' imported' + (imported > 0 ? where : '')];
3894
4176
  if (skipped > 0) parts.push(skipped + ' skipped');
3895
4177
  if (failed > 0) parts.push(failed + ' failed');
3896
4178
 
@@ -3898,7 +4180,19 @@ var MbeditorApp = function MbeditorApp() {
3898
4180
  EditorStore.setStatus(parts.join(', ') + '.', level);
3899
4181
 
3900
4182
  if (imported > 0) {
3901
- refreshProjectTree().then(function() { GitService.fetchStatus(); });
4183
+ // Expand down to what was just written, so the tree actually shows it —
4184
+ // importing into a collapsed folder otherwise leaves the explorer looking
4185
+ // untouched. Deliberately does not *select* the folder, which would move
4186
+ // the selection somewhere the user did not put it.
4187
+ var landed = parentDir(written[0].path);
4188
+ var toExpand = {};
4189
+ var bits = landed ? landed.split('/') : [];
4190
+ for (var i = 1; i <= bits.length; i++) toExpand[bits.slice(0, i).join('/')] = true;
4191
+
4192
+ refreshProjectTree().then(function() {
4193
+ if (bits.length) setExpandedDirs(function (prev) { return Object.assign({}, prev, toExpand); });
4194
+ GitService.fetchStatus();
4195
+ });
3902
4196
  }
3903
4197
  };
3904
4198
 
@@ -3908,8 +4202,7 @@ var MbeditorApp = function MbeditorApp() {
3908
4202
  // rather than a disaster. Real targets stopPropagation before this runs.
3909
4203
  useEffect(function () {
3910
4204
  var swallow = function (e) {
3911
- var types = (e.dataTransfer && e.dataTransfer.types) || [];
3912
- if (Array.prototype.indexOf.call(types, 'Files') === -1) return;
4205
+ if (!FileImport.hasExternalFiles(e.dataTransfer)) return;
3913
4206
  e.preventDefault();
3914
4207
  // dragover is left alone beyond preventDefault: setting dropEffect here
3915
4208
  // would override the 'copy' cursor the tree sets on a valid folder.
@@ -3937,12 +4230,20 @@ var MbeditorApp = function MbeditorApp() {
3937
4230
  (entries.length === 1 ? '' : 's') + '...', 'info');
3938
4231
  }
3939
4232
 
3940
- 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)
3941
4242
  .then(function(result) {
3942
4243
  if (result.conflicts && result.conflicts.length > 0) {
3943
4244
  setImportConflict({ result: result, entries: entries, targetFolderPath: targetFolderPath });
3944
4245
  } else {
3945
- finishImport(result);
4246
+ finishImport(result, targetFolderPath);
3946
4247
  }
3947
4248
  })['catch'](function(err) {
3948
4249
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3955,14 +4256,14 @@ var MbeditorApp = function MbeditorApp() {
3955
4256
  setImportConflict(null);
3956
4257
  if (!pending) return;
3957
4258
 
3958
- if (mode === 'skip') { finishImport(pending.result); return; }
4259
+ if (mode === 'skip') { finishImport(pending.result, pending.targetFolderPath); return; }
3959
4260
 
3960
4261
  var retry = FileImport.conflictedEntries(
3961
4262
  pending.entries,
3962
4263
  pending.targetFolderPath,
3963
4264
  pending.result.conflicts
3964
4265
  );
3965
- if (retry.length === 0) { finishImport(pending.result); return; }
4266
+ if (retry.length === 0) { finishImport(pending.result, pending.targetFolderPath); return; }
3966
4267
 
3967
4268
  FileService.importFiles(FileImport.buildFormData(retry, pending.targetFolderPath, mode))
3968
4269
  .then(function(second) {
@@ -3970,39 +4271,88 @@ var MbeditorApp = function MbeditorApp() {
3970
4271
  imported: (pending.result.imported || []).concat(second.imported || []),
3971
4272
  conflicts: [],
3972
4273
  errors: (pending.result.errors || []).concat(second.errors || [])
3973
- });
4274
+ }, pending.targetFolderPath);
3974
4275
  })['catch'](function(err) {
3975
4276
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
3976
4277
  EditorStore.setStatus('Import failed: ' + message, 'error');
3977
4278
  });
3978
4279
  };
3979
4280
 
3980
- // Downloads go straight through /raw?download=1 — the browser's own save
3981
- // flow, so nothing is buffered client-side and a 5 MB file costs no memory.
3982
- // A synthetic anchor rather than location.href: navigating away from the
3983
- // editor to an attachment response leaves the page in a half-unloaded state
3984
- // 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
+
3985
4292
  var handleDownloadFile = function handleDownloadFile(node) {
3986
- if (!node || node.type !== 'file') return;
3987
- var link = document.createElement('a');
3988
- link.href = window.mbeditorBasePath() + '/raw?download=1&path=' + encodeURIComponent(node.path);
3989
- link.download = node.name;
3990
- link.rel = 'noopener';
3991
- document.body.appendChild(link);
3992
- link.click();
3993
- document.body.removeChild(link);
3994
- 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';
3995
4350
  };
3996
4351
 
3997
4352
  // A file node means "upload alongside this file", so the dialog opens on its
3998
4353
  // parent — right-clicking a file to upload next to it is the common gesture.
3999
4354
  var openImportDialog = function openImportDialog(node) {
4000
- var folder = '';
4001
- if (node && node.type === 'folder') {
4002
- folder = node.path;
4003
- } else if (node && node.path) {
4004
- folder = node.path.split('/').slice(0, -1).join('/');
4005
- }
4355
+ var folder = node ? (node.type === 'folder' ? node.path : parentDir(node.path)) : '';
4006
4356
  setImportDialog({ initialFolder: folder });
4007
4357
  };
4008
4358
 
@@ -4013,13 +4363,18 @@ var MbeditorApp = function MbeditorApp() {
4013
4363
 
4014
4364
  var openContextMenu = function openContextMenu(e, node) {
4015
4365
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
4016
- handleNodeSelect(node);
4366
+ if (!actsOnSelection(node)) handleNodeSelect(node);
4017
4367
  };
4018
4368
 
4019
4369
  var closeContextMenu = function closeContextMenu() {
4020
4370
  return setContextMenu(null);
4021
4371
  };
4022
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
+
4023
4378
  var handleContextMenuAction = function handleContextMenuAction(action) {
4024
4379
  var node = contextMenu && contextMenu.node;
4025
4380
  closeContextMenu();
@@ -4078,17 +4433,34 @@ var MbeditorApp = function MbeditorApp() {
4078
4433
  document.body.style.userSelect = 'none';
4079
4434
  };
4080
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
+
4081
4446
  var handleActivityBarClick = function handleActivityBarClick(tab) {
4082
4447
  if (tab === 'settings') {
4083
- openSettingsTab();
4448
+ setShowModelGraphView(false);
4449
+ setShowSettings(!showSettings);
4084
4450
  return;
4085
4451
  }
4086
4452
  // The model graph is a view, not a panel: it takes over the central area
4087
- // 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.
4088
4455
  if (tab === 'models') {
4089
- openModelGraphTab();
4456
+ setShowModelGraphView(function (prev) {
4457
+ var next = !prev;
4458
+ if (next) loadModelGraph(false);
4459
+ return next;
4460
+ });
4090
4461
  return;
4091
4462
  }
4463
+ setShowModelGraphView(false);
4092
4464
  if (!sidebarCollapsed && activeSidebarTab === tab) {
4093
4465
  setSidebarCollapsed(true);
4094
4466
  } else {
@@ -4243,6 +4615,7 @@ var MbeditorApp = function MbeditorApp() {
4243
4615
  // Optimistically insert the new node so the tree doesn't flash empty while waiting for the server
4244
4616
  var optimisticNode = { path: path, name: name, type: type === 'folder' ? 'folder' : 'file', children: type === 'folder' ? [] : undefined };
4245
4617
  setTreeData(function (prev) { return insertNodeIntoTree(prev, parentPath, optimisticNode); });
4618
+ setRevealTarget({ path: path });
4246
4619
 
4247
4620
  if (type === 'file') {
4248
4621
  setLoading(function (prev) {
@@ -4373,6 +4746,7 @@ var MbeditorApp = function MbeditorApp() {
4373
4746
  EditorStore.setStatus('Renamed to: ' + renamedPath, 'success');
4374
4747
  return refreshProjectTree().then(function () {
4375
4748
  GitService.fetchStatusLite({ background: true });
4749
+ setRevealTarget({ path: renamedPath });
4376
4750
  });
4377
4751
  })["catch"](function (err) {
4378
4752
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -4389,10 +4763,12 @@ var MbeditorApp = function MbeditorApp() {
4389
4763
  };
4390
4764
 
4391
4765
  var handleDeletePath = function handleDeletePath(targetNode) {
4392
- // If a specific node is provided (e.g. from context menu), delete just that.
4393
- // 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.
4394
4770
  var pathsToDelete;
4395
- if (targetNode !== undefined && targetNode) {
4771
+ if (targetNode && !actsOnSelection(targetNode)) {
4396
4772
  pathsToDelete = [targetNode.path];
4397
4773
  } else if (selectedPaths && selectedPaths.size > 0) {
4398
4774
  pathsToDelete = Array.from(selectedPaths);
@@ -4416,6 +4792,10 @@ var MbeditorApp = function MbeditorApp() {
4416
4792
  var confirmed = window.confirm('Delete ' + label + '? This cannot be undone.');
4417
4793
  if (!confirmed) return;
4418
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
+
4419
4799
  setLoading(function (prev) {
4420
4800
  return _extends({}, prev, { deletePath: true });
4421
4801
  });
@@ -4434,6 +4814,7 @@ var MbeditorApp = function MbeditorApp() {
4434
4814
  }
4435
4815
  return refreshProjectTree().then(function () {
4436
4816
  GitService.fetchStatusLite({ background: true });
4817
+ if (failures.length === 0 && deleteRevealTarget) setRevealTarget({ path: deleteRevealTarget });
4437
4818
  });
4438
4819
  })["finally"](function () {
4439
4820
  setLoading(function (prev) {
@@ -4451,6 +4832,35 @@ var MbeditorApp = function MbeditorApp() {
4451
4832
  var canLintAndFormat = !!activeTab;
4452
4833
  var hasGitBranch = !!(state.gitBranch && state.gitBranch.trim());
4453
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
+
4454
4864
  var renderTabBar = function renderTabBar(paneId, tabs, activeId) {
4455
4865
  return React.createElement(TabBar, {
4456
4866
  tabs: tabs,
@@ -4463,7 +4873,7 @@ var MbeditorApp = function MbeditorApp() {
4463
4873
  onSelect: function (id) {
4464
4874
  // Sync explorer selection with the newly active tab so there's only one highlight
4465
4875
  var tab = tabs.find(function(t) { return t.id === id; });
4466
- if (tab && tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
4876
+ if (tab && tab.path && !tab.path.startsWith('mbeditor://')) {
4467
4877
  handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
4468
4878
  }
4469
4879
  TabManager.switchTab(paneId, id);
@@ -4500,86 +4910,14 @@ var MbeditorApp = function MbeditorApp() {
4500
4910
  });
4501
4911
  };
4502
4912
 
4503
- function openSettingsTab() {
4913
+ var CHANGELOG_TAB_ID = 'mbeditor://changelog';
4914
+ function openChangelogTab() {
4504
4915
  var st = EditorStore.getState();
4505
- var foundPaneId = null;
4506
- var foundTab = null;
4916
+ // Focus existing tab if already open
4917
+ var foundPaneId = null, foundTab = null;
4507
4918
  st.panes.forEach(function(p) {
4508
4919
  if (!foundTab) {
4509
- var t = p.tabs.find(function(tab) { return tab.path === '__settings__'; });
4510
- if (t) { foundTab = t; foundPaneId = p.id; }
4511
- }
4512
- });
4513
- if (foundTab) {
4514
- var newPanes = st.panes.map(function(p) {
4515
- if (p.id === foundPaneId) return Object.assign({}, p, { activeTabId: '__settings__' });
4516
- return p;
4517
- });
4518
- EditorStore.setState({ panes: newPanes, focusedPaneId: foundPaneId, activeTabId: '__settings__' });
4519
- return;
4520
- }
4521
- var paneId = st.focusedPaneId;
4522
- var pane = st.panes.find(function(p) { return p.id === paneId; }) || st.panes[0];
4523
- if (!pane) return;
4524
- paneId = pane.id;
4525
- var newTab = { id: '__settings__', path: '__settings__', name: 'Settings', dirty: false, content: '', isSettings: true };
4526
- var newPanes2 = st.panes.map(function(p) {
4527
- if (p.id === paneId) return Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: '__settings__' });
4528
- return p;
4529
- });
4530
- EditorStore.setState({ panes: newPanes2, focusedPaneId: paneId, activeTabId: '__settings__' });
4531
- }
4532
-
4533
- // The diagram lives in an editor tab, not the sidebar: a layered graph is
4534
- // inherently wide and a ~300px panel can only ever show its first column.
4535
- // The sidebar tab is the entry point and the searchable model list.
4536
- var MODEL_GRAPH_TAB_ID = 'mbeditor://model-graph';
4537
- function openModelGraphTab() {
4538
- var st = EditorStore.getState();
4539
- var paneId = st.focusedPaneId;
4540
-
4541
- var existing = null;
4542
- st.panes.forEach(function (p) {
4543
- if (!existing && p.tabs.some(function (t) { return t.id === MODEL_GRAPH_TAB_ID; })) {
4544
- existing = p.id;
4545
- }
4546
- });
4547
- if (existing) {
4548
- EditorStore.setState({
4549
- panes: st.panes.map(function (p) {
4550
- return p.id === existing ? Object.assign({}, p, { activeTabId: MODEL_GRAPH_TAB_ID }) : p;
4551
- }),
4552
- focusedPaneId: existing
4553
- });
4554
- return;
4555
- }
4556
-
4557
- var pane = st.panes.find(function (p) { return p.id === paneId; }) || st.panes[0];
4558
- if (!pane) return;
4559
-
4560
- var newTab = {
4561
- id: MODEL_GRAPH_TAB_ID, path: MODEL_GRAPH_TAB_ID, name: 'Model Graph',
4562
- dirty: false, content: '', isModelGraph: true
4563
- };
4564
- EditorStore.setState({
4565
- panes: st.panes.map(function (p) {
4566
- return p.id === pane.id
4567
- ? Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: MODEL_GRAPH_TAB_ID })
4568
- : p;
4569
- }),
4570
- focusedPaneId: pane.id
4571
- });
4572
- loadModelGraph(false);
4573
- }
4574
-
4575
- var CHANGELOG_TAB_ID = 'mbeditor://changelog';
4576
- function openChangelogTab() {
4577
- var st = EditorStore.getState();
4578
- // Focus existing tab if already open
4579
- var foundPaneId = null, foundTab = null;
4580
- st.panes.forEach(function(p) {
4581
- if (!foundTab) {
4582
- var t = p.tabs.find(function(tab) { return tab.id === CHANGELOG_TAB_ID; });
4920
+ var t = p.tabs.find(function(tab) { return tab.id === CHANGELOG_TAB_ID; });
4583
4921
  if (t) { foundTab = t; foundPaneId = p.id; }
4584
4922
  }
4585
4923
  });
@@ -4629,10 +4967,11 @@ var MbeditorApp = function MbeditorApp() {
4629
4967
  { className: "ide-titlebar" },
4630
4968
  React.createElement("i", { className: "fas fa-layer-group ide-titlebar-icon" }),
4631
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.
4632
4972
  "div",
4633
- { className: "ide-titlebar-title" },
4634
- "Mini Browser Editor — ",
4635
- window.location.host
4973
+ { className: "ide-titlebar-title", title: "Mini Browser Editor — " + window.location.host },
4974
+ "Mbeditor"
4636
4975
  ),
4637
4976
  // The slot claims all the room between the title and the buttons; the
4638
4977
  // search pill then takes 75% of it, centred. Sizing the pill against a
@@ -4722,6 +5061,35 @@ var MbeditorApp = function MbeditorApp() {
4722
5061
  !toolbarIconOnly && " Git"
4723
5062
  )
4724
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
+ ),
4725
5093
  // Collaboration trouble chip. Deliberately not shown when everything is
4726
5094
  // healthy and you are simply alone — that was the whole point of hiding
4727
5095
  // the solo chip. It appears only when a condition pairing needs has
@@ -4773,45 +5141,48 @@ var MbeditorApp = function MbeditorApp() {
4773
5141
  React.Fragment,
4774
5142
  null,
4775
5143
  React.createElement("div", { className: "statusbar-sep" }),
4776
- collabPeerIds.map(function (cid) {
4777
- var peer = collabRoster[cid];
4778
- var file = peer.current_file;
4779
- var name = peer.name || 'Anonymous';
4780
- var colour = peer.colour || '#888888';
4781
- var following = followedClientId === cid;
4782
- // Solid dot: they are in the file you are looking at, so their caret
4783
- // is on screen. Hollow ring: they are somewhere else and there is
4784
- // nothing to see — without this the chip looked identical either way
4785
- // and a peer's caret just vanished with no explanation.
4786
- var elsewhere = file !== presenceFile;
4787
- return React.createElement(
4788
- "button",
4789
- {
4790
- key: cid,
4791
- type: "button",
4792
- className: "statusbar-btn",
4793
- style: following
4794
- ? { background: 'color-mix(in srgb, ' + colour + ' 28%, transparent)' }
4795
- : undefined,
4796
- onMouseEnter: function (e) { openCollabHover(cid, e); },
4797
- onMouseLeave: function () { setCollabHover(null); },
4798
- onClick: function () { toggleFollow(cid); }
4799
- },
4800
- React.createElement("i", {
4801
- className: (following ? "fas fa-eye" : (elsewhere ? "far fa-circle" : "fas fa-circle")) +
4802
- " collab-pulse",
4803
- style: { color: colour, fontSize: "0.7em", marginRight: "2px" }
4804
- }),
4805
- collabPeerLabels && (" " + name),
4806
- // Where they went, when they are not where you are. Basename only —
4807
- // the chip has ~110px to spend and the full path is in the tooltip.
4808
- collabPeerLabels && elsewhere && file && React.createElement(
4809
- "span",
4810
- { style: { opacity: 0.65, marginLeft: "4px" } },
4811
- file.split('/').pop()
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
4812
5183
  )
4813
- );
4814
- })
5184
+ )
5185
+ )
4815
5186
  ),
4816
5187
  React.createElement("div", { className: "statusbar-sep" }),
4817
5188
  React.createElement(
@@ -4841,6 +5212,31 @@ var MbeditorApp = function MbeditorApp() {
4841
5212
  )
4842
5213
  )
4843
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
+ ),
4844
5240
  collabHover && (function () {
4845
5241
  var isMe = collabHover.cid === '__me__';
4846
5242
  var peer = isMe ? null : collabRoster[collabHover.cid];
@@ -4929,6 +5325,15 @@ var MbeditorApp = function MbeditorApp() {
4929
5325
  );
4930
5326
  })(),
4931
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
+ }),
4932
5337
  React.createElement(
4933
5338
  "div",
4934
5339
  { className: "ide-body", id: "ide-body-container" },
@@ -4973,7 +5378,7 @@ var MbeditorApp = function MbeditorApp() {
4973
5378
  "button",
4974
5379
  {
4975
5380
  type: "button",
4976
- className: "ide-activity-btn" + (activeTab && activeTab.isModelGraph ? ' active' : ''),
5381
+ className: "ide-activity-btn" + (showModelGraphView ? ' active' : ''),
4977
5382
  title: "Model graph",
4978
5383
  onClick: function() { handleActivityBarClick('models'); }
4979
5384
  },
@@ -4987,16 +5392,16 @@ var MbeditorApp = function MbeditorApp() {
4987
5392
  "button",
4988
5393
  {
4989
5394
  type: "button",
4990
- className: "ide-activity-btn" + (activeTab && activeTab.isSettings ? ' active' : ''),
5395
+ className: "ide-activity-btn" + (showSettings ? ' active' : ''),
4991
5396
  title: "Editor Preferences",
4992
- onClick: openSettingsTab
5397
+ onClick: function() { handleActivityBarClick('settings'); }
4993
5398
  },
4994
5399
  React.createElement("i", { className: "fas fa-cog" })
4995
5400
  )
4996
5401
  )
4997
5402
  ),
4998
- /* Panel content — shown when not collapsed and not in zen mode */
4999
- !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(
5000
5405
  "div",
5001
5406
  { className: "ide-sidebar", style: { width: sidebarWidth + "px" } },
5002
5407
  React.createElement("div", { className: "sidebar-panel-title" },
@@ -5034,101 +5439,126 @@ var MbeditorApp = function MbeditorApp() {
5034
5439
  React.createElement(
5035
5440
  "div",
5036
5441
  { style: { marginBottom: "12px" } },
5037
- state.panes.map(function (pane) {
5038
- if (pane.tabs.length === 0) return null;
5039
- var isPane2 = pane.id === 2;
5040
- return React.createElement(
5041
- "div",
5042
- {
5043
- key: pane.id,
5044
- className: "open-editors-group",
5045
- style: { marginBottom: pane.id === 1 && state.panes[1].tabs.length > 0 ? "10px" : "0" }
5046
- },
5047
- 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(
5048
5451
  "div",
5049
- { className: "ide-sidebar-header open-editors-group-header" },
5050
- React.createElement(
5051
- "span",
5052
- { className: "open-editors-group-title" },
5053
- "GROUP ",
5054
- 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
+ )
5055
5478
  ),
5056
5479
  React.createElement(
5057
- SectionActionGroup,
5058
- { ariaLabel: "Group " + pane.id + " actions", className: "collapsible-actions open-editors-group-actions" },
5059
- React.createElement(SidebarActionButton, {
5060
- title: "Close all editors in Group " + pane.id,
5061
- ariaLabel: "Close all editors in Group " + pane.id,
5062
- iconClass: "far fa-window-close",
5063
- onClick: function (e) {
5064
- e.stopPropagation();handleCloseEditorsInGroup(pane.id);
5065
- }
5066
- })
5067
- )
5068
- ),
5069
- React.createElement(
5070
- "div",
5071
- { className: "file-tree" },
5072
- pane.tabs.map(function (tab) {
5073
- return React.createElement(
5074
- "div",
5075
- {
5076
- key: tab.id,
5077
- className: "tree-item " + (pane.activeTabId === tab.id && state.focusedPaneId === pane.id ? "active" : ""),
5078
- // The name ellipsises in a narrow sidebar, so the row
5079
- // carries the full path the way file-tree rows do.
5080
- title: tab.path || tab.name,
5081
- onClick: function () {
5082
- if (tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
5083
- handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
5084
- }
5085
- TabManager.focusPane(pane.id);TabManager.switchTab(pane.id, tab.id);
5086
- }
5087
- },
5088
- React.createElement("i", { className: "tree-item-icon " + (window.getFileIcon ? window.getFileIcon(tab.name) : 'far fa-file-code') + " tree-file-icon" }),
5089
- React.createElement(
5090
- "div",
5091
- { className: "tree-item-name", style: { display: 'flex', alignItems: 'center' } },
5092
- React.createElement(
5093
- "span",
5094
- { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
5095
- tab.name
5096
- ),
5097
- tab.dirty && React.createElement("i", { className: "fas fa-circle", style: { fontSize: '5px', color: '#e3d286', marginLeft: '6px', marginTop: '1px' } })
5098
- ),
5099
- 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(
5100
5486
  "div",
5101
- { className: "tab-actions", style: { display: 'flex', position: 'absolute', right: '4px', top: 0, height: '100%', alignItems: 'center' } },
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" }),
5102
5501
  React.createElement(
5103
5502
  "div",
5104
- { className: "tab-split", onClick: function (e) {
5105
- e.stopPropagation();TabManager.moveTabToPane(pane.id, pane.id === 1 ? 2 : 1, tab.id);
5106
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 }, title: "Move to Group " + (pane.id === 1 ? 2 : 1) },
5107
- 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
+ )
5108
5518
  ),
5109
5519
  React.createElement(
5110
5520
  "div",
5111
- { className: "tab-close", onClick: function (e) {
5112
- e.stopPropagation();requestCloseTab(pane.id, tab.id);
5113
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 },
5114
- // See TabBar: role="button" would pick up Pico's
5115
- // button skin from the host app and square this off.
5116
- title: "Close " + tab.name + (tab.dirty ? " (unsaved changes)" : "") },
5117
- 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
+ )
5118
5546
  )
5119
- )
5120
- );
5121
- })
5122
- )
5123
- );
5124
- })
5547
+ );
5548
+ })
5549
+ )
5550
+ );
5551
+ });
5552
+ })()
5125
5553
  )
5126
5554
  )
5127
5555
  ),
5128
- state.panes.flatMap(function (p) { return p.tabs; }).length > 0 && React.createElement(
5129
- "div",
5130
- { className: "open-editors-resize-handle", onMouseDown: startOpenEditorsResize }
5131
- ),
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
+ }),
5132
5562
  React.createElement(
5133
5563
  "div",
5134
5564
  { className: "ide-sidebar-scrollable" },
@@ -5136,10 +5566,7 @@ var MbeditorApp = function MbeditorApp() {
5136
5566
  CollapsibleSection,
5137
5567
  {
5138
5568
  title: projectSectionTitle,
5139
- isCollapsed: collapsedSections.projects,
5140
- onToggle: function (isCollapsed) {
5141
- return handleToggleSection('projects', isCollapsed);
5142
- },
5569
+ collapsible: false,
5143
5570
  actions: React.createElement(
5144
5571
  SectionActionGroup,
5145
5572
  { ariaLabel: "Project actions" },
@@ -5174,10 +5601,15 @@ var MbeditorApp = function MbeditorApp() {
5174
5601
  disabled: !!loading.createDir
5175
5602
  }),
5176
5603
  React.createElement(SidebarActionButton, {
5177
- title: "Upload files",
5604
+ title: "Upload files to the workspace root",
5178
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.
5179
5611
  onClick: function () {
5180
- return openImportDialog(selectedTreeNode);
5612
+ return openImportDialog(null);
5181
5613
  }
5182
5614
  }),
5183
5615
  React.createElement(SidebarActionButton, {
@@ -5207,6 +5639,7 @@ var MbeditorApp = function MbeditorApp() {
5207
5639
  activePath: editorPrefs.autoRevealInExplorer !== false ? (activeTab && activeTab.path) : null,
5208
5640
  selectedPaths: selectedPaths,
5209
5641
  anchorPath: selectedTreePath,
5642
+ revealTarget: revealTarget,
5210
5643
  onNodeSelect: handleNodeSelect,
5211
5644
  onMultiSelect: handleMultiSelect,
5212
5645
  onMove: handleMoveNodes,
@@ -5337,13 +5770,29 @@ var MbeditorApp = function MbeditorApp() {
5337
5770
  var total = searchTotalCount > 0 ? searchTotalCount : loadedCount;
5338
5771
  var hasAny = loadedCount > 0;
5339
5772
 
5773
+ // Grouped into a VS Code-style tree, then flattened straight back
5774
+ // into one row array: the windowing below is unchanged, it just
5775
+ // indexes rows instead of results. Every tier emits its hits file
5776
+ // by file, so a run of the same path is the whole group.
5777
+ var rows = [];
5778
+ var group = null;
5779
+ allResults.forEach(function (res, idx) {
5780
+ if (!group || group.file !== res.file) {
5781
+ group = { type: 'file', file: res.file, count: 0 };
5782
+ rows.push(group);
5783
+ }
5784
+ group.count += 1;
5785
+ if (!searchCollapsedFiles[res.file]) rows.push({ type: 'match', res: res, idx: idx });
5786
+ });
5787
+ var rowCount = rows.length;
5788
+
5340
5789
  // Only the rows on screen (plus a small buffer either side) are
5341
5790
  // built. Everything else is represented by the height of the
5342
5791
  // spacer, so the scrollbar and every scroll position stay exactly
5343
5792
  // as they would be for the full list.
5344
5793
  var winStart = Math.max(0, Math.floor(searchViewport.scrollTop / SEARCH_ROW_HEIGHT) - SEARCH_ROW_BUFFER);
5345
- var winEnd = Math.min(loadedCount, Math.ceil((searchViewport.scrollTop + (searchViewport.height || 600)) / SEARCH_ROW_HEIGHT) + SEARCH_ROW_BUFFER);
5346
- var visible = allResults.slice(winStart, winEnd);
5794
+ var winEnd = Math.min(rowCount, Math.ceil((searchViewport.scrollTop + (searchViewport.height || 600)) / SEARCH_ROW_HEIGHT) + SEARCH_ROW_BUFFER);
5795
+ var visible = rows.slice(winStart, winEnd);
5347
5796
 
5348
5797
  return React.createElement(
5349
5798
  React.Fragment,
@@ -5367,30 +5816,57 @@ var MbeditorApp = function MbeditorApp() {
5367
5816
  },
5368
5817
  React.createElement(
5369
5818
  "div",
5370
- { style: { height: loadedCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5371
- visible.map(function(res, vi) {
5819
+ { style: { height: rowCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5820
+ visible.map(function(row, vi) {
5372
5821
  var i = winStart + vi;
5373
- var fileName = res.file.split('/').pop();
5822
+ var top = { position: 'absolute', top: i * SEARCH_ROW_HEIGHT, left: 0, right: 0 };
5823
+
5824
+ if (row.type === 'file') {
5825
+ var fileName = row.file.split('/').pop();
5826
+ var dir = row.file.slice(0, row.file.length - fileName.length).replace(/\/$/, '');
5827
+ var collapsed = !!searchCollapsedFiles[row.file];
5828
+ return React.createElement(
5829
+ "div",
5830
+ {
5831
+ key: 'f:' + row.file,
5832
+ className: "search-result-file-row",
5833
+ style: top,
5834
+ title: row.file,
5835
+ onClick: (function(f) { return function() { toggleSearchFile(f); }; })(row.file)
5836
+ },
5837
+ React.createElement("i", { className: "codicon codicon-chevron-" + (collapsed ? "right" : "down") + " search-result-chevron" }),
5838
+ React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
5839
+ React.createElement("span", { className: "search-result-file-name" }, fileName),
5840
+ dir && React.createElement("span", { className: "search-result-file-dir" }, dir),
5841
+ React.createElement("span", { className: "search-result-count" }, row.count)
5842
+ );
5843
+ }
5844
+
5845
+ var res = row.res;
5374
5846
  return React.createElement(
5375
5847
  "div",
5376
5848
  {
5377
- key: i,
5849
+ key: 'm:' + row.idx,
5378
5850
  className: "search-result-item",
5379
- style: { position: 'absolute', top: i * SEARCH_ROW_HEIGHT, left: 0, right: 0 },
5380
- // end_col puts the cursor just past the match, which is
5381
- // where you want to start typing after jumping to a hit.
5382
- onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.end_col || r.col); }; })(res)
5851
+ style: top,
5852
+ title: res.file + ":" + res.line,
5853
+ // col..end_col selects the match and leaves the cursor
5854
+ // just past it, which is where you want to start
5855
+ // typing after jumping to a hit — not column 1.
5856
+ onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.col || r.end_col, r.end_col); }; })(res)
5383
5857
  },
5384
- React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
5385
- React.createElement(
5386
- "div", { className: "search-result-body" },
5387
- React.createElement(
5388
- "div", { className: "search-result-file" },
5389
- fileName,
5390
- React.createElement("span", { className: "search-result-line-num" }, " ", res.file, ":", res.line)
5391
- ),
5392
- React.createElement("div", { className: "search-result-text" }, res.text)
5393
- )
5858
+ React.createElement("span", { className: "search-result-line-num" }, res.line),
5859
+ (function () {
5860
+ var parts = searchMatchParts(res);
5861
+ if (!parts) return React.createElement("span", { className: "search-result-text" }, res.text);
5862
+ return React.createElement(
5863
+ "span",
5864
+ { className: "search-result-text search-result-text-split" },
5865
+ React.createElement("span", { className: "search-result-pre" }, parts[0]),
5866
+ React.createElement("mark", { className: "search-result-match" }, parts[1]),
5867
+ React.createElement("span", { className: "search-result-post" }, parts[2])
5868
+ );
5869
+ })()
5394
5870
  );
5395
5871
  })
5396
5872
  ),
@@ -5502,18 +5978,32 @@ var MbeditorApp = function MbeditorApp() {
5502
5978
  })()
5503
5979
  )
5504
5980
  ),
5505
- /* Sidebar resize divider — only when panel is open */
5506
- !sidebarCollapsed && !zenMode && React.createElement("div", {
5507
- className: "panel-divider sidebar-divider " + (activeResizeMode === 'sidebar' ? 'active' : ''),
5508
- onMouseDown: startSidebarResize,
5509
- role: "separator",
5510
- "aria-orientation": "vertical",
5511
- "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); }
5512
5987
  }),
5513
- // Column wrapping the split panes and the bottom drawers. ide-main is a
5514
- // row of panes, so the drawers need a vertical parent to push against;
5515
- // as absolute overlays they covered the editor instead.
5516
- 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(
5517
6007
  "div",
5518
6008
  { className: "ide-center-column" },
5519
6009
  React.createElement(
@@ -5611,13 +6101,6 @@ var MbeditorApp = function MbeditorApp() {
5611
6101
  commits: pActiveTab.commits || [],
5612
6102
  onSelectCommit: handleSelectCommit
5613
6103
  });
5614
- } else if (pActiveTab.isModelGraph) {
5615
- content = React.createElement(ModelGraph, {
5616
- graph: modelGraph,
5617
- loading: modelGraphLoading,
5618
- onRefresh: function () { loadModelGraph(true); },
5619
- onOpenModel: function (model) { openSchemaModal(model.name); }
5620
- });
5621
6104
  } else if (pActiveTab.isChangelog) {
5622
6105
  content = React.createElement(ChangelogView, {
5623
6106
  changelogState: changelogState,
@@ -5630,597 +6113,6 @@ var MbeditorApp = function MbeditorApp() {
5630
6113
  }
5631
6114
  }
5632
6115
  });
5633
- } else if (pActiveTab.isSettings) {
5634
- content = React.createElement(
5635
- 'div',
5636
- { className: 'ide-settings-tab-content' },
5637
- React.createElement(
5638
- 'div',
5639
- { className: 'ide-settings-body' },
5640
-
5641
- /* ── Appearance ──────────────────────────────── */
5642
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Appearance'),
5643
- React.createElement(
5644
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Color theme for the editor' },
5645
- React.createElement('span', { className: 'ide-settings-label' }, 'Theme'),
5646
- React.createElement(
5647
- 'select', {
5648
- value: editorPrefs.theme || 'vs-dark',
5649
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { theme: e.target.value }); }); }
5650
- },
5651
- React.createElement('option', { value: 'vs-dark' }, 'Dark'),
5652
- React.createElement('option', { value: 'vs' }, 'Light'),
5653
- React.createElement('option', { value: 'hc-black' }, 'HC Dark'),
5654
- React.createElement('option', { value: 'hc-light' }, 'HC Light'),
5655
- React.createElement('option', { value: 'dracula' }, 'Dracula'),
5656
- React.createElement('option', { value: 'night-owl' }, 'Night Owl'),
5657
- React.createElement('option', { value: 'monokai' }, 'Monokai'),
5658
- React.createElement('option', { value: 'nord' }, 'Nord'),
5659
- React.createElement('option', { value: 'github-dark' }, 'GitHub Dark'),
5660
- React.createElement('option', { value: 'tomorrow-night' }, 'Tomorrow Night'),
5661
- React.createElement('option', { value: 'github-light' }, 'GitHub Light')
5662
- )
5663
- ),
5664
- React.createElement(
5665
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Editor font size in pixels (8–32)' },
5666
- React.createElement('span', { className: 'ide-settings-label' }, 'Font size'),
5667
- React.createElement('input', {
5668
- key: String(editorPrefs.fontSize || 13),
5669
- type: 'number', min: '8', max: '32', step: '1',
5670
- className: 'ide-settings-input',
5671
- defaultValue: editorPrefs.fontSize || 13,
5672
- onChange: function(e) {
5673
- var v = parseInt(e.target.value, 10);
5674
- if (v >= 8 && v <= 32) setEditorPrefs(function(p) { return Object.assign({}, p, { fontSize: v }); });
5675
- },
5676
- onBlur: function(e) {
5677
- var v = parseInt(e.target.value, 10);
5678
- if (isNaN(v) || v < 8 || v > 32) e.target.value = String(editorPrefs.fontSize || 13);
5679
- }
5680
- })
5681
- ),
5682
- React.createElement(
5683
- 'label', { className: 'ide-settings-row-full', title: 'Font stack used in the editor — the first font available on your system is used' },
5684
- React.createElement('span', { className: 'ide-settings-label' }, 'Font family'),
5685
- React.createElement('input', {
5686
- type: 'text',
5687
- className: 'ide-settings-input ide-settings-input-wide',
5688
- value: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
5689
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { fontFamily: e.target.value }); }); }
5690
- })
5691
- ),
5692
- React.createElement(
5693
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Row height in pixels. 0 = auto (roughly font size × 1.5)' },
5694
- React.createElement('span', { className: 'ide-settings-label' }, 'Line height (0=auto)'),
5695
- React.createElement('input', {
5696
- key: String(editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0),
5697
- type: 'number', min: '0', max: '100', step: '1',
5698
- className: 'ide-settings-input',
5699
- defaultValue: editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0,
5700
- onChange: function(e) {
5701
- var v = parseInt(e.target.value, 10);
5702
- if (!isNaN(v) && v >= 0 && v <= 100) setEditorPrefs(function(p) { return Object.assign({}, p, { lineHeight: v }); });
5703
- },
5704
- onBlur: function(e) {
5705
- var v = parseInt(e.target.value, 10);
5706
- if (isNaN(v) || v < 0 || v > 100) e.target.value = String(editorPrefs.lineHeight != null ? editorPrefs.lineHeight : 0);
5707
- }
5708
- })
5709
- ),
5710
- React.createElement(
5711
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Extra space between characters in pixels. 0 = default' },
5712
- React.createElement('span', { className: 'ide-settings-label' }, 'Letter spacing (px)'),
5713
- React.createElement('input', {
5714
- key: String(editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0),
5715
- type: 'number', min: '-5', max: '20', step: '0.5',
5716
- className: 'ide-settings-input',
5717
- defaultValue: editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0,
5718
- onChange: function(e) {
5719
- var v = parseFloat(e.target.value);
5720
- if (!isNaN(v) && v >= -5 && v <= 20) setEditorPrefs(function(p) { return Object.assign({}, p, { letterSpacing: v }); });
5721
- },
5722
- onBlur: function(e) {
5723
- var v = parseFloat(e.target.value);
5724
- if (isNaN(v) || v < -5 || v > 20) e.target.value = String(editorPrefs.letterSpacing != null ? editorPrefs.letterSpacing : 0);
5725
- }
5726
- })
5727
- ),
5728
-
5729
- /* ── Indentation (unified editor + Prettier) ── */
5730
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Indentation'),
5731
- React.createElement(
5732
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Number of spaces per indentation level (also sets Prettier tab width)' },
5733
- React.createElement('span', { className: 'ide-settings-label' }, 'Tab size'),
5734
- React.createElement('input', {
5735
- key: String(editorPrefs.tabSize || 4),
5736
- type: 'number', min: '1', max: '8', step: '1',
5737
- className: 'ide-settings-input',
5738
- defaultValue: editorPrefs.tabSize || 4,
5739
- onChange: function(e) {
5740
- var v = parseInt(e.target.value, 10);
5741
- if (v >= 1 && v <= 8) setEditorPrefs(function(p) { return Object.assign({}, p, { tabSize: v }); });
5742
- },
5743
- onBlur: function(e) {
5744
- var v = parseInt(e.target.value, 10);
5745
- if (isNaN(v) || v < 1 || v > 8) e.target.value = String(editorPrefs.tabSize || 4);
5746
- }
5747
- })
5748
- ),
5749
- React.createElement(
5750
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Insert spaces instead of tab characters when pressing Tab' },
5751
- React.createElement('span', { className: 'ide-settings-label' }, 'Use spaces'),
5752
- React.createElement('input', {
5753
- type: 'checkbox',
5754
- className: 'ide-settings-checkbox',
5755
- checked: !!(editorPrefs.insertSpaces),
5756
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { insertSpaces: v }); }); }
5757
- })
5758
- ),
5759
-
5760
- /* ── Editor ──────────────────────────────────── */
5761
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Editor'),
5762
- React.createElement(
5763
- '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' },
5764
- React.createElement('span', { className: 'ide-settings-label' }, 'Word wrap'),
5765
- React.createElement(
5766
- 'select', {
5767
- value: editorPrefs.wordWrap || 'off',
5768
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { wordWrap: e.target.value }); }); }
5769
- },
5770
- React.createElement('option', { value: 'off' }, 'Off'),
5771
- React.createElement('option', { value: 'on' }, 'On'),
5772
- React.createElement('option', { value: 'wordWrapColumn' }, 'Column')
5773
- )
5774
- ),
5775
- React.createElement(
5776
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Show line numbers in the gutter — On, Off, or Relative (useful with Vim mode)' },
5777
- React.createElement('span', { className: 'ide-settings-label' }, 'Line numbers'),
5778
- React.createElement(
5779
- 'select', {
5780
- value: editorPrefs.lineNumbers || 'on',
5781
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { lineNumbers: e.target.value }); }); }
5782
- },
5783
- React.createElement('option', { value: 'on' }, 'On'),
5784
- React.createElement('option', { value: 'off' }, 'Off'),
5785
- React.createElement('option', { value: 'relative' }, 'Relative')
5786
- )
5787
- ),
5788
- React.createElement(
5789
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Render whitespace characters visually — None, Selection only, Boundary (leading/trailing), or All' },
5790
- React.createElement('span', { className: 'ide-settings-label' }, 'Whitespace'),
5791
- React.createElement(
5792
- 'select', {
5793
- value: editorPrefs.renderWhitespace || 'none',
5794
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { renderWhitespace: e.target.value }); }); }
5795
- },
5796
- React.createElement('option', { value: 'none' }, 'None'),
5797
- React.createElement('option', { value: 'selection' }, 'Selection'),
5798
- React.createElement('option', { value: 'boundary' }, 'Boundary'),
5799
- React.createElement('option', { value: 'all' }, 'All')
5800
- )
5801
- ),
5802
- React.createElement(
5803
- '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' },
5804
- React.createElement('span', { className: 'ide-settings-label' }, 'Minimap'),
5805
- React.createElement('input', {
5806
- type: 'checkbox',
5807
- className: 'ide-settings-checkbox',
5808
- checked: !!(editorPrefs.minimap),
5809
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { minimap: v }); }); }
5810
- })
5811
- ),
5812
- React.createElement(
5813
- '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' },
5814
- React.createElement('span', { className: 'ide-settings-label' }, 'Scroll past end'),
5815
- React.createElement('input', {
5816
- type: 'checkbox',
5817
- className: 'ide-settings-checkbox',
5818
- checked: !!(editorPrefs.scrollBeyondLastLine),
5819
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { scrollBeyondLastLine: v }); }); }
5820
- })
5821
- ),
5822
- React.createElement(
5823
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Colorize matching bracket pairs with distinct colors to make nesting easier to read' },
5824
- React.createElement('span', { className: 'ide-settings-label' }, 'Bracket colors'),
5825
- React.createElement('input', {
5826
- type: 'checkbox',
5827
- className: 'ide-settings-checkbox',
5828
- checked: !!(editorPrefs.bracketPairColorization),
5829
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { bracketPairColorization: v }); }); }
5830
- })
5831
- ),
5832
- React.createElement(
5833
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Enable Vim keybindings (Normal/Insert/Visual modes). Press Escape to return to Normal mode.' },
5834
- React.createElement('span', { className: 'ide-settings-label' }, 'Vim mode'),
5835
- React.createElement('input', {
5836
- type: 'checkbox',
5837
- className: 'ide-settings-checkbox',
5838
- checked: !!(editorPrefs.vimMode),
5839
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { vimMode: v }); }); }
5840
- })
5841
- ),
5842
- React.createElement(
5843
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'When to insert a matching closing bracket automatically' },
5844
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto-close brackets'),
5845
- React.createElement(
5846
- 'select', {
5847
- value: editorPrefs.autoClosingBrackets || 'always',
5848
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoClosingBrackets: e.target.value }); }); }
5849
- },
5850
- React.createElement('option', { value: 'always' }, 'Always'),
5851
- React.createElement('option', { value: 'languageDefined' }, 'Per language rules'),
5852
- React.createElement('option', { value: 'beforeWhitespace' }, 'Only before whitespace'),
5853
- React.createElement('option', { value: 'never' }, 'Never')
5854
- )
5855
- ),
5856
- React.createElement(
5857
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'When to insert a matching closing quote automatically' },
5858
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto-close quotes'),
5859
- React.createElement(
5860
- 'select', {
5861
- value: editorPrefs.autoClosingQuotes || 'always',
5862
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoClosingQuotes: 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: 'What to highlight on the current editor line' },
5872
- React.createElement('span', { className: 'ide-settings-label' }, 'Line highlight'),
5873
- React.createElement(
5874
- 'select', {
5875
- value: editorPrefs.renderLineHighlight || 'none',
5876
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { renderLineHighlight: e.target.value }); }); }
5877
- },
5878
- React.createElement('option', { value: 'none' }, 'None'),
5879
- React.createElement('option', { value: 'gutter' }, 'Line number only'),
5880
- React.createElement('option', { value: 'line' }, 'Current line background'),
5881
- React.createElement('option', { value: 'all' }, 'Line number + background')
5882
- )
5883
- ),
5884
- React.createElement(
5885
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Shape of the text cursor in the editor' },
5886
- React.createElement('span', { className: 'ide-settings-label' }, 'Cursor style'),
5887
- React.createElement(
5888
- 'select', {
5889
- value: editorPrefs.cursorStyle || 'line',
5890
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { cursorStyle: e.target.value }); }); }
5891
- },
5892
- React.createElement('option', { value: 'line' }, 'Line (|)'),
5893
- React.createElement('option', { value: 'block' }, 'Block (filled)'),
5894
- React.createElement('option', { value: 'underline' }, 'Underline (_)'),
5895
- React.createElement('option', { value: 'line-thin' }, 'Line thin'),
5896
- React.createElement('option', { value: 'block-outline' }, 'Block outline'),
5897
- React.createElement('option', { value: 'underline-thin' }, 'Underline thin')
5898
- )
5899
- ),
5900
- React.createElement(
5901
- '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)' },
5902
- React.createElement('span', { className: 'ide-settings-label' }, 'Cursor blinking'),
5903
- React.createElement(
5904
- 'select', {
5905
- value: editorPrefs.cursorBlinking || 'blink',
5906
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { cursorBlinking: e.target.value }); }); }
5907
- },
5908
- React.createElement('option', { value: 'blink' }, 'Blink (on/off)'),
5909
- React.createElement('option', { value: 'smooth' }, 'Smooth (fade)'),
5910
- React.createElement('option', { value: 'phase' }, 'Phase (offset fade)'),
5911
- React.createElement('option', { value: 'expand' }, 'Expand (grow/shrink)'),
5912
- React.createElement('option', { value: 'solid' }, 'Solid (no blink)')
5913
- )
5914
- ),
5915
- React.createElement(
5916
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show collapse arrows next to foldable regions (functions, classes, blocks)' },
5917
- React.createElement('span', { className: 'ide-settings-label' }, 'Code folding'),
5918
- React.createElement('input', {
5919
- type: 'checkbox',
5920
- className: 'ide-settings-checkbox',
5921
- checked: editorPrefs.folding !== false,
5922
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { folding: v }); }); }
5923
- })
5924
- ),
5925
- React.createElement(
5926
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Animate scrolling instead of jumping instantly' },
5927
- React.createElement('span', { className: 'ide-settings-label' }, 'Smooth scrolling'),
5928
- React.createElement('input', {
5929
- type: 'checkbox',
5930
- className: 'ide-settings-checkbox',
5931
- checked: !!(editorPrefs.smoothScrolling),
5932
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { smoothScrolling: v }); }); }
5933
- })
5934
- ),
5935
- React.createElement(
5936
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Hold Ctrl (or Cmd) and scroll the mouse wheel to zoom the font size' },
5937
- React.createElement('span', { className: 'ide-settings-label' }, 'Ctrl+scroll to zoom'),
5938
- React.createElement('input', {
5939
- type: 'checkbox',
5940
- className: 'ide-settings-checkbox',
5941
- checked: !!(editorPrefs.mouseWheelZoom),
5942
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { mouseWheelZoom: v }); }); }
5943
- })
5944
- ),
5945
-
5946
- /* ── Behaviour ───────────────────────────────── */
5947
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Behaviour'),
5948
- React.createElement(
5949
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'How aggressively the editor re-indents lines as you type' },
5950
- React.createElement('span', { className: 'ide-settings-label' }, 'Auto indent'),
5951
- React.createElement(
5952
- 'select', {
5953
- value: editorPrefs.autoIndent || 'full',
5954
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { autoIndent: e.target.value }); }); }
5955
- },
5956
- React.createElement('option', { value: 'none' }, 'None (disabled)'),
5957
- React.createElement('option', { value: 'keep' }, 'Keep current level'),
5958
- React.createElement('option', { value: 'brackets' }, 'Indent on { and ['),
5959
- React.createElement('option', { value: 'advanced' }, 'Language indent rules'),
5960
- React.createElement('option', { value: 'full' }, 'Full (language grammar)')
5961
- )
5962
- ),
5963
- React.createElement(
5964
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Whether pressing Enter accepts the highlighted autocomplete suggestion' },
5965
- React.createElement('span', { className: 'ide-settings-label' }, 'Accept suggestion on Enter'),
5966
- React.createElement(
5967
- 'select', {
5968
- value: editorPrefs.acceptSuggestionOnEnter || 'on',
5969
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { acceptSuggestionOnEnter: e.target.value }); }); }
5970
- },
5971
- React.createElement('option', { value: 'on' }, 'Always'),
5972
- React.createElement('option', { value: 'smart' }, 'Only when navigated (↑↓)'),
5973
- React.createElement('option', { value: 'off' }, 'Never (Tab only)')
5974
- )
5975
- ),
5976
- React.createElement(
5977
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Suggest completions based on words already present in open files' },
5978
- React.createElement('span', { className: 'ide-settings-label' }, 'Word-based suggestions'),
5979
- React.createElement(
5980
- 'select', {
5981
- value: editorPrefs.wordBasedSuggestions || 'matchingDocuments',
5982
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { wordBasedSuggestions: e.target.value }); }); }
5983
- },
5984
- React.createElement('option', { value: 'off' }, 'Off'),
5985
- React.createElement('option', { value: 'currentDocument' }, 'Current file only'),
5986
- React.createElement('option', { value: 'matchingDocuments' }, 'Same language files'),
5987
- React.createElement('option', { value: 'allDocuments' }, 'All open files')
5988
- )
5989
- ),
5990
- React.createElement(
5991
- '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 {})' },
5992
- React.createElement('span', { className: 'ide-settings-label' }, 'Format on type'),
5993
- React.createElement('input', {
5994
- type: 'checkbox',
5995
- className: 'ide-settings-checkbox',
5996
- checked: editorPrefs.formatOnType === true,
5997
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { formatOnType: v }); }); }
5998
- })
5999
- ),
6000
- React.createElement(
6001
- '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' },
6002
- React.createElement('span', { className: 'ide-settings-label' }, 'Format on save'),
6003
- React.createElement('input', {
6004
- type: 'checkbox',
6005
- className: 'ide-settings-checkbox',
6006
- checked: editorPrefs.formatOnSave === true,
6007
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { formatOnSave: v }); }); }
6008
- })
6009
- ),
6010
- React.createElement(
6011
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show autocomplete suggestions while typing (not just on trigger characters like .)' },
6012
- React.createElement('span', { className: 'ide-settings-label' }, 'Quick suggestions'),
6013
- React.createElement('input', {
6014
- type: 'checkbox',
6015
- className: 'ide-settings-checkbox',
6016
- checked: editorPrefs.quickSuggestions !== false,
6017
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { quickSuggestions: v }); }); }
6018
- })
6019
- ),
6020
-
6021
- /* ── Formatting (Prettier) ───────────────────── */
6022
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Formatting'),
6023
- React.createElement(
6024
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Prettier: maximum line length before wrapping (40–200)' },
6025
- React.createElement('span', { className: 'ide-settings-label' }, 'Print width'),
6026
- React.createElement('input', {
6027
- key: String(editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80),
6028
- type: 'number', min: '40', max: '200', step: '1',
6029
- className: 'ide-settings-input',
6030
- defaultValue: editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80,
6031
- onChange: function(e) {
6032
- var v = parseInt(e.target.value, 10);
6033
- if (v >= 40 && v <= 200) setEditorPrefs(function(p) { return Object.assign({}, p, { prettierPrintWidth: v }); });
6034
- },
6035
- onBlur: function(e) {
6036
- var v = parseInt(e.target.value, 10);
6037
- if (isNaN(v) || v < 40 || v > 200) e.target.value = String(editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80);
6038
- }
6039
- })
6040
- ),
6041
- React.createElement(
6042
- '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' },
6043
- React.createElement('span', { className: 'ide-settings-label' }, 'Trailing commas'),
6044
- React.createElement(
6045
- 'select', {
6046
- value: editorPrefs.prettierTrailingComma || 'all',
6047
- onChange: function(e) { setEditorPrefs(function(p) { return Object.assign({}, p, { prettierTrailingComma: e.target.value }); }); }
6048
- },
6049
- React.createElement('option', { value: 'all' }, 'All'),
6050
- React.createElement('option', { value: 'es5' }, 'ES5'),
6051
- React.createElement('option', { value: 'none' }, 'None')
6052
- )
6053
- ),
6054
- React.createElement(
6055
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Prettier: add semicolons at the end of statements' },
6056
- React.createElement('span', { className: 'ide-settings-label' }, 'Semicolons'),
6057
- React.createElement('input', {
6058
- type: 'checkbox',
6059
- className: 'ide-settings-checkbox',
6060
- checked: editorPrefs.prettierSemi !== false,
6061
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierSemi: v }); }); }
6062
- })
6063
- ),
6064
- React.createElement(
6065
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: "Prettier: use single quotes instead of double quotes for strings" },
6066
- React.createElement('span', { className: 'ide-settings-label' }, 'Single quotes'),
6067
- React.createElement('input', {
6068
- type: 'checkbox',
6069
- className: 'ide-settings-checkbox',
6070
- checked: !!editorPrefs.prettierSingleQuote,
6071
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierSingleQuote: v }); }); }
6072
- })
6073
- ),
6074
- React.createElement(
6075
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Prettier: add spaces inside object literal braces, e.g. { a: 1 } vs {a: 1}' },
6076
- React.createElement('span', { className: 'ide-settings-label' }, 'Bracket spacing'),
6077
- React.createElement('input', {
6078
- type: 'checkbox',
6079
- className: 'ide-settings-checkbox',
6080
- checked: editorPrefs.prettierBracketSpacing !== false,
6081
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { prettierBracketSpacing: v }); }); }
6082
- })
6083
- ),
6084
-
6085
- /* ── Interface ───────────────────────────────── */
6086
- React.createElement('div', { className: 'ide-settings-section-header' }, 'Interface'),
6087
- React.createElement(
6088
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Automatically scroll the file explorer to reveal and highlight the file you are editing' },
6089
- React.createElement('span', { className: 'ide-settings-label' }, 'Explorer follows active file'),
6090
- React.createElement('input', {
6091
- type: 'checkbox',
6092
- className: 'ide-settings-checkbox',
6093
- checked: !!(editorPrefs.autoRevealInExplorer),
6094
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { autoRevealInExplorer: v }); }); }
6095
- })
6096
- ),
6097
- React.createElement(
6098
- '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' },
6099
- React.createElement('span', { className: 'ide-settings-label' }, 'Explorer type-ahead'),
6100
- React.createElement('input', {
6101
- type: 'checkbox',
6102
- className: 'ide-settings-checkbox',
6103
- checked: !!(editorPrefs.fileTreeTypeahead !== false),
6104
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { fileTreeTypeahead: v }); }); }
6105
- })
6106
- ),
6107
- React.createElement(
6108
- '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' },
6109
- React.createElement('span', { className: 'ide-settings-label' }, 'Show dotfiles'),
6110
- React.createElement('input', {
6111
- type: 'checkbox',
6112
- className: 'ide-settings-checkbox',
6113
- checked: !!(editorPrefs.showDotFiles),
6114
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { showDotFiles: v }); }); }
6115
- })
6116
- ),
6117
- React.createElement(
6118
- 'label', { className: 'ide-settings-row ide-settings-row-half', title: 'Scroll: tabs overflow horizontally with a scrollbar; Wrap: tabs flow onto multiple rows' },
6119
- React.createElement('span', { className: 'ide-settings-label' }, 'Tab bar layout'),
6120
- React.createElement(
6121
- 'select', {
6122
- value: editorPrefs.tabDisplayMode || 'scroll',
6123
- onChange: function(e) { var v = e.target.value; setEditorPrefs(function(p) { return Object.assign({}, p, { tabDisplayMode: v }); }); }
6124
- },
6125
- React.createElement('option', { value: 'scroll' }, 'Scroll'),
6126
- React.createElement('option', { value: 'wrap' }, 'Wrap (multi-row)')
6127
- )
6128
- ),
6129
- React.createElement(
6130
- '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' },
6131
- React.createElement('span', { className: 'ide-settings-label' }, 'Quick Open: show folders'),
6132
- React.createElement('input', {
6133
- type: 'checkbox',
6134
- className: 'ide-settings-checkbox',
6135
- checked: !!(editorPrefs.quickOpenShowFolders),
6136
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { quickOpenShowFolders: v }); }); }
6137
- })
6138
- ),
6139
- React.createElement(
6140
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Hide toolbar button labels and show only icons, giving more horizontal space' },
6141
- React.createElement('span', { className: 'ide-settings-label' }, 'Toolbar: icons only'),
6142
- React.createElement('input', {
6143
- type: 'checkbox',
6144
- className: 'ide-settings-checkbox',
6145
- // The stored preference, not the derived value: at a
6146
- // narrow width the box would otherwise show as checked
6147
- // and unchecking it would appear to do nothing.
6148
- checked: !!(editorPrefs.toolbarIconOnly),
6149
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { toolbarIconOnly: v }); }); }
6150
- })
6151
- ),
6152
-
6153
- React.createElement(
6154
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Keep the search/replace text when switching between files in the editor' },
6155
- React.createElement('span', { className: 'ide-settings-label' }, 'Persist find state across files'),
6156
- React.createElement('input', {
6157
- type: 'checkbox',
6158
- className: 'ide-settings-checkbox',
6159
- checked: editorPrefs.persistFindState !== false,
6160
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { persistFindState: v }); }); }
6161
- })
6162
- ),
6163
- React.createElement(
6164
- '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.' },
6165
- React.createElement('span', { className: 'ide-settings-label' }, 'Restore tabs on branch switch'),
6166
- React.createElement('input', {
6167
- type: 'checkbox',
6168
- className: 'ide-settings-checkbox',
6169
- checked: editorPrefs.branchStateRestore !== false,
6170
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { branchStateRestore: v }); }); }
6171
- })
6172
- ),
6173
-
6174
- React.createElement(
6175
- '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' },
6176
- React.createElement('span', { className: 'ide-settings-label' }, 'Controller route hints'),
6177
- React.createElement('input', {
6178
- type: 'checkbox',
6179
- className: 'ide-settings-checkbox',
6180
- checked: editorPrefs.routeHints !== false,
6181
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { routeHints: v }); }); }
6182
- })
6183
- ),
6184
-
6185
- /* ── RuboCop ─────────────────────────────────── */
6186
- React.createElement('div', { className: 'ide-settings-section-header' }, 'RuboCop'),
6187
- React.createElement(
6188
- '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' },
6189
- React.createElement('span', { className: 'ide-settings-label' }, 'Enable RuboCop linting'),
6190
- React.createElement('input', {
6191
- type: 'checkbox',
6192
- className: 'ide-settings-checkbox',
6193
- checked: editorPrefs.rubocopLintEnabled !== false,
6194
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { rubocopLintEnabled: v }); }); }
6195
- })
6196
- ),
6197
- rubocopAvailable && rubocopConfigPath ? React.createElement(
6198
- 'div', { className: 'ide-settings-row ide-settings-row-link' },
6199
- React.createElement('span', { className: 'ide-settings-label' }, 'Config file'),
6200
- React.createElement(
6201
- 'button', {
6202
- type: 'button',
6203
- className: 'ide-settings-config-link',
6204
- title: 'Open ' + rubocopConfigPath,
6205
- onClick: function() { handleSelectFile(rubocopConfigPath, rubocopConfigPath.split('/').pop()); }
6206
- },
6207
- React.createElement('i', { className: 'fas fa-file-alt', style: { marginRight: 5 } }),
6208
- rubocopConfigPath
6209
- )
6210
- ) : null,
6211
- React.createElement(
6212
- 'button',
6213
- {
6214
- className: 'ide-settings-reset-btn',
6215
- type: 'button',
6216
- title: 'Restore every editor preference on this page to its default',
6217
- onClick: function() { setEditorPrefs(Object.assign({}, DEFAULT_EDITOR_PREFS)); }
6218
- },
6219
- React.createElement('i', { className: 'fas fa-undo', style: { marginRight: 6 } }),
6220
- 'Reset to defaults'
6221
- )
6222
- )
6223
- );
6224
6116
  } else if (pActiveTab.isDiff) {
6225
6117
  var _t = editorPrefs.theme || 'vs-dark';
6226
6118
  var isDiffDark = _t !== 'vs' && _t !== 'hc-light' && _t !== 'github-light';
@@ -6240,17 +6132,14 @@ var MbeditorApp = function MbeditorApp() {
6240
6132
  paneId: pane.id,
6241
6133
  markers: markers[pActiveTab.id] || [],
6242
6134
  gitAvailable: gitAvailable,
6243
- testAvailable: testAvailable,
6244
6135
  treeData: treeData,
6245
6136
  testResult: testResult,
6246
6137
  testPanelFile: testPanelFile,
6247
- testLoading: testLoading,
6248
6138
  testInlineVisible: testInlineVisible,
6249
6139
  editorPrefs: editorPrefs,
6250
6140
  monacoReady: monacoReady,
6251
6141
  onFormat: function() { onFormatRef.current(); },
6252
6142
  onSave: function() { handleSave(pane.id, pActiveTab); },
6253
- onRunTest: handleRunTest,
6254
6143
  onRunTestAtCursor: handleRunTestAtCursor,
6255
6144
  onShowHistory: function(path) { setHistoryPanelPath(path); },
6256
6145
  onContentChange: function onContentChange(val) {
@@ -6272,9 +6161,12 @@ var MbeditorApp = function MbeditorApp() {
6272
6161
  return React.createElement(
6273
6162
  React.Fragment,
6274
6163
  { key: pane.id },
6275
- idx === 1 && isSplit && React.createElement("div", {
6276
- className: "panel-divider pane-divider " + (activeResizeMode === 'pane' ? 'active' : ''),
6277
- 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
6278
6170
  }),
6279
6171
  React.createElement(
6280
6172
  "div",
@@ -6282,10 +6174,6 @@ var MbeditorApp = function MbeditorApp() {
6282
6174
  className: "ide-pane " + (isFocused ? 'focused' : '') + " " + (isDropTarget ? 'drop-target' : ''),
6283
6175
  style: { flexBasis: flexBasis, flexShrink: 0, flexGrow: 0, display: 'flex', flexDirection: 'column', minWidth: 0 },
6284
6176
  onClickCapture: function (e) {
6285
- // Do not steal click events from controls inside the Settings tab.
6286
- // Focusing the pane in capture phase can rerender before checkbox
6287
- // change events are processed, making toggles appear stuck.
6288
- if (e.target && e.target.closest && e.target.closest('.ide-settings-tab-content')) return;
6289
6177
  return TabManager.focusPane(pane.id);
6290
6178
  },
6291
6179
  onDragOver: function (e) {
@@ -6427,40 +6315,54 @@ var MbeditorApp = function MbeditorApp() {
6427
6315
  );
6428
6316
  })
6429
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
+ }),
6430
6324
  showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
6325
+ height: logHeight,
6431
6326
  onClose: function () { setShowLogPanel(false); }
6432
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
+ }),
6433
6336
  showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
6337
+ height: problemsHeight,
6434
6338
  onClose: function () { setShowProblemsPanel(false); },
6435
- onOpenFile: function (path, line, col) {
6436
- handleSelectFile(path, path.split('/').pop(), line, col);
6437
- }
6438
- }),
6439
- showTestRunPanel && !zenMode && React.createElement(window.TestRunPanel, {
6440
- onClose: function () { setShowTestRunPanel(false); },
6441
6339
  onOpenFile: function (path, line, col) {
6442
6340
  handleSelectFile(path, path.split('/').pop(), line, col);
6443
6341
  },
6444
- // A suite result spans many files, so there is no single testPanelFile
6445
- // for it — each entry carries its own, and EditorPanel matches per
6446
- // entry. Clearing it here keeps the per-file modal from labelling a
6447
- // suite result with a file it did not come from.
6448
- onResult: function (res) {
6449
- setTestResult(res);
6450
- setTestPanelFile(null);
6451
- }
6452
- })
6342
+ // `rubocop -a` writes through a subprocess, so the server's
6343
+ // files_changed broadcast is the only notice — and there is no
6344
+ // broadcast at all without a cable connection. Re-read every open tab
6345
+ // here too: clean tabs take the corrected text (and re-lint), dirty
6346
+ // ones get the usual reload prompt instead of being silently clobbered.
6347
+ onFilesRewritten: function () { checkOpenTabsForExternalChanges(); }
6348
+ }),
6453
6349
  ),
6454
6350
 
6455
6351
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
6456
- showGitPanel && !zenMode && React.createElement("div", {
6457
- className: "panel-divider gitpanel-divider " + (activeResizeMode === 'gitpanel' ? 'active' : ''),
6458
- onMouseDown: startGitPanelResize,
6459
- role: "separator",
6460
- "aria-orientation": "vertical",
6461
- "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); }
6357
+ }),
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
6462
6364
  }),
6463
- showGitPanel && !zenMode && React.createElement(
6365
+ showGitPanel && !zenMode && !showModelGraphView && React.createElement(
6464
6366
  "div",
6465
6367
  { className: "ide-git-right-panel", style: { width: gitPanelWidth + "px" } },
6466
6368
  React.createElement(window.GitPanel || GitPanel, {
@@ -6476,6 +6378,19 @@ var MbeditorApp = function MbeditorApp() {
6476
6378
  })
6477
6379
  ),
6478
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
+ }),
6479
6394
  React.createElement(
6480
6395
  "div",
6481
6396
  { className: "ide-statusbar" },
@@ -6509,45 +6424,17 @@ var MbeditorApp = function MbeditorApp() {
6509
6424
  },
6510
6425
  React.createElement("i", { className: "fas fa-bug statusbar-problems-error-icon" }),
6511
6426
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.errors),
6512
- React.createElement("i", {
6513
- className: "fas fa-exclamation-triangle statusbar-problems-warning-icon",
6514
- style: { marginLeft: "8px" }
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
  ),
6518
- // Gated on the same probe as the per-file Test button: a project with no
6519
- // test directory has nothing for this to run.
6520
- testAvailable && React.createElement(
6521
- "button",
6522
- {
6523
- type: "button",
6524
- className: "statusbar-btn statusbar-testrun" + (showTestRunPanel ? " active" : ""),
6525
- onClick: toggleTestRunPanel,
6526
- title: "Run the whole test suite"
6527
- },
6528
- React.createElement("i", { className: "fas fa-flask" }),
6529
- React.createElement("span", null, " Tests")
6530
- ),
6531
- // ruby-lsp indicator. Hidden entirely when ruby-lsp was never available
6532
- // and nothing has gone wrong — a permanent "off" badge in a project with
6533
- // no Ruby is noise. A healthy server gets a quiet icon; a degraded one
6534
- // gets an amber chip you can click to restart.
6535
- (lspHealth.status !== 'off' || lspHealth.reason) && React.createElement(
6536
- "button",
6430
+ migrationsPending && React.createElement(
6431
+ "div",
6537
6432
  {
6538
- type: "button",
6539
- className: "statusbar-btn statusbar-lsp statusbar-lsp-" + lspHealth.status,
6540
- onClick: restartRubyLsp,
6541
- title: lspHealth.status === 'ok'
6542
- ? 'ruby-lsp is running — click to restart'
6543
- : 'ruby-lsp unavailable' + (lspHealth.reason ? ': ' + lspHealth.reason : '') +
6544
- '. Falling back to search-based lookups. Click to retry.'
6433
+ className: "statusbar-migration",
6434
+ title: "Pending migrations \u2014 run `rails db:migrate`. Editing still works in the meantime."
6545
6435
  },
6546
- React.createElement("i", {
6547
- className: "fas " + (lspHealth.status === 'ok' ? 'fa-gem' : 'fa-plug'),
6548
- "aria-hidden": "true"
6549
- }),
6550
- lspHealth.status !== 'ok' && React.createElement("span", null, " ruby-lsp")
6436
+ React.createElement("i", { className: "fas fa-database" }),
6437
+ " Migrations pending"
6551
6438
  ),
6552
6439
  !serverOnline && (function () {
6553
6440
  var dirtyCount = state.panes.reduce(function (acc, p) {
@@ -6608,6 +6495,7 @@ var MbeditorApp = function MbeditorApp() {
6608
6495
  },
6609
6496
  React.createElement("i", { className: "fas fa-paragraph" })
6610
6497
  ),
6498
+ React.createElement(CursorPosition, null),
6611
6499
  activeEOL && React.createElement(
6612
6500
  "button",
6613
6501
  {
@@ -6820,6 +6708,7 @@ var MbeditorApp = function MbeditorApp() {
6820
6708
  "div",
6821
6709
  {
6822
6710
  className: "context-menu",
6711
+ ref: contextMenuRef,
6823
6712
  style: { left: contextMenu.x, top: contextMenu.y },
6824
6713
  onClick: function (e) {
6825
6714
  return e.stopPropagation();
@@ -6864,16 +6753,16 @@ var MbeditorApp = function MbeditorApp() {
6864
6753
  return handleContextMenuAction('delete');
6865
6754
  } },
6866
6755
  React.createElement("i", { className: "far fa-trash-alt context-menu-icon" }),
6867
- " Delete"
6756
+ " " + deleteMenuLabel(contextMenu.node)
6868
6757
  ),
6869
6758
  React.createElement("div", { className: "context-menu-divider" }),
6870
- contextMenu.node && contextMenu.node.type === 'file' && React.createElement(
6759
+ contextMenu.node && React.createElement(
6871
6760
  "div",
6872
6761
  { className: "context-menu-item", onClick: function () {
6873
6762
  return handleContextMenuAction('download');
6874
6763
  } },
6875
6764
  React.createElement("i", { className: "fas fa-download context-menu-icon" }),
6876
- " Download"
6765
+ " " + downloadMenuLabel(contextMenu.node)
6877
6766
  ),
6878
6767
  React.createElement(
6879
6768
  "div",
@@ -7067,4 +6956,6 @@ var MbeditorApp = function MbeditorApp() {
7067
6956
  };
7068
6957
 
7069
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 };
7070
6961
  /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */