mbeditor 0.2.2

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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +180 -0
  4. data/app/assets/javascripts/mbeditor/application.js +21 -0
  5. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
  7. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
  8. data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
  9. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
  10. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
  11. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
  12. data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
  13. data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
  14. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
  16. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
  17. data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
  18. data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
  19. data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
  20. data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
  21. data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
  22. data/app/assets/javascripts/mbeditor/file_service.js +96 -0
  23. data/app/assets/javascripts/mbeditor/git_service.js +104 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +63 -0
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
  26. data/app/assets/stylesheets/mbeditor/application.css +848 -0
  27. data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
  28. data/app/controllers/mbeditor/application_controller.rb +70 -0
  29. data/app/controllers/mbeditor/editors_controller.rb +996 -0
  30. data/app/controllers/mbeditor/git_controller.rb +234 -0
  31. data/app/services/mbeditor/git_blame_service.rb +98 -0
  32. data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
  33. data/app/services/mbeditor/git_diff_service.rb +74 -0
  34. data/app/services/mbeditor/git_file_history_service.rb +42 -0
  35. data/app/services/mbeditor/git_service.rb +95 -0
  36. data/app/services/mbeditor/redmine_service.rb +86 -0
  37. data/app/services/mbeditor/ruby_definition_service.rb +168 -0
  38. data/app/services/mbeditor/test_runner_service.rb +286 -0
  39. data/app/views/layouts/mbeditor/application.html.erb +120 -0
  40. data/app/views/mbeditor/editors/index.html.erb +1 -0
  41. data/config/initializers/assets.rb +9 -0
  42. data/config/routes.rb +44 -0
  43. data/lib/mbeditor/configuration.rb +22 -0
  44. data/lib/mbeditor/engine.rb +37 -0
  45. data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
  46. data/lib/mbeditor/version.rb +3 -0
  47. data/lib/mbeditor.rb +19 -0
  48. data/mbeditor.gemspec +31 -0
  49. data/public/mbeditor-icon.svg +4 -0
  50. data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
  51. data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
  53. data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
  54. data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
  55. data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
  56. data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
  57. data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
  58. data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
  59. data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
  60. data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
  61. data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
  62. data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
  63. data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
  64. data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  65. data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
  66. data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
  67. data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
  68. data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
  69. data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
  70. data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
  71. data/public/monaco-editor/vs/editor/editor.api.js +6 -0
  72. data/public/monaco-editor/vs/editor/editor.main.css +8 -0
  73. data/public/monaco-editor/vs/editor/editor.main.js +797 -0
  74. data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
  75. data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
  76. data/public/monaco-editor/vs/loader.js +10 -0
  77. data/public/monaco-editor/vs/nls.messages.de.js +20 -0
  78. data/public/monaco-editor/vs/nls.messages.es.js +20 -0
  79. data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
  80. data/public/monaco-editor/vs/nls.messages.it.js +18 -0
  81. data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
  82. data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
  83. data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
  84. data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
  85. data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
  86. data/public/monaco_worker.js +5 -0
  87. data/public/sw.js +8 -0
  88. data/public/ts_worker.js +5 -0
  89. data/vendor/assets/javascripts/axios.min.js +5 -0
  90. data/vendor/assets/javascripts/emmet.js +5452 -0
  91. data/vendor/assets/javascripts/lodash.min.js +136 -0
  92. data/vendor/assets/javascripts/marked.min.js +6 -0
  93. data/vendor/assets/javascripts/minisearch.min.js +2044 -0
  94. data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
  95. data/vendor/assets/javascripts/monaco-vim.js +9867 -0
  96. data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
  97. data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
  98. data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
  99. data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
  100. data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
  101. data/vendor/assets/javascripts/prettier-standalone.js +37 -0
  102. data/vendor/assets/javascripts/react-dom.min.js +267 -0
  103. data/vendor/assets/javascripts/react.min.js +31 -0
  104. data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
  105. data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
  106. data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
  107. data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
  108. metadata +188 -0
@@ -0,0 +1,1139 @@
1
+ 'use strict';
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
+
5
+ var _React = React;
6
+ var useState = _React.useState;
7
+ var useEffect = _React.useEffect;
8
+ var useRef = _React.useRef;
9
+
10
+ var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'avif'];
11
+
12
+ var _hamlLangRegistered = false;
13
+ var _erbLangRegistered = false;
14
+ var _jsErbLangRegistered = false;
15
+
16
+ var EditorPanel = function EditorPanel(_ref) {
17
+ var tab = _ref.tab;
18
+ var paneId = _ref.paneId;
19
+ var onContentChange = _ref.onContentChange;
20
+ var markers = _ref.markers;
21
+ var gitAvailable = _ref.gitAvailable === true;
22
+ var testAvailable = _ref.testAvailable === true;
23
+ var onFormat = _ref.onFormat;
24
+ var onSave = _ref.onSave;
25
+ var onRunTest = _ref.onRunTest;
26
+ var onShowHistory = _ref.onShowHistory;
27
+ var treeData = _ref.treeData || [];
28
+ var testResult = _ref.testResult;
29
+ var testPanelFile = _ref.testPanelFile;
30
+ var testLoading = _ref.testLoading;
31
+ var testInlineVisible = _ref.testInlineVisible;
32
+ var editorPrefs = _ref.editorPrefs || {};
33
+
34
+ var editorRef = useRef(null);
35
+ var monacoRef = useRef(null);
36
+ var latestContentRef = useRef('');
37
+ var lastAppliedExternalVersionRef = useRef(-1);
38
+
39
+ var _useState = useState('');
40
+ var _useState2 = _slicedToArray(_useState, 2);
41
+ var markup = _useState2[0];
42
+ var setMarkup = _useState2[1];
43
+
44
+ var _useState3 = useState(false);
45
+ var _useState4 = _slicedToArray(_useState3, 2);
46
+ var isBlameVisible = _useState4[0];
47
+ var setIsBlameVisible = _useState4[1];
48
+
49
+ var _useState5 = useState(null);
50
+ var _useState6 = _slicedToArray(_useState5, 2);
51
+ var blameData = _useState6[0];
52
+ var setBlameData = _useState6[1];
53
+
54
+ var _useState7 = useState(false);
55
+ var _useState8 = _slicedToArray(_useState7, 2);
56
+ var isBlameLoading = _useState8[0];
57
+ var setIsBlameLoading = _useState8[1];
58
+
59
+ var blameDecorationsRef = useRef([]);
60
+ var blameZoneIdsRef = useRef([]);
61
+ var testDecorationIdsRef = useRef([]);
62
+ var testZoneIdsRef = useRef([]);
63
+
64
+ var _useState9 = useState(false);
65
+ var _useState10 = _slicedToArray(_useState9, 2);
66
+ var editorReady = _useState10[0];
67
+ var setEditorReady = _useState10[1];
68
+
69
+ var onFormatRef = useRef(onFormat);
70
+ onFormatRef.current = onFormat;
71
+
72
+ var onSaveRef = useRef(onSave);
73
+ onSaveRef.current = onSave;
74
+
75
+ var vimStatusRef = useRef(null);
76
+ var vimModeObjRef = useRef(null);
77
+
78
+ var clearTestZones = function clearTestZones(editor) {
79
+ if (!editor) return;
80
+ if (testZoneIdsRef.current.length === 0) return;
81
+ editor.changeViewZones(function(accessor) {
82
+ testZoneIdsRef.current.forEach(function(zoneId) {
83
+ accessor.removeZone(zoneId);
84
+ });
85
+ });
86
+ testZoneIdsRef.current = [];
87
+ };
88
+
89
+ var clearBlameZones = function clearBlameZones(editor) {
90
+ if (!editor) return;
91
+ if (blameZoneIdsRef.current.length === 0) return;
92
+
93
+ editor.changeViewZones(function(accessor) {
94
+ blameZoneIdsRef.current.forEach(function(zoneId) {
95
+ accessor.removeZone(zoneId);
96
+ });
97
+ });
98
+ blameZoneIdsRef.current = [];
99
+ };
100
+
101
+ var findTabByPath = function findTabByPath(path) {
102
+ if (!path) return null;
103
+ var state = EditorStore.getState();
104
+ for (var i = 0; i < state.panes.length; i += 1) {
105
+ var pane = state.panes[i];
106
+ var match = pane.tabs.find(function (t) {
107
+ return t.path === path;
108
+ });
109
+ if (match) return match;
110
+ }
111
+ return null;
112
+ };
113
+
114
+ useEffect(function () {
115
+ if (tab.isPreview) return;
116
+ if (!editorRef.current || !window.monaco) return;
117
+
118
+ if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.registerGlobalExtensions) {
119
+ window.MbeditorEditorPlugins.registerGlobalExtensions(window.monaco);
120
+ }
121
+
122
+ // Register HAML Monarch grammar once
123
+ if (!_hamlLangRegistered) {
124
+ _hamlLangRegistered = true;
125
+ window.monaco.languages.register({ id: 'haml', extensions: ['.haml'], aliases: ['HAML', 'haml'] });
126
+ window.monaco.languages.setMonarchTokensProvider('haml', {
127
+ // Monarch does not support ^ line anchors — use `@sol` state transitions instead.
128
+ // Strategy: tokenize each line character-by-character from the root state,
129
+ // which resets at the start of each line.
130
+ defaultToken: 'text',
131
+ tokenizer: {
132
+ root: [
133
+ // Doctype: !!! or !!!5 etc
134
+ [/!!!.*$/, 'keyword.doctype'],
135
+ // HAML comment: -#
136
+ [/-#.*$/, 'comment'],
137
+ // HTML comment: /
138
+ [/\/.*$/, 'comment'],
139
+ // Leading whitespace — must use \s+ (not \s*) to avoid a zero-width match
140
+ // which Monarch treats as "no progress" and throws a tokenizer error.
141
+ [/^\s+/, 'white'],
142
+ // Ruby output line: = expr
143
+ [/(=)(\s*)/, [{ token: 'keyword.operator' }, { token: '', next: '@rubyLine' }]],
144
+ // Ruby statement line: - stmt (but not -#)
145
+ [/(-)(\s+)/, [{ token: 'keyword.operator' }, { token: '', next: '@rubyLine' }]],
146
+ // Tag: %tag with optional .class/#id/{ attrs }/ text
147
+ [/%[\w:-]+/, { token: 'tag', next: '@afterTag' }],
148
+ // Class shorthand: .foo
149
+ [/\.[\w-]+/, { token: 'type.class', next: '@afterTag' }],
150
+ // ID shorthand: #foo (only at line start region — before any inline text)
151
+ [/#[\w-]+/, { token: 'type.id', next: '@afterTag' }],
152
+ // Inline Ruby interpolation: #{...}
153
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
154
+ // Strings
155
+ [/"/, { token: 'string.quote', next: '@dqString' }],
156
+ [/'/, { token: 'string.quote', next: '@sqString' }],
157
+ // Symbol keys in attribute hashes
158
+ [/:\w+/, 'attribute.name'],
159
+ // Numbers
160
+ [/\d+/, 'number'],
161
+ // Rest of line text
162
+ [/[^\s#"'%.\-={}]+/, 'text'],
163
+ ],
164
+ afterTag: [
165
+ // Chained .class
166
+ [/\.[\w-]+/, 'type.class'],
167
+ // Chained #id
168
+ [/#[\w-]+/, 'type.id'],
169
+ // Attribute hash open
170
+ [/\{/, { token: 'delimiter.bracket', next: '@attrHash' }],
171
+ // Attribute paren open (HTML-style)
172
+ [/\(/, { token: 'delimiter.paren', next: '@attrParen' }],
173
+ // Inline = output — switchTo so rubyLine pops back to root, not afterTag
174
+ [/=/, { token: 'keyword.operator', switchTo: '@rubyLine' }],
175
+ // Rest of inline text
176
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
177
+ [/"/, { token: 'string.quote', next: '@dqString' }],
178
+ [/'/, { token: 'string.quote', next: '@sqString' }],
179
+ [/[^\s{(\\.#="']+/, 'text'],
180
+ [/$/, '', '@pop'],
181
+ [/\s+/, 'white'],
182
+ ],
183
+ attrHash: [
184
+ [/\}/, { token: 'delimiter.bracket', next: '@pop' }],
185
+ [/:\w+/, 'attribute.name'],
186
+ [/\w+:/, 'attribute.name'],
187
+ [/"/, { token: 'string.quote', next: '@dqString' }],
188
+ [/'/, { token: 'string.quote', next: '@sqString' }],
189
+ [/=>/, 'keyword.operator'],
190
+ [/[,\s]+/, 'white'],
191
+ [/[^}:"',\s=>]+/, 'variable'],
192
+ ],
193
+ attrParen: [
194
+ [/\)/, { token: 'delimiter.paren', next: '@pop' }],
195
+ [/[\w-]+=?/, 'attribute.name'],
196
+ [/"/, { token: 'string.quote', next: '@dqString' }],
197
+ [/'/, { token: 'string.quote', next: '@sqString' }],
198
+ [/\s+/, 'white'],
199
+ ],
200
+ rubyLine: [
201
+ [/$/, '', '@pop'],
202
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
203
+ [/"/, { token: 'string.quote', next: '@dqString' }],
204
+ [/'/, { token: 'string.quote', next: '@sqString' }],
205
+ [/\d+(\.\d+)?/, 'number'],
206
+ [/\b(do|end|if|unless|else|elsif|case|when|then|while|until|for|in|return|yield|def|class|module|nil|true|false|self|super|and|or|not|begin|rescue|ensure|raise)\b/, 'keyword'],
207
+ [/[A-Z][\w]*/, 'type.identifier'],
208
+ [/[\w]+[?!]?/, 'identifier'],
209
+ [/[+\-*\/=<>!&|^~%]+/, 'keyword.operator'],
210
+ [/[,;.()\[\]{}]/, 'delimiter'],
211
+ ],
212
+ rubyInterp: [
213
+ [/\}/, { token: 'delimiter.bracket', next: '@pop' }],
214
+ [/"/, { token: 'string.quote', next: '@dqString' }],
215
+ [/'/, { token: 'string.quote', next: '@sqString' }],
216
+ [/\d+/, 'number'],
217
+ [/[A-Z][\w]*/, 'type.identifier'],
218
+ [/[\w]+[?!]?/, 'identifier'],
219
+ [/[+\-*\/=<>!&|^~%,.:()\[\]]+/, 'keyword.operator'],
220
+ ],
221
+ dqString: [
222
+ [/[^\\"#]+/, 'string'],
223
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
224
+ [/\\./, 'string.escape'],
225
+ [/"/, { token: 'string.quote', next: '@pop' }],
226
+ ],
227
+ sqString: [
228
+ [/[^\\']/, 'string'],
229
+ [/\\./, 'string.escape'],
230
+ [/'/, { token: 'string.quote', next: '@pop' }],
231
+ ],
232
+ }
233
+ });
234
+ }
235
+
236
+ // Register ERB (html.erb) Monarch grammar once
237
+ if (!_erbLangRegistered) {
238
+ _erbLangRegistered = true;
239
+ window.monaco.languages.register({ id: 'erb', aliases: ['ERB', 'erb', 'HTML+ERB'] });
240
+ window.monaco.languages.setMonarchTokensProvider('erb', {
241
+ defaultToken: 'text',
242
+ tokenizer: {
243
+ root: [
244
+ // ERB comment: <%#
245
+ [/<%#/, { token: 'comment.erb', next: '@erbComment' }],
246
+ // ERB output: <%= or <%==
247
+ [/<%==?/, { token: 'delimiter.erb', next: '@erbCode' }],
248
+ // ERB statement: <%
249
+ [/<%/, { token: 'delimiter.erb', next: '@erbCode' }],
250
+ // HTML tags
251
+ [/(<)([\w-]+)/, [{ token: 'delimiter.html' }, { token: 'tag.html', next: '@htmlTag' }]],
252
+ [/(<\/)([\w-]+)(>)/, [{ token: 'delimiter.html' }, { token: 'tag.html' }, { token: 'delimiter.html' }]],
253
+ [/<!--/, { token: 'comment.html', next: '@htmlComment' }],
254
+ [/<!DOCTYPE[^>]*>/, 'keyword.html'],
255
+ [/&\w+;/, 'string.html.entity'],
256
+ [/[^<&%]+/, 'text'],
257
+ ],
258
+ erbCode: [
259
+ [/-%>|%>/, { token: 'delimiter.erb', next: '@pop' }],
260
+ [/"/, { token: 'string.quote', next: '@dqString' }],
261
+ [/'/, { token: 'string.quote', next: '@sqString' }],
262
+ [/\d+(\.\d+)?/, 'number'],
263
+ [/\b(do|end|if|unless|else|elsif|case|when|then|while|until|for|in|return|yield|def|class|module|nil|true|false|self|super|and|or|not|begin|rescue|ensure|raise)\b/, 'keyword'],
264
+ [/[A-Z][\w]*/, 'type.identifier'],
265
+ [/[\w]+[?!]?/, 'identifier'],
266
+ [/[+\-*\/=<>!&|^~%]+/, 'keyword.operator'],
267
+ [/[,;.()\[\]{}]/, 'delimiter'],
268
+ [/#[^{].*$/, 'comment'],
269
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
270
+ ],
271
+ erbComment: [
272
+ [/%>/, { token: 'comment.erb', next: '@pop' }],
273
+ [/./, 'comment'],
274
+ ],
275
+ htmlTag: [
276
+ [/>/, { token: 'delimiter.html', next: '@pop' }],
277
+ [/\/?>/, { token: 'delimiter.html', next: '@pop' }],
278
+ [/<%#/, { token: 'comment.erb', next: '@erbComment' }],
279
+ [/<%==?/, { token: 'delimiter.erb', next: '@erbCode' }],
280
+ [/<%/, { token: 'delimiter.erb', next: '@erbCode' }],
281
+ [/[\w-]+=?/, 'attribute.name'],
282
+ [/"/, { token: 'string.quote', next: '@dqString' }],
283
+ [/'/, { token: 'string.quote', next: '@sqString' }],
284
+ [/\s+/, 'white'],
285
+ ],
286
+ htmlComment: [
287
+ [/-->/, { token: 'comment.html', next: '@pop' }],
288
+ [/./, 'comment'],
289
+ ],
290
+ rubyInterp: [
291
+ [/\}/, { token: 'delimiter.bracket', next: '@pop' }],
292
+ [/"/, { token: 'string.quote', next: '@dqString' }],
293
+ [/'/, { token: 'string.quote', next: '@sqString' }],
294
+ [/[\w]+[?!]?/, 'identifier'],
295
+ [/[+\-*\/=<>!&|^~%,.:()\[\]]+/, 'keyword.operator'],
296
+ ],
297
+ dqString: [
298
+ [/[^\\"#]+/, 'string'],
299
+ [/#\{/, { token: 'delimiter.bracket', next: '@rubyInterp' }],
300
+ [/\\./, 'string.escape'],
301
+ [/"/, { token: 'string.quote', next: '@pop' }],
302
+ ],
303
+ sqString: [
304
+ [/[^\\']/, 'string'],
305
+ [/\\./, 'string.escape'],
306
+ [/'/, { token: 'string.quote', next: '@pop' }],
307
+ ],
308
+ }
309
+ });
310
+ }
311
+
312
+ // Register JS+ERB Monarch grammar once
313
+ if (!_jsErbLangRegistered) {
314
+ _jsErbLangRegistered = true;
315
+ window.monaco.languages.register({ id: 'js-erb', aliases: ['JS+ERB', 'JavaScript+ERB'] });
316
+ window.monaco.languages.setMonarchTokensProvider('js-erb', {
317
+ defaultToken: 'text',
318
+ tokenizer: {
319
+ root: [
320
+ // ERB comment
321
+ [/<%#/, { token: 'comment.erb', next: '@erbComment' }],
322
+ // ERB output
323
+ [/<%==?/, { token: 'delimiter.erb', next: '@erbCode' }],
324
+ // ERB statement
325
+ [/<%/, { token: 'delimiter.erb', next: '@erbCode' }],
326
+ // JS line comments
327
+ [/\/\/.*$/, 'comment'],
328
+ // JS block comments
329
+ [/\/\*/, { token: 'comment', next: '@jsBlockComment' }],
330
+ // JS strings
331
+ [/"/, { token: 'string.quote', next: '@dqString' }],
332
+ [/'/, { token: 'string.quote', next: '@sqString' }],
333
+ [/`/, { token: 'string.quote', next: '@templateString' }],
334
+ // JS numbers
335
+ [/\d+(\.\d+)?([eE][+-]?\d+)?/, 'number'],
336
+ [/0x[0-9a-fA-F]+/, 'number.hex'],
337
+ // JS keywords
338
+ [/\b(var|let|const|function|return|if|else|for|while|do|switch|case|break|continue|new|delete|typeof|instanceof|in|of|this|class|extends|import|export|default|async|await|try|catch|finally|throw|null|undefined|true|false)\b/, 'keyword'],
339
+ // Identifiers
340
+ [/[A-Z][\w]*/, 'type.identifier'],
341
+ [/[\w$]+/, 'identifier'],
342
+ // Operators and punctuation
343
+ [/[+\-*\/=<>!&|^~%?:]+/, 'keyword.operator'],
344
+ [/[{}()\[\],;.]/, 'delimiter'],
345
+ [/\s+/, 'white'],
346
+ ],
347
+ erbCode: [
348
+ [/-%>|%>/, { token: 'delimiter.erb', next: '@pop' }],
349
+ [/"/, { token: 'string.quote', next: '@dqString' }],
350
+ [/'/, { token: 'string.quote', next: '@sqString' }],
351
+ [/\d+(\.\d+)?/, 'number'],
352
+ [/\b(do|end|if|unless|else|elsif|case|when|then|while|until|for|in|return|yield|def|class|module|nil|true|false|self|super|and|or|not|begin|rescue|ensure|raise)\b/, 'keyword'],
353
+ [/[A-Z][\w]*/, 'type.identifier'],
354
+ [/[\w]+[?!]?/, 'identifier'],
355
+ [/[+\-*\/=<>!&|^~%]+/, 'keyword.operator'],
356
+ [/[,;.()\[\]{}]/, 'delimiter'],
357
+ [/#[^{].*$/, 'comment'],
358
+ ],
359
+ erbComment: [
360
+ [/%>/, { token: 'comment.erb', next: '@pop' }],
361
+ [/./, 'comment'],
362
+ ],
363
+ jsBlockComment: [
364
+ [/\*\//, { token: 'comment', next: '@pop' }],
365
+ [/./, 'comment'],
366
+ ],
367
+ dqString: [
368
+ [/[^\\"]+/, 'string'],
369
+ [/\\./, 'string.escape'],
370
+ [/"/, { token: 'string.quote', next: '@pop' }],
371
+ ],
372
+ sqString: [
373
+ [/[^\\']/, 'string'],
374
+ [/\\./, 'string.escape'],
375
+ [/'/, { token: 'string.quote', next: '@pop' }],
376
+ ],
377
+ templateString: [
378
+ [/[^`\\$]+/, 'string'],
379
+ [/\\./, 'string.escape'],
380
+ [/\$\{/, { token: 'delimiter.bracket', next: '@jsExpr' }],
381
+ [/`/, { token: 'string.quote', next: '@pop' }],
382
+ ],
383
+ jsExpr: [
384
+ [/\}/, { token: 'delimiter.bracket', next: '@pop' }],
385
+ [/[\w$]+/, 'identifier'],
386
+ [/[+\-*\/=<>!&|^~%?:.,]+/, 'keyword.operator'],
387
+ ],
388
+ }
389
+ });
390
+ }
391
+
392
+ var fileName = tab.path.split('/').pop() || '';
393
+ var fileNameLower = fileName.toLowerCase();
394
+ var language = 'plaintext';
395
+
396
+ // Compound extensions (must check before single-extension switch)
397
+ if (/\.js\.erb$/.test(fileNameLower)) {
398
+ language = 'js-erb';
399
+ } else if (/\.ts\.erb$/.test(fileNameLower)) {
400
+ language = 'typescript';
401
+ } else if (/\.js\.haml$/.test(fileNameLower)) {
402
+ language = 'javascript';
403
+ } else if (/\.css\.erb$/.test(fileNameLower)) {
404
+ language = 'css';
405
+ } else if (/\.html\.erb$/.test(fileNameLower)) {
406
+ language = 'erb';
407
+ } else if (/\.html\.haml$/.test(fileNameLower)) {
408
+ language = 'haml';
409
+ } else {
410
+
411
+ var parts = fileName.split('.');
412
+ var extension = parts.length > 1 ? parts.pop().toLowerCase() : '';
413
+ switch (fileName.toLowerCase()) {
414
+ case 'gemfile':
415
+ case 'gemfile.lock':
416
+ case 'rakefile':
417
+ language = 'ruby';break;
418
+ default:
419
+ switch (extension) {
420
+ case 'rb':case 'ruby':case 'gemspec':
421
+ language = 'ruby';break;
422
+ case 'js':case 'jsx':
423
+ language = 'javascript';break;
424
+ case 'ts':case 'tsx':
425
+ language = 'typescript';break;
426
+ case 'css':case 'scss':case 'sass':
427
+ language = 'css';break;
428
+ case 'html':
429
+ language = 'html';break;
430
+ case 'erb':
431
+ language = 'erb';break;
432
+ case 'haml':
433
+ language = 'haml';break;
434
+ case 'json':
435
+ language = 'json';break;
436
+ case 'yaml':case 'yml':
437
+ language = 'yaml';break;
438
+ case 'md':case 'markdown':
439
+ language = 'markdown';break;
440
+ case 'sh':case 'bash':case 'zsh':
441
+ language = 'shell';break;
442
+ case 'png':case 'jpg':case 'jpeg':case 'gif':case 'svg':case 'ico':case 'webp':case 'bmp':case 'avif':
443
+ language = 'image';break;
444
+ }
445
+ }
446
+
447
+ } // end compound-extension else
448
+
449
+ if (language === 'image') return;
450
+
451
+ lastAppliedExternalVersionRef.current = -1;
452
+ var editor = window.monaco.editor.create(editorRef.current, {
453
+ value: tab.content,
454
+ language: language,
455
+ theme: editorPrefs.theme || 'vs-dark',
456
+ automaticLayout: true,
457
+ minimap: { enabled: !!(editorPrefs.minimap) },
458
+ renderLineHighlight: 'none',
459
+ bracketPairColorization: { enabled: editorPrefs.bracketPairColorization !== false },
460
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
461
+ fontSize: editorPrefs.fontSize || 13,
462
+ tabSize: editorPrefs.tabSize || 4,
463
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
464
+ wordWrap: editorPrefs.wordWrap || 'off',
465
+ lineNumbers: editorPrefs.lineNumbers || 'on',
466
+ renderWhitespace: editorPrefs.renderWhitespace || 'none',
467
+ scrollBeyondLastLine: !!(editorPrefs.scrollBeyondLastLine),
468
+ linkedEditing: true, // Enables Auto-Rename Tag natively!
469
+ fixedOverflowWidgets: true,
470
+ hover: { above: false },
471
+ autoClosingBrackets: 'always',
472
+ autoClosingQuotes: 'always',
473
+ autoIndent: 'full',
474
+ formatOnPaste: true,
475
+ formatOnType: true
476
+ });
477
+
478
+ monacoRef.current = editor;
479
+ window.__mbeditorActiveEditor = editor;
480
+ setEditorReady(true);
481
+
482
+ if (tab.viewState) {
483
+ editor.restoreViewState(tab.viewState);
484
+ }
485
+
486
+ // Stash the workspace-relative path on the model so code-action providers
487
+ // can identify which file they are operating on without needing React state.
488
+ var modelObj = editor.getModel();
489
+ if (modelObj) modelObj._mbeditorPath = tab.path;
490
+
491
+ var formatActionDisposable = editor.addAction({
492
+ id: 'mbeditor.formatDocument',
493
+ label: 'Format Document',
494
+ contextMenuGroupId: '1_modification',
495
+ contextMenuOrder: 1.5,
496
+ run: function() {
497
+ if (onFormatRef.current) onFormatRef.current();
498
+ }
499
+ });
500
+
501
+ var editorPluginDisposable = null;
502
+ if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.attachEditorFeatures) {
503
+ editorPluginDisposable = window.MbeditorEditorPlugins.attachEditorFeatures(editor, language);
504
+ }
505
+
506
+ // Column selection only when Alt is held during drag.
507
+ // We toggle Monaco's columnSelection option on Alt+mousedown and reset on mouseup.
508
+ var onColumnMouseDown = function(ev) {
509
+ if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
510
+ editor.updateOptions({ columnSelection: true });
511
+ }
512
+ };
513
+ var onColumnMouseUp = function() {
514
+ editor.updateOptions({ columnSelection: false });
515
+ };
516
+ var editorDomNode = editor.getDomNode();
517
+ editorDomNode.addEventListener('mousedown', onColumnMouseDown, true);
518
+ document.addEventListener('mouseup', onColumnMouseUp, true);
519
+ var columnSelectDisposable = {
520
+ dispose: function() {
521
+ editorDomNode.removeEventListener('mousedown', onColumnMouseDown, true);
522
+ document.removeEventListener('mouseup', onColumnMouseUp, true);
523
+ }
524
+ };
525
+
526
+ var contentDisposable = modelObj.onDidChangeContent(function (e) {
527
+ var val = editor.getValue();
528
+ var currentContent = latestContentRef.current;
529
+
530
+ // Normalize before comparing to prevent false positive dirty edits
531
+ var vNorm = val.replace(/\r\n/g, '\n');
532
+ var cNorm = currentContent.replace(/\r\n/g, '\n');
533
+ if (vNorm !== cNorm) {
534
+ // Update the ref immediately so rapid undo/redo events compare against the
535
+ // latest content rather than a stale snapshot from a previous React render.
536
+ latestContentRef.current = val;
537
+ onContentChange(val);
538
+ }
539
+ });
540
+
541
+ return function () {
542
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
543
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
544
+ clearBlameZones(editor);
545
+ clearTestZones(editor);
546
+ TabManager.saveTabViewState(tab.id, editor.saveViewState());
547
+ if (window.__mbeditorActiveEditor === editor) {
548
+ window.__mbeditorActiveEditor = null;
549
+ }
550
+ if (editorPluginDisposable) editorPluginDisposable.dispose();
551
+ formatActionDisposable.dispose();
552
+ columnSelectDisposable.dispose();
553
+ contentDisposable.dispose();
554
+ editor.dispose();
555
+ };
556
+ }, [tab.id, tab.isPreview]); // re-run ONLY on tab switch, not on content change (Monaco handles its own content state)
557
+
558
+ // Listen for external content changes (e.g. after Format/Load)
559
+ // Only applies when externalContentVersion advances — prevents stale typing-originated
560
+ // React renders from rolling back content the user just typed.
561
+ useEffect(function () {
562
+ var editor = monacoRef.current;
563
+ if (!editor || typeof tab.content !== 'string') return;
564
+
565
+ var extVersion = tab.externalContentVersion || 0;
566
+ if (extVersion <= lastAppliedExternalVersionRef.current) return;
567
+
568
+ lastAppliedExternalVersionRef.current = extVersion;
569
+ latestContentRef.current = tab.content; // keep ref in sync for onDidChangeContent closure
570
+
571
+ var model = editor.getModel();
572
+ if (!model) return;
573
+
574
+ // Normalize before comparing to prevent false positive dirty edits
575
+ var vNorm = editor.getValue().replace(/\r\n/g, '\n');
576
+ var cNorm = tab.content.replace(/\r\n/g, '\n');
577
+ if (vNorm === cNorm) return;
578
+
579
+ if (!vNorm) {
580
+ // If the editor is currently completely empty, treat it as an initial load.
581
+ // setValue clears the undo stack which is correct for initial load.
582
+ editor.setValue(tab.content);
583
+ } else {
584
+ // Keep undo stack for formats or replaces by using executeEdits
585
+ editor.pushUndoStop();
586
+ editor.executeEdits("external", [{
587
+ range: model.getFullModelRange(),
588
+ text: tab.content
589
+ }]);
590
+ editor.pushUndoStop();
591
+ }
592
+ }, [tab.content, tab.externalContentVersion]);
593
+
594
+ // Apply editorPrefs changes to a running editor without remounting
595
+ useEffect(function () {
596
+ if (!window.monaco) return;
597
+ var theme = editorPrefs.theme || 'vs-dark';
598
+ window.monaco.editor.setTheme(theme);
599
+ if (monacoRef.current) {
600
+ monacoRef.current.updateOptions({
601
+ fontSize: editorPrefs.fontSize || 13,
602
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
603
+ tabSize: editorPrefs.tabSize || 4,
604
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
605
+ wordWrap: editorPrefs.wordWrap || 'off',
606
+ lineNumbers: editorPrefs.lineNumbers || 'on',
607
+ renderWhitespace: editorPrefs.renderWhitespace || 'none',
608
+ minimap: { enabled: !!(editorPrefs.minimap) },
609
+ scrollBeyondLastLine: !!(editorPrefs.scrollBeyondLastLine),
610
+ bracketPairColorization: { enabled: editorPrefs.bracketPairColorization !== false }
611
+ });
612
+ }
613
+ }, [editorPrefs]);
614
+
615
+ // Toggle vim mode when editorPrefs.vimMode changes
616
+ useEffect(function () {
617
+ var editor = monacoRef.current;
618
+ if (!editor) return;
619
+
620
+ if (editorPrefs.vimMode) {
621
+ // Lazy-load monaco-vim via the same AMD loader that Monaco uses
622
+ require(['monaco-vim'], function(MonacoVim) {
623
+ // Dispose any previous instance first (e.g. editorPrefs changed rapidly)
624
+ if (vimModeObjRef.current) {
625
+ try { vimModeObjRef.current.dispose(); } catch (e) {}
626
+ vimModeObjRef.current = null;
627
+ }
628
+ var statusNode = vimStatusRef.current;
629
+ if (!statusNode || !monacoRef.current) return;
630
+ var vimInstance = MonacoVim.initVimMode(monacoRef.current, statusNode);
631
+ // Wire :w and :wq to the editor's save action
632
+ MonacoVim.VimMode.Vim.defineEx('write', 'w', function() {
633
+ if (onSaveRef.current) onSaveRef.current();
634
+ });
635
+ MonacoVim.VimMode.Vim.defineEx('wq', 'wq', function() {
636
+ if (onSaveRef.current) onSaveRef.current();
637
+ });
638
+ vimModeObjRef.current = vimInstance;
639
+ });
640
+ } else {
641
+ if (vimModeObjRef.current) {
642
+ try { vimModeObjRef.current.dispose(); } catch (e) {}
643
+ vimModeObjRef.current = null;
644
+ }
645
+ }
646
+
647
+ return function() {
648
+ if (vimModeObjRef.current) {
649
+ try { vimModeObjRef.current.dispose(); } catch (e) {}
650
+ vimModeObjRef.current = null;
651
+ }
652
+ };
653
+ }, [editorPrefs.vimMode]);
654
+
655
+ // Jump to line if specified
656
+ useEffect(function () {
657
+ if (tab.gotoLine && monacoRef.current) {
658
+ (function () {
659
+ var editor = monacoRef.current;
660
+ setTimeout(function () {
661
+ editor.revealLineInCenter(tab.gotoLine);
662
+ editor.setPosition({ lineNumber: tab.gotoLine, column: 1 });
663
+ editor.focus();
664
+
665
+ TabManager.saveTabViewState(tab.id, editor.saveViewState());
666
+ TabManager.clearGotoLine(paneId, tab.path);
667
+ }, 50);
668
+ })();
669
+ }
670
+ }, [tab.gotoLine, tab.content]); // need tab.content in dep array so if it loads asynchronously, the jump happens AFTER content loads
671
+
672
+ // Apply RuboCop markers
673
+ useEffect(function () {
674
+ if (monacoRef.current && window.monaco) {
675
+ var model = monacoRef.current.getModel();
676
+ if (model) {
677
+ var monacoMarkers = markers.map(function (m) {
678
+ var sev = m.severity === 'error'
679
+ ? window.monaco.MarkerSeverity.Error
680
+ : window.monaco.MarkerSeverity.Warning;
681
+ return {
682
+ severity: sev,
683
+ source: 'rubocop',
684
+ code: m.copName || '',
685
+ message: m.message,
686
+ startLineNumber: m.startLine,
687
+ startColumn: m.startCol,
688
+ endLineNumber: m.endLine,
689
+ endColumn: m.endCol
690
+ };
691
+ });
692
+ window.monaco.editor.setModelMarkers(model, 'rubocop', monacoMarkers);
693
+ // Track which cops are autocorrectable so the quick-fix provider can
694
+ // skip lightbulbs for cops that can never be machine-fixed.
695
+ model._mbeditorCorrectableCops = new Set(
696
+ markers.filter(function(m) { return m.correctable && m.copName; }).map(function(m) { return m.copName; })
697
+ );
698
+ }
699
+ }
700
+ }, [markers, tab.id]);
701
+
702
+ // Reset blame + test decorations when file path changes
703
+ useEffect(function () {
704
+ setBlameData(null);
705
+ setIsBlameLoading(false);
706
+
707
+ if (monacoRef.current && monacoRef.current.getModel()) {
708
+ clearBlameZones(monacoRef.current);
709
+ clearTestZones(monacoRef.current);
710
+ blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
711
+ testDecorationIdsRef.current = monacoRef.current.deltaDecorations(testDecorationIdsRef.current, []);
712
+ }
713
+ }, [tab.path]);
714
+
715
+ // Handle Blame data fetching
716
+ useEffect(function () {
717
+ if (!isBlameVisible) {
718
+ if (monacoRef.current && monacoRef.current.getModel()) {
719
+ clearBlameZones(monacoRef.current);
720
+ blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
721
+ }
722
+ return;
723
+ }
724
+
725
+ if (!blameData && !isBlameLoading) {
726
+ setIsBlameLoading(true);
727
+ GitService.fetchBlame(tab.path).then(function(data) {
728
+ var lines = data && Array.isArray(data.lines) ? data.lines : [];
729
+ setBlameData(lines);
730
+ if (lines.length === 0) {
731
+ EditorStore.setStatus('No blame data available for this file', 'warning');
732
+ } else {
733
+ EditorStore.setStatus('Loaded blame for ' + lines.length + ' lines', 'info');
734
+ }
735
+ setIsBlameLoading(false);
736
+ }).catch(function(err) {
737
+ var status = err.response && err.response.status;
738
+ var msg = status === 404
739
+ ? "File is not tracked by git"
740
+ : "Failed to load blame: " + ((err.response && err.response.data && err.response.data.error) || err.message);
741
+ EditorStore.setStatus(msg, "error");
742
+ setBlameData([]);
743
+ setIsBlameLoading(false);
744
+ });
745
+ }
746
+ }, [isBlameVisible, tab.path, blameData, isBlameLoading]);
747
+
748
+ // Render Blame block headers (author + summary) above contiguous commit regions.
749
+ useEffect(function () {
750
+ if (!monacoRef.current || !window.monaco || !isBlameVisible || !blameData) return;
751
+
752
+ var editor = monacoRef.current;
753
+ var model = editor.getModel();
754
+ var lineCount = model ? model.getLineCount() : 0;
755
+
756
+ try {
757
+ // Clear previous render before rebuilding.
758
+ clearBlameZones(editor);
759
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
760
+
761
+ var normalized = blameData.map(function(lineData) {
762
+ var ln = Number(lineData && lineData.line);
763
+ if (!model || !ln || ln < 1 || ln > lineCount) return null;
764
+
765
+ var sha = lineData && lineData.sha || '';
766
+ var author = lineData && lineData.author || 'Unknown';
767
+ var summary = lineData && lineData.summary || 'No commit message';
768
+ var isUncommitted = sha.substring(0, 8) === '00000000';
769
+
770
+ return {
771
+ line: ln,
772
+ sha: sha,
773
+ author: isUncommitted ? 'Not Committed' : author,
774
+ summary: summary,
775
+ isUncommitted: isUncommitted
776
+ };
777
+ }).filter(Boolean);
778
+
779
+ normalized.sort(function(a, b) { return a.line - b.line; });
780
+
781
+ var blocks = [];
782
+ normalized.forEach(function(item) {
783
+ var current = blocks.length > 0 ? blocks[blocks.length - 1] : null;
784
+ if (!current || current.sha !== item.sha || item.line !== current.endLine + 1) {
785
+ blocks.push({
786
+ sha: item.sha,
787
+ author: item.author,
788
+ summary: item.summary,
789
+ isUncommitted: item.isUncommitted,
790
+ startLine: item.line,
791
+ endLine: item.line
792
+ });
793
+ return;
794
+ }
795
+ current.endLine = item.line;
796
+ });
797
+
798
+ var zoneIds = [];
799
+ editor.changeViewZones(function(accessor) {
800
+ blocks.forEach(function(block, idx) {
801
+ var header = document.createElement('div');
802
+ header.className = block.isUncommitted
803
+ ? 'ide-blame-block-header ide-blame-block-header-uncommitted'
804
+ : 'ide-blame-block-header';
805
+ header.textContent = block.author + ' - ' + block.summary;
806
+
807
+ var zoneId = accessor.addZone({
808
+ afterLineNumber: block.startLine > 1 ? block.startLine - 1 : 0,
809
+ heightInLines: 1,
810
+ domNode: header,
811
+ suppressMouseDown: true
812
+ });
813
+ zoneIds.push(zoneId);
814
+ });
815
+ });
816
+ blameZoneIdsRef.current = zoneIds;
817
+ } catch (err) {
818
+ var message = err && err.message ? err.message : 'Unknown decoration error';
819
+ EditorStore.setStatus('Failed to render blame annotations: ' + message, 'error');
820
+ clearBlameZones(editor);
821
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
822
+ }
823
+
824
+ // Include tab.content so blame re-renders once async file contents finish loading.
825
+ }, [blameData, isBlameVisible, tab.id, tab.content]);
826
+
827
+ // Check whether the current tab is the source file for the test that was run.
828
+ // e.g. testPanelFile = "test/controllers/theme_controller_test.rb"
829
+ // tab.path = "app/controllers/theme_controller.rb"
830
+ var isSourceForTest = function(tabPath, testFilePath) {
831
+ if (!tabPath || !testFilePath) return false;
832
+ var norm = function(p) { return p.replace(/^\/+/, ''); };
833
+ // Direct match (viewing the test file itself)
834
+ if (norm(tabPath) === norm(testFilePath)) return true;
835
+ // Derive the expected source path from the test file path
836
+ var derived = norm(testFilePath)
837
+ .replace(/^test\//, '').replace(/^spec\//, '')
838
+ .replace(/_test\.rb$/, '.rb').replace(/_spec\.rb$/, '.rb');
839
+ var src = norm(tabPath).replace(/^app\//, '');
840
+ return src === derived;
841
+ };
842
+
843
+ var testFileCandidates = function(relativePath) {
844
+ if (!relativePath || !relativePath.endsWith('.rb')) return [];
845
+
846
+ var basename = relativePath.slice(0, -3);
847
+ var dirParts = relativePath.split('/');
848
+ var leafName = basename.split('/').pop();
849
+ var candidates = [];
850
+
851
+ if (dirParts[0] === 'app' && dirParts.length > 1) {
852
+ var subPath = dirParts.slice(1).join('/');
853
+ var subDir = subPath.indexOf('/') !== -1 ? subPath.slice(0, subPath.lastIndexOf('/')) : '';
854
+ candidates.push('test/' + (subDir ? subDir + '/' : '') + leafName + '_test.rb');
855
+ candidates.push('spec/' + (subDir ? subDir + '/' : '') + leafName + '_spec.rb');
856
+ }
857
+
858
+ if (dirParts[0] === 'lib') {
859
+ var libSubPath = dirParts.slice(1).join('/');
860
+ var libSubDir = libSubPath.indexOf('/') !== -1 ? libSubPath.slice(0, libSubPath.lastIndexOf('/')) : '';
861
+ candidates.push('test/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
862
+ candidates.push('test/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_test.rb');
863
+ candidates.push('spec/lib/' + (libSubDir ? libSubDir + '/' : '') + leafName + '_spec.rb');
864
+ }
865
+
866
+ candidates.push('test/' + leafName + '_test.rb');
867
+ candidates.push('spec/' + leafName + '_spec.rb');
868
+
869
+ return candidates.filter(function(candidate, index, list) {
870
+ return list.indexOf(candidate) === index;
871
+ });
872
+ };
873
+
874
+ var treeHasPath = function(nodes, targetPath) {
875
+ if (!targetPath) return false;
876
+ var stack = (nodes || []).slice();
877
+
878
+ while (stack.length) {
879
+ var node = stack.pop();
880
+ if (!node) continue;
881
+ if (node.path === targetPath) return true;
882
+ if (node.children && node.children.length) {
883
+ stack.push.apply(stack, node.children);
884
+ }
885
+ }
886
+
887
+ return false;
888
+ };
889
+
890
+ var matchingTestFilePath = function(sourcePath) {
891
+ var normalized = (sourcePath || '').replace(/^\/+/, '');
892
+ if (!normalized || !normalized.endsWith('.rb')) return null;
893
+ if (/^(test|spec)\//.test(normalized) && /_(test|spec)\.rb$/.test(normalized)) return normalized;
894
+
895
+ var candidates = testFileCandidates(normalized);
896
+ for (var i = 0; i < candidates.length; i += 1) {
897
+ if (treeHasPath(treeData, candidates[i])) return candidates[i];
898
+ }
899
+
900
+ return null;
901
+ };
902
+
903
+ // Map a test method name to the best-matching line in the source file.
904
+ // Extracts keywords from the test name and scores each source line.
905
+ var mapTestToSourceLine = function(testName, sourceContent) {
906
+ // Strip Minitest-style "ClassName#" prefix before the usual transformations
907
+ var name = (testName || '').replace(/^\w+#/, '').replace(/^test_/, '').replace(/^(it |should )/, '');
908
+ var tokens = name.split('_').filter(function(t) { return t.length > 1; });
909
+ if (tokens.length === 0) return 1;
910
+
911
+ var lines = sourceContent.split('\n');
912
+ var bestLine = 1;
913
+ var bestScore = 0;
914
+
915
+ lines.forEach(function(line, idx) {
916
+ var lineNum = idx + 1;
917
+ var lower = line.toLowerCase();
918
+ var score = 0;
919
+ tokens.forEach(function(tok) {
920
+ if (lower.indexOf(tok.toLowerCase()) !== -1) score++;
921
+ });
922
+ // Prefer def/attr/constant lines
923
+ if (score > 0 && (/\bdef\b/.test(lower) || /\battr_/.test(lower) || /^ [A-Z]/.test(line))) {
924
+ score += 0.5;
925
+ }
926
+ if (score > bestScore) { bestScore = score; bestLine = lineNum; }
927
+ });
928
+
929
+ return bestLine;
930
+ };
931
+
932
+ // Render test result annotations above source lines (same pattern as blame zones).
933
+ useEffect(function () {
934
+ if (!monacoRef.current || !window.monaco) return;
935
+
936
+ var editor = monacoRef.current;
937
+ var model = editor.getModel();
938
+ var lineCount = model ? model.getLineCount() : 0;
939
+
940
+ var showHere = testPanelFile && tab.path && isSourceForTest(tab.path, testPanelFile);
941
+
942
+ if (!testResult || !testInlineVisible || !showHere) {
943
+ clearTestZones(editor);
944
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
945
+ return;
946
+ }
947
+
948
+ try {
949
+ clearTestZones(editor);
950
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
951
+
952
+ var normPath = function(p) { return p ? p.replace(/^\/+/, '') : ''; };
953
+ var viewingTestFile = normPath(tab.path) === normPath(testPanelFile);
954
+
955
+ var tests = testResult.tests || [];
956
+ var testsWithStatus = tests.filter(function (t) {
957
+ return t.status === 'pass' || t.status === 'fail' || t.status === 'error';
958
+ });
959
+
960
+ if (testsWithStatus.length === 0) return;
961
+
962
+ // Determine line number for each test
963
+ var sourceContent = tab.content || '';
964
+ var mapped = testsWithStatus.map(function(t) {
965
+ var line;
966
+ if (viewingTestFile && t.line && t.line >= 1 && t.line <= lineCount) {
967
+ line = t.line;
968
+ } else {
969
+ line = mapTestToSourceLine(t.name, sourceContent);
970
+ }
971
+ return { name: t.name, status: t.status, message: t.message, line: line };
972
+ });
973
+
974
+ // Sort by line so zones appear in order
975
+ mapped.sort(function(a, b) { return a.line - b.line; });
976
+
977
+ var zoneIds = [];
978
+ var decorations = [];
979
+
980
+ editor.changeViewZones(function(accessor) {
981
+ mapped.forEach(function(t) {
982
+ if (t.line < 1 || t.line > lineCount) return;
983
+
984
+ var isPassing = t.status === 'pass';
985
+ var icon = isPassing ? '\u2713' : '\u2717';
986
+ var label = icon + ' ' + (t.name || 'Test');
987
+ if (!isPassing && t.message) {
988
+ label += ' \u2014 ' + t.message.split('\n')[0];
989
+ }
990
+
991
+ var header = document.createElement('div');
992
+ header.className = isPassing
993
+ ? 'ide-test-zone-header ide-test-zone-pass'
994
+ : 'ide-test-zone-header ide-test-zone-fail';
995
+ header.textContent = label;
996
+
997
+ var zoneId = accessor.addZone({
998
+ afterLineNumber: t.line > 1 ? t.line - 1 : 0,
999
+ heightInLines: 1,
1000
+ domNode: header,
1001
+ suppressMouseDown: true
1002
+ });
1003
+ zoneIds.push(zoneId);
1004
+
1005
+ decorations.push({
1006
+ range: new window.monaco.Range(t.line, 1, t.line, 1),
1007
+ options: {
1008
+ isWholeLine: true,
1009
+ className: isPassing ? 'ide-test-line-pass' : 'ide-test-line-fail',
1010
+ stickiness: 1
1011
+ }
1012
+ });
1013
+ });
1014
+ });
1015
+
1016
+ testZoneIdsRef.current = zoneIds;
1017
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, decorations);
1018
+ } catch (err) {
1019
+ clearTestZones(editor);
1020
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1021
+ }
1022
+
1023
+ // Include tab.content so zones re-render once async file content loads (same as blame).
1024
+ }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, tab.content]);
1025
+
1026
+ var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
1027
+ var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
1028
+
1029
+ var sourcePath = tab.isPreview ? tab.previewFor || tab.path : tab.path;
1030
+ var parts = sourcePath.split('.');
1031
+ var ext = parts.length > 1 ? parts.pop().toLowerCase() : '';
1032
+ var isImage = tab.isImage || IMAGE_EXTENSIONS.includes(ext);
1033
+ var isMarkdown = ['md', 'markdown'].includes(ext);
1034
+
1035
+ useEffect(function () {
1036
+ if (isMarkdown && window.marked) {
1037
+ (function () {
1038
+ var renderer = new window.marked.Renderer();
1039
+ var escapeHtml = function escapeHtml(str) {
1040
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1041
+ };
1042
+ renderer.html = function (token) {
1043
+ return '<pre>' + escapeHtml(typeof token === 'object' ? token.text : token) + '</pre>';
1044
+ };
1045
+ setMarkup(window.marked.parse(markdownContent, { renderer: renderer }));
1046
+ })();
1047
+ }
1048
+ }, [markdownContent, isMarkdown]);
1049
+
1050
+ if (tab.fileNotFound) {
1051
+ return React.createElement(
1052
+ 'div',
1053
+ { className: 'monaco-container file-not-found-overlay' },
1054
+ React.createElement('i', { className: 'fas fa-exclamation-circle file-not-found-icon' }),
1055
+ React.createElement('p', { className: 'file-not-found-title' }, 'File not available on this branch'),
1056
+ React.createElement('p', { className: 'file-not-found-path' }, tab.path)
1057
+ );
1058
+ }
1059
+
1060
+ if (tab.isDiff) {
1061
+ var isDiffDark = (editorPrefs.theme || 'vs-dark') !== 'vs' && (editorPrefs.theme || 'vs-dark') !== 'hc-light';
1062
+ return React.createElement(window.DiffViewer || DiffViewer, {
1063
+ path: tab.repoPath || tab.path,
1064
+ original: tab.diffOriginal || "",
1065
+ modified: tab.diffModified || "",
1066
+ isDark: isDiffDark,
1067
+ editorPrefs: editorPrefs
1068
+ });
1069
+ }
1070
+
1071
+ if (tab.isCombinedDiff) {
1072
+ return React.createElement(window.CombinedDiffViewer, {
1073
+ diffText: tab.combinedDiffText || '',
1074
+ label: tab.combinedDiffLabel || 'All Changes',
1075
+ isLoading: !tab.combinedDiffText && !tab.combinedDiffLoaded
1076
+ });
1077
+ }
1078
+
1079
+ if (isImage) {
1080
+ var basePath = (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
1081
+ return React.createElement(
1082
+ 'div',
1083
+ { className: 'monaco-container', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#1e1e1e' } },
1084
+ React.createElement('img', { src: basePath + '/raw?path=' + encodeURIComponent(tab.path), style: { maxWidth: '90%', maxHeight: '90%', objectFit: 'contain' }, alt: tab.name })
1085
+ );
1086
+ }
1087
+
1088
+ if (tab.isPreview && isMarkdown) {
1089
+ return React.createElement('div', { className: 'markdown-preview markdown-preview-full', dangerouslySetInnerHTML: { __html: markup } });
1090
+ }
1091
+
1092
+ // Always render the same wrapper structure so the editorRef div is never
1093
+ // unmounted when gitAvailable changes (e.g. loaded async after workspace
1094
+ // call returns). The toolbar is conditionally included inside the wrapper.
1095
+ return React.createElement(
1096
+ 'div',
1097
+ { className: 'ide-editor-wrapper', style: { display: 'flex', flexDirection: 'column', height: '100%' } },
1098
+ (gitAvailable || testAvailable) && React.createElement(
1099
+ 'div',
1100
+ { className: 'ide-editor-toolbar' },
1101
+ gitAvailable && tab.path && React.createElement(
1102
+ 'button',
1103
+ {
1104
+ className: 'ide-icon-btn',
1105
+ onClick: function() { if (onShowHistory) onShowHistory(tab.path); },
1106
+ title: 'File History'
1107
+ },
1108
+ React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
1109
+ !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
1110
+ ),
1111
+ gitAvailable && React.createElement(
1112
+ 'button',
1113
+ {
1114
+ className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
1115
+ onClick: function() { setIsBlameVisible(function(prev) { return !prev; }); },
1116
+ title: 'Toggle Git Blame'
1117
+ },
1118
+ React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
1119
+ !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
1120
+ ),
1121
+ testAvailable && matchingTestFilePath(tab.path) && React.createElement(
1122
+ 'button',
1123
+ {
1124
+ className: 'ide-icon-btn',
1125
+ onClick: function() { if (onRunTest) onRunTest(); },
1126
+ disabled: testLoading,
1127
+ title: 'Run Tests',
1128
+ style: { cursor: testLoading ? 'wait' : 'pointer' }
1129
+ },
1130
+ React.createElement('i', { className: testLoading ? 'fas fa-spinner fa-spin' : 'fas fa-flask', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
1131
+ !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, testLoading ? 'Running...' : 'Test')
1132
+ )
1133
+ ),
1134
+ React.createElement('div', { ref: editorRef, className: 'monaco-container', style: { flex: 1, minHeight: 0 } }),
1135
+ 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' } })
1136
+ );
1137
+ };
1138
+
1139
+ window.EditorPanel = EditorPanel;