mbeditor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })();
4
3
 
5
4
  var _React = React;
6
5
  var useState = _React.useState;
@@ -24,16 +23,13 @@ var EditorPanel = function EditorPanel(_ref) {
24
23
  var onContentChange = _ref.onContentChange;
25
24
  var markers = _ref.markers;
26
25
  var gitAvailable = _ref.gitAvailable === true;
27
- var testAvailable = _ref.testAvailable === true;
28
26
  var onFormat = _ref.onFormat;
29
27
  var onSave = _ref.onSave;
30
- var onRunTest = _ref.onRunTest;
31
28
  var onRunTestAtCursor = _ref.onRunTestAtCursor;
32
29
  var onShowHistory = _ref.onShowHistory;
33
30
  var treeData = _ref.treeData || [];
34
31
  var testResult = _ref.testResult;
35
32
  var testPanelFile = _ref.testPanelFile;
36
- var testLoading = _ref.testLoading;
37
33
  var testInlineVisible = _ref.testInlineVisible;
38
34
  var editorPrefs = _ref.editorPrefs || {};
39
35
  var monacoReady = _ref.monacoReady !== false; // undefined means Monaco already loaded (legacy callers)
@@ -42,6 +38,11 @@ var EditorPanel = function EditorPanel(_ref) {
42
38
  var monacoRef = useRef(null);
43
39
  var latestContentRef = useRef('');
44
40
  var lastAppliedExternalVersionRef = useRef(0);
41
+ // Set by the editor-creation effect when the tab mounts before its file content
42
+ // has arrived. The external-content effect calls it once the load lands, so
43
+ // persistent-undo tracking starts with the loaded file as its base instead of
44
+ // recording the load itself as an undo op.
45
+ var pendingHistorySetupRef = useRef(null);
45
46
  var conflictDecorationsRef = React.useRef([]);
46
47
  var conflictBlocksRef = React.useRef([]);
47
48
  var aviBaseRef = useRef(0);
@@ -70,24 +71,20 @@ var EditorPanel = function EditorPanel(_ref) {
70
71
  var currentConflictIndexRef = React.useRef(0);
71
72
 
72
73
  var _useState = useState('');
73
- var _useState2 = _slicedToArray(_useState, 2);
74
- var markup = _useState2[0];
75
- var setMarkup = _useState2[1];
74
+ var markup = _useState[0];
75
+ var setMarkup = _useState[1];
76
76
 
77
77
  var _useState3 = useState(false);
78
- var _useState4 = _slicedToArray(_useState3, 2);
79
- var isBlameVisible = _useState4[0];
80
- var setIsBlameVisible = _useState4[1];
78
+ var isBlameVisible = _useState3[0];
79
+ var setIsBlameVisible = _useState3[1];
81
80
 
82
81
  var _useState5 = useState(null);
83
- var _useState6 = _slicedToArray(_useState5, 2);
84
- var blameData = _useState6[0];
85
- var setBlameData = _useState6[1];
82
+ var blameData = _useState5[0];
83
+ var setBlameData = _useState5[1];
86
84
 
87
85
  var _useState7 = useState(false);
88
- var _useState8 = _slicedToArray(_useState7, 2);
89
- var isBlameLoading = _useState8[0];
90
- var setIsBlameLoading = _useState8[1];
86
+ var isBlameLoading = _useState7[0];
87
+ var setIsBlameLoading = _useState7[1];
91
88
 
92
89
  var blameDecorationsRef = useRef([]);
93
90
  var gitLineDecorationsRef = useRef([]);
@@ -100,34 +97,28 @@ var EditorPanel = function EditorPanel(_ref) {
100
97
  var testZoneIdsRef = useRef([]);
101
98
 
102
99
  var _useState9 = useState(false);
103
- var _useState10 = _slicedToArray(_useState9, 2);
104
- var editorReady = _useState10[0];
105
- var setEditorReady = _useState10[1];
100
+ var editorReady = _useState9[0];
101
+ var setEditorReady = _useState9[1];
106
102
 
107
103
  var _useState11 = useState(false);
108
- var _useState12 = _slicedToArray(_useState11, 2);
109
- var methodsOpen = _useState12[0];
110
- var setMethodsOpen = _useState12[1];
104
+ var methodsOpen = _useState11[0];
105
+ var setMethodsOpen = _useState11[1];
111
106
 
112
107
  var _useState13 = useState([]);
113
- var _useState14 = _slicedToArray(_useState13, 2);
114
- var methodsList = _useState14[0];
115
- var setMethodsList = _useState14[1];
108
+ var methodsList = _useState13[0];
109
+ var setMethodsList = _useState13[1];
116
110
 
117
111
  var _useState15 = useState(false);
118
- var _useState16 = _slicedToArray(_useState15, 2);
119
- var methodsTruncated = _useState16[0];
120
- var setMethodsTruncated = _useState16[1];
112
+ var methodsTruncated = _useState15[0];
113
+ var setMethodsTruncated = _useState15[1];
121
114
 
122
115
  var _useState17 = useState(false);
123
- var _useState18 = _slicedToArray(_useState17, 2);
124
- var methodsUnavailable = _useState18[0];
125
- var setMethodsUnavailable = _useState18[1];
116
+ var methodsUnavailable = _useState17[0];
117
+ var setMethodsUnavailable = _useState17[1];
126
118
 
127
119
  var _useState19 = useState(null);
128
- var _useState20 = _slicedToArray(_useState19, 2);
129
- var methodsDropdownPos = _useState20[0];
130
- var setMethodsDropdownPos = _useState20[1];
120
+ var methodsDropdownPos = _useState19[0];
121
+ var setMethodsDropdownPos = _useState19[1];
131
122
 
132
123
  var methodsBtnRef = useRef(null);
133
124
  var methodsDropdownRef = useRef(null);
@@ -136,24 +127,20 @@ var EditorPanel = function EditorPanel(_ref) {
136
127
 
137
128
  // Local pagination state — initialized from tab props; updated on page navigation
138
129
  var _useState21 = useState(tab.startLine || 0);
139
- var _useState22 = _slicedToArray(_useState21, 2);
140
- var pageStartLine = _useState22[0];
141
- var setPageStartLine = _useState22[1];
130
+ var pageStartLine = _useState21[0];
131
+ var setPageStartLine = _useState21[1];
142
132
 
143
133
  var _useState23 = useState(tab.lineCount || 0);
144
- var _useState24 = _slicedToArray(_useState23, 2);
145
- var pageLineCount = _useState24[0];
146
- var setPageLineCount = _useState24[1];
134
+ var pageLineCount = _useState23[0];
135
+ var setPageLineCount = _useState23[1];
147
136
 
148
137
  var _useState25 = useState(tab.totalLines || 0);
149
- var _useState26 = _slicedToArray(_useState25, 2);
150
- var pageTotalLines = _useState26[0];
151
- var setPageTotalLines = _useState26[1];
138
+ var pageTotalLines = _useState25[0];
139
+ var setPageTotalLines = _useState25[1];
152
140
 
153
141
  var _useState27 = useState(tab.totalBytes || 0);
154
- var _useState28 = _slicedToArray(_useState27, 2);
155
- var pageTotalBytes = _useState28[0];
156
- var setPageTotalBytes = _useState28[1];
142
+ var pageTotalBytes = _useState27[0];
143
+ var setPageTotalBytes = _useState27[1];
157
144
 
158
145
  var onFormatRef = useRef(onFormat);
159
146
  onFormatRef.current = onFormat;
@@ -168,12 +155,7 @@ var EditorPanel = function EditorPanel(_ref) {
168
155
 
169
156
  var vimStatusRef = useRef(null);
170
157
  var vimModeObjRef = useRef(null);
171
-
172
- function humanSize(bytes) {
173
- if (bytes < 1024) return bytes + ' B';
174
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
175
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
176
- }
158
+ var previewScrollRef = useRef(null);
177
159
 
178
160
  var clearTestZones = function clearTestZones(editor) {
179
161
  if (!editor) return;
@@ -518,6 +500,10 @@ var EditorPanel = function EditorPanel(_ref) {
518
500
  language = 'javascript';
519
501
  } else if (/\.css\.erb$/.test(fileNameLower)) {
520
502
  language = 'css';
503
+ } else if (/\.scss\.erb$/.test(fileNameLower)) {
504
+ language = 'scss';
505
+ } else if (/\.less\.erb$/.test(fileNameLower)) {
506
+ language = 'less';
521
507
  } else if (/\.html\.erb$/.test(fileNameLower)) {
522
508
  language = 'erb';
523
509
  } else if (/\.html\.haml$/.test(fileNameLower)) {
@@ -543,8 +529,16 @@ var EditorPanel = function EditorPanel(_ref) {
543
529
  language = 'javascript';break;
544
530
  case 'ts':case 'tsx':
545
531
  language = 'typescript';break;
546
- case 'css':case 'scss':case 'sass':
532
+ // scss and less are their own Monaco languages, and reach nothing useful
533
+ // without these: scss went to css and got flagged line by line, less went
534
+ // nowhere and fell through to plaintext. '.sass' stays on css because
535
+ // Monaco has no sass language, and scss would reject it just as loudly.
536
+ case 'css':case 'sass':
547
537
  language = 'css';break;
538
+ case 'scss':
539
+ language = 'scss';break;
540
+ case 'less':
541
+ language = 'less';break;
548
542
  case 'html':
549
543
  language = 'html';break;
550
544
  case 'erb':
@@ -607,16 +601,26 @@ var EditorPanel = function EditorPanel(_ref) {
607
601
  // untitled://, mbeditor:// and friends have no file to persist history
608
602
  // against — tracking them just produces doomed /file_history requests.
609
603
  var _virtualPath = (tab.path || '').indexOf('://') >= 0;
610
-
611
- if (!_collabActive && !_virtualPath && typeof HistoryService !== 'undefined') {
604
+ var _historyEnabled = !_collabActive && !_virtualPath && typeof HistoryService !== 'undefined';
605
+ // A freshly opened tab mounts empty and receives its content asynchronously.
606
+ // Tracking must wait for that load (and start with the loaded file as its
607
+ // base) or the load is recorded as an insert-at-origin op — the root cause of
608
+ // both the doubled buffer (#92) and histories that grow by a file per open (#93).
609
+ var _contentLoaded = (tab.externalContentVersion || 0) > 0 ||
610
+ (_reusingModel && modelObj.getValue().length > 0);
611
+
612
+ if (_historyEnabled && _reusingModel && _contentLoaded) {
612
613
  var _histBranch = EditorStore.getState().gitBranch || '';
613
614
  if (_histBranch) {
614
- if (_reusingModel) {
615
- HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
616
- } else {
617
- HistoryService.beginTracking(_histBranch, tab.path, tab.content || '');
618
- }
615
+ HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
619
616
  }
617
+ } else if (_historyEnabled && _reusingModel) {
618
+ // A cached model that is still empty (the tab was switched away before the
619
+ // load landed). Defer like the new-model path so the load isn't recorded.
620
+ pendingHistorySetupRef.current = function () {
621
+ var _branch = EditorStore.getState().gitBranch || '';
622
+ if (_branch) HistoryService.resumeTracking(_branch, tab.path, modelObj.getValue());
623
+ };
620
624
  }
621
625
 
622
626
  // Sync latestContentRef from the actual model content so the onDidChangeContent
@@ -655,15 +659,20 @@ var EditorPanel = function EditorPanel(_ref) {
655
659
  quickSuggestions: editorPrefs.quickSuggestions !== false,
656
660
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
657
661
  acceptSuggestionOnEnter: editorPrefs.acceptSuggestionOnEnter || 'on',
658
- linkedEditing: !!(editorPrefs.linkedEditing),
662
+ linkedEditing: true,
659
663
  fixedOverflowWidgets: true,
660
664
  hover: { above: false }
661
665
  });
662
666
 
663
667
  applyLargeFileTuning(editor, modelObj);
664
668
 
665
- if (tab.viewState) {
666
- editor.restoreViewState(tab.viewState);
669
+ // Read the view state from the store, not from the captured `tab` prop: this
670
+ // effect re-runs on collabReady, and the cleanup that just ran wrote a fresher
671
+ // snapshot than the prop this closure was created with.
672
+ var _liveTab = findTabByPath(tab.path);
673
+ var _viewState = (_liveTab && _liveTab.viewState) || tab.viewState;
674
+ if (_viewState) {
675
+ editor.restoreViewState(_viewState);
667
676
  }
668
677
 
669
678
  // Restore the find widget state from the previous editor (when persistFindState is on).
@@ -708,6 +717,32 @@ var EditorPanel = function EditorPanel(_ref) {
708
717
 
709
718
  monacoRef.current = editor;
710
719
  window.__mbeditorActiveEditor = editor;
720
+ // Publish the live editor instance so a markdown preview in another pane
721
+ // (a separate EditorPanel instance) can find it for scroll sync.
722
+ _modelEntry.editor = editor;
723
+
724
+ var scrollSyncDisposable = null;
725
+ if (/\.(md|markdown)$/i.test(tab.path || '')) {
726
+ scrollSyncDisposable = editor.onDidScrollChange(function () {
727
+ if (_modelEntry.scrollSyncing) return;
728
+ var scrollHeight = editor.getScrollHeight() - editor.getLayoutInfo().height;
729
+ if (scrollHeight <= 0) return;
730
+ var fraction = editor.getScrollTop() / scrollHeight;
731
+ var previews = document.querySelectorAll('[data-preview-for]');
732
+ for (var _p = 0; _p < previews.length; _p++) {
733
+ if (previews[_p].getAttribute('data-preview-for') !== tab.path) continue;
734
+ var max = previews[_p].scrollHeight - previews[_p].clientHeight;
735
+ if (max <= 0) continue;
736
+ _modelEntry.scrollSyncing = true;
737
+ previews[_p].scrollTop = fraction * max;
738
+ _modelEntry.scrollSyncing = false;
739
+ }
740
+ });
741
+ }
742
+ // Tells the status bar's cursor readout to re-attach. An event rather than
743
+ // a prop because the editor is published imperatively here, and a listener
744
+ // that guessed at the timing would miss the swap on a tab switch.
745
+ window.dispatchEvent(new CustomEvent('mbeditor:active-editor'));
711
746
  // Apply read-only for paginated (truncated) files
712
747
  if (tab.truncated) {
713
748
  editor.updateOptions({ readOnly: true });
@@ -771,14 +806,13 @@ var EditorPanel = function EditorPanel(_ref) {
771
806
  EditorStore.setState({ isQuickOpenVisible: true });
772
807
  });
773
808
 
774
- // PageUp/PageDown cycle between the current cursor position and where the
775
- // cursor was before the last jump (go-to-definition, search result, etc.),
776
- // replacing the default page-scroll behaviour.
809
+ // PageUp/PageDown walk a back/forward navigation history (go-to-definition,
810
+ // search results, file switches), replacing the default page-scroll behaviour.
777
811
  editor.addCommand(window.monaco.KeyCode.PageUp, function() {
778
- TabManager.toggleJumpOrigin();
812
+ TabManager.navigateBack();
779
813
  });
780
814
  editor.addCommand(window.monaco.KeyCode.PageDown, function() {
781
- TabManager.toggleJumpOrigin();
815
+ TabManager.navigateForward();
782
816
  });
783
817
 
784
818
  var editorPluginDisposable = null;
@@ -846,7 +880,25 @@ var EditorPanel = function EditorPanel(_ref) {
846
880
  // below can swap the model, so this must be re-attached to the replacement —
847
881
  // a listener left on the old model would silently stop tracking edits.
848
882
  var _attachContentListener = function (model) {
849
- return model.onDidChangeContent(function (e) {
883
+ // onDidChangeContent fires per keystroke, and the full-buffer work it used
884
+ // to do — one getValue() plus two whole-file CRLF normalisations — costs
885
+ // ~12ms per keystroke on a large file, on the main thread, ahead of
886
+ // Monaco's own processing. The AVI bookkeeping below stays per-event and
887
+ // O(1); the buffer is materialised once on a trailing edge (and once more
888
+ // by TabManager's own 250ms flush, via the provider passed to markDirty).
889
+ var _contentSyncTimer = null;
890
+ var _contentProvider = function () {
891
+ return model.isDisposed() ? null : model.getValue();
892
+ };
893
+ var _flushContentSync = function () {
894
+ _contentSyncTimer = null;
895
+ var val = _contentProvider();
896
+ if (val === null) return;
897
+ latestContentRef.current = val;
898
+ onContentChange(val);
899
+ };
900
+
901
+ var _contentListener = model.onDidChangeContent(function (e) {
850
902
  if (!_collabActive && typeof HistoryService !== 'undefined') {
851
903
  HistoryService.recordOps(tab.path, e.changes);
852
904
  }
@@ -864,8 +916,6 @@ var EditorPanel = function EditorPanel(_ref) {
864
916
  EditorStore.setState({ canUndo: newCanUndo, canRedo: newCanRedo });
865
917
  }
866
918
 
867
- var val = model.getValue();
868
-
869
919
  // Dirty-state tracking via alternativeVersionId — O(1), no string comparison.
870
920
  // AVI decrements on undo so it returns to cleanVersionId after a full undo.
871
921
  // Skip entirely when cleanVersionId is null — file is mid-load, not yet settled.
@@ -873,155 +923,179 @@ var EditorPanel = function EditorPanel(_ref) {
873
923
  var _cleanAvi = _entry && _entry.cleanVersionId;
874
924
  if (_cleanAvi !== null && _cleanAvi !== undefined) {
875
925
  if (currentAvi !== _cleanAvi) {
876
- TabManager.markDirty(paneId, tab.id, val);
926
+ TabManager.markDirty(paneId, tab.id, _contentProvider);
877
927
  } else {
878
- TabManager.markClean(paneId, tab.id, val);
928
+ TabManager.markClean(paneId, tab.id, _contentProvider);
879
929
  }
880
930
  }
881
931
 
882
- var currentContent = latestContentRef.current;
883
-
884
- // Normalize before comparing to prevent false positive dirty edits
885
- var vNorm = val.replace(/\r\n/g, '\n');
886
- var cNorm = currentContent.replace(/\r\n/g, '\n');
887
- if (vNorm !== cNorm) {
888
- // Update the ref immediately so rapid undo/redo events compare against the
889
- // latest content rather than a stale snapshot from a previous React render.
890
- latestContentRef.current = val;
891
- onContentChange(val);
932
+ // Draft persistence is the only remaining consumer of the full text.
933
+ // Coalesce it onto the same 250ms edge TabManager uses for content writes.
934
+ if (_contentSyncTimer === null) {
935
+ _contentSyncTimer = setTimeout(_flushContentSync, 250);
892
936
  }
893
937
  });
938
+ return {
939
+ dispose: function () {
940
+ if (_contentSyncTimer !== null) { clearTimeout(_contentSyncTimer); _contentSyncTimer = null; }
941
+ _contentListener.dispose();
942
+ }
943
+ };
894
944
  };
895
945
 
896
946
  var contentDisposable = _attachContentListener(modelObj);
897
947
 
898
948
  // Phase 2: background undo-history replay.
899
- // Only run for newly-created models (reused models already have their undo stack).
949
+ // Only run for newly-created models (reused models already have their undo
950
+ // stack). Tracking and replay both wait for the file load: the base is the
951
+ // loaded content, so the load is neither recorded nor buffered.
900
952
  var _phase2CleanupFn = null;
901
- if (!_collabActive && !_virtualPath && !_reusingModel && typeof HistoryService !== 'undefined') {
902
- var _phase2Branch = EditorStore.getState().gitBranch || '';
903
- var _phase2Path = tab.path;
904
- var _phase2Content = tab.content || '';
905
- var _phase2Buf = [];
906
- var _phase2Active = true;
907
-
908
- var _phase2ModelA = modelObj;
909
- var _phase2Listener = _phase2ModelA.onDidChangeContent(function (ev) {
910
- if (!_phase2Active) return;
911
- for (var _ci = 0; _ci < ev.changes.length; _ci++) {
912
- var _c = ev.changes[_ci];
913
- _phase2Buf.push([
914
- _c.range.startLineNumber, _c.range.startColumn,
915
- _c.range.endLineNumber, _c.range.endColumn,
916
- _c.text
917
- ]);
918
- }
919
- });
953
+ if (_historyEnabled && !_reusingModel) {
954
+ var _phase2Path = tab.path;
955
+ var _phase2Buf = [];
956
+ var _phase2Active = true;
957
+ var _phase2ModelA = modelObj;
958
+ var _phase2Listener = null;
959
+ var _historyStarted = false;
960
+ var _startHistory = null;
961
+
962
+ _phase2CleanupFn = function () {
963
+ _phase2Active = false;
964
+ if (_phase2Listener) _phase2Listener.dispose();
965
+ };
966
+
967
+ _startHistory = function () {
968
+ if (_historyStarted || !_phase2Active) return;
969
+ var _phase2Branch = EditorStore.getState().gitBranch || '';
970
+ if (!_phase2Branch) return;
971
+ _historyStarted = true;
972
+
973
+ HistoryService.beginTracking(_phase2Branch, _phase2Path, _phase2ModelA.getValue());
920
974
 
921
- var _runPhase2 = function () {
922
- if (!_phase2Active || !_phase2Branch) return;
923
- HistoryService.fetchHistory(_phase2Branch, _phase2Path).then(function (hist) {
975
+ // Buffer edits made on model A between the load and the replay swap so
976
+ // they can be re-applied to the replayed model B.
977
+ _phase2Listener = _phase2ModelA.onDidChangeContent(function (ev) {
924
978
  if (!_phase2Active) return;
925
- if (!hist || !hist.ops || hist.ops.length === 0) {
926
- _phase2Listener.dispose();
927
- return;
979
+ for (var _ci = 0; _ci < ev.changes.length; _ci++) {
980
+ var _c = ev.changes[_ci];
981
+ _phase2Buf.push([
982
+ _c.range.startLineNumber, _c.range.startColumn,
983
+ _c.range.endLineNumber, _c.range.endColumn,
984
+ _c.text
985
+ ]);
928
986
  }
987
+ });
929
988
 
930
- var _lang = modelObj.getLanguageId();
931
- var modelB = window.monaco.editor.createModel(hist.base, _lang);
932
-
933
- HistoryService.setReplayInProgress(_phase2Path, true);
934
- try {
935
- for (var _oi = 0; _oi < hist.ops.length; _oi++) {
936
- var _op = hist.ops[_oi];
937
- modelB.pushEditOperations([], [{
938
- range: new window.monaco.Range(_op[0], _op[1], _op[2], _op[3]),
939
- text: _op[4] || ''
940
- }], function () { return null; });
989
+ var _runPhase2 = function () {
990
+ if (!_phase2Active) return;
991
+ HistoryService.fetchHistory(_phase2Branch, _phase2Path).then(function (hist) {
992
+ if (!_phase2Active) return;
993
+ if (!hist || !hist.ops || hist.ops.length === 0) {
994
+ if (_phase2Listener) _phase2Listener.dispose();
995
+ return;
941
996
  }
942
- } catch (e) {
943
- HistoryService.setReplayInProgress(_phase2Path, false);
944
- _phase2Listener.dispose();
945
- modelB.dispose();
946
- return;
947
- }
948
- HistoryService.setReplayInProgress(_phase2Path, false);
949
997
 
950
- var _expectedContent = _phase2ModelA.getValue();
951
- if (modelB.getValue() !== _expectedContent) {
952
- _phase2Listener.dispose();
953
- modelB.dispose();
954
- return;
955
- }
998
+ var _lang = modelObj.getLanguageId();
999
+ var modelB = window.monaco.editor.createModel(hist.base, _lang);
956
1000
 
957
- if (_phase2Buf.length > 0) {
1001
+ HistoryService.setReplayInProgress(_phase2Path, true);
958
1002
  try {
959
- for (var _bi = 0; _bi < _phase2Buf.length; _bi++) {
960
- var _bop = _phase2Buf[_bi];
1003
+ for (var _oi = 0; _oi < hist.ops.length; _oi++) {
1004
+ var _op = hist.ops[_oi];
961
1005
  modelB.pushEditOperations([], [{
962
- range: new window.monaco.Range(_bop[0], _bop[1], _bop[2], _bop[3]),
963
- text: _bop[4] || ''
1006
+ range: new window.monaco.Range(_op[0], _op[1], _op[2], _op[3]),
1007
+ text: _op[4] || ''
964
1008
  }], function () { return null; });
965
1009
  }
966
1010
  } catch (e) {
967
- _phase2Listener.dispose();
1011
+ HistoryService.setReplayInProgress(_phase2Path, false);
1012
+ if (_phase2Listener) _phase2Listener.dispose();
968
1013
  modelB.dispose();
969
1014
  return;
970
1015
  }
971
- }
1016
+ HistoryService.setReplayInProgress(_phase2Path, false);
972
1017
 
973
- _phase2Listener.dispose();
974
- if (!_phase2Active) { modelB.dispose(); return; }
1018
+ // Fold in the edits made on model A while the replay was in flight,
1019
+ // THEN validate. Applying them after the check is what let a buffered
1020
+ // initial load slip through and duplicate the file (#92).
1021
+ if (_phase2Buf.length > 0) {
1022
+ try {
1023
+ for (var _bi = 0; _bi < _phase2Buf.length; _bi++) {
1024
+ var _bop = _phase2Buf[_bi];
1025
+ modelB.pushEditOperations([], [{
1026
+ range: new window.monaco.Range(_bop[0], _bop[1], _bop[2], _bop[3]),
1027
+ text: _bop[4] || ''
1028
+ }], function () { return null; });
1029
+ }
1030
+ } catch (e) {
1031
+ if (_phase2Listener) _phase2Listener.dispose();
1032
+ modelB.dispose();
1033
+ return;
1034
+ }
1035
+ }
975
1036
 
976
- if (modelB.getLanguageId() !== _lang) {
977
- window.monaco.editor.setModelLanguage(modelB, _lang);
978
- }
979
- modelB._mbeditorPath = _phase2Path;
980
-
981
- var _vs = editor.saveViewState();
982
- editor.setModel(modelB);
983
- if (_vs) editor.restoreViewState(_vs);
984
-
985
- var _oldEntry = window.__mbeditorModels[_phase2Path];
986
- if (_oldEntry && _oldEntry.model !== modelB) {
987
- var _oldModel = _oldEntry.model;
988
- // modelB holds the same text as modelA but its own version-id sequence,
989
- // so re-anchor the clean baseline. Carry the dirty state across: when the
990
- // tab was already dirty, -1 can never match a real AVI, so it stays dirty.
991
- var _oldClean = _oldEntry.cleanVersionId;
992
- var _wasDirty = _oldClean !== null && _oldClean !== undefined &&
993
- _oldModel.getAlternativeVersionId() !== _oldClean;
994
- var _newAvi = modelB.getAlternativeVersionId();
995
- window.__mbeditorModels[_phase2Path] = {
996
- model: modelB,
997
- aviBase: aviBaseRef.current,
998
- aviMax: _newAvi,
999
- lastAccessed: Date.now(),
1000
- cleanVersionId: _wasDirty ? -1 : _newAvi
1001
- };
1002
- aviMaxRef.current = _newAvi;
1003
- setTimeout(function () {
1004
- if (_oldModel && !_oldModel.isDisposed()) _oldModel.dispose();
1005
- }, 0);
1006
- }
1037
+ if (modelB.getValue() !== _phase2ModelA.getValue()) {
1038
+ if (_phase2Listener) _phase2Listener.dispose();
1039
+ modelB.dispose();
1040
+ return;
1041
+ }
1007
1042
 
1008
- // Move the content listener onto modelB. It carries dirty tracking, content
1009
- // sync and history recording — leaving it on modelA strands all three.
1010
- contentDisposable.dispose();
1011
- contentDisposable = _attachContentListener(modelB);
1012
- _phase2CleanupFn = function () { _phase2Active = false; };
1013
- }).catch(function () {
1014
- _phase2Listener.dispose();
1015
- });
1043
+ if (_phase2Listener) _phase2Listener.dispose();
1044
+ if (!_phase2Active) { modelB.dispose(); return; }
1045
+
1046
+ if (modelB.getLanguageId() !== _lang) {
1047
+ window.monaco.editor.setModelLanguage(modelB, _lang);
1048
+ }
1049
+ modelB._mbeditorPath = _phase2Path;
1050
+
1051
+ var _vs = editor.saveViewState();
1052
+ editor.setModel(modelB);
1053
+ if (_vs) editor.restoreViewState(_vs);
1054
+
1055
+ var _oldEntry = window.__mbeditorModels[_phase2Path];
1056
+ if (_oldEntry && _oldEntry.model !== modelB) {
1057
+ var _oldModel = _oldEntry.model;
1058
+ // modelB holds the same text as modelA but its own version-id sequence,
1059
+ // so re-anchor the clean baseline. Carry the dirty state across: when the
1060
+ // tab was already dirty, -1 can never match a real AVI, so it stays dirty.
1061
+ var _oldClean = _oldEntry.cleanVersionId;
1062
+ var _wasDirty = _oldClean !== null && _oldClean !== undefined &&
1063
+ _oldModel.getAlternativeVersionId() !== _oldClean;
1064
+ var _newAvi = modelB.getAlternativeVersionId();
1065
+ window.__mbeditorModels[_phase2Path] = {
1066
+ model: modelB,
1067
+ aviBase: aviBaseRef.current,
1068
+ aviMax: _newAvi,
1069
+ lastAccessed: Date.now(),
1070
+ cleanVersionId: _wasDirty ? -1 : _newAvi
1071
+ };
1072
+ aviMaxRef.current = _newAvi;
1073
+ setTimeout(function () {
1074
+ if (_oldModel && !_oldModel.isDisposed()) _oldModel.dispose();
1075
+ }, 0);
1076
+ }
1077
+
1078
+ // Move the content listener onto modelB. It carries dirty tracking, content
1079
+ // sync and history recording — leaving it on modelA strands all three.
1080
+ contentDisposable.dispose();
1081
+ contentDisposable = _attachContentListener(modelB);
1082
+ }).catch(function () {
1083
+ if (_phase2Listener) _phase2Listener.dispose();
1084
+ });
1085
+ };
1086
+
1087
+ if (typeof requestIdleCallback !== 'undefined') {
1088
+ requestIdleCallback(_runPhase2, { timeout: 2000 });
1089
+ } else {
1090
+ setTimeout(_runPhase2, 200);
1091
+ }
1016
1092
  };
1017
1093
 
1018
- if (typeof requestIdleCallback !== 'undefined') {
1019
- requestIdleCallback(_runPhase2, { timeout: 2000 });
1094
+ if (_contentLoaded) {
1095
+ _startHistory();
1020
1096
  } else {
1021
- setTimeout(_runPhase2, 200);
1097
+ pendingHistorySetupRef.current = _startHistory;
1022
1098
  }
1023
-
1024
- _phase2CleanupFn = function () { _phase2Active = false; _phase2Listener.dispose(); };
1025
1099
  }
1026
1100
 
1027
1101
  return function () {
@@ -1058,15 +1132,19 @@ var EditorPanel = function EditorPanel(_ref) {
1058
1132
  }
1059
1133
  if (window.__mbeditorActiveEditor === editor) {
1060
1134
  window.__mbeditorActiveEditor = null;
1135
+ window.dispatchEvent(new CustomEvent('mbeditor:active-editor'));
1061
1136
  }
1137
+ if (scrollSyncDisposable) scrollSyncDisposable.dispose();
1138
+ if (_modelEntry.editor === editor) _modelEntry.editor = null;
1062
1139
  if (editorPluginDisposable) editorPluginDisposable.dispose();
1063
1140
  if (formatActionDisposable) formatActionDisposable.dispose();
1064
1141
  runTestAtCursorDisposable.dispose();
1065
1142
  columnSelectDisposable.dispose();
1066
1143
  contentDisposable.dispose();
1067
1144
  EditorStore.setState({ canUndo: false, canRedo: false });
1145
+ pendingHistorySetupRef.current = null;
1068
1146
  if (_phase2CleanupFn) _phase2CleanupFn();
1069
- if (_collabActive) CollaborationService.unbindEditor(tab.path);
1147
+ if (_collabActive) CollaborationService.unbindEditor(tab.path, editor);
1070
1148
  // Detach the model before disposing the editor so the model (and its undo
1071
1149
  // history) survives for when the user returns to this tab.
1072
1150
  editor.setModel(null);
@@ -1081,14 +1159,29 @@ var EditorPanel = function EditorPanel(_ref) {
1081
1159
  var editor = monacoRef.current;
1082
1160
  if (!editor || typeof tab.content !== 'string') return;
1083
1161
 
1162
+ // The editor effect defers persistent-undo tracking until the file load
1163
+ // lands (see pendingHistorySetupRef). Start it here, after the content has
1164
+ // been applied, so the loaded file is the history base rather than an op.
1165
+ var _runPendingHistory = function () {
1166
+ var pending = pendingHistorySetupRef.current;
1167
+ if (pending) {
1168
+ pendingHistorySetupRef.current = null;
1169
+ pending();
1170
+ }
1171
+ };
1172
+
1084
1173
  var extVersion = tab.externalContentVersion || 0;
1085
- if (extVersion <= lastAppliedExternalVersionRef.current) return;
1174
+ if (extVersion <= lastAppliedExternalVersionRef.current) {
1175
+ _runPendingHistory();
1176
+ return;
1177
+ }
1086
1178
 
1087
1179
  // For a collaboration late-join the shared document is authoritative; applying
1088
1180
  // the disk content would clobber it. Mark this version consumed and skip.
1089
1181
  if (collabActiveRef.current && typeof CollaborationService !== 'undefined' &&
1090
1182
  CollaborationService.consumesDiskLoad(tab.path)) {
1091
1183
  lastAppliedExternalVersionRef.current = extVersion;
1184
+ _runPendingHistory();
1092
1185
  return;
1093
1186
  }
1094
1187
 
@@ -1101,39 +1194,48 @@ var EditorPanel = function EditorPanel(_ref) {
1101
1194
  // Normalize before comparing to prevent false positive dirty edits
1102
1195
  var vNorm = editor.getValue().replace(/\r\n/g, '\n');
1103
1196
  var cNorm = tab.content.replace(/\r\n/g, '\n');
1104
- if (vNorm === cNorm) return;
1105
-
1106
- if (!vNorm) {
1107
- // If the editor is currently completely empty, treat it as an initial load.
1108
- // setValue clears the undo stack which is correct for initial load.
1109
- // Null cleanVersionId before setValue so the synchronous onDidChangeContent
1110
- // fires during setValue and skips the dirty check (cleanVersionId is null).
1111
- var _initEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1112
- if (_initEntry) _initEntry.cleanVersionId = null;
1113
-
1114
- editor.setValue(tab.content);
1115
- // Reset the AVI baseline: setValue clears the undo stack so anything before
1116
- // this point is no longer reachable. Also clear the canUndo/canRedo display.
1117
- var newBase = model.getAlternativeVersionId();
1118
- aviBaseRef.current = newBase;
1119
- aviMaxRef.current = newBase;
1120
- if (_initEntry) _initEntry.cleanVersionId = newBase;
1121
- EditorStore.setState({ canUndo: false, canRedo: false });
1122
- } else {
1123
- // Keep undo stack for formats or replaces by using executeEdits
1124
- var _extEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1125
- if (_extEntry) _extEntry.cleanVersionId = null;
1126
- editor.pushUndoStop();
1127
- editor.executeEdits("external", [{
1128
- range: model.getFullModelRange(),
1129
- text: tab.content
1130
- }]);
1131
- editor.pushUndoStop();
1132
- // Re-anchor the clean baseline so onDidChangeContent doesn't mark this
1133
- // externally-applied content as a dirty edit.
1134
- var _newExtAvi = model.getAlternativeVersionId();
1135
- if (_extEntry) _extEntry.cleanVersionId = _newExtAvi;
1136
- aviBaseRef.current = _newExtAvi;
1197
+ if (vNorm !== cNorm) {
1198
+ if (!vNorm) {
1199
+ // If the editor is currently completely empty, treat it as an initial load.
1200
+ // setValue clears the undo stack which is correct for initial load.
1201
+ // Null cleanVersionId before setValue so the synchronous onDidChangeContent
1202
+ // fires during setValue and skips the dirty check (cleanVersionId is null).
1203
+ var _initEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1204
+ if (_initEntry) _initEntry.cleanVersionId = null;
1205
+
1206
+ editor.setValue(tab.content);
1207
+ // Reset the AVI baseline: setValue clears the undo stack so anything before
1208
+ // this point is no longer reachable. Also clear the canUndo/canRedo display.
1209
+ var newBase = model.getAlternativeVersionId();
1210
+ aviBaseRef.current = newBase;
1211
+ aviMaxRef.current = newBase;
1212
+ if (_initEntry) _initEntry.cleanVersionId = newBase;
1213
+ EditorStore.setState({ canUndo: false, canRedo: false });
1214
+ } else {
1215
+ // Keep undo stack for formats or replaces by using executeEdits
1216
+ var _extEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1217
+ if (_extEntry) _extEntry.cleanVersionId = null;
1218
+ editor.pushUndoStop();
1219
+ editor.executeEdits("external", [{
1220
+ range: model.getFullModelRange(),
1221
+ text: tab.content
1222
+ }]);
1223
+ editor.pushUndoStop();
1224
+ // Re-anchor the clean baseline so onDidChangeContent doesn't mark this
1225
+ // externally-applied content as a dirty edit.
1226
+ var _newExtAvi = model.getAlternativeVersionId();
1227
+ if (_extEntry) _extEntry.cleanVersionId = _newExtAvi;
1228
+ aviBaseRef.current = _newExtAvi;
1229
+ }
1230
+ }
1231
+
1232
+ // Empty file: content already matches, but tracking still has to start.
1233
+ _runPendingHistory();
1234
+
1235
+ // The model now holds the disk content. A room that deferred attaching while
1236
+ // this fetch was in flight can seed the shared doc from it.
1237
+ if (collabActiveRef.current && typeof CollaborationService !== 'undefined') {
1238
+ CollaborationService.contentReady(tab.path);
1137
1239
  }
1138
1240
  }, [tab.content, tab.externalContentVersion]);
1139
1241
 
@@ -1219,7 +1321,7 @@ var EditorPanel = function EditorPanel(_ref) {
1219
1321
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1220
1322
  quickSuggestions: editorPrefs.quickSuggestions !== false,
1221
1323
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
1222
- linkedEditing: !!(editorPrefs.linkedEditing),
1324
+ linkedEditing: true,
1223
1325
  acceptSuggestionOnEnter: editorPrefs.acceptSuggestionOnEnter || 'on'
1224
1326
  });
1225
1327
  // Re-apply large-file tuning last so a prefs change can't re-enable the
@@ -1323,9 +1425,18 @@ var EditorPanel = function EditorPanel(_ref) {
1323
1425
  var column = tab.gotoCol || 1;
1324
1426
  var maxColumn = model.getLineMaxColumn(line);
1325
1427
  if (column > maxColumn) column = maxColumn;
1428
+ // With both bounds the match itself is selected and the cursor ends up
1429
+ // just past it — where you'd start typing. With only gotoCol the
1430
+ // selection is empty, which is exactly setPosition.
1431
+ var endColumn = Math.min(tab.gotoEndCol || column, maxColumn);
1326
1432
 
1327
1433
  editor.revealLineInCenter(line);
1328
- editor.setPosition({ lineNumber: line, column: column });
1434
+ // setSelection(IRange) parks the cursor at the range's end, so no
1435
+ // setPosition afterwards — that would collapse the selection again.
1436
+ editor.setSelection({
1437
+ startLineNumber: line, startColumn: column,
1438
+ endLineNumber: line, endColumn: endColumn
1439
+ });
1329
1440
  editor.focus();
1330
1441
 
1331
1442
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
@@ -1333,7 +1444,7 @@ var EditorPanel = function EditorPanel(_ref) {
1333
1444
  }, 50);
1334
1445
 
1335
1446
  return function () { clearTimeout(timer); };
1336
- }, [tab.gotoLine, tab.gotoCol, tab.content, tab.loading]);
1447
+ }, [tab.gotoLine, tab.gotoCol, tab.gotoEndCol, tab.content, tab.loading]);
1337
1448
 
1338
1449
  // Apply RuboCop markers
1339
1450
  useEffect(function () {
@@ -1688,9 +1799,8 @@ var EditorPanel = function EditorPanel(_ref) {
1688
1799
  // keystroke. This ticks 250 ms after the last change instead — the same
1689
1800
  // treatment the route-hint redraw above already gets.
1690
1801
  var _useStateZT = useState(0);
1691
- var _useStateZT2 = _slicedToArray(_useStateZT, 2);
1692
- var zoneTick = _useStateZT2[0];
1693
- var setZoneTick = _useStateZT2[1];
1802
+ var zoneTick = _useStateZT[0];
1803
+ var setZoneTick = _useStateZT[1];
1694
1804
  useEffect(function () {
1695
1805
  var timer = setTimeout(function () {
1696
1806
  setZoneTick(function (n) { return n + 1; });
@@ -1803,74 +1913,6 @@ var EditorPanel = function EditorPanel(_ref) {
1803
1913
  return src === derived;
1804
1914
  };
1805
1915
 
1806
- var testFileCandidates = function(relativePath) {
1807
- if (!relativePath || !relativePath.endsWith('.rb')) return [];
1808
-
1809
- var basename = relativePath.slice(0, -3);
1810
- var dirParts = relativePath.split('/');
1811
- var leafName = basename.split('/').pop();
1812
- var candidates = [];
1813
-
1814
- if (dirParts[0] === 'app' && dirParts.length > 1) {
1815
- var subPath = dirParts.slice(1).join('/');
1816
- var subDir = subPath.indexOf('/') !== -1 ? subPath.slice(0, subPath.lastIndexOf('/')) : '';
1817
- candidates.push('test/' + (subDir ? subDir + '/' : '') + leafName + '_test.rb');
1818
- candidates.push('spec/' + (subDir ? subDir + '/' : '') + leafName + '_spec.rb');
1819
- }
1820
-
1821
- if (dirParts[0] === 'lib') {
1822
- var libSubPath = dirParts.slice(1).join('/');
1823
- var libSubDir = libSubPath.indexOf('/') !== -1 ? libSubPath.slice(0, libSubPath.lastIndexOf('/')) : '';
1824
- candidates.push('test/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
1825
- candidates.push('test/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
1826
- candidates.push('spec/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_spec.rb');
1827
- }
1828
-
1829
- candidates.push('test/' + leafName + '_test.rb');
1830
- candidates.push('spec/' + leafName + '_spec.rb');
1831
-
1832
- return candidates.filter(function(candidate, index, list) {
1833
- return list.indexOf(candidate) === index;
1834
- });
1835
- };
1836
-
1837
- var treeHasPath = function(nodes, targetPath) {
1838
- if (!targetPath) return false;
1839
- var stack = (nodes || []).slice();
1840
-
1841
- while (stack.length) {
1842
- var node = stack.pop();
1843
- if (!node) continue;
1844
- if (node.path === targetPath) return true;
1845
- if (node.children && node.children.length) {
1846
- stack.push.apply(stack, node.children);
1847
- }
1848
- }
1849
-
1850
- return false;
1851
- };
1852
-
1853
- var matchingTestFilePath = function(sourcePath) {
1854
- var normalized = (sourcePath || '').replace(/^\/+/, '');
1855
- if (!normalized || !normalized.endsWith('.rb')) return null;
1856
- if (/^(test|spec)\//.test(normalized) && /_(test|spec)\.rb$/.test(normalized)) return normalized;
1857
-
1858
- var candidates = testFileCandidates(normalized);
1859
- for (var i = 0; i < candidates.length; i += 1) {
1860
- if (treeHasPath(treeData, candidates[i])) return candidates[i];
1861
- }
1862
-
1863
- return null;
1864
- };
1865
-
1866
- // treeHasPath walks the whole file tree once per candidate, and the Test
1867
- // button asked for this on every render — so once per keystroke. treeData is
1868
- // deliberately outside the React.memo comparator below, so it can only change
1869
- // on a render this component was going to do anyway.
1870
- var testFileForTab = useMemo(function () {
1871
- return matchingTestFilePath(tab.path);
1872
- }, [tab.path, treeData]);
1873
-
1874
1916
  // Map a test method name to the best-matching line in the source file.
1875
1917
  // Extracts keywords from the test name and scores each source line.
1876
1918
  var mapTestToSourceLine = function(testName, sourceContent) {
@@ -2028,33 +2070,71 @@ var EditorPanel = function EditorPanel(_ref) {
2028
2070
  if (isMarkdown && window.marked) {
2029
2071
  (function () {
2030
2072
  var renderer = new window.marked.Renderer();
2031
- var escapeHtml = function escapeHtml(str) {
2032
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2033
- };
2073
+ // escapeHtml and safeHref are defined once in ChangelogView.js — the
2074
+ // href allow-list guards both markdown surfaces and must not fork.
2075
+ var escapeHtml = window.mbeditorEscapeHtml;
2076
+ var safeHref = window.mbeditorSafeHref;
2034
2077
  renderer.html = function (token) {
2035
2078
  return '<pre>' + escapeHtml(typeof token === 'object' ? token.text : token) + '</pre>';
2036
2079
  };
2037
- // Sanitize link/image hrefs to block javascript: and data: schemes.
2038
2080
  var _origLink = renderer.link.bind(renderer);
2039
2081
  var _origImage = renderer.image.bind(renderer);
2040
- var SAFE_HREF_SCHEME = /^(https?:|mailto:|#|\/)/i;
2041
- var safeHref = function(href) {
2042
- if (!href) return href;
2043
- return SAFE_HREF_SCHEME.test(href.trim()) ? href : '#';
2082
+ // marked 10 calls these positionally — `.link(href, title, text)`, see
2083
+ // the vendored bundle. An earlier version took a token object, and
2084
+ // against the real signature the object branch never ran: safeHref was
2085
+ // dead, so javascript: hrefs passed through, and dropping title/text
2086
+ // rendered every link as "undefined".
2087
+ renderer.link = function(href, title, text) {
2088
+ return _origLink(safeHref(href), title, text);
2044
2089
  };
2045
- renderer.link = function(token) {
2046
- if (token && typeof token === 'object') { token = Object.assign({}, token, { href: safeHref(token.href) }); }
2047
- return _origLink(token);
2090
+ renderer.image = function(href, title, text) {
2091
+ return _origImage(safeHref(href), title, text);
2048
2092
  };
2049
- renderer.image = function(token) {
2050
- if (token && typeof token === 'object') { token = Object.assign({}, token, { href: safeHref(token.href) }); }
2051
- return _origImage(token);
2093
+ // marked 10 dropped heading ids, so every in-document `[x](#anchor)`
2094
+ // link pointed at nothing. Slugs follow GitHub's: strip markup, lower,
2095
+ // drop punctuation, spaces to hyphens, `-1`/`-2` for repeats.
2096
+ var slugSeen = {};
2097
+ renderer.heading = function (text, level) {
2098
+ var slug = String(text)
2099
+ .replace(/<[^>]+>/g, '')
2100
+ .replace(/&[#\w]+;/g, '')
2101
+ .trim().toLowerCase()
2102
+ .replace(/[^\w\- ]+/g, '')
2103
+ .replace(/\s/g, '-');
2104
+ slugSeen[slug] = (slugSeen[slug] || 0) + 1;
2105
+ if (slugSeen[slug] > 1) slug += '-' + (slugSeen[slug] - 1);
2106
+ return '<h' + level + ' id="' + slug + '">' + text + '</h' + level + '>\n';
2052
2107
  };
2053
2108
  setMarkup(window.marked.parse(markdownContent, { renderer: renderer }));
2054
2109
  })();
2055
2110
  }
2056
2111
  }, [markdownContent, isMarkdown]);
2057
2112
 
2113
+ // Preview -> source scroll sync. The source editor instance (a different
2114
+ // EditorPanel, possibly a different pane) is published on the shared model
2115
+ // entry by the editor-creation effect above; scrollSyncing on that same
2116
+ // entry is the shared flag that stops the two directions ping-ponging.
2117
+ useEffect(function () {
2118
+ if (!tab.isPreview || !isMarkdown) return;
2119
+ var el = previewScrollRef.current;
2120
+ if (!el) return;
2121
+ function onScroll() {
2122
+ var modelEntry = window.__mbeditorModels && window.__mbeditorModels[sourcePath];
2123
+ var editor = modelEntry && modelEntry.editor;
2124
+ if (!editor || (modelEntry && modelEntry.scrollSyncing)) return;
2125
+ var max = el.scrollHeight - el.clientHeight;
2126
+ if (max <= 0) return;
2127
+ var fraction = el.scrollTop / max;
2128
+ var scrollHeight = editor.getScrollHeight() - editor.getLayoutInfo().height;
2129
+ if (scrollHeight <= 0) return;
2130
+ modelEntry.scrollSyncing = true;
2131
+ editor.setScrollTop(fraction * scrollHeight);
2132
+ modelEntry.scrollSyncing = false;
2133
+ }
2134
+ el.addEventListener('scroll', onScroll);
2135
+ return function () { el.removeEventListener('scroll', onScroll); };
2136
+ }, [tab.isPreview, isMarkdown, sourcePath]);
2137
+
2058
2138
  // Click-outside handler to close the methods dropdown
2059
2139
  useEffect(function() {
2060
2140
  if (!methodsOpen) return;
@@ -2164,7 +2244,12 @@ var EditorPanel = function EditorPanel(_ref) {
2164
2244
  }
2165
2245
 
2166
2246
  if (tab.isPreview && isMarkdown) {
2167
- return React.createElement('div', { className: 'markdown-preview markdown-preview-full', dangerouslySetInnerHTML: { __html: markup } });
2247
+ return React.createElement('div', {
2248
+ className: 'markdown-preview markdown-preview-full',
2249
+ ref: previewScrollRef,
2250
+ 'data-preview-for': sourcePath,
2251
+ dangerouslySetInnerHTML: { __html: markup }
2252
+ });
2168
2253
  }
2169
2254
 
2170
2255
  // Helper: shorten long paths by showing the last 2 segments with a leading ellipsis
@@ -2175,6 +2260,38 @@ var EditorPanel = function EditorPanel(_ref) {
2175
2260
  return '\u2026/' + parts.slice(-2).join('/');
2176
2261
  }
2177
2262
 
2263
+ // Breadcrumb: shortPath()'s segments, chevron-separated, the final one an
2264
+ // icon + filename in the normal text colour, everything before it muted \u2014
2265
+ // VS Code's editor breadcrumb. Look only; not a navigation control.
2266
+ function renderBreadcrumb(path) {
2267
+ var segments = shortPath(path).split('/');
2268
+ var last = segments.length - 1;
2269
+ var nodes = [];
2270
+ segments.forEach(function (seg, i) {
2271
+ if (i > 0) {
2272
+ nodes.push(React.createElement('i', {
2273
+ key: 'sep-' + i,
2274
+ className: 'fas fa-chevron-right ide-breadcrumb-sep',
2275
+ 'aria-hidden': 'true'
2276
+ }));
2277
+ }
2278
+ if (i === last) {
2279
+ nodes.push(React.createElement(
2280
+ 'span',
2281
+ { key: 'seg-' + i, className: 'ide-breadcrumb-file' },
2282
+ React.createElement('i', {
2283
+ className: (window.getFileIcon ? window.getFileIcon(path) : 'far fa-file-code') + ' ide-breadcrumb-file-icon',
2284
+ 'aria-hidden': 'true'
2285
+ }),
2286
+ seg
2287
+ ));
2288
+ } else {
2289
+ nodes.push(React.createElement('span', { key: 'seg-' + i, className: 'ide-breadcrumb-dir' }, seg));
2290
+ }
2291
+ });
2292
+ return nodes;
2293
+ }
2294
+
2178
2295
  // While Monaco is still loading, show a lightweight skeleton so the UI is
2179
2296
  // visible immediately without calling monaco.editor.create() too early.
2180
2297
  if (!monacoReady) {
@@ -2253,7 +2370,7 @@ var EditorPanel = function EditorPanel(_ref) {
2253
2370
  React.createElement(
2254
2371
  'span',
2255
2372
  { className: 'ide-editor-file-location', title: tab.path },
2256
- shortPath(tab.path)
2373
+ renderBreadcrumb(tab.path)
2257
2374
  ),
2258
2375
  gitAvailable && tab.path && React.createElement(
2259
2376
  'button',
@@ -2262,8 +2379,8 @@ var EditorPanel = function EditorPanel(_ref) {
2262
2379
  onClick: function() { if (onShowHistory) onShowHistory(tab.path); },
2263
2380
  title: 'File History'
2264
2381
  },
2265
- React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2266
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
2382
+ React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2383
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
2267
2384
  ),
2268
2385
  hasOutline && React.createElement(
2269
2386
  'button',
@@ -2317,8 +2434,8 @@ var EditorPanel = function EditorPanel(_ref) {
2317
2434
  },
2318
2435
  title: isTestOutline ? 'Jump to Outline' : 'Jump to Method'
2319
2436
  },
2320
- React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2321
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
2437
+ React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2438
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
2322
2439
  ),
2323
2440
  gitAvailable && React.createElement(
2324
2441
  'button',
@@ -2327,21 +2444,9 @@ var EditorPanel = function EditorPanel(_ref) {
2327
2444
  onClick: function() { setIsBlameVisible(function(prev) { return !prev; }); },
2328
2445
  title: 'Toggle Git Blame'
2329
2446
  },
2330
- React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2331
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2447
+ React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2448
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2332
2449
  ),
2333
- testAvailable && testFileForTab && React.createElement(
2334
- 'button',
2335
- {
2336
- className: 'ide-icon-btn',
2337
- onClick: function() { if (onRunTest) onRunTest(); },
2338
- disabled: testLoading,
2339
- 'aria-busy': !!testLoading,
2340
- title: 'Run Tests'
2341
- },
2342
- !testLoading && React.createElement('i', { className: 'fas fa-flask', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2343
- !editorPrefs.toolbarIconOnly && !testLoading && React.createElement('span', { className: 'ide-toolbar-label' }, 'Test')
2344
- )
2345
2450
  ),
2346
2451
  conflictCount > 0 && React.createElement(
2347
2452
  'div', { className: 'mb-conflict-banner' },
@@ -2460,7 +2565,7 @@ var EditorPanel = function EditorPanel(_ref) {
2460
2565
  React.createElement(
2461
2566
  'span',
2462
2567
  { style: { flex: 1, textAlign: 'center' } },
2463
- 'Lines ' + (pageStartLine + 1) + '–' + (pageStartLine + pageLineCount) + ' of ' + pageTotalLines + ' (' + humanSize(pageTotalBytes) + ')'
2568
+ 'Lines ' + (pageStartLine + 1) + '–' + (pageStartLine + pageLineCount) + ' of ' + pageTotalLines + ' (' + formatSize(pageTotalBytes) + ')'
2464
2569
  ),
2465
2570
  React.createElement(
2466
2571
  'button',
@@ -2502,7 +2607,10 @@ var EditorPanel = function EditorPanel(_ref) {
2502
2607
  )
2503
2608
  ),
2504
2609
  React.createElement('div', { ref: editorRef, className: 'monaco-container', style: { flex: 1, minHeight: 0 } }),
2505
- methodsOpen && methodsDropdownPos && React.createElement(
2610
+ // Portalled to body: under the glass chrome the centre column has a
2611
+ // backdrop-filter, which makes it the containing block for position:fixed
2612
+ // and shoves the menu ~130px off its button.
2613
+ methodsOpen && methodsDropdownPos && ReactDOM.createPortal(React.createElement(
2506
2614
  'div',
2507
2615
  {
2508
2616
  ref: methodsDropdownRef,
@@ -2600,7 +2708,7 @@ var EditorPanel = function EditorPanel(_ref) {
2600
2708
  }
2601
2709
  return rows;
2602
2710
  })()
2603
- ),
2711
+ ), document.body),
2604
2712
  React.createElement('div', { ref: vimStatusRef, className: 'vim-statusbar', style: { display: editorPrefs.vimMode ? 'flex' : 'none', height: '22px', alignItems: 'center', padding: '0 10px', fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace", fontSize: '12px', background: 'var(--ide-statusbar-bg, #1e1e2e)', color: 'var(--ide-statusbar-fg, #9cdcfe)', borderTop: '1px solid var(--ide-border, #3e3e3e)', flexShrink: 0, userSelect: 'none', letterSpacing: '0.02em' } })
2605
2713
  );
2606
2714
  };
@@ -2612,10 +2720,8 @@ window.EditorPanel = React.memo(EditorPanel, function(prev, next) {
2612
2720
  prev.paneId === next.paneId &&
2613
2721
  (prev.markers === next.markers || (prev.markers.length === 0 && next.markers.length === 0)) &&
2614
2722
  prev.gitAvailable === next.gitAvailable &&
2615
- prev.testAvailable === next.testAvailable &&
2616
2723
  prev.testResult === next.testResult &&
2617
2724
  prev.testPanelFile === next.testPanelFile &&
2618
- prev.testLoading === next.testLoading &&
2619
2725
  prev.testInlineVisible === next.testInlineVisible &&
2620
2726
  prev.editorPrefs === next.editorPrefs &&
2621
2727
  prev.monacoReady === next.monacoReady;