mbeditor 0.12.9 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +155 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. metadata +8 -2
@@ -6,6 +6,7 @@ var _React = React;
6
6
  var useState = _React.useState;
7
7
  var useEffect = _React.useEffect;
8
8
  var useRef = _React.useRef;
9
+ var useMemo = _React.useMemo;
9
10
 
10
11
  var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'avif'];
11
12
 
@@ -23,16 +24,13 @@ var EditorPanel = function EditorPanel(_ref) {
23
24
  var onContentChange = _ref.onContentChange;
24
25
  var markers = _ref.markers;
25
26
  var gitAvailable = _ref.gitAvailable === true;
26
- var testAvailable = _ref.testAvailable === true;
27
27
  var onFormat = _ref.onFormat;
28
28
  var onSave = _ref.onSave;
29
- var onRunTest = _ref.onRunTest;
30
29
  var onRunTestAtCursor = _ref.onRunTestAtCursor;
31
30
  var onShowHistory = _ref.onShowHistory;
32
31
  var treeData = _ref.treeData || [];
33
32
  var testResult = _ref.testResult;
34
33
  var testPanelFile = _ref.testPanelFile;
35
- var testLoading = _ref.testLoading;
36
34
  var testInlineVisible = _ref.testInlineVisible;
37
35
  var editorPrefs = _ref.editorPrefs || {};
38
36
  var monacoReady = _ref.monacoReady !== false; // undefined means Monaco already loaded (legacy callers)
@@ -611,7 +609,7 @@ var EditorPanel = function EditorPanel(_ref) {
611
609
  var _histBranch = EditorStore.getState().gitBranch || '';
612
610
  if (_histBranch) {
613
611
  if (_reusingModel) {
614
- HistoryService.resumeTracking(_histBranch, tab.path);
612
+ HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
615
613
  } else {
616
614
  HistoryService.beginTracking(_histBranch, tab.path, tab.content || '');
617
615
  }
@@ -647,9 +645,8 @@ var EditorPanel = function EditorPanel(_ref) {
647
645
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
648
646
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
649
647
  autoIndent: editorPrefs.autoIndent || 'full',
650
- // Monaco own format-on-paste is left off: it never ran the formatter
651
- // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
652
- // instead, gated on editorPrefs.indentOnPaste.
648
+ // Paste inserts what was copied, unchanged. See attachEditorFeatures for
649
+ // why re-indenting it was removed rather than repaired.
653
650
  formatOnPaste: false,
654
651
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
655
652
  quickSuggestions: editorPrefs.quickSuggestions !== false,
@@ -1213,10 +1210,9 @@ var EditorPanel = function EditorPanel(_ref) {
1213
1210
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
1214
1211
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
1215
1212
  autoIndent: editorPrefs.autoIndent || 'full',
1216
- // Monaco own format-on-paste is left off: it never ran the formatter
1217
- // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
1218
- // instead, gated on editorPrefs.indentOnPaste.
1219
- formatOnPaste: false,
1213
+ // Paste inserts what was copied, unchanged. See attachEditorFeatures
1214
+ // for why re-indenting it was removed rather than repaired.
1215
+ formatOnPaste: false,
1220
1216
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1221
1217
  quickSuggestions: editorPrefs.quickSuggestions !== false,
1222
1218
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
@@ -1324,9 +1320,18 @@ var EditorPanel = function EditorPanel(_ref) {
1324
1320
  var column = tab.gotoCol || 1;
1325
1321
  var maxColumn = model.getLineMaxColumn(line);
1326
1322
  if (column > maxColumn) column = maxColumn;
1323
+ // With both bounds the match itself is selected and the cursor ends up
1324
+ // just past it — where you'd start typing. With only gotoCol the
1325
+ // selection is empty, which is exactly setPosition.
1326
+ var endColumn = Math.min(tab.gotoEndCol || column, maxColumn);
1327
1327
 
1328
1328
  editor.revealLineInCenter(line);
1329
- editor.setPosition({ lineNumber: line, column: column });
1329
+ // setSelection(IRange) parks the cursor at the range's end, so no
1330
+ // setPosition afterwards — that would collapse the selection again.
1331
+ editor.setSelection({
1332
+ startLineNumber: line, startColumn: column,
1333
+ endLineNumber: line, endColumn: endColumn
1334
+ });
1330
1335
  editor.focus();
1331
1336
 
1332
1337
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
@@ -1334,7 +1339,7 @@ var EditorPanel = function EditorPanel(_ref) {
1334
1339
  }, 50);
1335
1340
 
1336
1341
  return function () { clearTimeout(timer); };
1337
- }, [tab.gotoLine, tab.gotoCol, tab.content, tab.loading]);
1342
+ }, [tab.gotoLine, tab.gotoCol, tab.gotoEndCol, tab.content, tab.loading]);
1338
1343
 
1339
1344
  // Apply RuboCop markers
1340
1345
  useEffect(function () {
@@ -1495,8 +1500,19 @@ var EditorPanel = function EditorPanel(_ref) {
1495
1500
  gitLineRefreshRef.current = refresh;
1496
1501
 
1497
1502
  // Immediate path for writes mbeditor made itself.
1503
+ //
1504
+ // Only when *this* file is one of the ones that changed: saving some other
1505
+ // file cannot move this file's diff against HEAD. Refreshing regardless
1506
+ // meant every open pane ran a git diff on every save anywhere in the
1507
+ // workspace, so the cost of a save scaled with how many editors were open.
1508
+ // A broadcast that names no paths still refreshes — that is the "something
1509
+ // happened, re-check everything" case (a commit, a branch switch), which
1510
+ // genuinely can change this file's diff without touching it.
1498
1511
  var hasSocket = typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged;
1499
- var onChanged = hasSocket ? function () { refresh(); } : null;
1512
+ var onChanged = hasSocket ? function (payload) {
1513
+ if (payload && payload.paths && payload.paths.indexOf(tab.path) === -1) return;
1514
+ refresh();
1515
+ } : null;
1500
1516
  if (onChanged) WebSocketService.onFilesChanged(onChanged);
1501
1517
 
1502
1518
  return function () {
@@ -1572,6 +1588,16 @@ var EditorPanel = function EditorPanel(_ref) {
1572
1588
  //
1573
1589
  // Decorations, not view zones: a hint belongs on the line, and a zone would
1574
1590
  // push the code around every time a file was opened.
1591
+ //
1592
+ // Redrawn whenever the text changes, not only when the file is opened. A
1593
+ // decoration is a zero-width range pinned at the end of the `def` line, and
1594
+ // Monaco keeps that position through an edit rather than re-deriving it — so
1595
+ // replacing a method (a paste over the selection is the usual way) left the
1596
+ // hint at a column that now falls in the middle of whatever text took its
1597
+ // place, rendering as `def sho GET /orders/:idw`. Nothing recomputed it,
1598
+ // so saving did not clear it either; it survived until the file was
1599
+ // reopened. The route data itself only depends on the route set, so an edit
1600
+ // redraws from the cached response without asking the server again.
1575
1601
  useEffect(function () {
1576
1602
  var editor = monacoRef.current;
1577
1603
  if (!editor || !window.monaco) return;
@@ -1581,17 +1607,19 @@ var EditorPanel = function EditorPanel(_ref) {
1581
1607
  routeDecorationsRef.current = editor.deltaDecorations(routeDecorationsRef.current, []);
1582
1608
  };
1583
1609
 
1584
- if (!tab.path || !/^app\/controllers\/.+_controller\.rb$/.test(tab.path)) {
1610
+ if (editorPrefs.routeHints === false ||
1611
+ !tab.path || !/^app\/controllers\/.+_controller\.rb$/.test(tab.path)) {
1585
1612
  clear();
1586
1613
  return;
1587
1614
  }
1588
1615
 
1589
1616
  var cancelled = false;
1590
- FileService.getRoutes(tab.path).then(function (data) {
1617
+ var redrawTimer = null;
1618
+
1619
+ var draw = function (actions) {
1591
1620
  if (cancelled) return;
1592
- var actions = (data && data.actions) || {};
1593
1621
  var model = editor.getModel();
1594
- if (!model) return;
1622
+ if (!model || model.isDisposed()) return;
1595
1623
 
1596
1624
  var decorations = [];
1597
1625
  var text = model.getValue().split('\n');
@@ -1634,10 +1662,47 @@ var EditorPanel = function EditorPanel(_ref) {
1634
1662
  });
1635
1663
 
1636
1664
  routeDecorationsRef.current = editor.deltaDecorations(routeDecorationsRef.current, decorations);
1665
+ };
1666
+
1667
+ var contentSub = null;
1668
+ FileService.getRoutes(tab.path).then(function (data) {
1669
+ if (cancelled) return;
1670
+ var actions = (data && data.actions) || {};
1671
+ draw(actions);
1672
+
1673
+ // Debounced: a redraw per keystroke would rebuild every decoration in
1674
+ // the file for an edit that usually cannot have moved one.
1675
+ var model = editor.getModel();
1676
+ if (!model || !model.onDidChangeContent) return;
1677
+ contentSub = model.onDidChangeContent(function () {
1678
+ clearTimeout(redrawTimer);
1679
+ redrawTimer = setTimeout(function () { draw(actions); }, 250);
1680
+ });
1637
1681
  }).catch(function () { /* hints are additive; a failure just means none */ });
1638
1682
 
1639
- return function () { cancelled = true; clear(); };
1640
- }, [tab.path, tab.externalContentVersion, monacoReady]);
1683
+ return function () {
1684
+ cancelled = true;
1685
+ clearTimeout(redrawTimer);
1686
+ if (contentSub) contentSub.dispose();
1687
+ clear();
1688
+ };
1689
+ }, [tab.path, tab.externalContentVersion, monacoReady, editorPrefs.routeHints]);
1690
+
1691
+ // Blame and test view zones are anchored to line numbers, so they only need
1692
+ // rebuilding once an edit has settled. Both effects used to list tab.content
1693
+ // directly, which tore down and re-created every zone in the file on every
1694
+ // keystroke. This ticks 250 ms after the last change instead — the same
1695
+ // treatment the route-hint redraw above already gets.
1696
+ var _useStateZT = useState(0);
1697
+ var _useStateZT2 = _slicedToArray(_useStateZT, 2);
1698
+ var zoneTick = _useStateZT2[0];
1699
+ var setZoneTick = _useStateZT2[1];
1700
+ useEffect(function () {
1701
+ var timer = setTimeout(function () {
1702
+ setZoneTick(function (n) { return n + 1; });
1703
+ }, 250);
1704
+ return function () { clearTimeout(timer); };
1705
+ }, [tab.content]);
1641
1706
 
1642
1707
  // Render Blame block headers (author + summary) above contiguous commit regions.
1643
1708
  useEffect(function () {
@@ -1725,8 +1790,8 @@ var EditorPanel = function EditorPanel(_ref) {
1725
1790
  blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
1726
1791
  }
1727
1792
 
1728
- // Include tab.content so blame re-renders once async file contents finish loading.
1729
- }, [blameData, isBlameVisible, tab.id, tab.content]);
1793
+ // zoneTick, not tab.content: the zones follow the content, 250 ms behind it.
1794
+ }, [blameData, isBlameVisible, tab.id, zoneTick]);
1730
1795
 
1731
1796
  // Check whether the current tab is the source file for the test that was run.
1732
1797
  // e.g. testPanelFile = "test/controllers/theme_controller_test.rb"
@@ -1744,66 +1809,6 @@ var EditorPanel = function EditorPanel(_ref) {
1744
1809
  return src === derived;
1745
1810
  };
1746
1811
 
1747
- var testFileCandidates = function(relativePath) {
1748
- if (!relativePath || !relativePath.endsWith('.rb')) return [];
1749
-
1750
- var basename = relativePath.slice(0, -3);
1751
- var dirParts = relativePath.split('/');
1752
- var leafName = basename.split('/').pop();
1753
- var candidates = [];
1754
-
1755
- if (dirParts[0] === 'app' && dirParts.length > 1) {
1756
- var subPath = dirParts.slice(1).join('/');
1757
- var subDir = subPath.indexOf('/') !== -1 ? subPath.slice(0, subPath.lastIndexOf('/')) : '';
1758
- candidates.push('test/' + (subDir ? subDir + '/' : '') + leafName + '_test.rb');
1759
- candidates.push('spec/' + (subDir ? subDir + '/' : '') + leafName + '_spec.rb');
1760
- }
1761
-
1762
- if (dirParts[0] === 'lib') {
1763
- var libSubPath = dirParts.slice(1).join('/');
1764
- var libSubDir = libSubPath.indexOf('/') !== -1 ? libSubPath.slice(0, libSubPath.lastIndexOf('/')) : '';
1765
- candidates.push('test/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
1766
- candidates.push('test/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
1767
- candidates.push('spec/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_spec.rb');
1768
- }
1769
-
1770
- candidates.push('test/' + leafName + '_test.rb');
1771
- candidates.push('spec/' + leafName + '_spec.rb');
1772
-
1773
- return candidates.filter(function(candidate, index, list) {
1774
- return list.indexOf(candidate) === index;
1775
- });
1776
- };
1777
-
1778
- var treeHasPath = function(nodes, targetPath) {
1779
- if (!targetPath) return false;
1780
- var stack = (nodes || []).slice();
1781
-
1782
- while (stack.length) {
1783
- var node = stack.pop();
1784
- if (!node) continue;
1785
- if (node.path === targetPath) return true;
1786
- if (node.children && node.children.length) {
1787
- stack.push.apply(stack, node.children);
1788
- }
1789
- }
1790
-
1791
- return false;
1792
- };
1793
-
1794
- var matchingTestFilePath = function(sourcePath) {
1795
- var normalized = (sourcePath || '').replace(/^\/+/, '');
1796
- if (!normalized || !normalized.endsWith('.rb')) return null;
1797
- if (/^(test|spec)\//.test(normalized) && /_(test|spec)\.rb$/.test(normalized)) return normalized;
1798
-
1799
- var candidates = testFileCandidates(normalized);
1800
- for (var i = 0; i < candidates.length; i += 1) {
1801
- if (treeHasPath(treeData, candidates[i])) return candidates[i];
1802
- }
1803
-
1804
- return null;
1805
- };
1806
-
1807
1812
  // Map a test method name to the best-matching line in the source file.
1808
1813
  // Extracts keywords from the test name and scores each source line.
1809
1814
  var mapTestToSourceLine = function(testName, sourceContent) {
@@ -1840,9 +1845,17 @@ var EditorPanel = function EditorPanel(_ref) {
1840
1845
  var model = editor.getModel();
1841
1846
  var lineCount = model ? model.getLineCount() : 0;
1842
1847
 
1843
- var showHere = testPanelFile && tab.path && isSourceForTest(tab.path, testPanelFile);
1848
+ // Which test file a result entry belongs to. A whole-suite run spans many
1849
+ // of them and stamps each entry with its own `file`, so there is no single
1850
+ // testPanelFile to gate on; a per-file run leaves `file` unset on the
1851
+ // passing entries and testPanelFile is the answer for those.
1852
+ var testFileFor = function (t) { return (t && t.file) || testPanelFile; };
1853
+ var relevantTests = ((testResult && testResult.tests) || []).filter(function (t) {
1854
+ var file = testFileFor(t);
1855
+ return file && tab.path && isSourceForTest(tab.path, file);
1856
+ });
1844
1857
 
1845
- if (!testResult || !testInlineVisible || !showHere) {
1858
+ if (!testResult || !testInlineVisible || relevantTests.length === 0) {
1846
1859
  clearTestZones(editor);
1847
1860
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1848
1861
  return;
@@ -1853,10 +1866,8 @@ var EditorPanel = function EditorPanel(_ref) {
1853
1866
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1854
1867
 
1855
1868
  var normPath = function(p) { return p ? p.replace(/^\/+/, '') : ''; };
1856
- var viewingTestFile = normPath(tab.path) === normPath(testPanelFile);
1857
1869
 
1858
- var tests = testResult.tests || [];
1859
- var testsWithStatus = tests.filter(function (t) {
1870
+ var testsWithStatus = relevantTests.filter(function (t) {
1860
1871
  return t.status === 'pass' || t.status === 'fail' || t.status === 'error';
1861
1872
  });
1862
1873
 
@@ -1865,6 +1876,9 @@ var EditorPanel = function EditorPanel(_ref) {
1865
1876
  // Determine line number for each test
1866
1877
  var sourceContent = tab.content || '';
1867
1878
  var mapped = testsWithStatus.map(function(t) {
1879
+ // Per entry, not per panel: in a suite run this tab is the test file
1880
+ // for some entries and the source counterpart for others.
1881
+ var viewingTestFile = normPath(tab.path) === normPath(testFileFor(t));
1868
1882
  var line;
1869
1883
  if (viewingTestFile && t.line && t.line >= 1 && t.line <= lineCount) {
1870
1884
  line = t.line;
@@ -1923,8 +1937,9 @@ var EditorPanel = function EditorPanel(_ref) {
1923
1937
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1924
1938
  }
1925
1939
 
1926
- // Include tab.content so zones re-render once async file content loads (same as blame).
1927
- }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, tab.content]);
1940
+ // zoneTick, not tab.content see the blame effect. The effect still reads
1941
+ // tab.content for mapTestToSourceLine; the tick only decides when it re-runs.
1942
+ }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, zoneTick]);
1928
1943
 
1929
1944
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
1930
1945
  var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
@@ -2253,18 +2268,6 @@ var EditorPanel = function EditorPanel(_ref) {
2253
2268
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2254
2269
  !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2255
2270
  ),
2256
- testAvailable && matchingTestFilePath(tab.path) && React.createElement(
2257
- 'button',
2258
- {
2259
- className: 'ide-icon-btn',
2260
- onClick: function() { if (onRunTest) onRunTest(); },
2261
- disabled: testLoading,
2262
- 'aria-busy': !!testLoading,
2263
- title: 'Run Tests'
2264
- },
2265
- !testLoading && React.createElement('i', { className: 'fas fa-flask', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2266
- !editorPrefs.toolbarIconOnly && !testLoading && React.createElement('span', { className: 'ide-toolbar-label' }, 'Test')
2267
- )
2268
2271
  ),
2269
2272
  conflictCount > 0 && React.createElement(
2270
2273
  'div', { className: 'mb-conflict-banner' },
@@ -2535,10 +2538,8 @@ window.EditorPanel = React.memo(EditorPanel, function(prev, next) {
2535
2538
  prev.paneId === next.paneId &&
2536
2539
  (prev.markers === next.markers || (prev.markers.length === 0 && next.markers.length === 0)) &&
2537
2540
  prev.gitAvailable === next.gitAvailable &&
2538
- prev.testAvailable === next.testAvailable &&
2539
2541
  prev.testResult === next.testResult &&
2540
2542
  prev.testPanelFile === next.testPanelFile &&
2541
- prev.testLoading === next.testLoading &&
2542
2543
  prev.testInlineVisible === next.testInlineVisible &&
2543
2544
  prev.editorPrefs === next.editorPrefs &&
2544
2545
  prev.monacoReady === next.monacoReady;
@@ -423,7 +423,12 @@ var FileTree = function FileTree(_ref) {
423
423
  return result;
424
424
  };
425
425
 
426
- var flatItems = flattenVisible(items || [], 0, '');
426
+ // A per-level localeCompare sort of the whole visible tree, re-run on every
427
+ // render — including the ones caused by a git poll or a tab switch, neither
428
+ // of which can change the shape of the tree. Only these three inputs can.
429
+ var flatItems = useMemo(function () {
430
+ return flattenVisible(items || [], 0, '');
431
+ }, [items, expandedDirs, pendingCreate]);
427
432
  flatItemsRef.current = flatItems;
428
433
 
429
434
  var totalHeight = flatItems.length * ROW_HEIGHT;
@@ -516,4 +516,13 @@ var GitPanel = function GitPanel(_ref) {
516
516
  );
517
517
  };
518
518
 
519
- window.GitPanel = GitPanel;
519
+ // Same convention as FileTreeMemo: compare the data props only. The handlers
520
+ // are re-created on every MbeditorApp render but each one only calls a setState
521
+ // or a service, so they are stable in effect. Without this the panel — commit
522
+ // history, file lists, the Redmine card — re-rendered on every keystroke in the
523
+ // editor, because MbeditorApp is subscribed to the whole store.
524
+ window.GitPanel = React.memo(GitPanel, function (prev, next) {
525
+ return prev.gitInfo === next.gitInfo &&
526
+ prev.error === next.error &&
527
+ prev.redmineEnabled === next.redmineEnabled;
528
+ });
@@ -7,12 +7,10 @@
7
7
  //
8
8
  // Errors from the same pass render here too, so a drop that both conflicts
9
9
  // and fails needs one dialog rather than two.
10
- var ImportConflictModal = function ImportConflictModal(_ref) {
11
- var conflicts = _ref.conflicts || [];
12
- var errors = _ref.errors || [];
13
- var onResolve = _ref.onResolve;
14
- var modalRef = React.useRef(null);
15
-
10
+ // Focus containment for a modal: initial focus in, Tab cycles inside, Escape
11
+ // dismisses, focus restored on unmount. Shared with ImportDialog — both are
12
+ // overlays over an editor that will happily steal focus back otherwise.
13
+ window.useModalFocusTrap = function useModalFocusTrap(modalRef, onDismiss) {
16
14
  React.useEffect(function () {
17
15
  var previouslyFocused = document.activeElement;
18
16
  var modal = modalRef.current;
@@ -25,7 +23,7 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
25
23
  if (e.key === 'Escape') {
26
24
  e.preventDefault();
27
25
  e.stopPropagation();
28
- onResolve('skip');
26
+ onDismiss();
29
27
  return;
30
28
  }
31
29
  if (e.key !== 'Tab' || !modal) return;
@@ -57,7 +55,16 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
57
55
  previouslyFocused.focus();
58
56
  }
59
57
  };
60
- }, [onResolve]);
58
+ }, [onDismiss]);
59
+ };
60
+
61
+ var ImportConflictModal = function ImportConflictModal(_ref) {
62
+ var conflicts = _ref.conflicts || [];
63
+ var errors = _ref.errors || [];
64
+ var onResolve = _ref.onResolve;
65
+ var modalRef = React.useRef(null);
66
+
67
+ window.useModalFocusTrap(modalRef, React.useCallback(function () { onResolve('skip'); }, [onResolve]));
61
68
 
62
69
  var button = function (label, mode, className) {
63
70
  return React.createElement(
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ // Upload dialog: the pointed-at half of the drag-and-drop import.
4
+ //
5
+ // Everything past "pick files" is the existing import pipeline — the same
6
+ // entries shape, the same /import call, the same ImportConflictModal for
7
+ // replace/keep-both/skip. The only thing this adds is *where* the files land,
8
+ // which drag-and-drop answers by what you dropped onto and a file picker
9
+ // cannot answer at all.
10
+ //
11
+ // Destination suggestions come from the file tree already in memory
12
+ // (SearchService's index), so picking a folder that lives at
13
+ // app/assets/javascripts/ux/component offers that prefix rather than dumping
14
+ // `ux/component/` at the workspace root. The root stays on offer as the
15
+ // "create the structure from scratch" option.
16
+ var ImportDialog = function ImportDialog(_ref) {
17
+ var initialFolder = _ref.initialFolder || '';
18
+ var docs = _ref.docs || [];
19
+ var onCancel = _ref.onCancel;
20
+ var onImport = _ref.onImport;
21
+
22
+ var modalRef = React.useRef(null);
23
+ var filesInputRef = React.useRef(null);
24
+ var folderInputRef = React.useRef(null);
25
+
26
+ var _picked = React.useState(null);
27
+ var picked = _picked[0];
28
+ var setPicked = _picked[1];
29
+
30
+ var _dest = React.useState(initialFolder);
31
+ var dest = _dest[0];
32
+ var setDest = _dest[1];
33
+
34
+ window.useModalFocusTrap(modalRef, onCancel);
35
+
36
+ // webkitdirectory has no JSX/React prop, and React strips unknown lowercase
37
+ // attributes on <input>, so it is set on the DOM node directly.
38
+ React.useEffect(function () {
39
+ var el = folderInputRef.current;
40
+ if (!el) return;
41
+ el.setAttribute('webkitdirectory', '');
42
+ el.setAttribute('directory', '');
43
+ }, []);
44
+
45
+ var entries = (picked && picked.entries) || [];
46
+
47
+ var suggestions = React.useMemo(function () {
48
+ if (entries.length === 0) return [];
49
+ return FileImport.suggestDestinations(entries, docs);
50
+ }, [picked, docs]);
51
+
52
+ var existingFiles = React.useMemo(function () {
53
+ var set = new Set();
54
+ docs.forEach(function (d) { if (d.type === 'file') set.add(d.path); });
55
+ return set;
56
+ }, [docs]);
57
+
58
+ var onPick = function (e) {
59
+ var result = FileImport.entriesFromFileList(e.target.files);
60
+ e.target.value = ''; // so picking the same folder twice still fires change
61
+ if (result.entries.length === 0) return;
62
+ setPicked(result);
63
+ };
64
+
65
+ var normalisedDest = String(dest || '').trim().replace(/^\/+|\/+$/g, '');
66
+
67
+ var destOption = function (prefix, label, hint) {
68
+ var active = normalisedDest === prefix;
69
+ return React.createElement(
70
+ 'button',
71
+ {
72
+ key: '$' + prefix,
73
+ type: 'button',
74
+ className: 'import-dest-option' + (active ? ' active' : ''),
75
+ onClick: function () { setDest(prefix); }
76
+ },
77
+ React.createElement('i', { className: (active ? 'fas fa-dot-circle' : 'far fa-circle') + ' import-dest-radio' }),
78
+ React.createElement('span', { className: 'import-dest-label' }, label),
79
+ hint && React.createElement('span', { className: 'import-dest-hint' }, hint)
80
+ );
81
+ };
82
+
83
+ var offered = {};
84
+ var options = [];
85
+ var offer = function (prefix, label, hint) {
86
+ if (offered['$' + prefix]) return;
87
+ offered['$' + prefix] = true;
88
+ options.push(destOption(prefix, label, hint));
89
+ };
90
+
91
+ // The folder this upload was started from is always first and always the
92
+ // active one. Offering it after the suggestions meant that once you had
93
+ // imported a folder anywhere, that same folder scored top of the suggestion
94
+ // list next time and sat at the head of the options — so the dialog read as
95
+ // "it remembered where I last put things". With nothing selected it was
96
+ // worse: the previous destination was the first option and the actually
97
+ // chosen one ("Workspace root") was last.
98
+ offer(initialFolder, initialFolder || 'Workspace root',
99
+ initialFolder ? 'selected folder' : 'create the folders from the root');
100
+ suggestions.forEach(function (s) {
101
+ var bits = [];
102
+ if (s.files) bits.push(s.files + ' matching file' + (s.files === 1 ? '' : 's'));
103
+ if (s.dirs) bits.push(s.dirs + ' matching folder' + (s.dirs === 1 ? '' : 's'));
104
+ offer(s.prefix, s.prefix, bits.join(', '));
105
+ });
106
+ offer('', 'Workspace root', 'create the folders from the root');
107
+
108
+ var targets = entries.map(function (e) {
109
+ return FileImport.joinPath(normalisedDest, e.relativePath);
110
+ });
111
+ var replacing = targets.filter(function (t) { return existingFiles.has(t); }).length;
112
+
113
+ return React.createElement(
114
+ 'div',
115
+ { className: 'schema-modal-overlay', onClick: onCancel },
116
+ React.createElement(
117
+ 'div',
118
+ {
119
+ ref: modalRef,
120
+ className: 'schema-modal import-dialog',
121
+ role: 'dialog',
122
+ 'aria-modal': 'true',
123
+ 'aria-label': 'Upload files',
124
+ tabIndex: -1,
125
+ onClick: function (e) { e.stopPropagation(); }
126
+ },
127
+ React.createElement(
128
+ 'div', { className: 'schema-modal-header' },
129
+ React.createElement('div', { className: 'schema-modal-title' },
130
+ React.createElement('i', { className: 'fas fa-upload', style: { marginRight: '8px', opacity: 0.7 } }),
131
+ 'Upload files'),
132
+ React.createElement(
133
+ 'button',
134
+ { className: 'schema-modal-close', onClick: onCancel, title: 'Close' },
135
+ React.createElement('i', { className: 'fas fa-times' })
136
+ )
137
+ ),
138
+ React.createElement(
139
+ 'div', { className: 'schema-modal-body' },
140
+
141
+ React.createElement(
142
+ 'div', { className: 'import-pick-row' },
143
+ React.createElement(
144
+ 'button',
145
+ { type: 'button', className: 'import-pick-btn',
146
+ onClick: function () { filesInputRef.current && filesInputRef.current.click(); } },
147
+ React.createElement('i', { className: 'far fa-file' }), ' Choose files…'
148
+ ),
149
+ React.createElement(
150
+ 'button',
151
+ { type: 'button', className: 'import-pick-btn',
152
+ onClick: function () { folderInputRef.current && folderInputRef.current.click(); } },
153
+ React.createElement('i', { className: 'far fa-folder' }), ' Choose folder…'
154
+ ),
155
+ React.createElement('input', {
156
+ ref: filesInputRef, type: 'file', multiple: true,
157
+ style: { display: 'none' }, onChange: onPick
158
+ }),
159
+ React.createElement('input', {
160
+ ref: folderInputRef, type: 'file', multiple: true,
161
+ style: { display: 'none' }, onChange: onPick
162
+ })
163
+ ),
164
+
165
+ entries.length === 0 && React.createElement(
166
+ 'div', { className: 'import-dialog-empty' },
167
+ 'Pick files or a folder to upload. You can also drag them onto the explorer.'
168
+ ),
169
+
170
+ picked && picked.truncated && React.createElement(
171
+ 'div', { className: 'import-dialog-warning' },
172
+ 'Only the first ' + FileImport.MAX_ENTRIES + ' files will be uploaded.'
173
+ ),
174
+
175
+ entries.length > 0 && React.createElement(
176
+ React.Fragment, null,
177
+ React.createElement('div', { className: 'import-dialog-section-title' }, 'Destination'),
178
+ React.createElement('div', { className: 'import-dest-options' }, options),
179
+ React.createElement('input', {
180
+ className: 'import-dest-input',
181
+ type: 'text',
182
+ value: dest,
183
+ placeholder: 'workspace root',
184
+ spellCheck: false,
185
+ autoComplete: 'off',
186
+ 'aria-label': 'Destination folder',
187
+ onChange: function (e) { setDest(e.target.value); }
188
+ }),
189
+
190
+ React.createElement('div', { className: 'import-dialog-section-title' },
191
+ entries.length + ' file' + (entries.length === 1 ? '' : 's') +
192
+ (replacing > 0 ? ' — ' + replacing + ' will replace an existing file' : '')),
193
+ React.createElement(
194
+ 'ul', { className: 'import-conflict-list' },
195
+ targets.map(function (t, i) {
196
+ return React.createElement(
197
+ 'li', { key: t + '#' + i, title: t },
198
+ t,
199
+ existingFiles.has(t) && React.createElement(
200
+ 'span', { className: 'import-target-replaces' }, 'replaces')
201
+ );
202
+ })
203
+ )
204
+ )
205
+ ),
206
+ React.createElement(
207
+ 'div', { className: 'import-conflict-actions' },
208
+ React.createElement('button', { className: 'import-conflict-btn', onClick: onCancel }, 'Cancel'),
209
+ React.createElement(
210
+ 'button',
211
+ {
212
+ className: 'import-conflict-btn import-conflict-btn-primary',
213
+ disabled: entries.length === 0,
214
+ onClick: function () { onImport(entries, normalisedDest, picked); }
215
+ },
216
+ entries.length === 0 ? 'Upload' : 'Upload ' + entries.length + ' file' + (entries.length === 1 ? '' : 's')
217
+ )
218
+ )
219
+ )
220
+ );
221
+ };
222
+
223
+ // Expose globally for sprockets require
224
+ window.ImportDialog = ImportDialog;