mbeditor 0.12.9 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +102 -25
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +268 -53
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +31 -5
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
@@ -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
 
@@ -347,6 +362,49 @@ var MbeditorApp = function MbeditorApp() {
347
362
  var searchWholeWordRef = useRef(false);
348
363
  var searchResultsContainerRef = useRef(null);
349
364
 
365
+ // Search results are windowed: a project-wide query can return up to
366
+ // SearchReplaceService::MAX_RESULTS (10_000) rows, and every row is five
367
+ // elements, so rendering the list in full built ~50k nodes — enough to kill
368
+ // 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;
372
+ var SEARCH_ROW_BUFFER = 5;
373
+ var _useStateSV = useState({ scrollTop: 0, height: 0 });
374
+ var _useStateSV2 = _slicedToArray(_useStateSV, 2);
375
+ var searchViewport = _useStateSV2[0];
376
+ var setSearchViewport = _useStateSV2[1];
377
+
378
+ // The container's height is only known once it is on screen, and a viewport
379
+ // of 0 would render a single row and never grow — nothing would scroll, so
380
+ // no scroll event would arrive to correct it.
381
+ //
382
+ // Measured from a callback ref rather than an effect: the results list
383
+ // mounts and unmounts as the sidebar tab changes and as a query goes from
384
+ // no-results to results, none of which an effect's dependency list sees.
385
+ // Same reason the model graph wires its pan/zoom this way.
386
+ var searchViewportObserverRef = useRef(null);
387
+ var measureSearchViewport = function measureSearchViewport(el) {
388
+ if (!el) return;
389
+ setSearchViewport(function (prev) {
390
+ if (prev.height === el.clientHeight && prev.scrollTop === el.scrollTop) return prev;
391
+ return { scrollTop: el.scrollTop, height: el.clientHeight };
392
+ });
393
+ };
394
+ var attachSearchResults = useRef(function (el) {
395
+ if (searchViewportObserverRef.current) {
396
+ searchViewportObserverRef.current.disconnect();
397
+ searchViewportObserverRef.current = null;
398
+ }
399
+ searchResultsContainerRef.current = el;
400
+ if (!el) return;
401
+ measureSearchViewport(el);
402
+ if (typeof ResizeObserver === 'undefined') return;
403
+ var obs = new ResizeObserver(function () { measureSearchViewport(el); });
404
+ obs.observe(el);
405
+ searchViewportObserverRef.current = obs;
406
+ }).current;
407
+
350
408
  var _useStateRM = useState(false);
351
409
  var _useStateRM2 = _slicedToArray(_useStateRM, 2);
352
410
  var replaceMode = _useStateRM2[0];
@@ -408,6 +466,12 @@ var MbeditorApp = function MbeditorApp() {
408
466
  var importConflict = _useStateImportConflict2[0];
409
467
  var setImportConflict = _useStateImportConflict2[1];
410
468
 
469
+ // { initialFolder } while the upload dialog is open, null otherwise.
470
+ var _useStateImportDialog = useState(null);
471
+ var _useStateImportDialog2 = _slicedToArray(_useStateImportDialog, 2);
472
+ var importDialog = _useStateImportDialog2[0];
473
+ var setImportDialog = _useStateImportDialog2[1];
474
+
411
475
  var _useStateSchemaLoading = useState(null);
412
476
  var _useStateSchemaLoading2 = _slicedToArray(_useStateSchemaLoading, 2);
413
477
  var schemaLoadingLabel = _useStateSchemaLoading2[0];
@@ -497,6 +561,11 @@ var MbeditorApp = function MbeditorApp() {
497
561
  var showProblemsPanel = _useStateProblems2[0];
498
562
  var setShowProblemsPanel = _useStateProblems2[1];
499
563
 
564
+ var _useStateTestRun = useState(false);
565
+ var _useStateTestRun2 = _slicedToArray(_useStateTestRun, 2);
566
+ var showTestRunPanel = _useStateTestRun2[0];
567
+ var setShowTestRunPanel = _useStateTestRun2[1];
568
+
500
569
  // Error/warning tallies across the open tabs, mirrored into the status bar.
501
570
  var _useStateProblemCounts = useState({ errors: 0, warnings: 0 });
502
571
  var _useStateProblemCounts2 = _slicedToArray(_useStateProblemCounts, 2);
@@ -757,6 +826,11 @@ var MbeditorApp = function MbeditorApp() {
757
826
  if (typeof content === 'string') {
758
827
  lastDiskContentRef.current[path] = content.replace(/\r\n/g, '\n');
759
828
  }
829
+ // The file on disk just moved, so any line-diff answer we are holding for
830
+ // it predates the write and must not be reused for the tint.
831
+ if (typeof GitService !== 'undefined' && GitService.invalidateLineDiff) {
832
+ GitService.invalidateLineDiff(path);
833
+ }
760
834
  }
761
835
 
762
836
  // ── Draft backup helpers ─────────────────────────────────────────────────
@@ -821,6 +895,13 @@ var MbeditorApp = function MbeditorApp() {
821
895
  });
822
896
  };
823
897
 
898
+ // filterDotFiles rebuilds the whole tree, so calling it inline in the render
899
+ // handed FileTreeMemo a fresh array every time and its `prev.items ===
900
+ // next.items` check never held — the memo was there but never fired.
901
+ var fileTreeItems = useMemo(function () {
902
+ return editorPrefs.showDotFiles ? treeData : filterDotFiles(treeData || []);
903
+ }, [treeData, editorPrefs.showDotFiles]);
904
+
824
905
  var normalizeRelativePath = function normalizeRelativePath(input) {
825
906
  return (input || "").replace(/\\/g, "/").trim().replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/+/g, "/");
826
907
  };
@@ -863,10 +944,22 @@ var MbeditorApp = function MbeditorApp() {
863
944
  // cached page was served for the same query. The per-path delta refresh on
864
945
  // the files_changed push can't cover it either: it re-scans named files,
865
946
  // and a file that just appeared or vanished isn't in the previous result set.
947
+ // True when the server will push a files_changed broadcast for a write we are
948
+ // about to make, so the coalesced handler will refresh the tree and git for
949
+ // us. Every mutation used to refresh both itself as well, which doubled or
950
+ // tripled the most expensive requests the editor makes. Without a socket
951
+ // nothing arrives, so callers keep their own refresh for that case.
952
+ var _socketWillBroadcast = function _socketWillBroadcast() {
953
+ return typeof WebSocketService !== 'undefined' &&
954
+ typeof WebSocketService.isCableAvailable === 'function' &&
955
+ WebSocketService.isCableAvailable();
956
+ };
957
+
866
958
  var refreshProjectTree = function refreshProjectTree() {
867
959
  return FileService.getTree().then(function (data) {
868
- setTreeData(data || []);
869
- SearchService.buildIndex(data || []);
960
+ // _treeUpdater keeps the previous array (and skips the index rebuild)
961
+ // when the tree is unchanged; going round it re-rendered the whole app.
962
+ setTreeData(_treeUpdater(data || []));
870
963
  SearchService.invalidate();
871
964
  if (searchQueryRef.current && searchPanelVisibleRef.current) {
872
965
  _debouncedSearch(searchQueryRef.current);
@@ -988,15 +1081,18 @@ var MbeditorApp = function MbeditorApp() {
988
1081
  });
989
1082
  };
990
1083
 
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;
1084
+ // The one writer for the markers map. Two things it must not do:
1085
+ //
1086
+ // * write a fresh map when nothing changed — the auto-lint fires per
1087
+ // keystroke on JS files and re-clearing an already-empty marker list
1088
+ // would re-render the whole app every time;
1089
+ // * mutate the tab object in the store — EditorStore's contract is that
1090
+ // every nested value is replaced, never edited in place, or
1091
+ // subscribeToSlice cannot see the change. TabBar reads this map instead.
1092
+ var applyMarkersForTab = function applyMarkersForTab(tabId, nextMarkers) {
999
1093
  setMarkers(function (prev) {
1094
+ var cur = prev[tabId];
1095
+ if (cur && cur.length === 0 && nextMarkers.length === 0) return prev;
1000
1096
  return _extends({}, prev, _defineProperty({}, tabId, nextMarkers));
1001
1097
  });
1002
1098
  };
@@ -1006,6 +1102,20 @@ var MbeditorApp = function MbeditorApp() {
1006
1102
 
1007
1103
  if (!tab || (!isRubyPath(tab.path) && !tab.path.endsWith('.haml'))) return Promise.resolve(null);
1008
1104
 
1105
+ // An empty buffer has no offenses, and answering that here rather than over
1106
+ // the wire skips the most expensive request the Ruby path makes: a
1107
+ // whole-document RuboCop run, budgeted at 10s server-side. It matters
1108
+ // because every newly created .rb file starts empty and is opened
1109
+ // immediately, so the lint fired on a document with nothing in it.
1110
+ // Resolving with an empty marker set (rather than null) keeps the
1111
+ // marker-clearing below intact, so deleting a file's contents still clears
1112
+ // its squiggles.
1113
+ if (!String(tab.content || '').trim()) {
1114
+ applyMarkersForTab(tab.id, []);
1115
+ if (options.showStatus) EditorStore.setStatus('No RuboCop offenses!', 'success');
1116
+ return Promise.resolve({ markers: [] });
1117
+ }
1118
+
1009
1119
  if (options.showLoading) {
1010
1120
  setLoading(function (prev) {
1011
1121
  return _extends({}, prev, { lint: true });
@@ -1037,7 +1147,7 @@ var MbeditorApp = function MbeditorApp() {
1037
1147
 
1038
1148
  return lintRequest.then(function (res) {
1039
1149
  var nextMarkers = res.markers || [];
1040
- applyMarkersForTab(paneId, tab.id, nextMarkers);
1150
+ applyMarkersForTab(tab.id, nextMarkers);
1041
1151
 
1042
1152
  if (options.showStatus) {
1043
1153
  var count = res.summary && res.summary.offense_count || 0;
@@ -1075,16 +1185,7 @@ var MbeditorApp = function MbeditorApp() {
1075
1185
  if (parserName && window.prettier && window.prettierPlugins) {
1076
1186
  var prefs = EditorStore.getState().editorPrefs || DEFAULT_EDITOR_PREFS;
1077
1187
  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
- });
1188
+ applyMarkersForTab(tab.id, []);
1088
1189
  })["catch"](function (err) {
1089
1190
  var newMarkers = [];
1090
1191
  if (err && err.loc) {
@@ -1101,16 +1202,7 @@ var MbeditorApp = function MbeditorApp() {
1101
1202
  endCol: endLoc ? endLoc.column : loc.column + 1
1102
1203
  });
1103
1204
  }
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
- });
1205
+ applyMarkersForTab(tab.id, newMarkers);
1114
1206
  });
1115
1207
  }
1116
1208
  }, 600)).current;
@@ -1168,6 +1260,12 @@ var MbeditorApp = function MbeditorApp() {
1168
1260
  if (workspace && typeof workspace.redmineEnabled === 'boolean') {
1169
1261
  setRedmineEnabled(workspace.redmineEnabled);
1170
1262
  }
1263
+ if (workspace && (workspace.testTimeout || workspace.testAllTimeout)) {
1264
+ FileService.setTestTimeouts({
1265
+ test: workspace.testTimeout,
1266
+ testAll: workspace.testAllTimeout
1267
+ });
1268
+ }
1171
1269
  if (workspace && typeof workspace.testAvailable === 'boolean') {
1172
1270
  setTestAvailable(workspace.testAvailable);
1173
1271
  }
@@ -1683,8 +1781,32 @@ var MbeditorApp = function MbeditorApp() {
1683
1781
  // WebSocket push — when the server broadcasts files_changed, refresh the tree
1684
1782
  // and git status immediately (same work as the 10s poll below does).
1685
1783
  useEffect(function () {
1686
- function handleFilesChanged(payload) {
1687
- if (document.hidden) return;
1784
+ // One broadcast per written file, so a bulk write (Save All, Format All,
1785
+ // a drag-and-drop import, a rename) used to cost one full tree walk and
1786
+ // one git status PER FILE, all in flight at once. On a real host repo
1787
+ // those are the two most expensive requests the editor makes, and with
1788
+ // the browser's six sockets per host the saves themselves ended up queued
1789
+ // behind their own fallout — past ~30 files the POSTs hit the 30 s axios
1790
+ // timeout and Save All reported failure for writes that had succeeded.
1791
+ // The refresh is idempotent, so a burst only needs one of each.
1792
+ var timer = null;
1793
+ var pendingPaths = [];
1794
+ var pendingFullCheck = false;
1795
+ var pendingStructural = false;
1796
+
1797
+ function flush() {
1798
+ timer = null;
1799
+ // The named paths and "re-check everything" are two separate questions.
1800
+ // Collapsing them lost the paths whenever a path-less broadcast (a new
1801
+ // directory, say) landed in the same window as a save, and the saved
1802
+ // file's search rows then stayed stale until the next edit.
1803
+ var paths = pendingPaths;
1804
+ var fullCheck = pendingFullCheck;
1805
+ var structural = pendingStructural;
1806
+ pendingPaths = [];
1807
+ pendingFullCheck = false;
1808
+ pendingStructural = false;
1809
+ var tabsToCheck = (fullCheck || paths.length === 0) ? null : paths;
1688
1810
  // The cheap /git_status probe, not the full /git_info fan-out. This
1689
1811
  // fires on every save, and the fan-out is the most expensive request
1690
1812
  // the editor makes — on a dev server with a handful of threads it
@@ -1693,17 +1815,75 @@ var MbeditorApp = function MbeditorApp() {
1693
1815
  // branch and file list immediately and escalates to the fan-out on its
1694
1816
  // own when the branch actually changed.
1695
1817
  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); });
1818
+
1819
+ // A save changes a file's contents, not the shape of the workspace, so
1820
+ // there is nothing in the tree for it to invalidate: the names, the
1821
+ // nesting and the quick-open index are all exactly as they were. Walking
1822
+ // the whole workspace to learn that and then rebuilding the MiniSearch
1823
+ // index from the result, because the file's byte count moved and made
1824
+ // the payload compare unequal — was pure cost on the save path, and it
1825
+ // grew with the size of the checkout rather than with the edit.
1826
+ // Git badges come from git_status above, not from this payload.
1827
+ if (structural) {
1828
+ FileService.getTree().then(function (data) {
1829
+ setTreeData(_treeUpdater(data || []));
1830
+ checkOpenTabsForExternalChanges(tabsToCheck);
1831
+ })["catch"](function () {});
1832
+ } else {
1833
+ checkOpenTabsForExternalChanges(tabsToCheck);
1834
+ }
1835
+
1836
+ if (paths.length && searchQueryRef.current && searchPanelVisibleRef.current) {
1837
+ paths.forEach(function (p) { _pendingSearchRefreshPaths.current.add(p); });
1702
1838
  _flushSearchRefresh();
1703
1839
  }
1704
1840
  }
1841
+
1842
+ function handleFilesChanged(payload) {
1843
+ if (document.hidden) return;
1844
+ // No paths named means "something changed, re-check everything" — it
1845
+ // must not be swallowed by a burst that did name paths.
1846
+ if (!payload || !payload.paths) pendingFullCheck = true;
1847
+ else pendingPaths = pendingPaths.concat(payload.paths);
1848
+ // Absent (an older server) counts as structural — the conservative read.
1849
+ if (!payload || payload.structural !== false) pendingStructural = true;
1850
+ if (timer) return;
1851
+ timer = setTimeout(flush, 200);
1852
+ }
1853
+ // Deferring the refresh means it can now come due after the page has begun
1854
+ // going away. Requests issued into a closing page are aborted mid-flight,
1855
+ // which wastes a tree walk and leaves the server holding a connection that
1856
+ // never completes.
1857
+ var cancelPending = function () {
1858
+ if (timer) clearTimeout(timer);
1859
+ timer = null;
1860
+ };
1861
+ window.addEventListener('pagehide', cancelPending);
1862
+
1705
1863
  WebSocketService.onFilesChanged(handleFilesChanged);
1706
- return function () { WebSocketService.offFilesChanged(handleFilesChanged); };
1864
+ return function () {
1865
+ cancelPending();
1866
+ window.removeEventListener('pagehide', cancelPending);
1867
+ WebSocketService.offFilesChanged(handleFilesChanged);
1868
+ };
1869
+ }, []);
1870
+
1871
+ // A branch switch changes every tracked file at once, and no push announces
1872
+ // it — the broadcast only covers mbeditor's own writes. Re-read the tree and
1873
+ // every open tab, the same work a manual workspace refresh does. Clean tabs
1874
+ // take the new branch's content; dirty ones queue the usual reload prompt,
1875
+ // so unsaved work is never overwritten.
1876
+ useEffect(function () {
1877
+ function onBranchChanged() {
1878
+ SearchService.invalidate();
1879
+ FileService.getTree({ refresh: true }).then(function (data) {
1880
+ setTreeData(_treeUpdater(data || []));
1881
+ })["catch"](function () {})["finally"](function () {
1882
+ checkOpenTabsForExternalChanges();
1883
+ });
1884
+ }
1885
+ window.addEventListener('mbeditor:branch-changed', onBranchChanged);
1886
+ return function () { window.removeEventListener('mbeditor:branch-changed', onBranchChanged); };
1707
1887
  }, []);
1708
1888
 
1709
1889
  // WebSocket push — when a peer saves a collaboratively-bound file, the CRDT has
@@ -1926,7 +2106,13 @@ var MbeditorApp = function MbeditorApp() {
1926
2106
  if (timer) clearTimeout(timer);
1927
2107
  timer = setTimeout(function () {
1928
2108
  timer = null;
1929
- setProblemCounts(window.ProblemsPanel.counts());
2109
+ var next = window.ProblemsPanel.counts();
2110
+ // Two object literals are never Object.is-equal, so handing React a
2111
+ // fresh {errors, warnings} re-rendered the app on every marker change
2112
+ // — which for a JS file is every keystroke. Compare the fields.
2113
+ setProblemCounts(function (prev) {
2114
+ return (prev && prev.errors === next.errors && prev.warnings === next.warnings) ? prev : next;
2115
+ });
1930
2116
  }, 250);
1931
2117
  };
1932
2118
 
@@ -1968,6 +2154,33 @@ var MbeditorApp = function MbeditorApp() {
1968
2154
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
1969
2155
  };
1970
2156
 
2157
+ // Both of these are keyed by something that dies with the tab — markers by
2158
+ // tab id, the external-change baseline by path — and nothing dropped them, so
2159
+ // a long session held a full copy of every file it had ever opened. Run after
2160
+ // a close (single or bulk) and reconcile against what is still open: a path
2161
+ // the other pane still shows keeps its baseline, which a per-tab delete
2162
+ // would have got wrong.
2163
+ var forgetClosedTabs = function forgetClosedTabs() {
2164
+ var openIds = {};
2165
+ var openPaths = {};
2166
+ EditorStore.getState().panes.forEach(function (p) {
2167
+ p.tabs.forEach(function (t) {
2168
+ openIds[t.id] = true;
2169
+ if (t.path) openPaths[t.path] = true;
2170
+ });
2171
+ });
2172
+ Object.keys(lastDiskContentRef.current).forEach(function (path) {
2173
+ if (!openPaths[path]) delete lastDiskContentRef.current[path];
2174
+ });
2175
+ setMarkers(function (prev) {
2176
+ var stale = Object.keys(prev).filter(function (id) { return !openIds[id]; });
2177
+ if (stale.length === 0) return prev;
2178
+ var next = _extends({}, prev);
2179
+ stale.forEach(function (id) { delete next[id]; });
2180
+ return next;
2181
+ });
2182
+ };
2183
+
1971
2184
  var requestCloseTab = function requestCloseTab(paneId, id) {
1972
2185
  var pane = state.panes.find(function (p) {
1973
2186
  return p.id === paneId;
@@ -1987,6 +2200,7 @@ var MbeditorApp = function MbeditorApp() {
1987
2200
  });
1988
2201
  })
1989
2202
  });
2203
+ forgetClosedTabs();
1990
2204
  }
1991
2205
  };
1992
2206
 
@@ -2040,6 +2254,7 @@ var MbeditorApp = function MbeditorApp() {
2040
2254
  });
2041
2255
  })
2042
2256
  });
2257
+ forgetClosedTabs();
2043
2258
  })["catch"](function (err) {
2044
2259
  EditorStore.setStatus("Save failed: " + err.message, "error");
2045
2260
  })["finally"](function () {
@@ -2059,6 +2274,7 @@ var MbeditorApp = function MbeditorApp() {
2059
2274
  });
2060
2275
  })
2061
2276
  });
2277
+ forgetClosedTabs();
2062
2278
  setClosingTabId(null);
2063
2279
  setClosingPaneId(null);
2064
2280
  }
@@ -2083,6 +2299,7 @@ var MbeditorApp = function MbeditorApp() {
2083
2299
  if (!confirmBulkClose(allTabs, "all editors")) return;
2084
2300
 
2085
2301
  TabManager.closeAllTabs();
2302
+ forgetClosedTabs();
2086
2303
  setClosingTabId(null);
2087
2304
  setClosingPaneId(null);
2088
2305
  EditorStore.setStatus("Closed " + allTabs.length + " editor" + (allTabs.length === 1 ? "" : "s"), "info");
@@ -2096,6 +2313,7 @@ var MbeditorApp = function MbeditorApp() {
2096
2313
  if (!confirmBulkClose(pane.tabs, "all editors in Group " + paneId)) return;
2097
2314
 
2098
2315
  TabManager.closeAllTabsInPane(paneId);
2316
+ forgetClosedTabs();
2099
2317
  setClosingTabId(null);
2100
2318
  setClosingPaneId(null);
2101
2319
  EditorStore.setStatus("Closed " + pane.tabs.length + " editor" + (pane.tabs.length === 1 ? "" : "s") + " in Group " + paneId, "info");
@@ -2108,6 +2326,7 @@ var MbeditorApp = function MbeditorApp() {
2108
2326
  if (others.length === 0) return;
2109
2327
  if (!confirmBulkClose(others, "other editors")) return;
2110
2328
  TabManager.closeOtherTabsInPane(paneId, keepId);
2329
+ forgetClosedTabs();
2111
2330
  EditorStore.setStatus("Closed " + others.length + " editor" + (others.length === 1 ? "" : "s"), "info");
2112
2331
  };
2113
2332
 
@@ -2117,6 +2336,7 @@ var MbeditorApp = function MbeditorApp() {
2117
2336
  var saved = pane.tabs.filter(function (t) { return !t.dirty; });
2118
2337
  if (saved.length === 0) return;
2119
2338
  TabManager.closeSavedTabsInPane(paneId);
2339
+ forgetClosedTabs();
2120
2340
  EditorStore.setStatus("Closed " + saved.length + " saved editor" + (saved.length === 1 ? "" : "s"), "info");
2121
2341
  };
2122
2342
 
@@ -2134,6 +2354,7 @@ var MbeditorApp = function MbeditorApp() {
2134
2354
  GitService.fetchStatusLite({ background: true });
2135
2355
  FileService.getTree().then(function (data) { setTreeData(_treeUpdater(data || [])); })["catch"](function () {});
2136
2356
  TabManager.closeTab(paneId, tab.id);
2357
+ forgetClosedTabs();
2137
2358
  if (!(opts && opts.close)) {
2138
2359
  TabManager.openTab(newPath, newPath.split('/').pop(), null, paneId);
2139
2360
  }
@@ -2296,7 +2517,10 @@ var MbeditorApp = function MbeditorApp() {
2296
2517
  var RAILS_MAX_RESOURCES = 10;
2297
2518
 
2298
2519
  // Map resource label → representative path (capped at RAILS_MAX_RESOURCES, focused pane first)
2299
- var railsResourceDeps = (function() {
2520
+ // Memoised: all three of these walk every tab in every pane and were rebuilt
2521
+ // on every render, including the ones a keystroke causes.
2522
+ // resourceLabelFromPath reads customPathsRef, hence customPaths in the deps.
2523
+ var railsResourceDeps = useMemo(function() {
2300
2524
  var deps = {};
2301
2525
  var panesOrdered = state.panes.slice().sort(function(a, b) {
2302
2526
  return a.id === state.focusedPaneId ? -1 : b.id === state.focusedPaneId ? 1 : 0;
@@ -2313,10 +2537,10 @@ var MbeditorApp = function MbeditorApp() {
2313
2537
  });
2314
2538
  });
2315
2539
  return deps;
2316
- })();
2540
+ }, [state.panes, state.focusedPaneId, customPaths]);
2317
2541
  var railsResourceDepStr = Object.keys(railsResourceDeps).sort().join('|');
2318
2542
 
2319
- var railsOverflow = (function() {
2543
+ var railsOverflow = useMemo(function() {
2320
2544
  var all = {};
2321
2545
  state.panes.forEach(function(p) {
2322
2546
  p.tabs.forEach(function(t) {
@@ -2326,9 +2550,9 @@ var MbeditorApp = function MbeditorApp() {
2326
2550
  });
2327
2551
  });
2328
2552
  return Math.max(0, Object.keys(all).length - Object.keys(railsResourceDeps).length);
2329
- })();
2553
+ }, [state.panes, railsResourceDeps, customPaths]);
2330
2554
 
2331
- var dirtyPaths = (function() {
2555
+ var dirtyPaths = useMemo(function() {
2332
2556
  var set = {};
2333
2557
  state.panes.forEach(function(p) {
2334
2558
  p.tabs.forEach(function(t) {
@@ -2336,7 +2560,7 @@ var MbeditorApp = function MbeditorApp() {
2336
2560
  });
2337
2561
  });
2338
2562
  return set;
2339
- })();
2563
+ }, [state.panes]);
2340
2564
 
2341
2565
  useEffect(function() {
2342
2566
  if (activeSidebarTab !== 'rails') return;
@@ -2552,17 +2776,43 @@ var MbeditorApp = function MbeditorApp() {
2552
2776
  // unchanged state is an identical string and React bails rather than
2553
2777
  // re-rendering the app every tick.
2554
2778
  useEffect(function () {
2779
+ // A reconnect is not a fault. Action Cable drops and re-establishes its
2780
+ // socket routinely, and a dev server busy with a burst of saves is enough
2781
+ // to cause it — the cable shares the same thread pool as every request the
2782
+ // editor makes. Reporting the first non-connected sample made the chip
2783
+ // announce "Pairing off" during exactly the moments the editor was already
2784
+ // struggling, then clear a few seconds later, which reads as a second
2785
+ // failure rather than as the self-healing reconnect it actually is.
2786
+ //
2787
+ // So a transient state has to be seen twice before it is shown, with the
2788
+ // confirming check brought forward so a real outage still surfaces in
2789
+ // seconds. Hard failures are exempt: missing libraries, no Action Cable, a
2790
+ // server that does not advertise it, or a *rejected* handshake never heal
2791
+ // on their own, so they report on the first sample as before.
2792
+ var TRANSIENT_PROBLEMS = { connected: true };
2793
+ var unconfirmed = null;
2794
+ var recheckTimer = null;
2795
+
2555
2796
  function check() {
2556
2797
  if (typeof CollaborationService === 'undefined' ||
2557
2798
  typeof CollaborationService.diagnostics !== 'function') return;
2558
2799
  var d = CollaborationService.diagnostics();
2559
2800
  // "Nobody else is here" is not a fault; everything else is.
2560
2801
  var problem = d.firstProblem && d.firstProblem.key !== 'peers' ? d.firstProblem.key : null;
2802
+ var transient = !!problem && !!TRANSIENT_PROBLEMS[problem] && d.cableStatus !== 'rejected';
2803
+
2804
+ if (transient && unconfirmed !== problem) {
2805
+ unconfirmed = problem;
2806
+ clearTimeout(recheckTimer);
2807
+ recheckTimer = setTimeout(check, 3000);
2808
+ return;
2809
+ }
2810
+ unconfirmed = transient ? problem : null;
2561
2811
  setCollabTrouble(function (prev) { return prev === problem ? prev : problem; });
2562
2812
  }
2563
2813
  check();
2564
2814
  var id = setInterval(check, 10000);
2565
- return function () { clearInterval(id); };
2815
+ return function () { clearInterval(id); clearTimeout(recheckTimer); };
2566
2816
  }, []);
2567
2817
 
2568
2818
  var collabPeerIds = Object.keys(collabRoster);
@@ -2689,7 +2939,7 @@ var MbeditorApp = function MbeditorApp() {
2689
2939
 
2690
2940
  // Clear markers and skip auto-lint when RuboCop linting is disabled
2691
2941
  if (isRubyPath(activeTab.path) && editorPrefs.rubocopLintEnabled === false) {
2692
- setMarkers(function(prev) { return _extends({}, prev, _defineProperty({}, activeTab.id, [])); });
2942
+ applyMarkersForTab(activeTab.id, []);
2693
2943
  return;
2694
2944
  }
2695
2945
 
@@ -2767,7 +3017,18 @@ var MbeditorApp = function MbeditorApp() {
2767
3017
  return runPrettier(tab.content, editorPrefs, parserName)["catch"](function () { return null; });
2768
3018
  };
2769
3019
 
3020
+ // Re-read a tab from the store after flushing TabManager's throttled content
3021
+ // write. `tab` here is whatever the render captured, so flushing alone is not
3022
+ // enough — the fresh text lands in the store, not in this object.
3023
+ var _freshTab = function _freshTab(paneId, tab) {
3024
+ TabManager.flushContent();
3025
+ var pane = EditorStore.getState().panes.find(function (p) { return p.id === paneId; });
3026
+ var live = pane && pane.tabs.find(function (t) { return t.id === tab.id; });
3027
+ return live || tab;
3028
+ };
3029
+
2770
3030
  var handleSave = function handleSave(paneId, tab) {
3031
+ tab = _freshTab(paneId, tab);
2771
3032
  if (editorPrefs.formatOnSave === true) {
2772
3033
  EditorStore.setStatus("Formatting " + tab.name + "...", "info");
2773
3034
  _formatContentForSave(tab).then(function (formatted) {
@@ -2844,21 +3105,25 @@ var MbeditorApp = function MbeditorApp() {
2844
3105
  FileService.lintFile(tab.path, tab.content, 'javascript').then(function (res) {
2845
3106
  var babelMarkers = (res && res.markers) || [];
2846
3107
  if (babelMarkers.length > 0) {
2847
- applyMarkersForTab(paneId, tab.id, babelMarkers);
3108
+ applyMarkersForTab(tab.id, babelMarkers);
2848
3109
  EditorStore.setStatus('Saved — babel: ' + babelMarkers[0].message, 'warning');
2849
3110
  } else {
2850
3111
  // Clear any previous babel marker for this tab (keep others none —
2851
3112
  // 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
- }
3113
+ // applyMarkersForTab keeps the previous map when it is already empty.
3114
+ applyMarkersForTab(tab.id, []);
2857
3115
  }
2858
3116
  })["catch"](function () {});
2859
3117
  }
2860
3118
 
2861
- GitService.fetchStatusLite({ background: true });
3119
+ // The server broadcasts files_changed for this very write, and the
3120
+ // coalesced handler refreshes git status there — so firing here too ran
3121
+ // the git work twice per save, and this copy is the one a burst cannot
3122
+ // coalesce. fetchStatusLite escalates to the full /git_info fan-out
3123
+ // whenever the working tree signature moved, which saving a different
3124
+ // file every time does by definition, so the duplicate was the expensive
3125
+ // one. Without a socket no broadcast arrives, so keep it for that case.
3126
+ if (!_socketWillBroadcast()) GitService.fetchStatusLite({ background: true });
2862
3127
  })["catch"](function (err) {
2863
3128
  EditorStore.setStatus("Save failed: " + err.message, "error");
2864
3129
  })["finally"](function () {
@@ -2932,7 +3197,10 @@ var MbeditorApp = function MbeditorApp() {
2932
3197
  }
2933
3198
 
2934
3199
  var handleSaveAll = function handleSaveAll() {
2935
- var dirtyTabs = state.panes.flatMap(function (p) {
3200
+ // Flush first, then read the panes back out of the store — the render's
3201
+ // `state` predates the flush. See TabManager's flush contract.
3202
+ TabManager.flushContent();
3203
+ var dirtyTabs = EditorStore.getState().panes.flatMap(function (p) {
2936
3204
  return p.tabs;
2937
3205
  }).filter(function (t) {
2938
3206
  // Untitled scratch tabs need a save-as prompt each — Ctrl+S them
@@ -2946,32 +3214,53 @@ var MbeditorApp = function MbeditorApp() {
2946
3214
  });
2947
3215
  EditorStore.setStatus("Saving " + dirtyTabs.length + " files...", "info");
2948
3216
  isSavingRef.current = true;
3217
+ // allSettled, not all: one slow or rejected write used to abandon the
3218
+ // bookkeeping for every other file, so tabs whose contents were already on
3219
+ // disk stayed dirty and the only thing on offer was to hit Save All again.
3220
+ // Each file is now settled on its own result.
2949
3221
  var promises = dirtyTabs.map(function (tab) {
2950
3222
  return FileService.saveFile(tab.path, tab.content);
2951
3223
  });
2952
- Promise.all(promises).then(function () {
2953
- dirtyTabs.forEach(function(tab) {
3224
+ Promise.allSettled(promises).then(function (results) {
3225
+ var saved = dirtyTabs.filter(function (tab, i) { return results[i].status === "fulfilled"; });
3226
+ // Object.create(null): these keys are file paths, and a file called
3227
+ // "constructor" would otherwise test as present against Object.prototype
3228
+ // and have its tab marked clean without ever being written.
3229
+ var savedPaths = Object.create(null);
3230
+ saved.forEach(function (tab) {
3231
+ savedPaths[tab.path] = tab.content;
2954
3232
  noteLocalSave(tab.path, tab.content);
2955
3233
  });
2956
3234
  var newPanes = EditorStore.getState().panes.map(function (p) {
2957
3235
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
3236
+ // Compare content, not just the path: a tab edited again while the
3237
+ // save was in flight is dirty against what actually reached disk.
3238
+ if (!(t.path in savedPaths) || t.content !== savedPaths[t.path]) return t;
2958
3239
  return _extends({}, t, { dirty: false, cleanContent: t.content });
2959
3240
  })
2960
3241
  });
2961
3242
  });
2962
3243
  EditorStore.setState({ panes: newPanes });
2963
3244
  // Reset AVI clean baselines for all saved files so undo past save shows dirty correctly.
2964
- dirtyTabs.forEach(function(tab) {
3245
+ saved.forEach(function(tab) {
2965
3246
  var _me = window.__mbeditorModels && window.__mbeditorModels[tab.path];
2966
3247
  if (_me && _me.model && !_me.model.isDisposed()) {
2967
3248
  _me.cleanVersionId = _me.model.getAlternativeVersionId();
2968
3249
  }
2969
3250
  });
2970
- EditorStore.setStatus("All files saved", "success");
3251
+ if (saved.length === dirtyTabs.length) {
3252
+ EditorStore.setStatus("All files saved", "success");
3253
+ } else {
3254
+ EditorStore.setStatus("Saved " + saved.length + " of " + dirtyTabs.length +
3255
+ " files — " + (dirtyTabs.length - saved.length) + " failed", "error");
3256
+ }
2971
3257
  SearchService.invalidate();
2972
3258
  GitService.fetchStatusLite({ background: true });
2973
- })["catch"](function (err) {
2974
- EditorStore.setStatus("Failed to save some files", "error");
3259
+ })["catch"](function () {
3260
+ // allSettled never rejects, so this only fires if the bookkeeping above
3261
+ // throws. Without it that would be an unhandled rejection and the status
3262
+ // bar would sit on "Saving N files..." forever.
3263
+ EditorStore.setStatus("Save All finished with errors", "error");
2975
3264
  })["finally"](function () {
2976
3265
  isSavingRef.current = false;
2977
3266
  return setLoading(function (prev) {
@@ -3029,7 +3318,7 @@ var MbeditorApp = function MbeditorApp() {
3029
3318
  return _extends({}, prev, { refreshWorkspace: true });
3030
3319
  });
3031
3320
  GitService.fetchStatus()["catch"](function () {});
3032
- FileService.getTree().then(function (data) {
3321
+ FileService.getTree({ refresh: true }).then(function (data) {
3033
3322
  setTreeData(_treeUpdater(data || []));
3034
3323
  checkOpenTabsForExternalChanges();
3035
3324
  EditorStore.setStatus("Workspace refreshed", "success");
@@ -3095,19 +3384,41 @@ var MbeditorApp = function MbeditorApp() {
3095
3384
  return runPrettier(tab.content, prefs, parserName);
3096
3385
  };
3097
3386
 
3387
+ // Markers are per-model and only the mounted editor re-lints, so a document
3388
+ // formatted in the background kept the diagnostics of the text it no longer
3389
+ // holds — the Problems panel and the status-bar tallies went on reporting
3390
+ // offenses at line numbers that had moved, and Format All barely moved the
3391
+ // counts because only the visible tab was re-checked. Dropping them says
3392
+ // "not known yet", which is true: the file re-lints when you open it.
3393
+ var discardStaleMarkers = function discardStaleMarkers(path) {
3394
+ if (!path || !window.monaco || !window.monaco.editor) return;
3395
+ var entry = window.__mbeditorModels && window.__mbeditorModels[path];
3396
+ if (!entry || !entry.model || entry.model.isDisposed()) return;
3397
+
3398
+ var owners = {};
3399
+ window.monaco.editor.getModelMarkers({ resource: entry.model.uri }).forEach(function (m) {
3400
+ owners[m.owner] = true;
3401
+ });
3402
+ Object.keys(owners).forEach(function (owner) {
3403
+ window.monaco.editor.setModelMarkers(entry.model, owner, []);
3404
+ });
3405
+ };
3406
+
3098
3407
  // Write formatted content back to a tab, dirty and unsaved — the user decides
3099
3408
  // when to save. EditorPanel applies it through executeEdits, so undo works.
3100
3409
  var applyFormattedContent = function applyFormattedContent(paneId, tabId, formatted) {
3410
+ var formattedPath = null;
3101
3411
  EditorStore.setState({
3102
3412
  panes: EditorStore.getState().panes.map(function (p) {
3103
3413
  if (p.id !== paneId) return p;
3104
3414
  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;
3415
+ if (t.id !== tabId) return t;
3416
+ formattedPath = t.path;
3417
+ return _extends({}, t, { content: formatted, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 });
3108
3418
  }) });
3109
3419
  })
3110
3420
  });
3421
+ discardStaleMarkers(formattedPath);
3111
3422
  };
3112
3423
 
3113
3424
  var highlightFormatChanges = function highlightFormatChanges(before, after) {
@@ -3124,8 +3435,8 @@ var MbeditorApp = function MbeditorApp() {
3124
3435
  var handleFormat = function handleFormat() {
3125
3436
  if (!activeTab) return;
3126
3437
 
3127
- var tab = activeTab;
3128
3438
  var paneId = focusedPane.id;
3439
+ var tab = _freshTab(paneId, activeTab);
3129
3440
  var originalContent = tab.content;
3130
3441
 
3131
3442
  setLoading(function (prev) { return _extends({}, prev, { format: true }); });
@@ -3163,6 +3474,8 @@ var MbeditorApp = function MbeditorApp() {
3163
3474
  // thrown, so one unparseable file cannot stop the rest. Virtual tabs (diffs,
3164
3475
  // settings, the changelog) have no path on disk and are skipped.
3165
3476
  var handleFormatAll = function handleFormatAll() {
3477
+ // See TabManager's flush contract — the panes are read straight after.
3478
+ TabManager.flushContent();
3166
3479
  var targets = [];
3167
3480
  EditorStore.getState().panes.forEach(function (p) {
3168
3481
  p.tabs.forEach(function (t) {
@@ -3430,8 +3743,13 @@ var MbeditorApp = function MbeditorApp() {
3430
3743
  // Update any open Monaco models whose content changed.
3431
3744
  if (files.length && window.__mbeditorModels) {
3432
3745
  files.forEach(function(relPath) {
3433
- var model = window.__mbeditorModels[relPath];
3434
- if (!model) return;
3746
+ // The registry holds {model, aviBase, ...} entries, not models. This
3747
+ // used to call setValue/isDisposed straight on the entry, which threw
3748
+ // into the .catch below — so an open tab kept its pre-replace text and
3749
+ // could save it back over the replacement.
3750
+ var entry = window.__mbeditorModels[relPath];
3751
+ var model = entry && entry.model;
3752
+ if (!model || model.isDisposed()) return;
3435
3753
  FileService.getFile(relPath).then(function(res) {
3436
3754
  if (res && res.content != null && !model.isDisposed()) {
3437
3755
  model.setValue(res.content);
@@ -3457,6 +3775,13 @@ var MbeditorApp = function MbeditorApp() {
3457
3775
  if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
3458
3776
  loadMoreSearchResults();
3459
3777
  }
3778
+ setSearchViewport(function (prev) {
3779
+ // Only the rows that moved in or out of the window matter, and those
3780
+ // change a row at a time. Comparing keeps a scroll gesture from
3781
+ // committing a render per pixel.
3782
+ if (prev.scrollTop === el.scrollTop && prev.height === el.clientHeight) return prev;
3783
+ return { scrollTop: el.scrollTop, height: el.clientHeight };
3784
+ });
3460
3785
  };
3461
3786
 
3462
3787
  var toggleGitPanel = function toggleGitPanel() {
@@ -3470,6 +3795,10 @@ var MbeditorApp = function MbeditorApp() {
3470
3795
  setShowLogPanel(function (prev) { return !prev; });
3471
3796
  };
3472
3797
 
3798
+ var toggleTestRunPanel = function toggleTestRunPanel() {
3799
+ setShowTestRunPanel(function (prev) { return !prev; });
3800
+ };
3801
+
3473
3802
  var toggleProblemsPanel = function toggleProblemsPanel() {
3474
3803
  setShowProblemsPanel(function (prev) { return !prev; });
3475
3804
  };
@@ -3648,6 +3977,40 @@ var MbeditorApp = function MbeditorApp() {
3648
3977
  });
3649
3978
  };
3650
3979
 
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.
3985
+ 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');
3995
+ };
3996
+
3997
+ // A file node means "upload alongside this file", so the dialog opens on its
3998
+ // parent — right-clicking a file to upload next to it is the common gesture.
3999
+ 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
+ }
4006
+ setImportDialog({ initialFolder: folder });
4007
+ };
4008
+
4009
+ var confirmImportDialog = function confirmImportDialog(entries, destFolder, meta) {
4010
+ setImportDialog(null);
4011
+ handleImportFiles(entries, destFolder, meta);
4012
+ };
4013
+
3651
4014
  var openContextMenu = function openContextMenu(e, node) {
3652
4015
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
3653
4016
  handleNodeSelect(node);
@@ -3675,6 +4038,12 @@ var MbeditorApp = function MbeditorApp() {
3675
4038
  if (action === 'delete') {
3676
4039
  handleDeletePath(node);return;
3677
4040
  }
4041
+ if (action === 'download' && node) {
4042
+ handleDownloadFile(node);return;
4043
+ }
4044
+ if (action === 'upload') {
4045
+ openImportDialog(node);return;
4046
+ }
3678
4047
  if (action === 'copyPath' && node) {
3679
4048
  if (navigator.clipboard) {
3680
4049
  navigator.clipboard.writeText(node.path)["catch"](function () {});
@@ -3823,15 +4192,7 @@ var MbeditorApp = function MbeditorApp() {
3823
4192
  activeTabId: activePane ? activePane.activeTabId : null
3824
4193
  });
3825
4194
 
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
- }
4195
+ if (removedTabIds.length) forgetClosedTabs();
3835
4196
  };
3836
4197
 
3837
4198
  // Reveal a folder path in the explorer: switch to explorer tab and expand
@@ -3890,8 +4251,27 @@ var MbeditorApp = function MbeditorApp() {
3890
4251
  FileService.createFile(path, '').then(function (res) {
3891
4252
  var createdPath = res && res.path || path;
3892
4253
  var createdName = createdPath.split('/').pop();
4254
+ // We just wrote this file and know it is empty, so opening it does not
4255
+ // need to ask the server what is in it. noteLocalSave records the write
4256
+ // for the same reason a save does: the create's own broadcast comes
4257
+ // straight back as a files_changed naming this path, and without this
4258
+ // the external-change check read the file back off disk to ask whether
4259
+ // it had changed since we wrote it a moment earlier.
4260
+ if (FileService.seedPrefetch) FileService.seedPrefetch(createdPath, '');
4261
+ noteLocalSave(createdPath, '');
3893
4262
  handleNodeSelect({ path: createdPath, name: createdName, type: 'file' });
3894
4263
  EditorStore.setStatus('Created file: ' + createdName, 'success');
4264
+ // The optimistic node is already in the tree and the server's
4265
+ // structural broadcast refreshes it for real a moment later, so
4266
+ // walking the workspace here as well made one create cost three full
4267
+ // tree walks — and every write invalidates the server's tree cache, so
4268
+ // each one is a fresh recursive scan of the whole checkout. Same for
4269
+ // git status, which the same broadcast handler already refreshes.
4270
+ // Without a socket neither happens on its own, so keep both for that.
4271
+ if (_socketWillBroadcast()) {
4272
+ handleSelectFile(createdPath, createdName);
4273
+ return;
4274
+ }
3895
4275
  return refreshProjectTree().then(function () {
3896
4276
  handleSelectFile(createdPath, createdName);
3897
4277
  GitService.fetchStatusLite({ background: true });
@@ -3914,6 +4294,8 @@ var MbeditorApp = function MbeditorApp() {
3914
4294
  var createdPath = res && res.path || path;
3915
4295
  handleNodeSelect({ path: createdPath, name: createdPath.split('/').pop(), type: 'folder' });
3916
4296
  EditorStore.setStatus('Created folder: ' + createdPath, 'success');
4297
+ // See handleCreateConfirm's file branch — the broadcast covers both.
4298
+ if (_socketWillBroadcast()) return;
3917
4299
  return refreshProjectTree().then(function () {
3918
4300
  return GitService.fetchStatusLite({ background: true });
3919
4301
  });
@@ -4074,6 +4456,9 @@ var MbeditorApp = function MbeditorApp() {
4074
4456
  tabs: tabs,
4075
4457
  activeId: activeId,
4076
4458
  paneId: paneId,
4459
+ // Diagnostics live in this map, not on the tab object — writing them onto
4460
+ // the tab would mutate a value inside EditorStore.
4461
+ markers: markers,
4077
4462
  tabDisplayMode: editorPrefs.tabDisplayMode || 'scroll',
4078
4463
  onSelect: function (id) {
4079
4464
  // Sync explorer selection with the newly active tab so there's only one highlight
@@ -4275,7 +4660,8 @@ var MbeditorApp = function MbeditorApp() {
4275
4660
  "button",
4276
4661
  { className: "statusbar-btn", onClick: function () {
4277
4662
  return activeTab && handleSave(focusedPane.id, activeTab);
4278
- }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save },
4663
+ }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save,
4664
+ title: "Save the active file (Ctrl+S)" },
4279
4665
  !loading.save && React.createElement("i", { className: "fas fa-save" }),
4280
4666
  !toolbarIconOnly && !loading.save && " Save",
4281
4667
  !loading.save && activeTab && activeTab.dirty ? " ●" : ""
@@ -4286,7 +4672,7 @@ var MbeditorApp = function MbeditorApp() {
4286
4672
  return p.tabs;
4287
4673
  }).some(function (t) {
4288
4674
  return t.dirty;
4289
- }), 'aria-busy': !!loading.saveAll },
4675
+ }), 'aria-busy': !!loading.saveAll, title: "Save every file with unsaved changes" },
4290
4676
  !loading.saveAll && React.createElement(
4291
4677
  "i",
4292
4678
  { className: "fas fa-save", style: { position: 'relative' } },
@@ -4330,7 +4716,8 @@ var MbeditorApp = function MbeditorApp() {
4330
4716
  React.createElement("div", { className: "statusbar-sep" }),
4331
4717
  React.createElement(
4332
4718
  "button",
4333
- { type: "button", className: "statusbar-btn", onClick: toggleGitPanel },
4719
+ { type: "button", className: "statusbar-btn", onClick: toggleGitPanel,
4720
+ title: (showGitPanel ? "Hide" : "Show") + " the git panel (Ctrl+Shift+G)" },
4334
4721
  React.createElement("i", { className: "fas fa-code-branch" }),
4335
4722
  !toolbarIconOnly && " Git"
4336
4723
  )
@@ -4545,7 +4932,7 @@ var MbeditorApp = function MbeditorApp() {
4545
4932
  React.createElement(
4546
4933
  "div",
4547
4934
  { className: "ide-body", id: "ide-body-container" },
4548
- /* Activity bar — always visible, 48px wide */
4935
+ /* Activity bar — always visible, 60px wide */
4549
4936
  !zenMode && React.createElement(
4550
4937
  "div",
4551
4938
  { className: "ide-activity-bar" },
@@ -4688,6 +5075,9 @@ var MbeditorApp = function MbeditorApp() {
4688
5075
  {
4689
5076
  key: tab.id,
4690
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,
4691
5081
  onClick: function () {
4692
5082
  if (tab.path && !tab.path.startsWith('mbeditor://') && tab.path !== '__settings__') {
4693
5083
  handleNodeSelect({ path: tab.path, name: tab.name, type: 'file' });
@@ -4720,7 +5110,10 @@ var MbeditorApp = function MbeditorApp() {
4720
5110
  "div",
4721
5111
  { className: "tab-close", onClick: function (e) {
4722
5112
  e.stopPropagation();requestCloseTab(pane.id, tab.id);
4723
- }, style: { padding: '0 4px', cursor: 'pointer', opacity: 0.6 } },
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)" : "") },
4724
5117
  React.createElement("i", { className: "fas fa-times" })
4725
5118
  )
4726
5119
  )
@@ -4780,6 +5173,13 @@ var MbeditorApp = function MbeditorApp() {
4780
5173
  },
4781
5174
  disabled: !!loading.createDir
4782
5175
  }),
5176
+ React.createElement(SidebarActionButton, {
5177
+ title: "Upload files",
5178
+ iconClass: 'fas fa-upload',
5179
+ onClick: function () {
5180
+ return openImportDialog(selectedTreeNode);
5181
+ }
5182
+ }),
4783
5183
  React.createElement(SidebarActionButton, {
4784
5184
  title: "Rename selected",
4785
5185
  iconClass: 'fas fa-pen',
@@ -4802,7 +5202,7 @@ var MbeditorApp = function MbeditorApp() {
4802
5202
  )
4803
5203
  },
4804
5204
  React.createElement(FileTree, {
4805
- items: editorPrefs.showDotFiles ? treeData : filterDotFiles(treeData || []),
5205
+ items: fileTreeItems,
4806
5206
  onSelect: handleSoftOpenFile,
4807
5207
  activePath: editorPrefs.autoRevealInExplorer !== false ? (activeTab && activeTab.path) : null,
4808
5208
  selectedPaths: selectedPaths,
@@ -4937,6 +5337,14 @@ var MbeditorApp = function MbeditorApp() {
4937
5337
  var total = searchTotalCount > 0 ? searchTotalCount : loadedCount;
4938
5338
  var hasAny = loadedCount > 0;
4939
5339
 
5340
+ // Only the rows on screen (plus a small buffer either side) are
5341
+ // built. Everything else is represented by the height of the
5342
+ // spacer, so the scrollbar and every scroll position stay exactly
5343
+ // as they would be for the full list.
5344
+ 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);
5347
+
4940
5348
  return React.createElement(
4941
5349
  React.Fragment,
4942
5350
  null,
@@ -4954,32 +5362,38 @@ var MbeditorApp = function MbeditorApp() {
4954
5362
  "div",
4955
5363
  {
4956
5364
  className: "search-results" + (searchLoading ? " search-results-blurred" : ""),
4957
- ref: searchResultsContainerRef,
5365
+ ref: attachSearchResults,
4958
5366
  onScroll: handleSearchResultsScroll
4959
5367
  },
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" },
5368
+ React.createElement(
5369
+ "div",
5370
+ { style: { height: loadedCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5371
+ visible.map(function(res, vi) {
5372
+ var i = winStart + vi;
5373
+ var fileName = res.file.split('/').pop();
5374
+ return React.createElement(
5375
+ "div",
5376
+ {
5377
+ key: i,
5378
+ 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)
5383
+ },
5384
+ React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
4974
5385
  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
- }),
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
+ )
5394
+ );
5395
+ })
5396
+ ),
4983
5397
  searchHasMore && React.createElement(
4984
5398
  "div", { className: "search-loading-more" },
4985
5399
  React.createElement("i", { className: "fas fa-spinner fa-spin" }),
@@ -5573,16 +5987,6 @@ var MbeditorApp = function MbeditorApp() {
5573
5987
  React.createElement('option', { value: 'allDocuments' }, 'All open files')
5574
5988
  )
5575
5989
  ),
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
5990
  React.createElement(
5587
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 {})' },
5588
5992
  React.createElement('span', { className: 'ide-settings-label' }, 'Format on type'),
@@ -5767,6 +6171,17 @@ var MbeditorApp = function MbeditorApp() {
5767
6171
  })
5768
6172
  ),
5769
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
+
5770
6185
  /* ── RuboCop ─────────────────────────────────── */
5771
6186
  React.createElement('div', { className: 'ide-settings-section-header' }, 'RuboCop'),
5772
6187
  React.createElement(
@@ -5798,6 +6213,7 @@ var MbeditorApp = function MbeditorApp() {
5798
6213
  {
5799
6214
  className: 'ide-settings-reset-btn',
5800
6215
  type: 'button',
6216
+ title: 'Restore every editor preference on this page to its default',
5801
6217
  onClick: function() { setEditorPrefs(Object.assign({}, DEFAULT_EDITOR_PREFS)); }
5802
6218
  },
5803
6219
  React.createElement('i', { className: 'fas fa-undo', style: { marginRight: 6 } }),
@@ -6019,6 +6435,20 @@ var MbeditorApp = function MbeditorApp() {
6019
6435
  onOpenFile: function (path, line, col) {
6020
6436
  handleSelectFile(path, path.split('/').pop(), line, col);
6021
6437
  }
6438
+ }),
6439
+ showTestRunPanel && !zenMode && React.createElement(window.TestRunPanel, {
6440
+ onClose: function () { setShowTestRunPanel(false); },
6441
+ onOpenFile: function (path, line, col) {
6442
+ handleSelectFile(path, path.split('/').pop(), line, col);
6443
+ },
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
+ }
6022
6452
  })
6023
6453
  ),
6024
6454
 
@@ -6085,6 +6515,19 @@ var MbeditorApp = function MbeditorApp() {
6085
6515
  }),
6086
6516
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
6087
6517
  ),
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
+ ),
6088
6531
  // ruby-lsp indicator. Hidden entirely when ruby-lsp was never available
6089
6532
  // and nothing has gone wrong — a permanent "off" badge in a project with
6090
6533
  // no Ruby is noise. A healthy server gets a quiet icon; a degraded one
@@ -6424,6 +6867,23 @@ var MbeditorApp = function MbeditorApp() {
6424
6867
  " Delete"
6425
6868
  ),
6426
6869
  React.createElement("div", { className: "context-menu-divider" }),
6870
+ contextMenu.node && contextMenu.node.type === 'file' && React.createElement(
6871
+ "div",
6872
+ { className: "context-menu-item", onClick: function () {
6873
+ return handleContextMenuAction('download');
6874
+ } },
6875
+ React.createElement("i", { className: "fas fa-download context-menu-icon" }),
6876
+ " Download"
6877
+ ),
6878
+ React.createElement(
6879
+ "div",
6880
+ { className: "context-menu-item", onClick: function () {
6881
+ return handleContextMenuAction('upload');
6882
+ } },
6883
+ React.createElement("i", { className: "fas fa-upload context-menu-icon" }),
6884
+ " Upload Files Here..."
6885
+ ),
6886
+ React.createElement("div", { className: "context-menu-divider" }),
6427
6887
  React.createElement(
6428
6888
  "div",
6429
6889
  { className: "context-menu-item", onClick: function () {
@@ -6499,6 +6959,14 @@ var MbeditorApp = function MbeditorApp() {
6499
6959
  )
6500
6960
  ),
6501
6961
 
6962
+ /* ── Upload dialog ─────────────────────────────────────────────────── */
6963
+ importDialog && React.createElement(ImportDialog, {
6964
+ initialFolder: importDialog.initialFolder,
6965
+ docs: SearchService.allDocs(),
6966
+ onCancel: function () { setImportDialog(null); },
6967
+ onImport: confirmImportDialog
6968
+ }),
6969
+
6502
6970
  /* ── Import conflict modal ─────────────────────────────────────────── */
6503
6971
  importConflict && React.createElement(ImportConflictModal, {
6504
6972
  conflicts: importConflict.result.conflicts,