mbeditor 0.12.9 → 0.13.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +155 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. metadata +8 -2
@@ -10,6 +10,7 @@ var _React = React;
10
10
  var useState = _React.useState;
11
11
  var useEffect = _React.useEffect;
12
12
  var useRef = _React.useRef;
13
+ var useMemo = _React.useMemo;
13
14
 
14
15
  // Functional setTreeData updater shared by every path that re-fetches the tree
15
16
  // (WebSocket push, the 10s poll, the manual refresh button).
@@ -109,7 +110,6 @@ var DEFAULT_EDITOR_PREFS = {
109
110
  autoClosingBrackets: 'always',
110
111
  autoClosingQuotes: 'always',
111
112
  autoIndent: 'full',
112
- indentOnPaste: true,
113
113
  formatOnType: false,
114
114
  formatOnSave: false,
115
115
  quickSuggestions: true,
@@ -118,6 +118,7 @@ var DEFAULT_EDITOR_PREFS = {
118
118
  autoRevealInExplorer: true,
119
119
  toolbarIconOnly: false,
120
120
  rubocopLintEnabled: true,
121
+ routeHints: true,
121
122
  prettierPrintWidth: 80,
122
123
  prettierSemi: true,
123
124
  prettierSingleQuote: false,
@@ -169,18 +170,32 @@ var SidebarActionButton = function SidebarActionButton(_ref) {
169
170
  var _ref$ariaBusy = _ref.ariaBusy;
170
171
  var ariaBusy = _ref$ariaBusy === undefined ? false : _ref$ariaBusy;
171
172
 
173
+ // The tooltip lives on a wrapper, not on the button, and is drawn by CSS
174
+ // rather than the title attribute. Both parts are load-bearing:
175
+ //
176
+ // The host app's Pico CSS sets `pointer-events: none` on [disabled], so a
177
+ // disabled button never receives hover and a native title never appears at
178
+ // all — and Rename/Delete are disabled whenever nothing is selected, which
179
+ // is most of the time. Hanging the tooltip on an always-enabled wrapper is
180
+ // what keeps it working in that state.
181
+ //
182
+ // Native titles are also ~1s late, which is already why .collab-hovercard
183
+ // exists rather than a title on the collab chip.
172
184
  return React.createElement(
173
- "button",
174
- {
175
- type: "button",
176
- className: "project-action-btn" + (danger ? " danger" : ""),
177
- title: title,
178
- "aria-label": ariaLabel || title,
179
- "aria-busy": !!ariaBusy,
180
- onClick: onClick,
181
- disabled: !!disabled
182
- },
183
- !ariaBusy && React.createElement("i", { className: iconClass })
185
+ "span",
186
+ { className: "sb-tip", "data-tip": title },
187
+ React.createElement(
188
+ "button",
189
+ {
190
+ type: "button",
191
+ className: "project-action-btn" + (danger ? " danger" : ""),
192
+ "aria-label": ariaLabel || title,
193
+ "aria-busy": !!ariaBusy,
194
+ onClick: onClick,
195
+ disabled: !!disabled
196
+ },
197
+ !ariaBusy && React.createElement("i", { className: iconClass })
198
+ )
184
199
  );
185
200
  };
186
201
 
@@ -201,6 +216,23 @@ var SectionActionGroup = function SectionActionGroup(_ref2) {
201
216
  );
202
217
  };
203
218
 
219
+ // Split a search hit into [before, match, after] so the match can be tinted
220
+ // and pinned on screen. `col`/`end_col` are 1-based against the RAW line while
221
+ // the row renders the stripped `text`, so `lead` — the characters strip took
222
+ // off the front — is what maps one onto the other. Returns null for the tiers
223
+ // and queries that produce no columns; the row then renders as plain text.
224
+ function searchMatchParts(res) {
225
+ var text = res.text == null ? "" : String(res.text);
226
+ if (!res.col || !res.end_col) return null;
227
+ var start = res.col - 1 - (res.lead || 0);
228
+ var end = Math.min(res.end_col - 1 - (res.lead || 0), text.length);
229
+ if (!(start >= 0 && end > start && start < text.length)) return null;
230
+ // U+200E: a strong LTR character, so the left-ellipsis trick in
231
+ // .search-result-pre (direction: rtl) can never reorder a segment that
232
+ // happens to be all punctuation.
233
+ return ["‎" + text.slice(0, start), text.slice(start, end), text.slice(end)];
234
+ }
235
+
204
236
  function FileReloadBanner(_ref) {
205
237
  var pendingReloads = _ref.pendingReloads;
206
238
  var onSaveAndReload = _ref.onSaveAndReload;
@@ -347,6 +379,67 @@ var MbeditorApp = function MbeditorApp() {
347
379
  var searchWholeWordRef = useRef(false);
348
380
  var searchResultsContainerRef = useRef(null);
349
381
 
382
+ // Search results are windowed: a project-wide query can return up to
383
+ // SearchReplaceService::MAX_RESULTS (10_000) rows, and every row is five
384
+ // elements, so rendering the list in full built ~50k nodes — enough to kill
385
+ // the tab outright, and 92 ms of render for a mere 3_000 rows. Rows are a
386
+ // fixed 22px, which is what makes the arithmetic here as simple as the file
387
+ // tree's. The results are a VS Code-style tree — a header row per file, its
388
+ // matches nested under it — but header and match rows are deliberately the
389
+ // *same* height (.search-result-file-row and .search-result-item both pin
390
+ // it), so the flattened row array still windows by plain multiplication.
391
+ // Give the two rows different heights and every offset here is wrong.
392
+ var SEARCH_ROW_HEIGHT = 22;
393
+ var SEARCH_ROW_BUFFER = 5;
394
+
395
+ // File paths whose match list is folded away. Keyed by path, so a file that
396
+ // scrolls out of the window keeps its state.
397
+ var _useStateSC = useState({});
398
+ var _useStateSC2 = _slicedToArray(_useStateSC, 2);
399
+ var searchCollapsedFiles = _useStateSC2[0];
400
+ var setSearchCollapsedFiles = _useStateSC2[1];
401
+ var toggleSearchFile = function toggleSearchFile(file) {
402
+ setSearchCollapsedFiles(function (prev) {
403
+ var next = Object.assign({}, prev);
404
+ if (next[file]) delete next[file]; else next[file] = true;
405
+ return next;
406
+ });
407
+ };
408
+ var _useStateSV = useState({ scrollTop: 0, height: 0 });
409
+ var _useStateSV2 = _slicedToArray(_useStateSV, 2);
410
+ var searchViewport = _useStateSV2[0];
411
+ var setSearchViewport = _useStateSV2[1];
412
+
413
+ // The container's height is only known once it is on screen, and a viewport
414
+ // of 0 would render a single row and never grow — nothing would scroll, so
415
+ // no scroll event would arrive to correct it.
416
+ //
417
+ // Measured from a callback ref rather than an effect: the results list
418
+ // mounts and unmounts as the sidebar tab changes and as a query goes from
419
+ // no-results to results, none of which an effect's dependency list sees.
420
+ // Same reason the model graph wires its pan/zoom this way.
421
+ var searchViewportObserverRef = useRef(null);
422
+ var measureSearchViewport = function measureSearchViewport(el) {
423
+ if (!el) return;
424
+ setSearchViewport(function (prev) {
425
+ if (prev.height === el.clientHeight && prev.scrollTop === el.scrollTop) return prev;
426
+ return { scrollTop: el.scrollTop, height: el.clientHeight };
427
+ });
428
+ };
429
+ var attachSearchResults = useRef(function (el) {
430
+ if (searchViewportObserverRef.current) {
431
+ searchViewportObserverRef.current.disconnect();
432
+ searchViewportObserverRef.current = null;
433
+ }
434
+ searchResultsContainerRef.current = el;
435
+ if (!el) return;
436
+ measureSearchViewport(el);
437
+ if (typeof ResizeObserver === 'undefined') return;
438
+ var obs = new ResizeObserver(function () { measureSearchViewport(el); });
439
+ obs.observe(el);
440
+ searchViewportObserverRef.current = obs;
441
+ }).current;
442
+
350
443
  var _useStateRM = useState(false);
351
444
  var _useStateRM2 = _slicedToArray(_useStateRM, 2);
352
445
  var replaceMode = _useStateRM2[0];
@@ -408,6 +501,12 @@ var MbeditorApp = function MbeditorApp() {
408
501
  var importConflict = _useStateImportConflict2[0];
409
502
  var setImportConflict = _useStateImportConflict2[1];
410
503
 
504
+ // { initialFolder } while the upload dialog is open, null otherwise.
505
+ var _useStateImportDialog = useState(null);
506
+ var _useStateImportDialog2 = _slicedToArray(_useStateImportDialog, 2);
507
+ var importDialog = _useStateImportDialog2[0];
508
+ var setImportDialog = _useStateImportDialog2[1];
509
+
411
510
  var _useStateSchemaLoading = useState(null);
412
511
  var _useStateSchemaLoading2 = _slicedToArray(_useStateSchemaLoading, 2);
413
512
  var schemaLoadingLabel = _useStateSchemaLoading2[0];
@@ -544,13 +643,6 @@ var MbeditorApp = function MbeditorApp() {
544
643
  var modelGraphLoading = _useStateModelGraphLoading2[0];
545
644
  var setModelGraphLoading = _useStateModelGraphLoading2[1];
546
645
 
547
- // ruby-lsp status for the status-bar chip. 'off' means never available here,
548
- // 'degraded' means we backed off after a failure, 'ok' means it's answering.
549
- var _useStateLspHealth = useState({ status: 'off', reason: null });
550
- var _useStateLspHealth2 = _slicedToArray(_useStateLspHealth, 2);
551
- var lspHealth = _useStateLspHealth2[0];
552
- var setLspHealth = _useStateLspHealth2[1];
553
-
554
646
  var _useState18g = useState(320);
555
647
  var _useState18g2 = _slicedToArray(_useState18g, 2);
556
648
  var gitPanelWidth = _useState18g2[0];
@@ -606,11 +698,6 @@ var MbeditorApp = function MbeditorApp() {
606
698
  var rubocopConfigPath = _useState18rc2[0];
607
699
  var setRubocopConfigPath = _useState18rc2[1];
608
700
 
609
- var _useState18t = useState(false);
610
- var _useState18t2 = _slicedToArray(_useState18t, 2);
611
- var testAvailable = _useState18t2[0];
612
- var setTestAvailable = _useState18t2[1];
613
-
614
701
  var _useState18u = useState(null);
615
702
  var _useState18u2 = _slicedToArray(_useState18u, 2);
616
703
  var testResult = _useState18u2[0];
@@ -757,6 +844,11 @@ var MbeditorApp = function MbeditorApp() {
757
844
  if (typeof content === 'string') {
758
845
  lastDiskContentRef.current[path] = content.replace(/\r\n/g, '\n');
759
846
  }
847
+ // The file on disk just moved, so any line-diff answer we are holding for
848
+ // it predates the write and must not be reused for the tint.
849
+ if (typeof GitService !== 'undefined' && GitService.invalidateLineDiff) {
850
+ GitService.invalidateLineDiff(path);
851
+ }
760
852
  }
761
853
 
762
854
  // ── Draft backup helpers ─────────────────────────────────────────────────
@@ -821,6 +913,13 @@ var MbeditorApp = function MbeditorApp() {
821
913
  });
822
914
  };
823
915
 
916
+ // filterDotFiles rebuilds the whole tree, so calling it inline in the render
917
+ // handed FileTreeMemo a fresh array every time and its `prev.items ===
918
+ // next.items` check never held — the memo was there but never fired.
919
+ var fileTreeItems = useMemo(function () {
920
+ return editorPrefs.showDotFiles ? treeData : filterDotFiles(treeData || []);
921
+ }, [treeData, editorPrefs.showDotFiles]);
922
+
824
923
  var normalizeRelativePath = function normalizeRelativePath(input) {
825
924
  return (input || "").replace(/\\/g, "/").trim().replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/+/g, "/");
826
925
  };
@@ -863,10 +962,22 @@ var MbeditorApp = function MbeditorApp() {
863
962
  // cached page was served for the same query. The per-path delta refresh on
864
963
  // the files_changed push can't cover it either: it re-scans named files,
865
964
  // and a file that just appeared or vanished isn't in the previous result set.
965
+ // True when the server will push a files_changed broadcast for a write we are
966
+ // about to make, so the coalesced handler will refresh the tree and git for
967
+ // us. Every mutation used to refresh both itself as well, which doubled or
968
+ // tripled the most expensive requests the editor makes. Without a socket
969
+ // nothing arrives, so callers keep their own refresh for that case.
970
+ var _socketWillBroadcast = function _socketWillBroadcast() {
971
+ return typeof WebSocketService !== 'undefined' &&
972
+ typeof WebSocketService.isCableAvailable === 'function' &&
973
+ WebSocketService.isCableAvailable();
974
+ };
975
+
866
976
  var refreshProjectTree = function refreshProjectTree() {
867
977
  return FileService.getTree().then(function (data) {
868
- setTreeData(data || []);
869
- SearchService.buildIndex(data || []);
978
+ // _treeUpdater keeps the previous array (and skips the index rebuild)
979
+ // when the tree is unchanged; going round it re-rendered the whole app.
980
+ setTreeData(_treeUpdater(data || []));
870
981
  SearchService.invalidate();
871
982
  if (searchQueryRef.current && searchPanelVisibleRef.current) {
872
983
  _debouncedSearch(searchQueryRef.current);
@@ -930,73 +1041,18 @@ var MbeditorApp = function MbeditorApp() {
930
1041
  loadModelGraph(false);
931
1042
  }, [activeSidebarTab, sidebarCollapsed]);
932
1043
 
933
- var readLspHealth = function readLspHealth() {
934
- if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) {
935
- return { status: 'off', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
936
- }
937
- if (window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff()) {
938
- return { status: 'degraded', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
939
- }
940
- return { status: 'ok', reason: null };
941
- };
942
-
943
- // The backoff expires on a wall-clock deadline rather than a timer, so the
944
- // chip also re-reads on a slow interval — otherwise it would sit on
945
- // 'degraded' until the next failure or restart click.
1044
+ // The one writer for the markers map. Two things it must not do:
946
1045
  //
947
- // readLspHealth() builds a fresh object every call, so handing it straight to
948
- // setLspHealth re-rendered the whole app every 10 seconds whether or not the
949
- // health had changed React bails on Object.is, and two object literals are
950
- // never identical. Compare the fields and keep the previous object when they
951
- // match. (Same shape of bug as the file-tree poll; see _treeUpdater.)
952
- useEffect(function () {
953
- var sync = function () {
954
- setLspHealth(function (prev) {
955
- var next = readLspHealth();
956
- if (prev && prev.status === next.status && prev.reason === next.reason) return prev;
957
- return next;
958
- });
959
- };
960
- sync();
961
- window.addEventListener('mbeditor:lsp-health', sync);
962
- var tick = setInterval(sync, 10000);
963
- return function () {
964
- window.removeEventListener('mbeditor:lsp-health', sync);
965
- clearInterval(tick);
966
- };
967
- }, []);
968
-
969
- var restartRubyLsp = function restartRubyLsp() {
970
- if (!FileService.rubyLspRequest) return;
971
- EditorStore.setStatus('Restarting ruby-lsp…', 'info');
972
- FileService.rubyLspRequest('restart', '', '', 1, 1).then(function (data) {
973
- var ok = data && data.available && data.state !== 'failed';
974
- if (ok) {
975
- window.MBEDITOR_RUBY_LSP_AVAILABLE = true;
976
- window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL = 0;
977
- window.MBEDITOR_RUBY_LSP_REASON = null;
978
- } else {
979
- window.MBEDITOR_RUBY_LSP_REASON =
980
- (data && (data.reason || data.error)) || 'ruby-lsp did not come back';
981
- }
982
- EditorStore.setStatus(ok ? 'ruby-lsp restarted' : 'ruby-lsp unavailable', ok ? 'success' : 'warning');
983
- setLspHealth(readLspHealth());
984
- })["catch"](function (err) {
985
- noteLspFailure(err);
986
- EditorStore.setStatus('Could not restart ruby-lsp', 'error');
987
- setLspHealth(readLspHealth());
988
- });
989
- };
990
-
991
- var applyMarkersForTab = function applyMarkersForTab(paneId, tabId, nextMarkers) {
992
- var currentPane = EditorStore.getState().panes.find(function (p) {
993
- return p.id === paneId;
994
- });
995
- var current = currentPane ? currentPane.tabs.find(function (t) {
996
- return t.id === tabId;
997
- }) : null;
998
- if (current) current.markers = nextMarkers;
1046
+ // * write a fresh map when nothing changed the auto-lint fires per
1047
+ // keystroke on JS files and re-clearing an already-empty marker list
1048
+ // would re-render the whole app every time;
1049
+ // * mutate the tab object in the store EditorStore's contract is that
1050
+ // every nested value is replaced, never edited in place, or
1051
+ // subscribeToSlice cannot see the change. TabBar reads this map instead.
1052
+ var applyMarkersForTab = function applyMarkersForTab(tabId, nextMarkers) {
999
1053
  setMarkers(function (prev) {
1054
+ var cur = prev[tabId];
1055
+ if (cur && cur.length === 0 && nextMarkers.length === 0) return prev;
1000
1056
  return _extends({}, prev, _defineProperty({}, tabId, nextMarkers));
1001
1057
  });
1002
1058
  };
@@ -1006,6 +1062,20 @@ var MbeditorApp = function MbeditorApp() {
1006
1062
 
1007
1063
  if (!tab || (!isRubyPath(tab.path) && !tab.path.endsWith('.haml'))) return Promise.resolve(null);
1008
1064
 
1065
+ // An empty buffer has no offenses, and answering that here rather than over
1066
+ // the wire skips the most expensive request the Ruby path makes: a
1067
+ // whole-document RuboCop run, budgeted at 10s server-side. It matters
1068
+ // because every newly created .rb file starts empty and is opened
1069
+ // immediately, so the lint fired on a document with nothing in it.
1070
+ // Resolving with an empty marker set (rather than null) keeps the
1071
+ // marker-clearing below intact, so deleting a file's contents still clears
1072
+ // its squiggles.
1073
+ if (!String(tab.content || '').trim()) {
1074
+ applyMarkersForTab(tab.id, []);
1075
+ if (options.showStatus) EditorStore.setStatus('No RuboCop offenses!', 'success');
1076
+ return Promise.resolve({ markers: [] });
1077
+ }
1078
+
1009
1079
  if (options.showLoading) {
1010
1080
  setLoading(function (prev) {
1011
1081
  return _extends({}, prev, { lint: true });
@@ -1037,7 +1107,7 @@ var MbeditorApp = function MbeditorApp() {
1037
1107
 
1038
1108
  return lintRequest.then(function (res) {
1039
1109
  var nextMarkers = res.markers || [];
1040
- applyMarkersForTab(paneId, tab.id, nextMarkers);
1110
+ applyMarkersForTab(tab.id, nextMarkers);
1041
1111
 
1042
1112
  if (options.showStatus) {
1043
1113
  var count = res.summary && res.summary.offense_count || 0;
@@ -1075,16 +1145,7 @@ var MbeditorApp = function MbeditorApp() {
1075
1145
  if (parserName && window.prettier && window.prettierPlugins) {
1076
1146
  var prefs = EditorStore.getState().editorPrefs || DEFAULT_EDITOR_PREFS;
1077
1147
  window.prettier.format(tab.content, prettierOptions(prefs, parserName)).then(function () {
1078
- var currentPane = EditorStore.getState().panes.find(function (p) {
1079
- return p.id === paneId;
1080
- });
1081
- var current = currentPane ? currentPane.tabs.find(function (t) {
1082
- return t.id === tab.id;
1083
- }) : null;
1084
- if (current) current.markers = [];
1085
- setMarkers(function (prev) {
1086
- return _extends({}, prev, _defineProperty({}, tab.id, []));
1087
- });
1148
+ applyMarkersForTab(tab.id, []);
1088
1149
  })["catch"](function (err) {
1089
1150
  var newMarkers = [];
1090
1151
  if (err && err.loc) {
@@ -1101,16 +1162,7 @@ var MbeditorApp = function MbeditorApp() {
1101
1162
  endCol: endLoc ? endLoc.column : loc.column + 1
1102
1163
  });
1103
1164
  }
1104
- var currentPane = EditorStore.getState().panes.find(function (p) {
1105
- return p.id === paneId;
1106
- });
1107
- var current = currentPane ? currentPane.tabs.find(function (t) {
1108
- return t.id === tab.id;
1109
- }) : null;
1110
- if (current) current.markers = newMarkers;
1111
- setMarkers(function (prev) {
1112
- return _extends({}, prev, _defineProperty({}, tab.id, newMarkers));
1113
- });
1165
+ applyMarkersForTab(tab.id, newMarkers);
1114
1166
  });
1115
1167
  }
1116
1168
  }, 600)).current;
@@ -1168,8 +1220,8 @@ var MbeditorApp = function MbeditorApp() {
1168
1220
  if (workspace && typeof workspace.redmineEnabled === 'boolean') {
1169
1221
  setRedmineEnabled(workspace.redmineEnabled);
1170
1222
  }
1171
- if (workspace && typeof workspace.testAvailable === 'boolean') {
1172
- setTestAvailable(workspace.testAvailable);
1223
+ if (workspace && workspace.testTimeout) {
1224
+ FileService.setTestTimeout(workspace.testTimeout);
1173
1225
  }
1174
1226
  if (workspace && typeof workspace.actionCableEnabled === 'boolean') {
1175
1227
  WebSocketService.connect(workspace.actionCableEnabled);
@@ -1683,8 +1735,32 @@ var MbeditorApp = function MbeditorApp() {
1683
1735
  // WebSocket push — when the server broadcasts files_changed, refresh the tree
1684
1736
  // and git status immediately (same work as the 10s poll below does).
1685
1737
  useEffect(function () {
1686
- function handleFilesChanged(payload) {
1687
- if (document.hidden) return;
1738
+ // One broadcast per written file, so a bulk write (Save All, Format All,
1739
+ // a drag-and-drop import, a rename) used to cost one full tree walk and
1740
+ // one git status PER FILE, all in flight at once. On a real host repo
1741
+ // those are the two most expensive requests the editor makes, and with
1742
+ // the browser's six sockets per host the saves themselves ended up queued
1743
+ // behind their own fallout — past ~30 files the POSTs hit the 30 s axios
1744
+ // timeout and Save All reported failure for writes that had succeeded.
1745
+ // The refresh is idempotent, so a burst only needs one of each.
1746
+ var timer = null;
1747
+ var pendingPaths = [];
1748
+ var pendingFullCheck = false;
1749
+ var pendingStructural = false;
1750
+
1751
+ function flush() {
1752
+ timer = null;
1753
+ // The named paths and "re-check everything" are two separate questions.
1754
+ // Collapsing them lost the paths whenever a path-less broadcast (a new
1755
+ // directory, say) landed in the same window as a save, and the saved
1756
+ // file's search rows then stayed stale until the next edit.
1757
+ var paths = pendingPaths;
1758
+ var fullCheck = pendingFullCheck;
1759
+ var structural = pendingStructural;
1760
+ pendingPaths = [];
1761
+ pendingFullCheck = false;
1762
+ pendingStructural = false;
1763
+ var tabsToCheck = (fullCheck || paths.length === 0) ? null : paths;
1688
1764
  // The cheap /git_status probe, not the full /git_info fan-out. This
1689
1765
  // fires on every save, and the fan-out is the most expensive request
1690
1766
  // the editor makes — on a dev server with a handful of threads it
@@ -1693,17 +1769,75 @@ var MbeditorApp = function MbeditorApp() {
1693
1769
  // branch and file list immediately and escalates to the fan-out on its
1694
1770
  // own when the branch actually changed.
1695
1771
  GitService.fetchStatusLite({ background: true })["catch"](function () {});
1696
- FileService.getTree().then(function (data) {
1697
- setTreeData(_treeUpdater(data || []));
1698
- checkOpenTabsForExternalChanges(payload && payload.paths);
1699
- })["catch"](function () {});
1700
- if (payload && payload.paths && searchQueryRef.current && searchPanelVisibleRef.current) {
1701
- payload.paths.forEach(function (p) { _pendingSearchRefreshPaths.current.add(p); });
1772
+
1773
+ // A save changes a file's contents, not the shape of the workspace, so
1774
+ // there is nothing in the tree for it to invalidate: the names, the
1775
+ // nesting and the quick-open index are all exactly as they were. Walking
1776
+ // the whole workspace to learn that and then rebuilding the MiniSearch
1777
+ // index from the result, because the file's byte count moved and made
1778
+ // the payload compare unequal — was pure cost on the save path, and it
1779
+ // grew with the size of the checkout rather than with the edit.
1780
+ // Git badges come from git_status above, not from this payload.
1781
+ if (structural) {
1782
+ FileService.getTree().then(function (data) {
1783
+ setTreeData(_treeUpdater(data || []));
1784
+ checkOpenTabsForExternalChanges(tabsToCheck);
1785
+ })["catch"](function () {});
1786
+ } else {
1787
+ checkOpenTabsForExternalChanges(tabsToCheck);
1788
+ }
1789
+
1790
+ if (paths.length && searchQueryRef.current && searchPanelVisibleRef.current) {
1791
+ paths.forEach(function (p) { _pendingSearchRefreshPaths.current.add(p); });
1702
1792
  _flushSearchRefresh();
1703
1793
  }
1704
1794
  }
1795
+
1796
+ function handleFilesChanged(payload) {
1797
+ if (document.hidden) return;
1798
+ // No paths named means "something changed, re-check everything" — it
1799
+ // must not be swallowed by a burst that did name paths.
1800
+ if (!payload || !payload.paths) pendingFullCheck = true;
1801
+ else pendingPaths = pendingPaths.concat(payload.paths);
1802
+ // Absent (an older server) counts as structural — the conservative read.
1803
+ if (!payload || payload.structural !== false) pendingStructural = true;
1804
+ if (timer) return;
1805
+ timer = setTimeout(flush, 200);
1806
+ }
1807
+ // Deferring the refresh means it can now come due after the page has begun
1808
+ // going away. Requests issued into a closing page are aborted mid-flight,
1809
+ // which wastes a tree walk and leaves the server holding a connection that
1810
+ // never completes.
1811
+ var cancelPending = function () {
1812
+ if (timer) clearTimeout(timer);
1813
+ timer = null;
1814
+ };
1815
+ window.addEventListener('pagehide', cancelPending);
1816
+
1705
1817
  WebSocketService.onFilesChanged(handleFilesChanged);
1706
- return function () { WebSocketService.offFilesChanged(handleFilesChanged); };
1818
+ return function () {
1819
+ cancelPending();
1820
+ window.removeEventListener('pagehide', cancelPending);
1821
+ WebSocketService.offFilesChanged(handleFilesChanged);
1822
+ };
1823
+ }, []);
1824
+
1825
+ // A branch switch changes every tracked file at once, and no push announces
1826
+ // it — the broadcast only covers mbeditor's own writes. Re-read the tree and
1827
+ // every open tab, the same work a manual workspace refresh does. Clean tabs
1828
+ // take the new branch's content; dirty ones queue the usual reload prompt,
1829
+ // so unsaved work is never overwritten.
1830
+ useEffect(function () {
1831
+ function onBranchChanged() {
1832
+ SearchService.invalidate();
1833
+ FileService.getTree({ refresh: true }).then(function (data) {
1834
+ setTreeData(_treeUpdater(data || []));
1835
+ })["catch"](function () {})["finally"](function () {
1836
+ checkOpenTabsForExternalChanges();
1837
+ });
1838
+ }
1839
+ window.addEventListener('mbeditor:branch-changed', onBranchChanged);
1840
+ return function () { window.removeEventListener('mbeditor:branch-changed', onBranchChanged); };
1707
1841
  }, []);
1708
1842
 
1709
1843
  // WebSocket push — when a peer saves a collaboratively-bound file, the CRDT has
@@ -1839,6 +1973,12 @@ var MbeditorApp = function MbeditorApp() {
1839
1973
  });
1840
1974
  })
1841
1975
  });
1976
+ // The text just moved under the diagnostics. Only the mounted editor
1977
+ // re-lints, so without this a background tab kept reporting offenses
1978
+ // at line numbers the external write had shifted — and the Problems
1979
+ // panel and status-bar tallies reported them too. Dropping them says
1980
+ // "not known yet", which is true: the file re-lints when you open it.
1981
+ discardStaleMarkers(pt.tab.path);
1842
1982
  } else {
1843
1983
  // Re-verify the tab still exists before queuing
1844
1984
  var currentState = EditorStore.getState();
@@ -1926,7 +2066,13 @@ var MbeditorApp = function MbeditorApp() {
1926
2066
  if (timer) clearTimeout(timer);
1927
2067
  timer = setTimeout(function () {
1928
2068
  timer = null;
1929
- setProblemCounts(window.ProblemsPanel.counts());
2069
+ var next = window.ProblemsPanel.counts();
2070
+ // Two object literals are never Object.is-equal, so handing React a
2071
+ // fresh {errors, warnings} re-rendered the app on every marker change
2072
+ // — which for a JS file is every keystroke. Compare the fields.
2073
+ setProblemCounts(function (prev) {
2074
+ return (prev && prev.errors === next.errors && prev.warnings === next.warnings) ? prev : next;
2075
+ });
1930
2076
  }, 250);
1931
2077
  };
1932
2078
 
@@ -1939,8 +2085,8 @@ var MbeditorApp = function MbeditorApp() {
1939
2085
  };
1940
2086
  }, [monacoReady]);
1941
2087
 
1942
- var handleSelectFile = function handleSelectFile(path, name, line, col) {
1943
- TabManager.openTab(path, name, line, null, false, col);
2088
+ var handleSelectFile = function handleSelectFile(path, name, line, col, endCol) {
2089
+ TabManager.openTab(path, name, line, null, false, col, endCol);
1944
2090
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
1945
2091
  setQuickOpen(false);
1946
2092
  };
@@ -1968,6 +2114,33 @@ var MbeditorApp = function MbeditorApp() {
1968
2114
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
1969
2115
  };
1970
2116
 
2117
+ // Both of these are keyed by something that dies with the tab — markers by
2118
+ // tab id, the external-change baseline by path — and nothing dropped them, so
2119
+ // a long session held a full copy of every file it had ever opened. Run after
2120
+ // a close (single or bulk) and reconcile against what is still open: a path
2121
+ // the other pane still shows keeps its baseline, which a per-tab delete
2122
+ // would have got wrong.
2123
+ var forgetClosedTabs = function forgetClosedTabs() {
2124
+ var openIds = {};
2125
+ var openPaths = {};
2126
+ EditorStore.getState().panes.forEach(function (p) {
2127
+ p.tabs.forEach(function (t) {
2128
+ openIds[t.id] = true;
2129
+ if (t.path) openPaths[t.path] = true;
2130
+ });
2131
+ });
2132
+ Object.keys(lastDiskContentRef.current).forEach(function (path) {
2133
+ if (!openPaths[path]) delete lastDiskContentRef.current[path];
2134
+ });
2135
+ setMarkers(function (prev) {
2136
+ var stale = Object.keys(prev).filter(function (id) { return !openIds[id]; });
2137
+ if (stale.length === 0) return prev;
2138
+ var next = _extends({}, prev);
2139
+ stale.forEach(function (id) { delete next[id]; });
2140
+ return next;
2141
+ });
2142
+ };
2143
+
1971
2144
  var requestCloseTab = function requestCloseTab(paneId, id) {
1972
2145
  var pane = state.panes.find(function (p) {
1973
2146
  return p.id === paneId;
@@ -1987,6 +2160,7 @@ var MbeditorApp = function MbeditorApp() {
1987
2160
  });
1988
2161
  })
1989
2162
  });
2163
+ forgetClosedTabs();
1990
2164
  }
1991
2165
  };
1992
2166
 
@@ -2040,6 +2214,7 @@ var MbeditorApp = function MbeditorApp() {
2040
2214
  });
2041
2215
  })
2042
2216
  });
2217
+ forgetClosedTabs();
2043
2218
  })["catch"](function (err) {
2044
2219
  EditorStore.setStatus("Save failed: " + err.message, "error");
2045
2220
  })["finally"](function () {
@@ -2059,6 +2234,7 @@ var MbeditorApp = function MbeditorApp() {
2059
2234
  });
2060
2235
  })
2061
2236
  });
2237
+ forgetClosedTabs();
2062
2238
  setClosingTabId(null);
2063
2239
  setClosingPaneId(null);
2064
2240
  }
@@ -2083,6 +2259,7 @@ var MbeditorApp = function MbeditorApp() {
2083
2259
  if (!confirmBulkClose(allTabs, "all editors")) return;
2084
2260
 
2085
2261
  TabManager.closeAllTabs();
2262
+ forgetClosedTabs();
2086
2263
  setClosingTabId(null);
2087
2264
  setClosingPaneId(null);
2088
2265
  EditorStore.setStatus("Closed " + allTabs.length + " editor" + (allTabs.length === 1 ? "" : "s"), "info");
@@ -2096,6 +2273,7 @@ var MbeditorApp = function MbeditorApp() {
2096
2273
  if (!confirmBulkClose(pane.tabs, "all editors in Group " + paneId)) return;
2097
2274
 
2098
2275
  TabManager.closeAllTabsInPane(paneId);
2276
+ forgetClosedTabs();
2099
2277
  setClosingTabId(null);
2100
2278
  setClosingPaneId(null);
2101
2279
  EditorStore.setStatus("Closed " + pane.tabs.length + " editor" + (pane.tabs.length === 1 ? "" : "s") + " in Group " + paneId, "info");
@@ -2108,6 +2286,7 @@ var MbeditorApp = function MbeditorApp() {
2108
2286
  if (others.length === 0) return;
2109
2287
  if (!confirmBulkClose(others, "other editors")) return;
2110
2288
  TabManager.closeOtherTabsInPane(paneId, keepId);
2289
+ forgetClosedTabs();
2111
2290
  EditorStore.setStatus("Closed " + others.length + " editor" + (others.length === 1 ? "" : "s"), "info");
2112
2291
  };
2113
2292
 
@@ -2117,6 +2296,7 @@ var MbeditorApp = function MbeditorApp() {
2117
2296
  var saved = pane.tabs.filter(function (t) { return !t.dirty; });
2118
2297
  if (saved.length === 0) return;
2119
2298
  TabManager.closeSavedTabsInPane(paneId);
2299
+ forgetClosedTabs();
2120
2300
  EditorStore.setStatus("Closed " + saved.length + " saved editor" + (saved.length === 1 ? "" : "s"), "info");
2121
2301
  };
2122
2302
 
@@ -2134,6 +2314,7 @@ var MbeditorApp = function MbeditorApp() {
2134
2314
  GitService.fetchStatusLite({ background: true });
2135
2315
  FileService.getTree().then(function (data) { setTreeData(_treeUpdater(data || [])); })["catch"](function () {});
2136
2316
  TabManager.closeTab(paneId, tab.id);
2317
+ forgetClosedTabs();
2137
2318
  if (!(opts && opts.close)) {
2138
2319
  TabManager.openTab(newPath, newPath.split('/').pop(), null, paneId);
2139
2320
  }
@@ -2296,7 +2477,10 @@ var MbeditorApp = function MbeditorApp() {
2296
2477
  var RAILS_MAX_RESOURCES = 10;
2297
2478
 
2298
2479
  // Map resource label → representative path (capped at RAILS_MAX_RESOURCES, focused pane first)
2299
- var railsResourceDeps = (function() {
2480
+ // Memoised: all three of these walk every tab in every pane and were rebuilt
2481
+ // on every render, including the ones a keystroke causes.
2482
+ // resourceLabelFromPath reads customPathsRef, hence customPaths in the deps.
2483
+ var railsResourceDeps = useMemo(function() {
2300
2484
  var deps = {};
2301
2485
  var panesOrdered = state.panes.slice().sort(function(a, b) {
2302
2486
  return a.id === state.focusedPaneId ? -1 : b.id === state.focusedPaneId ? 1 : 0;
@@ -2313,10 +2497,10 @@ var MbeditorApp = function MbeditorApp() {
2313
2497
  });
2314
2498
  });
2315
2499
  return deps;
2316
- })();
2500
+ }, [state.panes, state.focusedPaneId, customPaths]);
2317
2501
  var railsResourceDepStr = Object.keys(railsResourceDeps).sort().join('|');
2318
2502
 
2319
- var railsOverflow = (function() {
2503
+ var railsOverflow = useMemo(function() {
2320
2504
  var all = {};
2321
2505
  state.panes.forEach(function(p) {
2322
2506
  p.tabs.forEach(function(t) {
@@ -2326,9 +2510,9 @@ var MbeditorApp = function MbeditorApp() {
2326
2510
  });
2327
2511
  });
2328
2512
  return Math.max(0, Object.keys(all).length - Object.keys(railsResourceDeps).length);
2329
- })();
2513
+ }, [state.panes, railsResourceDeps, customPaths]);
2330
2514
 
2331
- var dirtyPaths = (function() {
2515
+ var dirtyPaths = useMemo(function() {
2332
2516
  var set = {};
2333
2517
  state.panes.forEach(function(p) {
2334
2518
  p.tabs.forEach(function(t) {
@@ -2336,7 +2520,7 @@ var MbeditorApp = function MbeditorApp() {
2336
2520
  });
2337
2521
  });
2338
2522
  return set;
2339
- })();
2523
+ }, [state.panes]);
2340
2524
 
2341
2525
  useEffect(function() {
2342
2526
  if (activeSidebarTab !== 'rails') return;
@@ -2552,17 +2736,43 @@ var MbeditorApp = function MbeditorApp() {
2552
2736
  // unchanged state is an identical string and React bails rather than
2553
2737
  // re-rendering the app every tick.
2554
2738
  useEffect(function () {
2739
+ // A reconnect is not a fault. Action Cable drops and re-establishes its
2740
+ // socket routinely, and a dev server busy with a burst of saves is enough
2741
+ // to cause it — the cable shares the same thread pool as every request the
2742
+ // editor makes. Reporting the first non-connected sample made the chip
2743
+ // announce "Pairing off" during exactly the moments the editor was already
2744
+ // struggling, then clear a few seconds later, which reads as a second
2745
+ // failure rather than as the self-healing reconnect it actually is.
2746
+ //
2747
+ // So a transient state has to be seen twice before it is shown, with the
2748
+ // confirming check brought forward so a real outage still surfaces in
2749
+ // seconds. Hard failures are exempt: missing libraries, no Action Cable, a
2750
+ // server that does not advertise it, or a *rejected* handshake never heal
2751
+ // on their own, so they report on the first sample as before.
2752
+ var TRANSIENT_PROBLEMS = { connected: true };
2753
+ var unconfirmed = null;
2754
+ var recheckTimer = null;
2755
+
2555
2756
  function check() {
2556
2757
  if (typeof CollaborationService === 'undefined' ||
2557
2758
  typeof CollaborationService.diagnostics !== 'function') return;
2558
2759
  var d = CollaborationService.diagnostics();
2559
2760
  // "Nobody else is here" is not a fault; everything else is.
2560
2761
  var problem = d.firstProblem && d.firstProblem.key !== 'peers' ? d.firstProblem.key : null;
2762
+ var transient = !!problem && !!TRANSIENT_PROBLEMS[problem] && d.cableStatus !== 'rejected';
2763
+
2764
+ if (transient && unconfirmed !== problem) {
2765
+ unconfirmed = problem;
2766
+ clearTimeout(recheckTimer);
2767
+ recheckTimer = setTimeout(check, 3000);
2768
+ return;
2769
+ }
2770
+ unconfirmed = transient ? problem : null;
2561
2771
  setCollabTrouble(function (prev) { return prev === problem ? prev : problem; });
2562
2772
  }
2563
2773
  check();
2564
2774
  var id = setInterval(check, 10000);
2565
- return function () { clearInterval(id); };
2775
+ return function () { clearInterval(id); clearTimeout(recheckTimer); };
2566
2776
  }, []);
2567
2777
 
2568
2778
  var collabPeerIds = Object.keys(collabRoster);
@@ -2689,7 +2899,7 @@ var MbeditorApp = function MbeditorApp() {
2689
2899
 
2690
2900
  // Clear markers and skip auto-lint when RuboCop linting is disabled
2691
2901
  if (isRubyPath(activeTab.path) && editorPrefs.rubocopLintEnabled === false) {
2692
- setMarkers(function(prev) { return _extends({}, prev, _defineProperty({}, activeTab.id, [])); });
2902
+ applyMarkersForTab(activeTab.id, []);
2693
2903
  return;
2694
2904
  }
2695
2905
 
@@ -2767,7 +2977,18 @@ var MbeditorApp = function MbeditorApp() {
2767
2977
  return runPrettier(tab.content, editorPrefs, parserName)["catch"](function () { return null; });
2768
2978
  };
2769
2979
 
2980
+ // Re-read a tab from the store after flushing TabManager's throttled content
2981
+ // write. `tab` here is whatever the render captured, so flushing alone is not
2982
+ // enough — the fresh text lands in the store, not in this object.
2983
+ var _freshTab = function _freshTab(paneId, tab) {
2984
+ TabManager.flushContent();
2985
+ var pane = EditorStore.getState().panes.find(function (p) { return p.id === paneId; });
2986
+ var live = pane && pane.tabs.find(function (t) { return t.id === tab.id; });
2987
+ return live || tab;
2988
+ };
2989
+
2770
2990
  var handleSave = function handleSave(paneId, tab) {
2991
+ tab = _freshTab(paneId, tab);
2771
2992
  if (editorPrefs.formatOnSave === true) {
2772
2993
  EditorStore.setStatus("Formatting " + tab.name + "...", "info");
2773
2994
  _formatContentForSave(tab).then(function (formatted) {
@@ -2844,21 +3065,25 @@ var MbeditorApp = function MbeditorApp() {
2844
3065
  FileService.lintFile(tab.path, tab.content, 'javascript').then(function (res) {
2845
3066
  var babelMarkers = (res && res.markers) || [];
2846
3067
  if (babelMarkers.length > 0) {
2847
- applyMarkersForTab(paneId, tab.id, babelMarkers);
3068
+ applyMarkersForTab(tab.id, babelMarkers);
2848
3069
  EditorStore.setStatus('Saved — babel: ' + babelMarkers[0].message, 'warning');
2849
3070
  } else {
2850
3071
  // Clear any previous babel marker for this tab (keep others none —
2851
3072
  // JS tabs have no rubocop markers, so replacing is safe).
2852
- var existing = (EditorStore.getState().panes.find(function (p) { return p.id === paneId; }) || { tabs: [] })
2853
- .tabs.find(function (t) { return t.id === tab.id; });
2854
- if (existing && existing.markers && existing.markers.length) {
2855
- applyMarkersForTab(paneId, tab.id, []);
2856
- }
3073
+ // applyMarkersForTab keeps the previous map when it is already empty.
3074
+ applyMarkersForTab(tab.id, []);
2857
3075
  }
2858
3076
  })["catch"](function () {});
2859
3077
  }
2860
3078
 
2861
- GitService.fetchStatusLite({ background: true });
3079
+ // The server broadcasts files_changed for this very write, and the
3080
+ // coalesced handler refreshes git status there — so firing here too ran
3081
+ // the git work twice per save, and this copy is the one a burst cannot
3082
+ // coalesce. fetchStatusLite escalates to the full /git_info fan-out
3083
+ // whenever the working tree signature moved, which saving a different
3084
+ // file every time does by definition, so the duplicate was the expensive
3085
+ // one. Without a socket no broadcast arrives, so keep it for that case.
3086
+ if (!_socketWillBroadcast()) GitService.fetchStatusLite({ background: true });
2862
3087
  })["catch"](function (err) {
2863
3088
  EditorStore.setStatus("Save failed: " + err.message, "error");
2864
3089
  })["finally"](function () {
@@ -2932,7 +3157,10 @@ var MbeditorApp = function MbeditorApp() {
2932
3157
  }
2933
3158
 
2934
3159
  var handleSaveAll = function handleSaveAll() {
2935
- var dirtyTabs = state.panes.flatMap(function (p) {
3160
+ // Flush first, then read the panes back out of the store — the render's
3161
+ // `state` predates the flush. See TabManager's flush contract.
3162
+ TabManager.flushContent();
3163
+ var dirtyTabs = EditorStore.getState().panes.flatMap(function (p) {
2936
3164
  return p.tabs;
2937
3165
  }).filter(function (t) {
2938
3166
  // Untitled scratch tabs need a save-as prompt each — Ctrl+S them
@@ -2946,32 +3174,53 @@ var MbeditorApp = function MbeditorApp() {
2946
3174
  });
2947
3175
  EditorStore.setStatus("Saving " + dirtyTabs.length + " files...", "info");
2948
3176
  isSavingRef.current = true;
3177
+ // allSettled, not all: one slow or rejected write used to abandon the
3178
+ // bookkeeping for every other file, so tabs whose contents were already on
3179
+ // disk stayed dirty and the only thing on offer was to hit Save All again.
3180
+ // Each file is now settled on its own result.
2949
3181
  var promises = dirtyTabs.map(function (tab) {
2950
3182
  return FileService.saveFile(tab.path, tab.content);
2951
3183
  });
2952
- Promise.all(promises).then(function () {
2953
- dirtyTabs.forEach(function(tab) {
3184
+ Promise.allSettled(promises).then(function (results) {
3185
+ var saved = dirtyTabs.filter(function (tab, i) { return results[i].status === "fulfilled"; });
3186
+ // Object.create(null): these keys are file paths, and a file called
3187
+ // "constructor" would otherwise test as present against Object.prototype
3188
+ // and have its tab marked clean without ever being written.
3189
+ var savedPaths = Object.create(null);
3190
+ saved.forEach(function (tab) {
3191
+ savedPaths[tab.path] = tab.content;
2954
3192
  noteLocalSave(tab.path, tab.content);
2955
3193
  });
2956
3194
  var newPanes = EditorStore.getState().panes.map(function (p) {
2957
3195
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
3196
+ // Compare content, not just the path: a tab edited again while the
3197
+ // save was in flight is dirty against what actually reached disk.
3198
+ if (!(t.path in savedPaths) || t.content !== savedPaths[t.path]) return t;
2958
3199
  return _extends({}, t, { dirty: false, cleanContent: t.content });
2959
3200
  })
2960
3201
  });
2961
3202
  });
2962
3203
  EditorStore.setState({ panes: newPanes });
2963
3204
  // Reset AVI clean baselines for all saved files so undo past save shows dirty correctly.
2964
- dirtyTabs.forEach(function(tab) {
3205
+ saved.forEach(function(tab) {
2965
3206
  var _me = window.__mbeditorModels && window.__mbeditorModels[tab.path];
2966
3207
  if (_me && _me.model && !_me.model.isDisposed()) {
2967
3208
  _me.cleanVersionId = _me.model.getAlternativeVersionId();
2968
3209
  }
2969
3210
  });
2970
- EditorStore.setStatus("All files saved", "success");
3211
+ if (saved.length === dirtyTabs.length) {
3212
+ EditorStore.setStatus("All files saved", "success");
3213
+ } else {
3214
+ EditorStore.setStatus("Saved " + saved.length + " of " + dirtyTabs.length +
3215
+ " files — " + (dirtyTabs.length - saved.length) + " failed", "error");
3216
+ }
2971
3217
  SearchService.invalidate();
2972
3218
  GitService.fetchStatusLite({ background: true });
2973
- })["catch"](function (err) {
2974
- EditorStore.setStatus("Failed to save some files", "error");
3219
+ })["catch"](function () {
3220
+ // allSettled never rejects, so this only fires if the bookkeeping above
3221
+ // throws. Without it that would be an unhandled rejection and the status
3222
+ // bar would sit on "Saving N files..." forever.
3223
+ EditorStore.setStatus("Save All finished with errors", "error");
2975
3224
  })["finally"](function () {
2976
3225
  isSavingRef.current = false;
2977
3226
  return setLoading(function (prev) {
@@ -3029,7 +3278,7 @@ var MbeditorApp = function MbeditorApp() {
3029
3278
  return _extends({}, prev, { refreshWorkspace: true });
3030
3279
  });
3031
3280
  GitService.fetchStatus()["catch"](function () {});
3032
- FileService.getTree().then(function (data) {
3281
+ FileService.getTree({ refresh: true }).then(function (data) {
3033
3282
  setTreeData(_treeUpdater(data || []));
3034
3283
  checkOpenTabsForExternalChanges();
3035
3284
  EditorStore.setStatus("Workspace refreshed", "success");
@@ -3095,19 +3344,53 @@ var MbeditorApp = function MbeditorApp() {
3095
3344
  return runPrettier(tab.content, prefs, parserName);
3096
3345
  };
3097
3346
 
3347
+ // Markers are per-model and only the mounted editor re-lints, so a document
3348
+ // formatted in the background kept the diagnostics of the text it no longer
3349
+ // holds — the Problems panel and the status-bar tallies went on reporting
3350
+ // offenses at line numbers that had moved, and Format All barely moved the
3351
+ // counts because only the visible tab was re-checked. Dropping them says
3352
+ // "not known yet", which is true: the file re-lints when you open it.
3353
+ var discardStaleMarkers = function discardStaleMarkers(path) {
3354
+ if (!path) return;
3355
+
3356
+ // The React map has to go too, not just Monaco's copy. It is what TabBar
3357
+ // counts, and EditorPanel re-applies it to the model the next time that tab
3358
+ // mounts — so clearing only Monaco left every background tab primed to put
3359
+ // its stale squiggles straight back on the next tab switch.
3360
+ EditorStore.getState().panes.forEach(function (p) {
3361
+ p.tabs.forEach(function (t) {
3362
+ if (t.path === path) applyMarkersForTab(t.id, []);
3363
+ });
3364
+ });
3365
+
3366
+ if (!window.monaco || !window.monaco.editor) return;
3367
+ var entry = window.__mbeditorModels && window.__mbeditorModels[path];
3368
+ if (!entry || !entry.model || entry.model.isDisposed()) return;
3369
+
3370
+ var owners = {};
3371
+ window.monaco.editor.getModelMarkers({ resource: entry.model.uri }).forEach(function (m) {
3372
+ owners[m.owner] = true;
3373
+ });
3374
+ Object.keys(owners).forEach(function (owner) {
3375
+ window.monaco.editor.setModelMarkers(entry.model, owner, []);
3376
+ });
3377
+ };
3378
+
3098
3379
  // Write formatted content back to a tab, dirty and unsaved — the user decides
3099
3380
  // when to save. EditorPanel applies it through executeEdits, so undo works.
3100
3381
  var applyFormattedContent = function applyFormattedContent(paneId, tabId, formatted) {
3382
+ var formattedPath = null;
3101
3383
  EditorStore.setState({
3102
3384
  panes: EditorStore.getState().panes.map(function (p) {
3103
3385
  if (p.id !== paneId) return p;
3104
3386
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
3105
- return t.id === tabId
3106
- ? _extends({}, t, { content: formatted, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 })
3107
- : t;
3387
+ if (t.id !== tabId) return t;
3388
+ formattedPath = t.path;
3389
+ return _extends({}, t, { content: formatted, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 });
3108
3390
  }) });
3109
3391
  })
3110
3392
  });
3393
+ discardStaleMarkers(formattedPath);
3111
3394
  };
3112
3395
 
3113
3396
  var highlightFormatChanges = function highlightFormatChanges(before, after) {
@@ -3124,8 +3407,8 @@ var MbeditorApp = function MbeditorApp() {
3124
3407
  var handleFormat = function handleFormat() {
3125
3408
  if (!activeTab) return;
3126
3409
 
3127
- var tab = activeTab;
3128
3410
  var paneId = focusedPane.id;
3411
+ var tab = _freshTab(paneId, activeTab);
3129
3412
  var originalContent = tab.content;
3130
3413
 
3131
3414
  setLoading(function (prev) { return _extends({}, prev, { format: true }); });
@@ -3163,6 +3446,8 @@ var MbeditorApp = function MbeditorApp() {
3163
3446
  // thrown, so one unparseable file cannot stop the rest. Virtual tabs (diffs,
3164
3447
  // settings, the changelog) have no path on disk and are skipped.
3165
3448
  var handleFormatAll = function handleFormatAll() {
3449
+ // See TabManager's flush contract — the panes are read straight after.
3450
+ TabManager.flushContent();
3166
3451
  var targets = [];
3167
3452
  EditorStore.getState().panes.forEach(function (p) {
3168
3453
  p.tabs.forEach(function (t) {
@@ -3209,15 +3494,6 @@ var MbeditorApp = function MbeditorApp() {
3209
3494
 
3210
3495
  var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
3211
3496
 
3212
- var loadCachedTestResult = function loadCachedTestResult(filePath) {
3213
- try {
3214
- var stored = localStorage.getItem(TEST_CACHE_PREFIX + filePath);
3215
- return stored ? JSON.parse(stored) : null;
3216
- } catch (e) {
3217
- return null;
3218
- }
3219
- };
3220
-
3221
3497
  var saveCachedTestResult = function saveCachedTestResult(filePath, result) {
3222
3498
  try {
3223
3499
  localStorage.setItem(TEST_CACHE_PREFIX + filePath, JSON.stringify(result));
@@ -3259,21 +3535,6 @@ var MbeditorApp = function MbeditorApp() {
3259
3535
  });
3260
3536
  };
3261
3537
 
3262
- var handleRunTest = function handleRunTest() {
3263
- if (!activeTab || !activeTab.path) return;
3264
- if (testLoading) return;
3265
-
3266
- var cached = loadCachedTestResult(activeTab.path);
3267
- if (cached && !testPanelOpen) {
3268
- setTestResult(cached);
3269
- setTestPanelFile(cached.testFile || activeTab.path);
3270
- setTestPanelOpen(true);
3271
- return;
3272
- }
3273
-
3274
- executeTestRun(activeTab.path);
3275
- };
3276
-
3277
3538
  var handleRerunTest = function handleRerunTest() {
3278
3539
  if (!activeTab || !activeTab.path) return;
3279
3540
  if (testLoading) return;
@@ -3314,6 +3575,7 @@ var MbeditorApp = function MbeditorApp() {
3314
3575
  searchOffsetRef.current = 0;
3315
3576
  searchLoadingMoreRef.current = false;
3316
3577
  searchQueryRef.current = q;
3578
+ setSearchCollapsedFiles({});
3317
3579
  EditorStore.setState({ searchResults: [], searchHasMore: false });
3318
3580
  EditorStore.setStatus("Searching project...", "info");
3319
3581
  SearchService.projectSearch(q, 0, SearchService.PAGE_SIZE, { regex: searchUseRegexRef.current, matchCase: searchMatchCaseRef.current, wholeWord: searchWholeWordRef.current }).then(function (res) {
@@ -3430,8 +3692,13 @@ var MbeditorApp = function MbeditorApp() {
3430
3692
  // Update any open Monaco models whose content changed.
3431
3693
  if (files.length && window.__mbeditorModels) {
3432
3694
  files.forEach(function(relPath) {
3433
- var model = window.__mbeditorModels[relPath];
3434
- if (!model) return;
3695
+ // The registry holds {model, aviBase, ...} entries, not models. This
3696
+ // used to call setValue/isDisposed straight on the entry, which threw
3697
+ // into the .catch below — so an open tab kept its pre-replace text and
3698
+ // could save it back over the replacement.
3699
+ var entry = window.__mbeditorModels[relPath];
3700
+ var model = entry && entry.model;
3701
+ if (!model || model.isDisposed()) return;
3435
3702
  FileService.getFile(relPath).then(function(res) {
3436
3703
  if (res && res.content != null && !model.isDisposed()) {
3437
3704
  model.setValue(res.content);
@@ -3457,6 +3724,13 @@ var MbeditorApp = function MbeditorApp() {
3457
3724
  if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
3458
3725
  loadMoreSearchResults();
3459
3726
  }
3727
+ setSearchViewport(function (prev) {
3728
+ // Only the rows that moved in or out of the window matter, and those
3729
+ // change a row at a time. Comparing keeps a scroll gesture from
3730
+ // committing a render per pixel.
3731
+ if (prev.scrollTop === el.scrollTop && prev.height === el.clientHeight) return prev;
3732
+ return { scrollTop: el.scrollTop, height: el.clientHeight };
3733
+ });
3460
3734
  };
3461
3735
 
3462
3736
  var toggleGitPanel = function toggleGitPanel() {
@@ -3556,12 +3830,17 @@ var MbeditorApp = function MbeditorApp() {
3556
3830
  });
3557
3831
  };
3558
3832
 
3559
- var finishImport = function finishImport(result) {
3560
- var imported = (result.imported || []).length;
3833
+ var finishImport = function finishImport(result, destFolder) {
3834
+ var written = result.imported || [];
3835
+ var imported = written.length;
3561
3836
  var skipped = (result.conflicts || []).length;
3562
3837
  var failed = (result.errors || []).length;
3563
3838
 
3564
- var parts = [imported + ' file' + (imported === 1 ? '' : 's') + ' imported'];
3839
+ // Say where the files went. A bulk upload that reports only a count looks
3840
+ // the same whether it landed where you meant it to or in the workspace
3841
+ // root, and the destination is the whole question a folder import raises.
3842
+ var where = destFolder ? ' to ' + destFolder : ' to the workspace root';
3843
+ var parts = [imported + ' file' + (imported === 1 ? '' : 's') + ' imported' + (imported > 0 ? where : '')];
3565
3844
  if (skipped > 0) parts.push(skipped + ' skipped');
3566
3845
  if (failed > 0) parts.push(failed + ' failed');
3567
3846
 
@@ -3569,7 +3848,21 @@ var MbeditorApp = function MbeditorApp() {
3569
3848
  EditorStore.setStatus(parts.join(', ') + '.', level);
3570
3849
 
3571
3850
  if (imported > 0) {
3572
- refreshProjectTree().then(function() { GitService.fetchStatus(); });
3851
+ // Expand down to what was just written, so the tree actually shows it —
3852
+ // importing into a collapsed folder otherwise leaves the explorer looking
3853
+ // untouched. Deliberately does not *select* the folder: the tree
3854
+ // selection is what the toolbar's Upload button reads for its default
3855
+ // destination, and pinning it here would make every later upload default
3856
+ // to this import's folder.
3857
+ var landed = parentDir(written[0].path);
3858
+ var toExpand = {};
3859
+ var bits = landed ? landed.split('/') : [];
3860
+ for (var i = 1; i <= bits.length; i++) toExpand[bits.slice(0, i).join('/')] = true;
3861
+
3862
+ refreshProjectTree().then(function() {
3863
+ if (bits.length) setExpandedDirs(function (prev) { return Object.assign({}, prev, toExpand); });
3864
+ GitService.fetchStatus();
3865
+ });
3573
3866
  }
3574
3867
  };
3575
3868
 
@@ -3613,7 +3906,7 @@ var MbeditorApp = function MbeditorApp() {
3613
3906
  if (result.conflicts && result.conflicts.length > 0) {
3614
3907
  setImportConflict({ result: result, entries: entries, targetFolderPath: targetFolderPath });
3615
3908
  } else {
3616
- finishImport(result);
3909
+ finishImport(result, targetFolderPath);
3617
3910
  }
3618
3911
  })['catch'](function(err) {
3619
3912
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3626,14 +3919,14 @@ var MbeditorApp = function MbeditorApp() {
3626
3919
  setImportConflict(null);
3627
3920
  if (!pending) return;
3628
3921
 
3629
- if (mode === 'skip') { finishImport(pending.result); return; }
3922
+ if (mode === 'skip') { finishImport(pending.result, pending.targetFolderPath); return; }
3630
3923
 
3631
3924
  var retry = FileImport.conflictedEntries(
3632
3925
  pending.entries,
3633
3926
  pending.targetFolderPath,
3634
3927
  pending.result.conflicts
3635
3928
  );
3636
- if (retry.length === 0) { finishImport(pending.result); return; }
3929
+ if (retry.length === 0) { finishImport(pending.result, pending.targetFolderPath); return; }
3637
3930
 
3638
3931
  FileService.importFiles(FileImport.buildFormData(retry, pending.targetFolderPath, mode))
3639
3932
  .then(function(second) {
@@ -3641,13 +3934,47 @@ var MbeditorApp = function MbeditorApp() {
3641
3934
  imported: (pending.result.imported || []).concat(second.imported || []),
3642
3935
  conflicts: [],
3643
3936
  errors: (pending.result.errors || []).concat(second.errors || [])
3644
- });
3937
+ }, pending.targetFolderPath);
3645
3938
  })['catch'](function(err) {
3646
3939
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
3647
3940
  EditorStore.setStatus('Import failed: ' + message, 'error');
3648
3941
  });
3649
3942
  };
3650
3943
 
3944
+ // Downloads go straight through /raw?download=1 — the browser's own save
3945
+ // flow, so nothing is buffered client-side and a 5 MB file costs no memory.
3946
+ // A synthetic anchor rather than location.href: navigating away from the
3947
+ // editor to an attachment response leaves the page in a half-unloaded state
3948
+ // in Safari.
3949
+ var handleDownloadFile = function handleDownloadFile(node) {
3950
+ if (!node || node.type !== 'file') return;
3951
+ var link = document.createElement('a');
3952
+ link.href = window.mbeditorBasePath() + '/raw?download=1&path=' + encodeURIComponent(node.path);
3953
+ link.download = node.name;
3954
+ link.rel = 'noopener';
3955
+ document.body.appendChild(link);
3956
+ link.click();
3957
+ document.body.removeChild(link);
3958
+ EditorStore.setStatus('Downloading ' + node.name + '...', 'info');
3959
+ };
3960
+
3961
+ // A file node means "upload alongside this file", so the dialog opens on its
3962
+ // parent — right-clicking a file to upload next to it is the common gesture.
3963
+ var openImportDialog = function openImportDialog(node) {
3964
+ var folder = '';
3965
+ if (node && node.type === 'folder') {
3966
+ folder = node.path;
3967
+ } else if (node && node.path) {
3968
+ folder = node.path.split('/').slice(0, -1).join('/');
3969
+ }
3970
+ setImportDialog({ initialFolder: folder });
3971
+ };
3972
+
3973
+ var confirmImportDialog = function confirmImportDialog(entries, destFolder, meta) {
3974
+ setImportDialog(null);
3975
+ handleImportFiles(entries, destFolder, meta);
3976
+ };
3977
+
3651
3978
  var openContextMenu = function openContextMenu(e, node) {
3652
3979
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
3653
3980
  handleNodeSelect(node);
@@ -3675,6 +4002,12 @@ var MbeditorApp = function MbeditorApp() {
3675
4002
  if (action === 'delete') {
3676
4003
  handleDeletePath(node);return;
3677
4004
  }
4005
+ if (action === 'download' && node) {
4006
+ handleDownloadFile(node);return;
4007
+ }
4008
+ if (action === 'upload') {
4009
+ openImportDialog(node);return;
4010
+ }
3678
4011
  if (action === 'copyPath' && node) {
3679
4012
  if (navigator.clipboard) {
3680
4013
  navigator.clipboard.writeText(node.path)["catch"](function () {});
@@ -3823,15 +4156,7 @@ var MbeditorApp = function MbeditorApp() {
3823
4156
  activeTabId: activePane ? activePane.activeTabId : null
3824
4157
  });
3825
4158
 
3826
- if (removedTabIds.length) {
3827
- setMarkers(function (prev) {
3828
- var next = _extends({}, prev);
3829
- removedTabIds.forEach(function (tabId) {
3830
- return delete next[tabId];
3831
- });
3832
- return next;
3833
- });
3834
- }
4159
+ if (removedTabIds.length) forgetClosedTabs();
3835
4160
  };
3836
4161
 
3837
4162
  // Reveal a folder path in the explorer: switch to explorer tab and expand
@@ -3890,8 +4215,27 @@ var MbeditorApp = function MbeditorApp() {
3890
4215
  FileService.createFile(path, '').then(function (res) {
3891
4216
  var createdPath = res && res.path || path;
3892
4217
  var createdName = createdPath.split('/').pop();
4218
+ // We just wrote this file and know it is empty, so opening it does not
4219
+ // need to ask the server what is in it. noteLocalSave records the write
4220
+ // for the same reason a save does: the create's own broadcast comes
4221
+ // straight back as a files_changed naming this path, and without this
4222
+ // the external-change check read the file back off disk to ask whether
4223
+ // it had changed since we wrote it a moment earlier.
4224
+ if (FileService.seedPrefetch) FileService.seedPrefetch(createdPath, '');
4225
+ noteLocalSave(createdPath, '');
3893
4226
  handleNodeSelect({ path: createdPath, name: createdName, type: 'file' });
3894
4227
  EditorStore.setStatus('Created file: ' + createdName, 'success');
4228
+ // The optimistic node is already in the tree and the server's
4229
+ // structural broadcast refreshes it for real a moment later, so
4230
+ // walking the workspace here as well made one create cost three full
4231
+ // tree walks — and every write invalidates the server's tree cache, so
4232
+ // each one is a fresh recursive scan of the whole checkout. Same for
4233
+ // git status, which the same broadcast handler already refreshes.
4234
+ // Without a socket neither happens on its own, so keep both for that.
4235
+ if (_socketWillBroadcast()) {
4236
+ handleSelectFile(createdPath, createdName);
4237
+ return;
4238
+ }
3895
4239
  return refreshProjectTree().then(function () {
3896
4240
  handleSelectFile(createdPath, createdName);
3897
4241
  GitService.fetchStatusLite({ background: true });
@@ -3914,6 +4258,8 @@ var MbeditorApp = function MbeditorApp() {
3914
4258
  var createdPath = res && res.path || path;
3915
4259
  handleNodeSelect({ path: createdPath, name: createdPath.split('/').pop(), type: 'folder' });
3916
4260
  EditorStore.setStatus('Created folder: ' + createdPath, 'success');
4261
+ // See handleCreateConfirm's file branch — the broadcast covers both.
4262
+ if (_socketWillBroadcast()) return;
3917
4263
  return refreshProjectTree().then(function () {
3918
4264
  return GitService.fetchStatusLite({ background: true });
3919
4265
  });
@@ -4074,6 +4420,9 @@ var MbeditorApp = function MbeditorApp() {
4074
4420
  tabs: tabs,
4075
4421
  activeId: activeId,
4076
4422
  paneId: paneId,
4423
+ // Diagnostics live in this map, not on the tab object — writing them onto
4424
+ // the tab would mutate a value inside EditorStore.
4425
+ markers: markers,
4077
4426
  tabDisplayMode: editorPrefs.tabDisplayMode || 'scroll',
4078
4427
  onSelect: function (id) {
4079
4428
  // Sync explorer selection with the newly active tab so there's only one highlight
@@ -4275,7 +4624,8 @@ var MbeditorApp = function MbeditorApp() {
4275
4624
  "button",
4276
4625
  { className: "statusbar-btn", onClick: function () {
4277
4626
  return activeTab && handleSave(focusedPane.id, activeTab);
4278
- }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save },
4627
+ }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save,
4628
+ title: "Save the active file (Ctrl+S)" },
4279
4629
  !loading.save && React.createElement("i", { className: "fas fa-save" }),
4280
4630
  !toolbarIconOnly && !loading.save && " Save",
4281
4631
  !loading.save && activeTab && activeTab.dirty ? " ●" : ""
@@ -4286,7 +4636,7 @@ var MbeditorApp = function MbeditorApp() {
4286
4636
  return p.tabs;
4287
4637
  }).some(function (t) {
4288
4638
  return t.dirty;
4289
- }), 'aria-busy': !!loading.saveAll },
4639
+ }), 'aria-busy': !!loading.saveAll, title: "Save every file with unsaved changes" },
4290
4640
  !loading.saveAll && React.createElement(
4291
4641
  "i",
4292
4642
  { className: "fas fa-save", style: { position: 'relative' } },
@@ -4330,7 +4680,8 @@ var MbeditorApp = function MbeditorApp() {
4330
4680
  React.createElement("div", { className: "statusbar-sep" }),
4331
4681
  React.createElement(
4332
4682
  "button",
4333
- { type: "button", className: "statusbar-btn", onClick: toggleGitPanel },
4683
+ { type: "button", className: "statusbar-btn", onClick: toggleGitPanel,
4684
+ title: (showGitPanel ? "Hide" : "Show") + " the git panel (Ctrl+Shift+G)" },
4334
4685
  React.createElement("i", { className: "fas fa-code-branch" }),
4335
4686
  !toolbarIconOnly && " Git"
4336
4687
  )
@@ -4545,7 +4896,7 @@ var MbeditorApp = function MbeditorApp() {
4545
4896
  React.createElement(
4546
4897
  "div",
4547
4898
  { className: "ide-body", id: "ide-body-container" },
4548
- /* Activity bar — always visible, 48px wide */
4899
+ /* Activity bar — always visible, 60px wide */
4549
4900
  !zenMode && React.createElement(
4550
4901
  "div",
4551
4902
  { className: "ide-activity-bar" },
@@ -4688,6 +5039,9 @@ var MbeditorApp = function MbeditorApp() {
4688
5039
  {
4689
5040
  key: tab.id,
4690
5041
  className: "tree-item " + (pane.activeTabId === tab.id && state.focusedPaneId === pane.id ? "active" : ""),
5042
+ // The name ellipsises in a narrow sidebar, so the row
5043
+ // carries the full path the way file-tree rows do.
5044
+ title: tab.path || tab.name,
4691
5045
  onClick: function () {
4692
5046
  if (tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
4693
5047
  handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
@@ -4698,17 +5052,24 @@ var MbeditorApp = function MbeditorApp() {
4698
5052
  React.createElement("i", { className: "tree-item-icon " + (window.getFileIcon ? window.getFileIcon(tab.name) : 'far fa-file-code') + " tree-file-icon" }),
4699
5053
  React.createElement(
4700
5054
  "div",
4701
- { className: "tree-item-name", style: { display: 'flex', alignItems: 'center' } },
5055
+ // minWidth:0 on both the row's name cell and the label
5056
+ // itself: without it a flex item refuses to shrink
5057
+ // below its content, so a long filename pushed out
5058
+ // under the (formerly absolute) action buttons.
5059
+ { className: "tree-item-name", style: { display: 'flex', alignItems: 'center', minWidth: 0 } },
4702
5060
  React.createElement(
4703
5061
  "span",
4704
- { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
5062
+ { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 } },
4705
5063
  tab.name
4706
5064
  ),
4707
- tab.dirty && React.createElement("i", { className: "fas fa-circle", style: { fontSize: '5px', color: '#e3d286', marginLeft: '6px', marginTop: '1px' } })
5065
+ tab.dirty && React.createElement("i", { className: "fas fa-circle", style: { fontSize: '5px', color: '#e3d286', marginLeft: '6px', marginTop: '1px', flexShrink: 0 } })
4708
5066
  ),
4709
5067
  React.createElement(
4710
5068
  "div",
4711
- { className: "tab-actions", style: { display: 'flex', position: 'absolute', right: '4px', top: 0, height: '100%', alignItems: 'center' } },
5069
+ // In flow, not absolute the buttons now claim their
5070
+ // own width so the name truncates instead of running
5071
+ // underneath them.
5072
+ { className: "tab-actions", style: { display: 'flex', alignItems: 'center', flexShrink: 0, marginLeft: 'auto' } },
4712
5073
  React.createElement(
4713
5074
  "div",
4714
5075
  { className: "tab-split", onClick: function (e) {
@@ -4720,7 +5081,10 @@ var MbeditorApp = function MbeditorApp() {
4720
5081
  "div",
4721
5082
  { className: "tab-close", onClick: function (e) {
4722
5083
  e.stopPropagation();requestCloseTab(pane.id, tab.id);
4723
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 } },
5084
+ }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 },
5085
+ // See TabBar: role="button" would pick up Pico's
5086
+ // button skin from the host app and square this off.
5087
+ title: "Close " + tab.name + (tab.dirty ? " (unsaved changes)" : "") },
4724
5088
  React.createElement("i", { className: "fas fa-times" })
4725
5089
  )
4726
5090
  )
@@ -4780,6 +5144,13 @@ var MbeditorApp = function MbeditorApp() {
4780
5144
  },
4781
5145
  disabled: !!loading.createDir
4782
5146
  }),
5147
+ React.createElement(SidebarActionButton, {
5148
+ title: "Upload files",
5149
+ iconClass: 'fas fa-upload',
5150
+ onClick: function () {
5151
+ return openImportDialog(selectedTreeNode);
5152
+ }
5153
+ }),
4783
5154
  React.createElement(SidebarActionButton, {
4784
5155
  title: "Rename selected",
4785
5156
  iconClass: 'fas fa-pen',
@@ -4802,7 +5173,7 @@ var MbeditorApp = function MbeditorApp() {
4802
5173
  )
4803
5174
  },
4804
5175
  React.createElement(FileTree, {
4805
- items: editorPrefs.showDotFiles ? treeData : filterDotFiles(treeData || []),
5176
+ items: fileTreeItems,
4806
5177
  onSelect: handleSoftOpenFile,
4807
5178
  activePath: editorPrefs.autoRevealInExplorer !== false ? (activeTab && activeTab.path) : null,
4808
5179
  selectedPaths: selectedPaths,
@@ -4937,6 +5308,30 @@ var MbeditorApp = function MbeditorApp() {
4937
5308
  var total = searchTotalCount > 0 ? searchTotalCount : loadedCount;
4938
5309
  var hasAny = loadedCount > 0;
4939
5310
 
5311
+ // Grouped into a VS Code-style tree, then flattened straight back
5312
+ // into one row array: the windowing below is unchanged, it just
5313
+ // indexes rows instead of results. Every tier emits its hits file
5314
+ // by file, so a run of the same path is the whole group.
5315
+ var rows = [];
5316
+ var group = null;
5317
+ allResults.forEach(function (res, idx) {
5318
+ if (!group || group.file !== res.file) {
5319
+ group = { type: 'file', file: res.file, count: 0 };
5320
+ rows.push(group);
5321
+ }
5322
+ group.count += 1;
5323
+ if (!searchCollapsedFiles[res.file]) rows.push({ type: 'match', res: res, idx: idx });
5324
+ });
5325
+ var rowCount = rows.length;
5326
+
5327
+ // Only the rows on screen (plus a small buffer either side) are
5328
+ // built. Everything else is represented by the height of the
5329
+ // spacer, so the scrollbar and every scroll position stay exactly
5330
+ // as they would be for the full list.
5331
+ var winStart = Math.max(0, Math.floor(searchViewport.scrollTop / SEARCH_ROW_HEIGHT) - SEARCH_ROW_BUFFER);
5332
+ var winEnd = Math.min(rowCount, Math.ceil((searchViewport.scrollTop + (searchViewport.height || 600)) / SEARCH_ROW_HEIGHT) + SEARCH_ROW_BUFFER);
5333
+ var visible = rows.slice(winStart, winEnd);
5334
+
4940
5335
  return React.createElement(
4941
5336
  React.Fragment,
4942
5337
  null,
@@ -4954,32 +5349,65 @@ var MbeditorApp = function MbeditorApp() {
4954
5349
  "div",
4955
5350
  {
4956
5351
  className: "search-results" + (searchLoading ? " search-results-blurred" : ""),
4957
- ref: searchResultsContainerRef,
5352
+ ref: attachSearchResults,
4958
5353
  onScroll: handleSearchResultsScroll
4959
5354
  },
4960
- allResults.map(function(res, i) {
4961
- var fileName = res.file.split('/').pop();
4962
- return React.createElement(
4963
- "div",
4964
- {
4965
- key: i,
4966
- className: "search-result-item",
4967
- // end_col puts the cursor just past the match, which is
4968
- // where you want to start typing after jumping to a hit.
4969
- onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.end_col || r.col); }; })(res)
4970
- },
4971
- React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
4972
- React.createElement(
4973
- "div", { className: "search-result-body" },
4974
- React.createElement(
4975
- "div", { className: "search-result-file" },
4976
- fileName,
4977
- React.createElement("span", { className: "search-result-line-num" }, " ", res.file, ":", res.line)
4978
- ),
4979
- React.createElement("div", { className: "search-result-text" }, res.text)
4980
- )
4981
- );
4982
- }),
5355
+ React.createElement(
5356
+ "div",
5357
+ { style: { height: rowCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5358
+ visible.map(function(row, vi) {
5359
+ var i = winStart + vi;
5360
+ var top = { position: 'absolute', top: i * SEARCH_ROW_HEIGHT, left: 0, right: 0 };
5361
+
5362
+ if (row.type === 'file') {
5363
+ var fileName = row.file.split('/').pop();
5364
+ var dir = row.file.slice(0, row.file.length - fileName.length).replace(/\/$/, '');
5365
+ var collapsed = !!searchCollapsedFiles[row.file];
5366
+ return React.createElement(
5367
+ "div",
5368
+ {
5369
+ key: 'f:' + row.file,
5370
+ className: "search-result-file-row",
5371
+ style: top,
5372
+ title: row.file,
5373
+ onClick: (function(f) { return function() { toggleSearchFile(f); }; })(row.file)
5374
+ },
5375
+ React.createElement("i", { className: "codicon codicon-chevron-" + (collapsed ? "right" : "down") + " search-result-chevron" }),
5376
+ React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
5377
+ React.createElement("span", { className: "search-result-file-name" }, fileName),
5378
+ dir && React.createElement("span", { className: "search-result-file-dir" }, dir),
5379
+ React.createElement("span", { className: "search-result-count" }, row.count)
5380
+ );
5381
+ }
5382
+
5383
+ var res = row.res;
5384
+ return React.createElement(
5385
+ "div",
5386
+ {
5387
+ key: 'm:' + row.idx,
5388
+ className: "search-result-item",
5389
+ style: top,
5390
+ title: res.file + ":" + res.line,
5391
+ // col..end_col selects the match and leaves the cursor
5392
+ // just past it, which is where you want to start
5393
+ // typing after jumping to a hit — not column 1.
5394
+ onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.col || r.end_col, r.end_col); }; })(res)
5395
+ },
5396
+ React.createElement("span", { className: "search-result-line-num" }, res.line),
5397
+ (function () {
5398
+ var parts = searchMatchParts(res);
5399
+ if (!parts) return React.createElement("span", { className: "search-result-text" }, res.text);
5400
+ return React.createElement(
5401
+ "span",
5402
+ { className: "search-result-text search-result-text-split" },
5403
+ React.createElement("span", { className: "search-result-pre" }, parts[0]),
5404
+ React.createElement("mark", { className: "search-result-match" }, parts[1]),
5405
+ React.createElement("span", { className: "search-result-post" }, parts[2])
5406
+ );
5407
+ })()
5408
+ );
5409
+ })
5410
+ ),
4983
5411
  searchHasMore && React.createElement(
4984
5412
  "div", { className: "search-loading-more" },
4985
5413
  React.createElement("i", { className: "fas fa-spinner fa-spin" }),
@@ -5573,16 +6001,6 @@ var MbeditorApp = function MbeditorApp() {
5573
6001
  React.createElement('option', { value: 'allDocuments' }, 'All open files')
5574
6002
  )
5575
6003
  ),
5576
- React.createElement(
5577
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Re-indent pasted code to match where it lands. Only leading whitespace changes — the formatter is not run.' },
5578
- React.createElement('span', { className: 'ide-settings-label' }, 'Indent on paste'),
5579
- React.createElement('input', {
5580
- type: 'checkbox',
5581
- className: 'ide-settings-checkbox',
5582
- checked: editorPrefs.indentOnPaste !== false,
5583
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { indentOnPaste: v }); }); }
5584
- })
5585
- ),
5586
6004
  React.createElement(
5587
6005
  'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Re-indent and auto-close blocks as you type (e.g. after pressing Enter inside {})' },
5588
6006
  React.createElement('span', { className: 'ide-settings-label' }, 'Format on type'),
@@ -5767,6 +6185,17 @@ var MbeditorApp = function MbeditorApp() {
5767
6185
  })
5768
6186
  ),
5769
6187
 
6188
+ React.createElement(
6189
+ 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Show the verb and path that route to each controller action after its def line, and mark public actions nothing routes to' },
6190
+ React.createElement('span', { className: 'ide-settings-label' }, 'Controller route hints'),
6191
+ React.createElement('input', {
6192
+ type: 'checkbox',
6193
+ className: 'ide-settings-checkbox',
6194
+ checked: editorPrefs.routeHints !== false,
6195
+ onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { routeHints: v }); }); }
6196
+ })
6197
+ ),
6198
+
5770
6199
  /* ── RuboCop ─────────────────────────────────── */
5771
6200
  React.createElement('div', { className: 'ide-settings-section-header' }, 'RuboCop'),
5772
6201
  React.createElement(
@@ -5798,6 +6227,7 @@ var MbeditorApp = function MbeditorApp() {
5798
6227
  {
5799
6228
  className: 'ide-settings-reset-btn',
5800
6229
  type: 'button',
6230
+ title: 'Restore every editor preference on this page to its default',
5801
6231
  onClick: function() { setEditorPrefs(Object.assign({}, DEFAULT_EDITOR_PREFS)); }
5802
6232
  },
5803
6233
  React.createElement('i', { className: 'fas fa-undo', style: { marginRight: 6 } }),
@@ -5824,17 +6254,14 @@ var MbeditorApp = function MbeditorApp() {
5824
6254
  paneId: pane.id,
5825
6255
  markers: markers[pActiveTab.id] || [],
5826
6256
  gitAvailable: gitAvailable,
5827
- testAvailable: testAvailable,
5828
6257
  treeData: treeData,
5829
6258
  testResult: testResult,
5830
6259
  testPanelFile: testPanelFile,
5831
- testLoading: testLoading,
5832
6260
  testInlineVisible: testInlineVisible,
5833
6261
  editorPrefs: editorPrefs,
5834
6262
  monacoReady: monacoReady,
5835
6263
  onFormat: function() { onFormatRef.current(); },
5836
6264
  onSave: function() { handleSave(pane.id, pActiveTab); },
5837
- onRunTest: handleRunTest,
5838
6265
  onRunTestAtCursor: handleRunTestAtCursor,
5839
6266
  onShowHistory: function(path) { setHistoryPanelPath(path); },
5840
6267
  onContentChange: function onContentChange(val) {
@@ -6018,8 +6445,14 @@ var MbeditorApp = function MbeditorApp() {
6018
6445
  onClose: function () { setShowProblemsPanel(false); },
6019
6446
  onOpenFile: function (path, line, col) {
6020
6447
  handleSelectFile(path, path.split('/').pop(), line, col);
6021
- }
6022
- })
6448
+ },
6449
+ // `rubocop -a` writes through a subprocess, so the server's
6450
+ // files_changed broadcast is the only notice — and there is no
6451
+ // broadcast at all without a cable connection. Re-read every open tab
6452
+ // here too: clean tabs take the corrected text (and re-lint), dirty
6453
+ // ones get the usual reload prompt instead of being silently clobbered.
6454
+ onFilesRewritten: function () { checkOpenTabsForExternalChanges(); }
6455
+ }),
6023
6456
  ),
6024
6457
 
6025
6458
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
@@ -6079,33 +6512,9 @@ var MbeditorApp = function MbeditorApp() {
6079
6512
  },
6080
6513
  React.createElement("i", { className: "fas fa-bug statusbar-problems-error-icon" }),
6081
6514
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.errors),
6082
- React.createElement("i", {
6083
- className: "fas fa-exclamation-triangle statusbar-problems-warning-icon",
6084
- style: { marginLeft: "8px" }
6085
- }),
6515
+ React.createElement("i", { className: "fas fa-exclamation-triangle statusbar-problems-warning-icon" }),
6086
6516
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
6087
6517
  ),
6088
- // ruby-lsp indicator. Hidden entirely when ruby-lsp was never available
6089
- // and nothing has gone wrong — a permanent "off" badge in a project with
6090
- // no Ruby is noise. A healthy server gets a quiet icon; a degraded one
6091
- // gets an amber chip you can click to restart.
6092
- (lspHealth.status !== 'off' || lspHealth.reason) && React.createElement(
6093
- "button",
6094
- {
6095
- type: "button",
6096
- className: "statusbar-btn statusbar-lsp statusbar-lsp-" + lspHealth.status,
6097
- onClick: restartRubyLsp,
6098
- title: lspHealth.status === 'ok'
6099
- ? 'ruby-lsp is running — click to restart'
6100
- : 'ruby-lsp unavailable' + (lspHealth.reason ? ': ' + lspHealth.reason : '') +
6101
- '. Falling back to search-based lookups. Click to retry.'
6102
- },
6103
- React.createElement("i", {
6104
- className: "fas " + (lspHealth.status === 'ok' ? 'fa-gem' : 'fa-plug'),
6105
- "aria-hidden": "true"
6106
- }),
6107
- lspHealth.status !== 'ok' && React.createElement("span", null, " ruby-lsp")
6108
- ),
6109
6518
  !serverOnline && (function () {
6110
6519
  var dirtyCount = state.panes.reduce(function (acc, p) {
6111
6520
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;
@@ -6424,6 +6833,23 @@ var MbeditorApp = function MbeditorApp() {
6424
6833
  " Delete"
6425
6834
  ),
6426
6835
  React.createElement("div", { className: "context-menu-divider" }),
6836
+ contextMenu.node && contextMenu.node.type === 'file' && React.createElement(
6837
+ "div",
6838
+ { className: "context-menu-item", onClick: function () {
6839
+ return handleContextMenuAction('download');
6840
+ } },
6841
+ React.createElement("i", { className: "fas fa-download context-menu-icon" }),
6842
+ " Download"
6843
+ ),
6844
+ React.createElement(
6845
+ "div",
6846
+ { className: "context-menu-item", onClick: function () {
6847
+ return handleContextMenuAction('upload');
6848
+ } },
6849
+ React.createElement("i", { className: "fas fa-upload context-menu-icon" }),
6850
+ " Upload Files Here..."
6851
+ ),
6852
+ React.createElement("div", { className: "context-menu-divider" }),
6427
6853
  React.createElement(
6428
6854
  "div",
6429
6855
  { className: "context-menu-item", onClick: function () {
@@ -6499,6 +6925,14 @@ var MbeditorApp = function MbeditorApp() {
6499
6925
  )
6500
6926
  ),
6501
6927
 
6928
+ /* ── Upload dialog ─────────────────────────────────────────────────── */
6929
+ importDialog && React.createElement(ImportDialog, {
6930
+ initialFolder: importDialog.initialFolder,
6931
+ docs: SearchService.allDocs(),
6932
+ onCancel: function () { setImportDialog(null); },
6933
+ onImport: confirmImportDialog
6934
+ }),
6935
+
6502
6936
  /* ── Import conflict modal ─────────────────────────────────────────── */
6503
6937
  importConflict && React.createElement(ImportConflictModal, {
6504
6938
  conflicts: importConflict.result.conflicts,