mbeditor 0.13.0 → 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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/app/assets/javascripts/mbeditor/application.js +0 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +22 -2
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +11 -87
  6. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  7. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +171 -205
  8. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +35 -7
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +97 -75
  10. data/app/assets/javascripts/mbeditor/editor_plugins.js +7 -1
  11. data/app/assets/javascripts/mbeditor/file_service.js +8 -18
  12. data/app/assets/javascripts/mbeditor/search_service.js +20 -2
  13. data/app/assets/javascripts/mbeditor/tab_manager.js +7 -4
  14. data/app/assets/stylesheets/mbeditor/editor.css +138 -67
  15. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  16. data/app/controllers/mbeditor/editors_controller.rb +8 -26
  17. data/app/services/mbeditor/collaboration_doc_store.rb +42 -3
  18. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  19. data/app/services/mbeditor/js_globals_service.rb +12 -1
  20. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  21. data/app/services/mbeditor/schema_service.rb +8 -2
  22. data/app/services/mbeditor/search_replace_service.rb +11 -2
  23. data/app/services/mbeditor/test_runner_service.rb +3 -56
  24. data/lib/mbeditor/configuration.rb +0 -8
  25. data/lib/mbeditor/route_map.rb +0 -1
  26. data/lib/mbeditor/version.rb +1 -1
  27. data/lib/tasks/mbeditor.rake +23 -0
  28. metadata +4 -3
  29. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -216,6 +216,23 @@ var SectionActionGroup = function SectionActionGroup(_ref2) {
216
216
  );
217
217
  };
218
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
+
219
236
  function FileReloadBanner(_ref) {
220
237
  var pendingReloads = _ref.pendingReloads;
221
238
  var onSaveAndReload = _ref.onSaveAndReload;
@@ -366,10 +383,28 @@ var MbeditorApp = function MbeditorApp() {
366
383
  // SearchReplaceService::MAX_RESULTS (10_000) rows, and every row is five
367
384
  // elements, so rendering the list in full built ~50k nodes — enough to kill
368
385
  // 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;
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;
372
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
+ };
373
408
  var _useStateSV = useState({ scrollTop: 0, height: 0 });
374
409
  var _useStateSV2 = _slicedToArray(_useStateSV, 2);
375
410
  var searchViewport = _useStateSV2[0];
@@ -561,11 +596,6 @@ var MbeditorApp = function MbeditorApp() {
561
596
  var showProblemsPanel = _useStateProblems2[0];
562
597
  var setShowProblemsPanel = _useStateProblems2[1];
563
598
 
564
- var _useStateTestRun = useState(false);
565
- var _useStateTestRun2 = _slicedToArray(_useStateTestRun, 2);
566
- var showTestRunPanel = _useStateTestRun2[0];
567
- var setShowTestRunPanel = _useStateTestRun2[1];
568
-
569
599
  // Error/warning tallies across the open tabs, mirrored into the status bar.
570
600
  var _useStateProblemCounts = useState({ errors: 0, warnings: 0 });
571
601
  var _useStateProblemCounts2 = _slicedToArray(_useStateProblemCounts, 2);
@@ -613,13 +643,6 @@ var MbeditorApp = function MbeditorApp() {
613
643
  var modelGraphLoading = _useStateModelGraphLoading2[0];
614
644
  var setModelGraphLoading = _useStateModelGraphLoading2[1];
615
645
 
616
- // ruby-lsp status for the status-bar chip. 'off' means never available here,
617
- // 'degraded' means we backed off after a failure, 'ok' means it's answering.
618
- var _useStateLspHealth = useState({ status: 'off', reason: null });
619
- var _useStateLspHealth2 = _slicedToArray(_useStateLspHealth, 2);
620
- var lspHealth = _useStateLspHealth2[0];
621
- var setLspHealth = _useStateLspHealth2[1];
622
-
623
646
  var _useState18g = useState(320);
624
647
  var _useState18g2 = _slicedToArray(_useState18g, 2);
625
648
  var gitPanelWidth = _useState18g2[0];
@@ -675,11 +698,6 @@ var MbeditorApp = function MbeditorApp() {
675
698
  var rubocopConfigPath = _useState18rc2[0];
676
699
  var setRubocopConfigPath = _useState18rc2[1];
677
700
 
678
- var _useState18t = useState(false);
679
- var _useState18t2 = _slicedToArray(_useState18t, 2);
680
- var testAvailable = _useState18t2[0];
681
- var setTestAvailable = _useState18t2[1];
682
-
683
701
  var _useState18u = useState(null);
684
702
  var _useState18u2 = _slicedToArray(_useState18u, 2);
685
703
  var testResult = _useState18u2[0];
@@ -1023,64 +1041,6 @@ var MbeditorApp = function MbeditorApp() {
1023
1041
  loadModelGraph(false);
1024
1042
  }, [activeSidebarTab, sidebarCollapsed]);
1025
1043
 
1026
- var readLspHealth = function readLspHealth() {
1027
- if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) {
1028
- return { status: 'off', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
1029
- }
1030
- if (window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff()) {
1031
- return { status: 'degraded', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
1032
- }
1033
- return { status: 'ok', reason: null };
1034
- };
1035
-
1036
- // The backoff expires on a wall-clock deadline rather than a timer, so the
1037
- // chip also re-reads on a slow interval — otherwise it would sit on
1038
- // 'degraded' until the next failure or restart click.
1039
- //
1040
- // readLspHealth() builds a fresh object every call, so handing it straight to
1041
- // setLspHealth re-rendered the whole app every 10 seconds whether or not the
1042
- // health had changed — React bails on Object.is, and two object literals are
1043
- // never identical. Compare the fields and keep the previous object when they
1044
- // match. (Same shape of bug as the file-tree poll; see _treeUpdater.)
1045
- useEffect(function () {
1046
- var sync = function () {
1047
- setLspHealth(function (prev) {
1048
- var next = readLspHealth();
1049
- if (prev && prev.status === next.status && prev.reason === next.reason) return prev;
1050
- return next;
1051
- });
1052
- };
1053
- sync();
1054
- window.addEventListener('mbeditor:lsp-health', sync);
1055
- var tick = setInterval(sync, 10000);
1056
- return function () {
1057
- window.removeEventListener('mbeditor:lsp-health', sync);
1058
- clearInterval(tick);
1059
- };
1060
- }, []);
1061
-
1062
- var restartRubyLsp = function restartRubyLsp() {
1063
- if (!FileService.rubyLspRequest) return;
1064
- EditorStore.setStatus('Restarting ruby-lsp…', 'info');
1065
- FileService.rubyLspRequest('restart', '', '', 1, 1).then(function (data) {
1066
- var ok = data && data.available && data.state !== 'failed';
1067
- if (ok) {
1068
- window.MBEDITOR_RUBY_LSP_AVAILABLE = true;
1069
- window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL = 0;
1070
- window.MBEDITOR_RUBY_LSP_REASON = null;
1071
- } else {
1072
- window.MBEDITOR_RUBY_LSP_REASON =
1073
- (data && (data.reason || data.error)) || 'ruby-lsp did not come back';
1074
- }
1075
- EditorStore.setStatus(ok ? 'ruby-lsp restarted' : 'ruby-lsp unavailable', ok ? 'success' : 'warning');
1076
- setLspHealth(readLspHealth());
1077
- })["catch"](function (err) {
1078
- noteLspFailure(err);
1079
- EditorStore.setStatus('Could not restart ruby-lsp', 'error');
1080
- setLspHealth(readLspHealth());
1081
- });
1082
- };
1083
-
1084
1044
  // The one writer for the markers map. Two things it must not do:
1085
1045
  //
1086
1046
  // * write a fresh map when nothing changed — the auto-lint fires per
@@ -1260,14 +1220,8 @@ var MbeditorApp = function MbeditorApp() {
1260
1220
  if (workspace && typeof workspace.redmineEnabled === 'boolean') {
1261
1221
  setRedmineEnabled(workspace.redmineEnabled);
1262
1222
  }
1263
- if (workspace && (workspace.testTimeout || workspace.testAllTimeout)) {
1264
- FileService.setTestTimeouts({
1265
- test: workspace.testTimeout,
1266
- testAll: workspace.testAllTimeout
1267
- });
1268
- }
1269
- if (workspace && typeof workspace.testAvailable === 'boolean') {
1270
- setTestAvailable(workspace.testAvailable);
1223
+ if (workspace && workspace.testTimeout) {
1224
+ FileService.setTestTimeout(workspace.testTimeout);
1271
1225
  }
1272
1226
  if (workspace && typeof workspace.actionCableEnabled === 'boolean') {
1273
1227
  WebSocketService.connect(workspace.actionCableEnabled);
@@ -2019,6 +1973,12 @@ var MbeditorApp = function MbeditorApp() {
2019
1973
  });
2020
1974
  })
2021
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);
2022
1982
  } else {
2023
1983
  // Re-verify the tab still exists before queuing
2024
1984
  var currentState = EditorStore.getState();
@@ -2125,8 +2085,8 @@ var MbeditorApp = function MbeditorApp() {
2125
2085
  };
2126
2086
  }, [monacoReady]);
2127
2087
 
2128
- var handleSelectFile = function handleSelectFile(path, name, line, col) {
2129
- 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);
2130
2090
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
2131
2091
  setQuickOpen(false);
2132
2092
  };
@@ -3391,7 +3351,19 @@ var MbeditorApp = function MbeditorApp() {
3391
3351
  // counts because only the visible tab was re-checked. Dropping them says
3392
3352
  // "not known yet", which is true: the file re-lints when you open it.
3393
3353
  var discardStaleMarkers = function discardStaleMarkers(path) {
3394
- if (!path || !window.monaco || !window.monaco.editor) return;
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;
3395
3367
  var entry = window.__mbeditorModels && window.__mbeditorModels[path];
3396
3368
  if (!entry || !entry.model || entry.model.isDisposed()) return;
3397
3369
 
@@ -3522,15 +3494,6 @@ var MbeditorApp = function MbeditorApp() {
3522
3494
 
3523
3495
  var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
3524
3496
 
3525
- var loadCachedTestResult = function loadCachedTestResult(filePath) {
3526
- try {
3527
- var stored = localStorage.getItem(TEST_CACHE_PREFIX + filePath);
3528
- return stored ? JSON.parse(stored) : null;
3529
- } catch (e) {
3530
- return null;
3531
- }
3532
- };
3533
-
3534
3497
  var saveCachedTestResult = function saveCachedTestResult(filePath, result) {
3535
3498
  try {
3536
3499
  localStorage.setItem(TEST_CACHE_PREFIX + filePath, JSON.stringify(result));
@@ -3572,21 +3535,6 @@ var MbeditorApp = function MbeditorApp() {
3572
3535
  });
3573
3536
  };
3574
3537
 
3575
- var handleRunTest = function handleRunTest() {
3576
- if (!activeTab || !activeTab.path) return;
3577
- if (testLoading) return;
3578
-
3579
- var cached = loadCachedTestResult(activeTab.path);
3580
- if (cached && !testPanelOpen) {
3581
- setTestResult(cached);
3582
- setTestPanelFile(cached.testFile || activeTab.path);
3583
- setTestPanelOpen(true);
3584
- return;
3585
- }
3586
-
3587
- executeTestRun(activeTab.path);
3588
- };
3589
-
3590
3538
  var handleRerunTest = function handleRerunTest() {
3591
3539
  if (!activeTab || !activeTab.path) return;
3592
3540
  if (testLoading) return;
@@ -3627,6 +3575,7 @@ var MbeditorApp = function MbeditorApp() {
3627
3575
  searchOffsetRef.current = 0;
3628
3576
  searchLoadingMoreRef.current = false;
3629
3577
  searchQueryRef.current = q;
3578
+ setSearchCollapsedFiles({});
3630
3579
  EditorStore.setState({ searchResults: [], searchHasMore: false });
3631
3580
  EditorStore.setStatus("Searching project...", "info");
3632
3581
  SearchService.projectSearch(q, 0, SearchService.PAGE_SIZE, { regex: searchUseRegexRef.current, matchCase: searchMatchCaseRef.current, wholeWord: searchWholeWordRef.current }).then(function (res) {
@@ -3795,10 +3744,6 @@ var MbeditorApp = function MbeditorApp() {
3795
3744
  setShowLogPanel(function (prev) { return !prev; });
3796
3745
  };
3797
3746
 
3798
- var toggleTestRunPanel = function toggleTestRunPanel() {
3799
- setShowTestRunPanel(function (prev) { return !prev; });
3800
- };
3801
-
3802
3747
  var toggleProblemsPanel = function toggleProblemsPanel() {
3803
3748
  setShowProblemsPanel(function (prev) { return !prev; });
3804
3749
  };
@@ -3885,12 +3830,17 @@ var MbeditorApp = function MbeditorApp() {
3885
3830
  });
3886
3831
  };
3887
3832
 
3888
- var finishImport = function finishImport(result) {
3889
- var imported = (result.imported || []).length;
3833
+ var finishImport = function finishImport(result, destFolder) {
3834
+ var written = result.imported || [];
3835
+ var imported = written.length;
3890
3836
  var skipped = (result.conflicts || []).length;
3891
3837
  var failed = (result.errors || []).length;
3892
3838
 
3893
- 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 : '')];
3894
3844
  if (skipped > 0) parts.push(skipped + ' skipped');
3895
3845
  if (failed > 0) parts.push(failed + ' failed');
3896
3846
 
@@ -3898,7 +3848,21 @@ var MbeditorApp = function MbeditorApp() {
3898
3848
  EditorStore.setStatus(parts.join(', ') + '.', level);
3899
3849
 
3900
3850
  if (imported > 0) {
3901
- 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
+ });
3902
3866
  }
3903
3867
  };
3904
3868
 
@@ -3942,7 +3906,7 @@ var MbeditorApp = function MbeditorApp() {
3942
3906
  if (result.conflicts && result.conflicts.length > 0) {
3943
3907
  setImportConflict({ result: result, entries: entries, targetFolderPath: targetFolderPath });
3944
3908
  } else {
3945
- finishImport(result);
3909
+ finishImport(result, targetFolderPath);
3946
3910
  }
3947
3911
  })['catch'](function(err) {
3948
3912
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3955,14 +3919,14 @@ var MbeditorApp = function MbeditorApp() {
3955
3919
  setImportConflict(null);
3956
3920
  if (!pending) return;
3957
3921
 
3958
- if (mode === 'skip') { finishImport(pending.result); return; }
3922
+ if (mode === 'skip') { finishImport(pending.result, pending.targetFolderPath); return; }
3959
3923
 
3960
3924
  var retry = FileImport.conflictedEntries(
3961
3925
  pending.entries,
3962
3926
  pending.targetFolderPath,
3963
3927
  pending.result.conflicts
3964
3928
  );
3965
- if (retry.length === 0) { finishImport(pending.result); return; }
3929
+ if (retry.length === 0) { finishImport(pending.result, pending.targetFolderPath); return; }
3966
3930
 
3967
3931
  FileService.importFiles(FileImport.buildFormData(retry, pending.targetFolderPath, mode))
3968
3932
  .then(function(second) {
@@ -3970,7 +3934,7 @@ var MbeditorApp = function MbeditorApp() {
3970
3934
  imported: (pending.result.imported || []).concat(second.imported || []),
3971
3935
  conflicts: [],
3972
3936
  errors: (pending.result.errors || []).concat(second.errors || [])
3973
- });
3937
+ }, pending.targetFolderPath);
3974
3938
  })['catch'](function(err) {
3975
3939
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
3976
3940
  EditorStore.setStatus('Import failed: ' + message, 'error');
@@ -5088,17 +5052,24 @@ var MbeditorApp = function MbeditorApp() {
5088
5052
  React.createElement("i", { className: "tree-item-icon " + (window.getFileIcon ? window.getFileIcon(tab.name) : 'far fa-file-code') + " tree-file-icon" }),
5089
5053
  React.createElement(
5090
5054
  "div",
5091
- { 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 } },
5092
5060
  React.createElement(
5093
5061
  "span",
5094
- { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
5062
+ { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 } },
5095
5063
  tab.name
5096
5064
  ),
5097
- 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 } })
5098
5066
  ),
5099
5067
  React.createElement(
5100
5068
  "div",
5101
- { 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' } },
5102
5073
  React.createElement(
5103
5074
  "div",
5104
5075
  { className: "tab-split", onClick: function (e) {
@@ -5337,13 +5308,29 @@ var MbeditorApp = function MbeditorApp() {
5337
5308
  var total = searchTotalCount > 0 ? searchTotalCount : loadedCount;
5338
5309
  var hasAny = loadedCount > 0;
5339
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
+
5340
5327
  // Only the rows on screen (plus a small buffer either side) are
5341
5328
  // built. Everything else is represented by the height of the
5342
5329
  // spacer, so the scrollbar and every scroll position stay exactly
5343
5330
  // as they would be for the full list.
5344
5331
  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);
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);
5347
5334
 
5348
5335
  return React.createElement(
5349
5336
  React.Fragment,
@@ -5367,30 +5354,57 @@ var MbeditorApp = function MbeditorApp() {
5367
5354
  },
5368
5355
  React.createElement(
5369
5356
  "div",
5370
- { style: { height: loadedCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5371
- visible.map(function(res, vi) {
5357
+ { style: { height: rowCount * SEARCH_ROW_HEIGHT, position: 'relative' } },
5358
+ visible.map(function(row, vi) {
5372
5359
  var i = winStart + vi;
5373
- var fileName = res.file.split('/').pop();
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;
5374
5384
  return React.createElement(
5375
5385
  "div",
5376
5386
  {
5377
- key: i,
5387
+ key: 'm:' + row.idx,
5378
5388
  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)
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)
5383
5395
  },
5384
- React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
5385
- React.createElement(
5386
- "div", { className: "search-result-body" },
5387
- React.createElement(
5388
- "div", { className: "search-result-file" },
5389
- fileName,
5390
- React.createElement("span", { className: "search-result-line-num" }, " ", res.file, ":", res.line)
5391
- ),
5392
- React.createElement("div", { className: "search-result-text" }, res.text)
5393
- )
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
+ })()
5394
5408
  );
5395
5409
  })
5396
5410
  ),
@@ -6240,17 +6254,14 @@ var MbeditorApp = function MbeditorApp() {
6240
6254
  paneId: pane.id,
6241
6255
  markers: markers[pActiveTab.id] || [],
6242
6256
  gitAvailable: gitAvailable,
6243
- testAvailable: testAvailable,
6244
6257
  treeData: treeData,
6245
6258
  testResult: testResult,
6246
6259
  testPanelFile: testPanelFile,
6247
- testLoading: testLoading,
6248
6260
  testInlineVisible: testInlineVisible,
6249
6261
  editorPrefs: editorPrefs,
6250
6262
  monacoReady: monacoReady,
6251
6263
  onFormat: function() { onFormatRef.current(); },
6252
6264
  onSave: function() { handleSave(pane.id, pActiveTab); },
6253
- onRunTest: handleRunTest,
6254
6265
  onRunTestAtCursor: handleRunTestAtCursor,
6255
6266
  onShowHistory: function(path) { setHistoryPanelPath(path); },
6256
6267
  onContentChange: function onContentChange(val) {
@@ -6432,24 +6443,16 @@ var MbeditorApp = function MbeditorApp() {
6432
6443
  }),
6433
6444
  showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
6434
6445
  onClose: function () { setShowProblemsPanel(false); },
6435
- onOpenFile: function (path, line, col) {
6436
- handleSelectFile(path, path.split('/').pop(), line, col);
6437
- }
6438
- }),
6439
- showTestRunPanel && !zenMode && React.createElement(window.TestRunPanel, {
6440
- onClose: function () { setShowTestRunPanel(false); },
6441
6446
  onOpenFile: function (path, line, col) {
6442
6447
  handleSelectFile(path, path.split('/').pop(), line, col);
6443
6448
  },
6444
- // A suite result spans many files, so there is no single testPanelFile
6445
- // for it each entry carries its own, and EditorPanel matches per
6446
- // entry. Clearing it here keeps the per-file modal from labelling a
6447
- // suite result with a file it did not come from.
6448
- onResult: function (res) {
6449
- setTestResult(res);
6450
- setTestPanelFile(null);
6451
- }
6452
- })
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
+ }),
6453
6456
  ),
6454
6457
 
6455
6458
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
@@ -6509,46 +6512,9 @@ var MbeditorApp = function MbeditorApp() {
6509
6512
  },
6510
6513
  React.createElement("i", { className: "fas fa-bug statusbar-problems-error-icon" }),
6511
6514
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.errors),
6512
- React.createElement("i", {
6513
- className: "fas fa-exclamation-triangle statusbar-problems-warning-icon",
6514
- style: { marginLeft: "8px" }
6515
- }),
6515
+ React.createElement("i", { className: "fas fa-exclamation-triangle statusbar-problems-warning-icon" }),
6516
6516
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
6517
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
- ),
6531
- // ruby-lsp indicator. Hidden entirely when ruby-lsp was never available
6532
- // and nothing has gone wrong — a permanent "off" badge in a project with
6533
- // no Ruby is noise. A healthy server gets a quiet icon; a degraded one
6534
- // gets an amber chip you can click to restart.
6535
- (lspHealth.status !== 'off' || lspHealth.reason) && React.createElement(
6536
- "button",
6537
- {
6538
- type: "button",
6539
- className: "statusbar-btn statusbar-lsp statusbar-lsp-" + lspHealth.status,
6540
- onClick: restartRubyLsp,
6541
- title: lspHealth.status === 'ok'
6542
- ? 'ruby-lsp is running — click to restart'
6543
- : 'ruby-lsp unavailable' + (lspHealth.reason ? ': ' + lspHealth.reason : '') +
6544
- '. Falling back to search-based lookups. Click to retry.'
6545
- },
6546
- React.createElement("i", {
6547
- className: "fas " + (lspHealth.status === 'ok' ? 'fa-gem' : 'fa-plug'),
6548
- "aria-hidden": "true"
6549
- }),
6550
- lspHealth.status !== 'ok' && React.createElement("span", null, " ruby-lsp")
6551
- ),
6552
6518
  !serverOnline && (function () {
6553
6519
  var dirtyCount = state.panes.reduce(function (acc, p) {
6554
6520
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;