mbeditor 0.13.1 → 0.14.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 +132 -0
  3. data/app/assets/javascripts/mbeditor/application.js +5 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  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/CombinedFileView.js +214 -0
  11. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  12. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +442 -248
  13. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  14. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  15. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  16. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  17. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +60 -5
  18. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  19. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1241 -1256
  20. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  21. data/app/assets/javascripts/mbeditor/components/PastePlaceModal.js +329 -0
  22. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  23. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  24. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  25. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +18 -1
  26. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  27. data/app/assets/javascripts/mbeditor/editor_plugins.js +705 -323
  28. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  29. data/app/assets/javascripts/mbeditor/file_service.js +48 -39
  30. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  31. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  32. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  33. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  34. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  35. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  36. data/app/assets/stylesheets/mbeditor/editor.css +672 -249
  37. data/app/assets/stylesheets/mbeditor/glass.css +174 -0
  38. data/app/assets/stylesheets/mbeditor/themes.css +93 -33
  39. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  40. data/app/controllers/mbeditor/application_controller.rb +26 -3
  41. data/app/controllers/mbeditor/editors_controller.rb +124 -439
  42. data/app/services/mbeditor/archive_service.rb +137 -0
  43. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  44. data/app/services/mbeditor/editor_state_service.rb +14 -51
  45. data/app/services/mbeditor/file_history_service.rb +222 -0
  46. data/app/services/mbeditor/git_info_service.rb +6 -0
  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/ruby_lsp_result_translator.rb +226 -0
  52. data/app/services/mbeditor/ruby_method_ranges_service.rb +120 -0
  53. data/app/services/mbeditor/search_replace_service.rb +8 -0
  54. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  55. data/lib/mbeditor/audit_log.rb +203 -0
  56. data/lib/mbeditor/configuration.rb +6 -1
  57. data/lib/mbeditor/rack/pending_migration_bypass.rb +31 -10
  58. data/lib/mbeditor/route_map.rb +5 -0
  59. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  60. data/lib/mbeditor/version.rb +1 -1
  61. data/lib/mbeditor.rb +1 -0
  62. data/public/monaco-editor/VERSIONS +1 -1
  63. data/public/monaco-editor/codicon.ttf +0 -0
  64. data/public/monaco-editor/css.worker.js +41 -37
  65. data/public/monaco-editor/editor.worker.js +19 -15
  66. data/public/monaco-editor/html.worker.js +53 -49
  67. data/public/monaco-editor/json.worker.js +36 -32
  68. data/public/monaco-editor/monaco.css +1 -1
  69. data/public/monaco-editor/monaco.js +388 -301
  70. data/public/monaco-editor/ts.worker.js +226 -222
  71. data/vendor/assets/javascripts/axios.min.js +3 -3
  72. metadata +15 -2
@@ -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;
@@ -39,6 +38,11 @@ var EditorPanel = function EditorPanel(_ref) {
39
38
  var monacoRef = useRef(null);
40
39
  var latestContentRef = useRef('');
41
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);
42
46
  var conflictDecorationsRef = React.useRef([]);
43
47
  var conflictBlocksRef = React.useRef([]);
44
48
  var aviBaseRef = useRef(0);
@@ -67,24 +71,20 @@ var EditorPanel = function EditorPanel(_ref) {
67
71
  var currentConflictIndexRef = React.useRef(0);
68
72
 
69
73
  var _useState = useState('');
70
- var _useState2 = _slicedToArray(_useState, 2);
71
- var markup = _useState2[0];
72
- var setMarkup = _useState2[1];
74
+ var markup = _useState[0];
75
+ var setMarkup = _useState[1];
73
76
 
74
77
  var _useState3 = useState(false);
75
- var _useState4 = _slicedToArray(_useState3, 2);
76
- var isBlameVisible = _useState4[0];
77
- var setIsBlameVisible = _useState4[1];
78
+ var isBlameVisible = _useState3[0];
79
+ var setIsBlameVisible = _useState3[1];
78
80
 
79
81
  var _useState5 = useState(null);
80
- var _useState6 = _slicedToArray(_useState5, 2);
81
- var blameData = _useState6[0];
82
- var setBlameData = _useState6[1];
82
+ var blameData = _useState5[0];
83
+ var setBlameData = _useState5[1];
83
84
 
84
85
  var _useState7 = useState(false);
85
- var _useState8 = _slicedToArray(_useState7, 2);
86
- var isBlameLoading = _useState8[0];
87
- var setIsBlameLoading = _useState8[1];
86
+ var isBlameLoading = _useState7[0];
87
+ var setIsBlameLoading = _useState7[1];
88
88
 
89
89
  var blameDecorationsRef = useRef([]);
90
90
  var gitLineDecorationsRef = useRef([]);
@@ -97,34 +97,28 @@ var EditorPanel = function EditorPanel(_ref) {
97
97
  var testZoneIdsRef = useRef([]);
98
98
 
99
99
  var _useState9 = useState(false);
100
- var _useState10 = _slicedToArray(_useState9, 2);
101
- var editorReady = _useState10[0];
102
- var setEditorReady = _useState10[1];
100
+ var editorReady = _useState9[0];
101
+ var setEditorReady = _useState9[1];
103
102
 
104
103
  var _useState11 = useState(false);
105
- var _useState12 = _slicedToArray(_useState11, 2);
106
- var methodsOpen = _useState12[0];
107
- var setMethodsOpen = _useState12[1];
104
+ var methodsOpen = _useState11[0];
105
+ var setMethodsOpen = _useState11[1];
108
106
 
109
107
  var _useState13 = useState([]);
110
- var _useState14 = _slicedToArray(_useState13, 2);
111
- var methodsList = _useState14[0];
112
- var setMethodsList = _useState14[1];
108
+ var methodsList = _useState13[0];
109
+ var setMethodsList = _useState13[1];
113
110
 
114
111
  var _useState15 = useState(false);
115
- var _useState16 = _slicedToArray(_useState15, 2);
116
- var methodsTruncated = _useState16[0];
117
- var setMethodsTruncated = _useState16[1];
112
+ var methodsTruncated = _useState15[0];
113
+ var setMethodsTruncated = _useState15[1];
118
114
 
119
115
  var _useState17 = useState(false);
120
- var _useState18 = _slicedToArray(_useState17, 2);
121
- var methodsUnavailable = _useState18[0];
122
- var setMethodsUnavailable = _useState18[1];
116
+ var methodsUnavailable = _useState17[0];
117
+ var setMethodsUnavailable = _useState17[1];
123
118
 
124
119
  var _useState19 = useState(null);
125
- var _useState20 = _slicedToArray(_useState19, 2);
126
- var methodsDropdownPos = _useState20[0];
127
- var setMethodsDropdownPos = _useState20[1];
120
+ var methodsDropdownPos = _useState19[0];
121
+ var setMethodsDropdownPos = _useState19[1];
128
122
 
129
123
  var methodsBtnRef = useRef(null);
130
124
  var methodsDropdownRef = useRef(null);
@@ -133,24 +127,20 @@ var EditorPanel = function EditorPanel(_ref) {
133
127
 
134
128
  // Local pagination state — initialized from tab props; updated on page navigation
135
129
  var _useState21 = useState(tab.startLine || 0);
136
- var _useState22 = _slicedToArray(_useState21, 2);
137
- var pageStartLine = _useState22[0];
138
- var setPageStartLine = _useState22[1];
130
+ var pageStartLine = _useState21[0];
131
+ var setPageStartLine = _useState21[1];
139
132
 
140
133
  var _useState23 = useState(tab.lineCount || 0);
141
- var _useState24 = _slicedToArray(_useState23, 2);
142
- var pageLineCount = _useState24[0];
143
- var setPageLineCount = _useState24[1];
134
+ var pageLineCount = _useState23[0];
135
+ var setPageLineCount = _useState23[1];
144
136
 
145
137
  var _useState25 = useState(tab.totalLines || 0);
146
- var _useState26 = _slicedToArray(_useState25, 2);
147
- var pageTotalLines = _useState26[0];
148
- var setPageTotalLines = _useState26[1];
138
+ var pageTotalLines = _useState25[0];
139
+ var setPageTotalLines = _useState25[1];
149
140
 
150
141
  var _useState27 = useState(tab.totalBytes || 0);
151
- var _useState28 = _slicedToArray(_useState27, 2);
152
- var pageTotalBytes = _useState28[0];
153
- var setPageTotalBytes = _useState28[1];
142
+ var pageTotalBytes = _useState27[0];
143
+ var setPageTotalBytes = _useState27[1];
154
144
 
155
145
  var onFormatRef = useRef(onFormat);
156
146
  onFormatRef.current = onFormat;
@@ -165,12 +155,7 @@ var EditorPanel = function EditorPanel(_ref) {
165
155
 
166
156
  var vimStatusRef = useRef(null);
167
157
  var vimModeObjRef = useRef(null);
168
-
169
- function humanSize(bytes) {
170
- if (bytes < 1024) return bytes + ' B';
171
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
172
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
173
- }
158
+ var previewScrollRef = useRef(null);
174
159
 
175
160
  var clearTestZones = function clearTestZones(editor) {
176
161
  if (!editor) return;
@@ -515,6 +500,10 @@ var EditorPanel = function EditorPanel(_ref) {
515
500
  language = 'javascript';
516
501
  } else if (/\.css\.erb$/.test(fileNameLower)) {
517
502
  language = 'css';
503
+ } else if (/\.scss\.erb$/.test(fileNameLower)) {
504
+ language = 'scss';
505
+ } else if (/\.less\.erb$/.test(fileNameLower)) {
506
+ language = 'less';
518
507
  } else if (/\.html\.erb$/.test(fileNameLower)) {
519
508
  language = 'erb';
520
509
  } else if (/\.html\.haml$/.test(fileNameLower)) {
@@ -540,8 +529,16 @@ var EditorPanel = function EditorPanel(_ref) {
540
529
  language = 'javascript';break;
541
530
  case 'ts':case 'tsx':
542
531
  language = 'typescript';break;
543
- 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':
544
537
  language = 'css';break;
538
+ case 'scss':
539
+ language = 'scss';break;
540
+ case 'less':
541
+ language = 'less';break;
545
542
  case 'html':
546
543
  language = 'html';break;
547
544
  case 'erb':
@@ -604,16 +601,26 @@ var EditorPanel = function EditorPanel(_ref) {
604
601
  // untitled://, mbeditor:// and friends have no file to persist history
605
602
  // against — tracking them just produces doomed /file_history requests.
606
603
  var _virtualPath = (tab.path || '').indexOf('://') >= 0;
607
-
608
- 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) {
609
613
  var _histBranch = EditorStore.getState().gitBranch || '';
610
614
  if (_histBranch) {
611
- if (_reusingModel) {
612
- HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
613
- } else {
614
- HistoryService.beginTracking(_histBranch, tab.path, tab.content || '');
615
- }
615
+ HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
616
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
+ };
617
624
  }
618
625
 
619
626
  // Sync latestContentRef from the actual model content so the onDidChangeContent
@@ -652,15 +659,20 @@ var EditorPanel = function EditorPanel(_ref) {
652
659
  quickSuggestions: editorPrefs.quickSuggestions !== false,
653
660
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
654
661
  acceptSuggestionOnEnter: editorPrefs.acceptSuggestionOnEnter || 'on',
655
- linkedEditing: !!(editorPrefs.linkedEditing),
662
+ linkedEditing: true,
656
663
  fixedOverflowWidgets: true,
657
664
  hover: { above: false }
658
665
  });
659
666
 
660
667
  applyLargeFileTuning(editor, modelObj);
661
668
 
662
- if (tab.viewState) {
663
- 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);
664
676
  }
665
677
 
666
678
  // Restore the find widget state from the previous editor (when persistFindState is on).
@@ -705,6 +717,32 @@ var EditorPanel = function EditorPanel(_ref) {
705
717
 
706
718
  monacoRef.current = editor;
707
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'));
708
746
  // Apply read-only for paginated (truncated) files
709
747
  if (tab.truncated) {
710
748
  editor.updateOptions({ readOnly: true });
@@ -754,6 +792,17 @@ var EditorPanel = function EditorPanel(_ref) {
754
792
  }
755
793
  });
756
794
 
795
+ // Paste & Place: Ruby only. An editor action gets the context-menu entry and
796
+ // the F1 command palette listing without another toolbar button.
797
+ var pastePlaceDisposable = editor.addAction({
798
+ id: 'mbeditor.pastePlace',
799
+ label: 'Paste & Place Ruby Method...',
800
+ precondition: 'editorLangId == ruby',
801
+ contextMenuGroupId: 'navigation',
802
+ contextMenuOrder: 1.7,
803
+ run: function() { EditorStore.setState({ isPastePlaceVisible: true }); }
804
+ });
805
+
757
806
  // No custom "Format Document" action: Ruby now has a real formatting
758
807
  // provider, so Monaco contributes that context-menu entry itself, and a
759
808
  // second one of our own showed up as a duplicate. Languages with no
@@ -768,14 +817,13 @@ var EditorPanel = function EditorPanel(_ref) {
768
817
  EditorStore.setState({ isQuickOpenVisible: true });
769
818
  });
770
819
 
771
- // PageUp/PageDown cycle between the current cursor position and where the
772
- // cursor was before the last jump (go-to-definition, search result, etc.),
773
- // replacing the default page-scroll behaviour.
820
+ // PageUp/PageDown walk a back/forward navigation history (go-to-definition,
821
+ // search results, file switches), replacing the default page-scroll behaviour.
774
822
  editor.addCommand(window.monaco.KeyCode.PageUp, function() {
775
- TabManager.toggleJumpOrigin();
823
+ TabManager.navigateBack();
776
824
  });
777
825
  editor.addCommand(window.monaco.KeyCode.PageDown, function() {
778
- TabManager.toggleJumpOrigin();
826
+ TabManager.navigateForward();
779
827
  });
780
828
 
781
829
  var editorPluginDisposable = null;
@@ -843,7 +891,25 @@ var EditorPanel = function EditorPanel(_ref) {
843
891
  // below can swap the model, so this must be re-attached to the replacement —
844
892
  // a listener left on the old model would silently stop tracking edits.
845
893
  var _attachContentListener = function (model) {
846
- return model.onDidChangeContent(function (e) {
894
+ // onDidChangeContent fires per keystroke, and the full-buffer work it used
895
+ // to do — one getValue() plus two whole-file CRLF normalisations — costs
896
+ // ~12ms per keystroke on a large file, on the main thread, ahead of
897
+ // Monaco's own processing. The AVI bookkeeping below stays per-event and
898
+ // O(1); the buffer is materialised once on a trailing edge (and once more
899
+ // by TabManager's own 250ms flush, via the provider passed to markDirty).
900
+ var _contentSyncTimer = null;
901
+ var _contentProvider = function () {
902
+ return model.isDisposed() ? null : model.getValue();
903
+ };
904
+ var _flushContentSync = function () {
905
+ _contentSyncTimer = null;
906
+ var val = _contentProvider();
907
+ if (val === null) return;
908
+ latestContentRef.current = val;
909
+ onContentChange(val);
910
+ };
911
+
912
+ var _contentListener = model.onDidChangeContent(function (e) {
847
913
  if (!_collabActive && typeof HistoryService !== 'undefined') {
848
914
  HistoryService.recordOps(tab.path, e.changes);
849
915
  }
@@ -861,8 +927,6 @@ var EditorPanel = function EditorPanel(_ref) {
861
927
  EditorStore.setState({ canUndo: newCanUndo, canRedo: newCanRedo });
862
928
  }
863
929
 
864
- var val = model.getValue();
865
-
866
930
  // Dirty-state tracking via alternativeVersionId — O(1), no string comparison.
867
931
  // AVI decrements on undo so it returns to cleanVersionId after a full undo.
868
932
  // Skip entirely when cleanVersionId is null — file is mid-load, not yet settled.
@@ -870,155 +934,179 @@ var EditorPanel = function EditorPanel(_ref) {
870
934
  var _cleanAvi = _entry && _entry.cleanVersionId;
871
935
  if (_cleanAvi !== null && _cleanAvi !== undefined) {
872
936
  if (currentAvi !== _cleanAvi) {
873
- TabManager.markDirty(paneId, tab.id, val);
937
+ TabManager.markDirty(paneId, tab.id, _contentProvider);
874
938
  } else {
875
- TabManager.markClean(paneId, tab.id, val);
939
+ TabManager.markClean(paneId, tab.id, _contentProvider);
876
940
  }
877
941
  }
878
942
 
879
- var currentContent = latestContentRef.current;
880
-
881
- // Normalize before comparing to prevent false positive dirty edits
882
- var vNorm = val.replace(/\r\n/g, '\n');
883
- var cNorm = currentContent.replace(/\r\n/g, '\n');
884
- if (vNorm !== cNorm) {
885
- // Update the ref immediately so rapid undo/redo events compare against the
886
- // latest content rather than a stale snapshot from a previous React render.
887
- latestContentRef.current = val;
888
- onContentChange(val);
943
+ // Draft persistence is the only remaining consumer of the full text.
944
+ // Coalesce it onto the same 250ms edge TabManager uses for content writes.
945
+ if (_contentSyncTimer === null) {
946
+ _contentSyncTimer = setTimeout(_flushContentSync, 250);
889
947
  }
890
948
  });
949
+ return {
950
+ dispose: function () {
951
+ if (_contentSyncTimer !== null) { clearTimeout(_contentSyncTimer); _contentSyncTimer = null; }
952
+ _contentListener.dispose();
953
+ }
954
+ };
891
955
  };
892
956
 
893
957
  var contentDisposable = _attachContentListener(modelObj);
894
958
 
895
959
  // Phase 2: background undo-history replay.
896
- // Only run for newly-created models (reused models already have their undo stack).
960
+ // Only run for newly-created models (reused models already have their undo
961
+ // stack). Tracking and replay both wait for the file load: the base is the
962
+ // loaded content, so the load is neither recorded nor buffered.
897
963
  var _phase2CleanupFn = null;
898
- if (!_collabActive && !_virtualPath && !_reusingModel && typeof HistoryService !== 'undefined') {
899
- var _phase2Branch = EditorStore.getState().gitBranch || '';
900
- var _phase2Path = tab.path;
901
- var _phase2Content = tab.content || '';
902
- var _phase2Buf = [];
903
- var _phase2Active = true;
904
-
905
- var _phase2ModelA = modelObj;
906
- var _phase2Listener = _phase2ModelA.onDidChangeContent(function (ev) {
907
- if (!_phase2Active) return;
908
- for (var _ci = 0; _ci < ev.changes.length; _ci++) {
909
- var _c = ev.changes[_ci];
910
- _phase2Buf.push([
911
- _c.range.startLineNumber, _c.range.startColumn,
912
- _c.range.endLineNumber, _c.range.endColumn,
913
- _c.text
914
- ]);
915
- }
916
- });
964
+ if (_historyEnabled && !_reusingModel) {
965
+ var _phase2Path = tab.path;
966
+ var _phase2Buf = [];
967
+ var _phase2Active = true;
968
+ var _phase2ModelA = modelObj;
969
+ var _phase2Listener = null;
970
+ var _historyStarted = false;
971
+ var _startHistory = null;
972
+
973
+ _phase2CleanupFn = function () {
974
+ _phase2Active = false;
975
+ if (_phase2Listener) _phase2Listener.dispose();
976
+ };
977
+
978
+ _startHistory = function () {
979
+ if (_historyStarted || !_phase2Active) return;
980
+ var _phase2Branch = EditorStore.getState().gitBranch || '';
981
+ if (!_phase2Branch) return;
982
+ _historyStarted = true;
917
983
 
918
- var _runPhase2 = function () {
919
- if (!_phase2Active || !_phase2Branch) return;
920
- HistoryService.fetchHistory(_phase2Branch, _phase2Path).then(function (hist) {
984
+ HistoryService.beginTracking(_phase2Branch, _phase2Path, _phase2ModelA.getValue());
985
+
986
+ // Buffer edits made on model A between the load and the replay swap so
987
+ // they can be re-applied to the replayed model B.
988
+ _phase2Listener = _phase2ModelA.onDidChangeContent(function (ev) {
921
989
  if (!_phase2Active) return;
922
- if (!hist || !hist.ops || hist.ops.length === 0) {
923
- _phase2Listener.dispose();
924
- return;
990
+ for (var _ci = 0; _ci < ev.changes.length; _ci++) {
991
+ var _c = ev.changes[_ci];
992
+ _phase2Buf.push([
993
+ _c.range.startLineNumber, _c.range.startColumn,
994
+ _c.range.endLineNumber, _c.range.endColumn,
995
+ _c.text
996
+ ]);
925
997
  }
998
+ });
926
999
 
927
- var _lang = modelObj.getLanguageId();
928
- var modelB = window.monaco.editor.createModel(hist.base, _lang);
929
-
930
- HistoryService.setReplayInProgress(_phase2Path, true);
931
- try {
932
- for (var _oi = 0; _oi < hist.ops.length; _oi++) {
933
- var _op = hist.ops[_oi];
934
- modelB.pushEditOperations([], [{
935
- range: new window.monaco.Range(_op[0], _op[1], _op[2], _op[3]),
936
- text: _op[4] || ''
937
- }], function () { return null; });
1000
+ var _runPhase2 = function () {
1001
+ if (!_phase2Active) return;
1002
+ HistoryService.fetchHistory(_phase2Branch, _phase2Path).then(function (hist) {
1003
+ if (!_phase2Active) return;
1004
+ if (!hist || !hist.ops || hist.ops.length === 0) {
1005
+ if (_phase2Listener) _phase2Listener.dispose();
1006
+ return;
938
1007
  }
939
- } catch (e) {
940
- HistoryService.setReplayInProgress(_phase2Path, false);
941
- _phase2Listener.dispose();
942
- modelB.dispose();
943
- return;
944
- }
945
- HistoryService.setReplayInProgress(_phase2Path, false);
946
1008
 
947
- var _expectedContent = _phase2ModelA.getValue();
948
- if (modelB.getValue() !== _expectedContent) {
949
- _phase2Listener.dispose();
950
- modelB.dispose();
951
- return;
952
- }
1009
+ var _lang = modelObj.getLanguageId();
1010
+ var modelB = window.monaco.editor.createModel(hist.base, _lang);
953
1011
 
954
- if (_phase2Buf.length > 0) {
1012
+ HistoryService.setReplayInProgress(_phase2Path, true);
955
1013
  try {
956
- for (var _bi = 0; _bi < _phase2Buf.length; _bi++) {
957
- var _bop = _phase2Buf[_bi];
1014
+ for (var _oi = 0; _oi < hist.ops.length; _oi++) {
1015
+ var _op = hist.ops[_oi];
958
1016
  modelB.pushEditOperations([], [{
959
- range: new window.monaco.Range(_bop[0], _bop[1], _bop[2], _bop[3]),
960
- text: _bop[4] || ''
1017
+ range: new window.monaco.Range(_op[0], _op[1], _op[2], _op[3]),
1018
+ text: _op[4] || ''
961
1019
  }], function () { return null; });
962
1020
  }
963
1021
  } catch (e) {
964
- _phase2Listener.dispose();
1022
+ HistoryService.setReplayInProgress(_phase2Path, false);
1023
+ if (_phase2Listener) _phase2Listener.dispose();
965
1024
  modelB.dispose();
966
1025
  return;
967
1026
  }
968
- }
1027
+ HistoryService.setReplayInProgress(_phase2Path, false);
969
1028
 
970
- _phase2Listener.dispose();
971
- if (!_phase2Active) { modelB.dispose(); return; }
1029
+ // Fold in the edits made on model A while the replay was in flight,
1030
+ // THEN validate. Applying them after the check is what let a buffered
1031
+ // initial load slip through and duplicate the file (#92).
1032
+ if (_phase2Buf.length > 0) {
1033
+ try {
1034
+ for (var _bi = 0; _bi < _phase2Buf.length; _bi++) {
1035
+ var _bop = _phase2Buf[_bi];
1036
+ modelB.pushEditOperations([], [{
1037
+ range: new window.monaco.Range(_bop[0], _bop[1], _bop[2], _bop[3]),
1038
+ text: _bop[4] || ''
1039
+ }], function () { return null; });
1040
+ }
1041
+ } catch (e) {
1042
+ if (_phase2Listener) _phase2Listener.dispose();
1043
+ modelB.dispose();
1044
+ return;
1045
+ }
1046
+ }
972
1047
 
973
- if (modelB.getLanguageId() !== _lang) {
974
- window.monaco.editor.setModelLanguage(modelB, _lang);
975
- }
976
- modelB._mbeditorPath = _phase2Path;
977
-
978
- var _vs = editor.saveViewState();
979
- editor.setModel(modelB);
980
- if (_vs) editor.restoreViewState(_vs);
981
-
982
- var _oldEntry = window.__mbeditorModels[_phase2Path];
983
- if (_oldEntry && _oldEntry.model !== modelB) {
984
- var _oldModel = _oldEntry.model;
985
- // modelB holds the same text as modelA but its own version-id sequence,
986
- // so re-anchor the clean baseline. Carry the dirty state across: when the
987
- // tab was already dirty, -1 can never match a real AVI, so it stays dirty.
988
- var _oldClean = _oldEntry.cleanVersionId;
989
- var _wasDirty = _oldClean !== null && _oldClean !== undefined &&
990
- _oldModel.getAlternativeVersionId() !== _oldClean;
991
- var _newAvi = modelB.getAlternativeVersionId();
992
- window.__mbeditorModels[_phase2Path] = {
993
- model: modelB,
994
- aviBase: aviBaseRef.current,
995
- aviMax: _newAvi,
996
- lastAccessed: Date.now(),
997
- cleanVersionId: _wasDirty ? -1 : _newAvi
998
- };
999
- aviMaxRef.current = _newAvi;
1000
- setTimeout(function () {
1001
- if (_oldModel && !_oldModel.isDisposed()) _oldModel.dispose();
1002
- }, 0);
1003
- }
1048
+ if (modelB.getValue() !== _phase2ModelA.getValue()) {
1049
+ if (_phase2Listener) _phase2Listener.dispose();
1050
+ modelB.dispose();
1051
+ return;
1052
+ }
1004
1053
 
1005
- // Move the content listener onto modelB. It carries dirty tracking, content
1006
- // sync and history recording — leaving it on modelA strands all three.
1007
- contentDisposable.dispose();
1008
- contentDisposable = _attachContentListener(modelB);
1009
- _phase2CleanupFn = function () { _phase2Active = false; };
1010
- }).catch(function () {
1011
- _phase2Listener.dispose();
1012
- });
1054
+ if (_phase2Listener) _phase2Listener.dispose();
1055
+ if (!_phase2Active) { modelB.dispose(); return; }
1056
+
1057
+ if (modelB.getLanguageId() !== _lang) {
1058
+ window.monaco.editor.setModelLanguage(modelB, _lang);
1059
+ }
1060
+ modelB._mbeditorPath = _phase2Path;
1061
+
1062
+ var _vs = editor.saveViewState();
1063
+ editor.setModel(modelB);
1064
+ if (_vs) editor.restoreViewState(_vs);
1065
+
1066
+ var _oldEntry = window.__mbeditorModels[_phase2Path];
1067
+ if (_oldEntry && _oldEntry.model !== modelB) {
1068
+ var _oldModel = _oldEntry.model;
1069
+ // modelB holds the same text as modelA but its own version-id sequence,
1070
+ // so re-anchor the clean baseline. Carry the dirty state across: when the
1071
+ // tab was already dirty, -1 can never match a real AVI, so it stays dirty.
1072
+ var _oldClean = _oldEntry.cleanVersionId;
1073
+ var _wasDirty = _oldClean !== null && _oldClean !== undefined &&
1074
+ _oldModel.getAlternativeVersionId() !== _oldClean;
1075
+ var _newAvi = modelB.getAlternativeVersionId();
1076
+ window.__mbeditorModels[_phase2Path] = {
1077
+ model: modelB,
1078
+ aviBase: aviBaseRef.current,
1079
+ aviMax: _newAvi,
1080
+ lastAccessed: Date.now(),
1081
+ cleanVersionId: _wasDirty ? -1 : _newAvi
1082
+ };
1083
+ aviMaxRef.current = _newAvi;
1084
+ setTimeout(function () {
1085
+ if (_oldModel && !_oldModel.isDisposed()) _oldModel.dispose();
1086
+ }, 0);
1087
+ }
1088
+
1089
+ // Move the content listener onto modelB. It carries dirty tracking, content
1090
+ // sync and history recording — leaving it on modelA strands all three.
1091
+ contentDisposable.dispose();
1092
+ contentDisposable = _attachContentListener(modelB);
1093
+ }).catch(function () {
1094
+ if (_phase2Listener) _phase2Listener.dispose();
1095
+ });
1096
+ };
1097
+
1098
+ if (typeof requestIdleCallback !== 'undefined') {
1099
+ requestIdleCallback(_runPhase2, { timeout: 2000 });
1100
+ } else {
1101
+ setTimeout(_runPhase2, 200);
1102
+ }
1013
1103
  };
1014
1104
 
1015
- if (typeof requestIdleCallback !== 'undefined') {
1016
- requestIdleCallback(_runPhase2, { timeout: 2000 });
1105
+ if (_contentLoaded) {
1106
+ _startHistory();
1017
1107
  } else {
1018
- setTimeout(_runPhase2, 200);
1108
+ pendingHistorySetupRef.current = _startHistory;
1019
1109
  }
1020
-
1021
- _phase2CleanupFn = function () { _phase2Active = false; _phase2Listener.dispose(); };
1022
1110
  }
1023
1111
 
1024
1112
  return function () {
@@ -1055,15 +1143,20 @@ var EditorPanel = function EditorPanel(_ref) {
1055
1143
  }
1056
1144
  if (window.__mbeditorActiveEditor === editor) {
1057
1145
  window.__mbeditorActiveEditor = null;
1146
+ window.dispatchEvent(new CustomEvent('mbeditor:active-editor'));
1058
1147
  }
1148
+ if (scrollSyncDisposable) scrollSyncDisposable.dispose();
1149
+ if (_modelEntry.editor === editor) _modelEntry.editor = null;
1059
1150
  if (editorPluginDisposable) editorPluginDisposable.dispose();
1060
1151
  if (formatActionDisposable) formatActionDisposable.dispose();
1061
1152
  runTestAtCursorDisposable.dispose();
1153
+ pastePlaceDisposable.dispose();
1062
1154
  columnSelectDisposable.dispose();
1063
1155
  contentDisposable.dispose();
1064
1156
  EditorStore.setState({ canUndo: false, canRedo: false });
1157
+ pendingHistorySetupRef.current = null;
1065
1158
  if (_phase2CleanupFn) _phase2CleanupFn();
1066
- if (_collabActive) CollaborationService.unbindEditor(tab.path);
1159
+ if (_collabActive) CollaborationService.unbindEditor(tab.path, editor);
1067
1160
  // Detach the model before disposing the editor so the model (and its undo
1068
1161
  // history) survives for when the user returns to this tab.
1069
1162
  editor.setModel(null);
@@ -1078,14 +1171,29 @@ var EditorPanel = function EditorPanel(_ref) {
1078
1171
  var editor = monacoRef.current;
1079
1172
  if (!editor || typeof tab.content !== 'string') return;
1080
1173
 
1174
+ // The editor effect defers persistent-undo tracking until the file load
1175
+ // lands (see pendingHistorySetupRef). Start it here, after the content has
1176
+ // been applied, so the loaded file is the history base rather than an op.
1177
+ var _runPendingHistory = function () {
1178
+ var pending = pendingHistorySetupRef.current;
1179
+ if (pending) {
1180
+ pendingHistorySetupRef.current = null;
1181
+ pending();
1182
+ }
1183
+ };
1184
+
1081
1185
  var extVersion = tab.externalContentVersion || 0;
1082
- if (extVersion <= lastAppliedExternalVersionRef.current) return;
1186
+ if (extVersion <= lastAppliedExternalVersionRef.current) {
1187
+ _runPendingHistory();
1188
+ return;
1189
+ }
1083
1190
 
1084
1191
  // For a collaboration late-join the shared document is authoritative; applying
1085
1192
  // the disk content would clobber it. Mark this version consumed and skip.
1086
1193
  if (collabActiveRef.current && typeof CollaborationService !== 'undefined' &&
1087
1194
  CollaborationService.consumesDiskLoad(tab.path)) {
1088
1195
  lastAppliedExternalVersionRef.current = extVersion;
1196
+ _runPendingHistory();
1089
1197
  return;
1090
1198
  }
1091
1199
 
@@ -1098,39 +1206,48 @@ var EditorPanel = function EditorPanel(_ref) {
1098
1206
  // Normalize before comparing to prevent false positive dirty edits
1099
1207
  var vNorm = editor.getValue().replace(/\r\n/g, '\n');
1100
1208
  var cNorm = tab.content.replace(/\r\n/g, '\n');
1101
- if (vNorm === cNorm) return;
1102
-
1103
- if (!vNorm) {
1104
- // If the editor is currently completely empty, treat it as an initial load.
1105
- // setValue clears the undo stack which is correct for initial load.
1106
- // Null cleanVersionId before setValue so the synchronous onDidChangeContent
1107
- // fires during setValue and skips the dirty check (cleanVersionId is null).
1108
- var _initEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1109
- if (_initEntry) _initEntry.cleanVersionId = null;
1110
-
1111
- editor.setValue(tab.content);
1112
- // Reset the AVI baseline: setValue clears the undo stack so anything before
1113
- // this point is no longer reachable. Also clear the canUndo/canRedo display.
1114
- var newBase = model.getAlternativeVersionId();
1115
- aviBaseRef.current = newBase;
1116
- aviMaxRef.current = newBase;
1117
- if (_initEntry) _initEntry.cleanVersionId = newBase;
1118
- EditorStore.setState({ canUndo: false, canRedo: false });
1119
- } else {
1120
- // Keep undo stack for formats or replaces by using executeEdits
1121
- var _extEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1122
- if (_extEntry) _extEntry.cleanVersionId = null;
1123
- editor.pushUndoStop();
1124
- editor.executeEdits("external", [{
1125
- range: model.getFullModelRange(),
1126
- text: tab.content
1127
- }]);
1128
- editor.pushUndoStop();
1129
- // Re-anchor the clean baseline so onDidChangeContent doesn't mark this
1130
- // externally-applied content as a dirty edit.
1131
- var _newExtAvi = model.getAlternativeVersionId();
1132
- if (_extEntry) _extEntry.cleanVersionId = _newExtAvi;
1133
- aviBaseRef.current = _newExtAvi;
1209
+ if (vNorm !== cNorm) {
1210
+ if (!vNorm) {
1211
+ // If the editor is currently completely empty, treat it as an initial load.
1212
+ // setValue clears the undo stack which is correct for initial load.
1213
+ // Null cleanVersionId before setValue so the synchronous onDidChangeContent
1214
+ // fires during setValue and skips the dirty check (cleanVersionId is null).
1215
+ var _initEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1216
+ if (_initEntry) _initEntry.cleanVersionId = null;
1217
+
1218
+ editor.setValue(tab.content);
1219
+ // Reset the AVI baseline: setValue clears the undo stack so anything before
1220
+ // this point is no longer reachable. Also clear the canUndo/canRedo display.
1221
+ var newBase = model.getAlternativeVersionId();
1222
+ aviBaseRef.current = newBase;
1223
+ aviMaxRef.current = newBase;
1224
+ if (_initEntry) _initEntry.cleanVersionId = newBase;
1225
+ EditorStore.setState({ canUndo: false, canRedo: false });
1226
+ } else {
1227
+ // Keep undo stack for formats or replaces by using executeEdits
1228
+ var _extEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1229
+ if (_extEntry) _extEntry.cleanVersionId = null;
1230
+ editor.pushUndoStop();
1231
+ editor.executeEdits("external", [{
1232
+ range: model.getFullModelRange(),
1233
+ text: tab.content
1234
+ }]);
1235
+ editor.pushUndoStop();
1236
+ // Re-anchor the clean baseline so onDidChangeContent doesn't mark this
1237
+ // externally-applied content as a dirty edit.
1238
+ var _newExtAvi = model.getAlternativeVersionId();
1239
+ if (_extEntry) _extEntry.cleanVersionId = _newExtAvi;
1240
+ aviBaseRef.current = _newExtAvi;
1241
+ }
1242
+ }
1243
+
1244
+ // Empty file: content already matches, but tracking still has to start.
1245
+ _runPendingHistory();
1246
+
1247
+ // The model now holds the disk content. A room that deferred attaching while
1248
+ // this fetch was in flight can seed the shared doc from it.
1249
+ if (collabActiveRef.current && typeof CollaborationService !== 'undefined') {
1250
+ CollaborationService.contentReady(tab.path);
1134
1251
  }
1135
1252
  }, [tab.content, tab.externalContentVersion]);
1136
1253
 
@@ -1216,7 +1333,7 @@ var EditorPanel = function EditorPanel(_ref) {
1216
1333
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1217
1334
  quickSuggestions: editorPrefs.quickSuggestions !== false,
1218
1335
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
1219
- linkedEditing: !!(editorPrefs.linkedEditing),
1336
+ linkedEditing: true,
1220
1337
  acceptSuggestionOnEnter: editorPrefs.acceptSuggestionOnEnter || 'on'
1221
1338
  });
1222
1339
  // Re-apply large-file tuning last so a prefs change can't re-enable the
@@ -1694,9 +1811,8 @@ var EditorPanel = function EditorPanel(_ref) {
1694
1811
  // keystroke. This ticks 250 ms after the last change instead — the same
1695
1812
  // treatment the route-hint redraw above already gets.
1696
1813
  var _useStateZT = useState(0);
1697
- var _useStateZT2 = _slicedToArray(_useStateZT, 2);
1698
- var zoneTick = _useStateZT2[0];
1699
- var setZoneTick = _useStateZT2[1];
1814
+ var zoneTick = _useStateZT[0];
1815
+ var setZoneTick = _useStateZT[1];
1700
1816
  useEffect(function () {
1701
1817
  var timer = setTimeout(function () {
1702
1818
  setZoneTick(function (n) { return n + 1; });
@@ -1966,33 +2082,71 @@ var EditorPanel = function EditorPanel(_ref) {
1966
2082
  if (isMarkdown && window.marked) {
1967
2083
  (function () {
1968
2084
  var renderer = new window.marked.Renderer();
1969
- var escapeHtml = function escapeHtml(str) {
1970
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1971
- };
2085
+ // escapeHtml and safeHref are defined once in ChangelogView.js — the
2086
+ // href allow-list guards both markdown surfaces and must not fork.
2087
+ var escapeHtml = window.mbeditorEscapeHtml;
2088
+ var safeHref = window.mbeditorSafeHref;
1972
2089
  renderer.html = function (token) {
1973
2090
  return '<pre>' + escapeHtml(typeof token === 'object' ? token.text : token) + '</pre>';
1974
2091
  };
1975
- // Sanitize link/image hrefs to block javascript: and data: schemes.
1976
2092
  var _origLink = renderer.link.bind(renderer);
1977
2093
  var _origImage = renderer.image.bind(renderer);
1978
- var SAFE_HREF_SCHEME = /^(https?:|mailto:|#|\/)/i;
1979
- var safeHref = function(href) {
1980
- if (!href) return href;
1981
- return SAFE_HREF_SCHEME.test(href.trim()) ? href : '#';
2094
+ // marked 10 calls these positionally — `.link(href, title, text)`, see
2095
+ // the vendored bundle. An earlier version took a token object, and
2096
+ // against the real signature the object branch never ran: safeHref was
2097
+ // dead, so javascript: hrefs passed through, and dropping title/text
2098
+ // rendered every link as "undefined".
2099
+ renderer.link = function(href, title, text) {
2100
+ return _origLink(safeHref(href), title, text);
1982
2101
  };
1983
- renderer.link = function(token) {
1984
- if (token && typeof token === 'object') { token = Object.assign({}, token, { href: safeHref(token.href) }); }
1985
- return _origLink(token);
2102
+ renderer.image = function(href, title, text) {
2103
+ return _origImage(safeHref(href), title, text);
1986
2104
  };
1987
- renderer.image = function(token) {
1988
- if (token && typeof token === 'object') { token = Object.assign({}, token, { href: safeHref(token.href) }); }
1989
- return _origImage(token);
2105
+ // marked 10 dropped heading ids, so every in-document `[x](#anchor)`
2106
+ // link pointed at nothing. Slugs follow GitHub's: strip markup, lower,
2107
+ // drop punctuation, spaces to hyphens, `-1`/`-2` for repeats.
2108
+ var slugSeen = {};
2109
+ renderer.heading = function (text, level) {
2110
+ var slug = String(text)
2111
+ .replace(/<[^>]+>/g, '')
2112
+ .replace(/&[#\w]+;/g, '')
2113
+ .trim().toLowerCase()
2114
+ .replace(/[^\w\- ]+/g, '')
2115
+ .replace(/\s/g, '-');
2116
+ slugSeen[slug] = (slugSeen[slug] || 0) + 1;
2117
+ if (slugSeen[slug] > 1) slug += '-' + (slugSeen[slug] - 1);
2118
+ return '<h' + level + ' id="' + slug + '">' + text + '</h' + level + '>\n';
1990
2119
  };
1991
2120
  setMarkup(window.marked.parse(markdownContent, { renderer: renderer }));
1992
2121
  })();
1993
2122
  }
1994
2123
  }, [markdownContent, isMarkdown]);
1995
2124
 
2125
+ // Preview -> source scroll sync. The source editor instance (a different
2126
+ // EditorPanel, possibly a different pane) is published on the shared model
2127
+ // entry by the editor-creation effect above; scrollSyncing on that same
2128
+ // entry is the shared flag that stops the two directions ping-ponging.
2129
+ useEffect(function () {
2130
+ if (!tab.isPreview || !isMarkdown) return;
2131
+ var el = previewScrollRef.current;
2132
+ if (!el) return;
2133
+ function onScroll() {
2134
+ var modelEntry = window.__mbeditorModels && window.__mbeditorModels[sourcePath];
2135
+ var editor = modelEntry && modelEntry.editor;
2136
+ if (!editor || (modelEntry && modelEntry.scrollSyncing)) return;
2137
+ var max = el.scrollHeight - el.clientHeight;
2138
+ if (max <= 0) return;
2139
+ var fraction = el.scrollTop / max;
2140
+ var scrollHeight = editor.getScrollHeight() - editor.getLayoutInfo().height;
2141
+ if (scrollHeight <= 0) return;
2142
+ modelEntry.scrollSyncing = true;
2143
+ editor.setScrollTop(fraction * scrollHeight);
2144
+ modelEntry.scrollSyncing = false;
2145
+ }
2146
+ el.addEventListener('scroll', onScroll);
2147
+ return function () { el.removeEventListener('scroll', onScroll); };
2148
+ }, [tab.isPreview, isMarkdown, sourcePath]);
2149
+
1996
2150
  // Click-outside handler to close the methods dropdown
1997
2151
  useEffect(function() {
1998
2152
  if (!methodsOpen) return;
@@ -2041,7 +2195,7 @@ var EditorPanel = function EditorPanel(_ref) {
2041
2195
  // our own, which makes it async — the worker is where the parse already
2042
2196
  // lives, and it tracks the live buffer, so unsaved edits are included.
2043
2197
  function loadJsOutline(model) {
2044
- var ts = window.monaco && window.monaco.languages && window.monaco.languages.typescript;
2198
+ var ts = window.monaco && window.monaco.typescript;
2045
2199
  if (!ts || !window.JsOutline) return Promise.reject(new Error('outline unavailable'));
2046
2200
 
2047
2201
  var workerFor = model.getLanguageId() === 'typescript' ? ts.getTypeScriptWorker : ts.getJavaScriptWorker;
@@ -2102,7 +2256,12 @@ var EditorPanel = function EditorPanel(_ref) {
2102
2256
  }
2103
2257
 
2104
2258
  if (tab.isPreview && isMarkdown) {
2105
- return React.createElement('div', { className: 'markdown-preview markdown-preview-full', dangerouslySetInnerHTML: { __html: markup } });
2259
+ return React.createElement('div', {
2260
+ className: 'markdown-preview markdown-preview-full',
2261
+ ref: previewScrollRef,
2262
+ 'data-preview-for': sourcePath,
2263
+ dangerouslySetInnerHTML: { __html: markup }
2264
+ });
2106
2265
  }
2107
2266
 
2108
2267
  // Helper: shorten long paths by showing the last 2 segments with a leading ellipsis
@@ -2113,6 +2272,38 @@ var EditorPanel = function EditorPanel(_ref) {
2113
2272
  return '\u2026/' + parts.slice(-2).join('/');
2114
2273
  }
2115
2274
 
2275
+ // Breadcrumb: shortPath()'s segments, chevron-separated, the final one an
2276
+ // icon + filename in the normal text colour, everything before it muted \u2014
2277
+ // VS Code's editor breadcrumb. Look only; not a navigation control.
2278
+ function renderBreadcrumb(path) {
2279
+ var segments = shortPath(path).split('/');
2280
+ var last = segments.length - 1;
2281
+ var nodes = [];
2282
+ segments.forEach(function (seg, i) {
2283
+ if (i > 0) {
2284
+ nodes.push(React.createElement('i', {
2285
+ key: 'sep-' + i,
2286
+ className: 'fas fa-chevron-right ide-breadcrumb-sep',
2287
+ 'aria-hidden': 'true'
2288
+ }));
2289
+ }
2290
+ if (i === last) {
2291
+ nodes.push(React.createElement(
2292
+ 'span',
2293
+ { key: 'seg-' + i, className: 'ide-breadcrumb-file' },
2294
+ React.createElement('i', {
2295
+ className: (window.getFileIcon ? window.getFileIcon(path) : 'far fa-file-code') + ' ide-breadcrumb-file-icon',
2296
+ 'aria-hidden': 'true'
2297
+ }),
2298
+ seg
2299
+ ));
2300
+ } else {
2301
+ nodes.push(React.createElement('span', { key: 'seg-' + i, className: 'ide-breadcrumb-dir' }, seg));
2302
+ }
2303
+ });
2304
+ return nodes;
2305
+ }
2306
+
2116
2307
  // While Monaco is still loading, show a lightweight skeleton so the UI is
2117
2308
  // visible immediately without calling monaco.editor.create() too early.
2118
2309
  if (!monacoReady) {
@@ -2191,7 +2382,7 @@ var EditorPanel = function EditorPanel(_ref) {
2191
2382
  React.createElement(
2192
2383
  'span',
2193
2384
  { className: 'ide-editor-file-location', title: tab.path },
2194
- shortPath(tab.path)
2385
+ renderBreadcrumb(tab.path)
2195
2386
  ),
2196
2387
  gitAvailable && tab.path && React.createElement(
2197
2388
  'button',
@@ -2200,8 +2391,8 @@ var EditorPanel = function EditorPanel(_ref) {
2200
2391
  onClick: function() { if (onShowHistory) onShowHistory(tab.path); },
2201
2392
  title: 'File History'
2202
2393
  },
2203
- React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2204
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
2394
+ React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2395
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
2205
2396
  ),
2206
2397
  hasOutline && React.createElement(
2207
2398
  'button',
@@ -2255,8 +2446,8 @@ var EditorPanel = function EditorPanel(_ref) {
2255
2446
  },
2256
2447
  title: isTestOutline ? 'Jump to Outline' : 'Jump to Method'
2257
2448
  },
2258
- React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2259
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
2449
+ React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2450
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
2260
2451
  ),
2261
2452
  gitAvailable && React.createElement(
2262
2453
  'button',
@@ -2265,8 +2456,8 @@ var EditorPanel = function EditorPanel(_ref) {
2265
2456
  onClick: function() { setIsBlameVisible(function(prev) { return !prev; }); },
2266
2457
  title: 'Toggle Git Blame'
2267
2458
  },
2268
- React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2269
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2459
+ React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarLabels ? '5px' : 0, flexShrink: 0 } }),
2460
+ editorPrefs.toolbarLabels && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2270
2461
  ),
2271
2462
  ),
2272
2463
  conflictCount > 0 && React.createElement(
@@ -2386,7 +2577,7 @@ var EditorPanel = function EditorPanel(_ref) {
2386
2577
  React.createElement(
2387
2578
  'span',
2388
2579
  { style: { flex: 1, textAlign: 'center' } },
2389
- 'Lines ' + (pageStartLine + 1) + '–' + (pageStartLine + pageLineCount) + ' of ' + pageTotalLines + ' (' + humanSize(pageTotalBytes) + ')'
2580
+ 'Lines ' + (pageStartLine + 1) + '–' + (pageStartLine + pageLineCount) + ' of ' + pageTotalLines + ' (' + formatSize(pageTotalBytes) + ')'
2390
2581
  ),
2391
2582
  React.createElement(
2392
2583
  'button',
@@ -2428,7 +2619,10 @@ var EditorPanel = function EditorPanel(_ref) {
2428
2619
  )
2429
2620
  ),
2430
2621
  React.createElement('div', { ref: editorRef, className: 'monaco-container', style: { flex: 1, minHeight: 0 } }),
2431
- methodsOpen && methodsDropdownPos && React.createElement(
2622
+ // Portalled to body: under the glass chrome the centre column has a
2623
+ // backdrop-filter, which makes it the containing block for position:fixed
2624
+ // and shoves the menu ~130px off its button.
2625
+ methodsOpen && methodsDropdownPos && ReactDOM.createPortal(React.createElement(
2432
2626
  'div',
2433
2627
  {
2434
2628
  ref: methodsDropdownRef,
@@ -2526,7 +2720,7 @@ var EditorPanel = function EditorPanel(_ref) {
2526
2720
  }
2527
2721
  return rows;
2528
2722
  })()
2529
- ),
2723
+ ), document.body),
2530
2724
  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' } })
2531
2725
  );
2532
2726
  };