mbeditor 0.1.5 → 0.1.7

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -2
  3. data/app/assets/javascripts/mbeditor/application.js +1 -0
  4. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +14 -2
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +281 -18
  6. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -1
  7. data/app/assets/javascripts/mbeditor/components/FileTree.js +12 -1
  8. data/app/assets/javascripts/mbeditor/components/GitPanel.js +94 -0
  9. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +403 -17
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +72 -3
  11. data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
  12. data/app/assets/javascripts/mbeditor/editor_plugins.js +49 -0
  13. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +11 -1
  15. data/app/assets/javascripts/mbeditor/search_service.js +8 -4
  16. data/app/assets/stylesheets/mbeditor/application.css +148 -11
  17. data/app/assets/stylesheets/mbeditor/editor.css +281 -0
  18. data/app/controllers/mbeditor/application_controller.rb +1 -1
  19. data/app/controllers/mbeditor/editors_controller.rb +164 -22
  20. data/app/controllers/mbeditor/git_controller.rb +24 -10
  21. data/app/services/mbeditor/git_diff_service.rb +3 -0
  22. data/app/services/mbeditor/git_service.rb +15 -3
  23. data/app/services/mbeditor/test_runner_service.rb +259 -0
  24. data/app/views/layouts/mbeditor/application.html.erb +5 -1
  25. data/config/routes.rb +2 -0
  26. data/lib/mbeditor/configuration.rb +5 -1
  27. data/lib/mbeditor/rack/silence_ping_request.rb +32 -6
  28. data/lib/mbeditor/version.rb +1 -1
  29. data/public/ts_worker.js +5 -0
  30. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 414b42474453e80d58cfafbab5db7d5381dd8ebb22ba1e852a10e720eb246d8a
4
- data.tar.gz: 0fbaffaedc1aed6196589975816fb9b009df7f4a810dcc41e3aa428d8e19ee37
3
+ metadata.gz: 2f9731ab7001f6b719f901bfa718ca47ae3ea9d38134a03a3602bdf31bcb6100
4
+ data.tar.gz: 8883cc001b712a8ce976a3bdd4b639e6c92fab9db54b1e1f0950204c104a2975
5
5
  SHA512:
6
- metadata.gz: 4bab09aab7ae92d7835838d3c4b240915d0c3ff0c3fc32516a69bdad24430246c86c0da3f508946e9c738ca94a0966d7457903ba9095000a4f01643ca298dbe7
7
- data.tar.gz: 522f1e56c9c280ccf9078f9f960ad1c3e3f1d1f99fafc2e9d80c18d539cd6ba409d18cb596b434037e551403623b74c54c7f2f4dfe8c03c90760eebe8b51862c
6
+ metadata.gz: ac7a9e30ed4bf071f2759281bc49c2225680133e3e8a93c1ce9f997092fd4525e29a3d54d788526b3abe18b6fd7b8ae6e8a08e25cbe5a0d6286b2a9f1e916dbe
7
+ data.tar.gz: 8bd58a3d638f4738e1848c0c157e88ac44a3a5b571f8c8409bed4df79c06d94e529c41c51e9322ef8a30a0a737f062bcd0f67cb4991c344c1bffb8534ec52e8d
data/README.md CHANGED
@@ -10,6 +10,7 @@ Mbeditor (Mini Browser Editor) is a mountable Rails engine that adds a browser-b
10
10
  - File tree and project search
11
11
  - Git panel with working tree changes, unpushed file changes, and branch commit titles
12
12
  - Optional RuboCop lint and format endpoints (uses host app RuboCop)
13
+ - Optional test runner with inline failure markers and a dedicated results panel (Minitest and RSpec)
13
14
 
14
15
  ## Security Warning
15
16
  Mbeditor exposes read and write access to your Rails application directory over HTTP. It is intended only for local development.
@@ -47,12 +48,17 @@ mount Mbeditor::Engine, at: "/mbeditor"
47
48
  Use a single initializer to set the engine options you need:
48
49
 
49
50
  ```ruby
50
- MBEditor.configure do |config|
51
+ Mbeditor.configure do |config|
51
52
  config.allowed_environments = [:development]
52
53
  # config.workspace_root = Rails.root
53
54
  config.excluded_paths = %w[.git tmp log node_modules .bundle coverage vendor/bundle]
54
55
  config.rubocop_command = "bundle exec rubocop"
55
56
 
57
+ # Optional test runner (Minitest or RSpec)
58
+ # config.test_framework = :minitest # :minitest or :rspec — auto-detected when nil
59
+ # config.test_command = "bundle exec rails test" # defaults to bin/rails test or bundle exec ruby -Itest
60
+ # config.test_timeout = 60
61
+
56
62
  # Optional Redmine integration
57
63
  # config.redmine_enabled = true
58
64
  # config.redmine_url = "https://redmine.example.com/"
@@ -66,17 +72,51 @@ Available options:
66
72
  - `workspace_root` sets the root directory exposed by Mbeditor. Default: `Rails.root` from the host app.
67
73
  - `excluded_paths` hides files and directories from the tree and path-based operations. Entries without `/` match names anywhere in the workspace path; entries with `/` match relative paths and their descendants. Default: `%w[.git tmp log node_modules .bundle coverage vendor/bundle]`.
68
74
  - `rubocop_command` sets the command used for inline Ruby linting and formatting. Default: `"rubocop"`.
75
+ - `test_framework` sets the test framework. `:minitest` or `:rspec`. Auto-detected from file suffix, `.rspec`, or `test`/`spec` directory when `nil`. Default: `nil`.
76
+ - `test_command` overrides the full command used to run a test file. When `nil`, the engine picks `bin/rails test` (Minitest) or `bin/rspec` / `bundle exec rspec` (RSpec). Default: `nil`.
77
+ - `test_timeout` sets the maximum seconds a test run may take before being killed. Default: `60`.
69
78
  - `redmine_enabled` enables issue lookup integration. Default: `false`.
70
79
  - `redmine_url` sets the Redmine base URL. Required when `redmine_enabled` is `true`.
71
80
  - `redmine_api_key` sets the Redmine API key. Required when `redmine_enabled` is `true`.
72
81
 
82
+ ## Test Runner
83
+
84
+ The Test button appears in the editor toolbar for any `.rb` file when a `test/` or `spec/` directory exists in the workspace root. Clicking it:
85
+
86
+ 1. Resolves the active source file to its matching test file using standard Rails conventions (`app/models/user.rb` → `test/models/user_test.rb`). If the open file is already a test file, it runs that file directly.
87
+ 2. Runs the test file using the configured command in a subprocess with a timeout.
88
+ 3. Shows a **Test Results** panel with pass/fail counts, per-test status icons, and error messages.
89
+ 4. Optionally overlays inline failure markers in the Monaco editor (separate from RuboCop markers — the two never interfere). Use the marker icon in the panel header to toggle them.
90
+
91
+ **Framework auto-detection order:**
92
+ 1. File suffix: `_spec.rb` → RSpec, `_test.rb` → Minitest
93
+ 2. `.rspec` file present → RSpec
94
+ 3. `spec/` directory present → RSpec
95
+ 4. `test/` directory present → Minitest
96
+
97
+ **Default commands** (when `test_command` is not set):
98
+ - Minitest: `bin/rails test <file>` if `bin/rails` exists, otherwise `bundle exec ruby -Itest <file>`
99
+ - RSpec: `bin/rspec <file>` if `bin/rspec` exists, otherwise `bundle exec rspec --format json <file>`
100
+
101
+ ## Keyboard Shortcuts
102
+
103
+ | Shortcut | Action |
104
+ |----------|--------|
105
+ | `Ctrl+P` | Quick-open file by name |
106
+ | `Ctrl+S` | Save the active file |
107
+ | `Ctrl+Shift+S` | Save all dirty files |
108
+ | `Alt+Shift+F` | Format the active file |
109
+ | `Ctrl+Shift+G` | Toggle the git panel |
110
+ | `Ctrl+Z` / `Ctrl+Y` | Undo / Redo (Monaco built-in) |
111
+
73
112
  ## Host Requirements (Optional)
74
113
  The gem keeps host/tooling responsibilities in the host app:
75
114
  - `rubocop` and `rubocop-rails` gems (optional, required for Ruby lint/format endpoints)
76
115
  - `haml_lint` gem (optional, required for HAML lint — add to your app's Gemfile if needed)
77
116
  - `git` installed in environment (for Git panel data)
117
+ - `minitest` or `rspec` in the host app's bundle (required for the test runner)
78
118
 
79
- All lint tools are auto-detected at startup. The engine gracefully disables lint features if the tools are not available. Neither `rubocop` nor `haml_lint` are runtime dependencies of the gem itself — they are discovered from the host app's environment.
119
+ All lint and test tools are auto-detected at runtime. The engine gracefully disables features if the tools are not available. Neither `rubocop`, `haml_lint`, nor any test framework are runtime dependencies of the gem itself — they are discovered from the host app's environment.
80
120
 
81
121
  ### Syntax Highlighting Support
82
122
  Monaco runtime assets are served from the engine route namespace (`/mbeditor/monaco-editor/*` and `/mbeditor/monaco_worker.js`).
@@ -99,6 +139,10 @@ The gem includes syntax highlighting for common Rails and React development file
99
139
 
100
140
  These language modules are packaged locally with the gem for true offline operation. No network fallback is needed—all highlighting works without internet connectivity.
101
141
 
142
+ ## Asset Pipeline
143
+
144
+ Mbeditor requires **Sprockets** (`sprockets-rails >= 3.4`). Host apps using **Propshaft** as their asset pipeline are not supported — the engine depends on Sprockets directives to load its JavaScript and CSS assets.
145
+
102
146
  ## Development
103
147
 
104
148
  A minimal dummy Rails app is included for local development and testing:
@@ -11,6 +11,7 @@
11
11
  //= require mbeditor/components/CombinedDiffViewer
12
12
  //= require mbeditor/components/CommitGraph
13
13
  //= require mbeditor/components/FileHistoryPanel
14
+ //= require mbeditor/components/TestResultsPanel
14
15
  //= require mbeditor/components/CodeReviewPanel
15
16
  //= require mbeditor/components/EditorPanel
16
17
  //= require mbeditor/components/FileTree
@@ -6,6 +6,7 @@ var DiffViewer = function DiffViewer(_ref) {
6
6
  var modified = _ref.modified;
7
7
  var isDark = _ref.isDark;
8
8
  var onClose = _ref.onClose;
9
+ var editorPrefs = _ref.editorPrefs || {};
9
10
  // If path is a diff:// URI (diff://baseSha..headSha/actual/file.rb), extract
10
11
  // just the file path portion for display so the title bar shows a clean name.
11
12
  var _rawDisplayPath = _ref.displayPath || path;
@@ -36,8 +37,9 @@ var DiffViewer = function DiffViewer(_ref) {
36
37
  ignoreTrimWhitespace: false,
37
38
  minimap: { enabled: false },
38
39
  scrollBeyondLastLine: false,
39
- fontFamily: "'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace",
40
- fontSize: 13
40
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace",
41
+ fontSize: editorPrefs.fontSize || 13,
42
+ wordWrap: editorPrefs.wordWrap || 'off'
41
43
  });
42
44
 
43
45
  diffEditor.setModel({
@@ -63,6 +65,16 @@ var DiffViewer = function DiffViewer(_ref) {
63
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
64
66
  }, [original, modified, displayPath, isDark]);
65
67
 
68
+ // Update font/wrap options live when editorPrefs changes, without recreating the editor
69
+ React.useEffect(function () {
70
+ if (!editorRef.current) return;
71
+ editorRef.current.updateOptions({
72
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace",
73
+ fontSize: editorPrefs.fontSize || 13,
74
+ wordWrap: editorPrefs.wordWrap || 'off'
75
+ });
76
+ }, [editorPrefs]);
77
+
66
78
  var handleNextDiff = function handleNextDiff() {
67
79
  if (!editorRef.current) return;
68
80
  var changes = editorRef.current.getLineChanges() || [];
@@ -15,9 +15,19 @@ var EditorPanel = function EditorPanel(_ref) {
15
15
  var onContentChange = _ref.onContentChange;
16
16
  var markers = _ref.markers;
17
17
  var gitAvailable = _ref.gitAvailable === true;
18
+ var testAvailable = _ref.testAvailable === true;
19
+ var onFormat = _ref.onFormat;
20
+ var onRunTest = _ref.onRunTest;
21
+ var onShowHistory = _ref.onShowHistory;
22
+ var testResult = _ref.testResult;
23
+ var testPanelFile = _ref.testPanelFile;
24
+ var testLoading = _ref.testLoading;
25
+ var testInlineVisible = _ref.testInlineVisible;
26
+ var editorPrefs = _ref.editorPrefs || {};
18
27
 
19
28
  var editorRef = useRef(null);
20
29
  var monacoRef = useRef(null);
30
+ var latestContentRef = useRef('');
21
31
 
22
32
  var _useState = useState('');
23
33
  var _useState2 = _slicedToArray(_useState, 2);
@@ -41,6 +51,27 @@ var EditorPanel = function EditorPanel(_ref) {
41
51
 
42
52
  var blameDecorationsRef = useRef([]);
43
53
  var blameZoneIdsRef = useRef([]);
54
+ var testDecorationIdsRef = useRef([]);
55
+ var testZoneIdsRef = useRef([]);
56
+
57
+ var _useState9 = useState(false);
58
+ var _useState10 = _slicedToArray(_useState9, 2);
59
+ var editorReady = _useState10[0];
60
+ var setEditorReady = _useState10[1];
61
+
62
+ var onFormatRef = useRef(onFormat);
63
+ onFormatRef.current = onFormat;
64
+
65
+ var clearTestZones = function clearTestZones(editor) {
66
+ if (!editor) return;
67
+ if (testZoneIdsRef.current.length === 0) return;
68
+ editor.changeViewZones(function(accessor) {
69
+ testZoneIdsRef.current.forEach(function(zoneId) {
70
+ accessor.removeZone(zoneId);
71
+ });
72
+ });
73
+ testZoneIdsRef.current = [];
74
+ };
44
75
 
45
76
  var clearBlameZones = function clearBlameZones(editor) {
46
77
  if (!editor) return;
@@ -116,16 +147,19 @@ var EditorPanel = function EditorPanel(_ref) {
116
147
  var editor = window.monaco.editor.create(editorRef.current, {
117
148
  value: tab.content,
118
149
  language: language,
119
- theme: 'vs-dark',
150
+ theme: editorPrefs.theme || 'vs-dark',
120
151
  automaticLayout: true,
121
- minimap: { enabled: false },
152
+ minimap: { enabled: !!(editorPrefs.minimap) },
122
153
  renderLineHighlight: 'none',
123
- bracketPairColorization: { enabled: true },
124
- fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
125
- fontSize: 13,
126
- tabSize: 4,
127
- insertSpaces: true,
128
- wordWrap: 'on',
154
+ bracketPairColorization: { enabled: editorPrefs.bracketPairColorization !== false },
155
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
156
+ fontSize: editorPrefs.fontSize || 13,
157
+ tabSize: editorPrefs.tabSize || 4,
158
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
159
+ wordWrap: editorPrefs.wordWrap || 'off',
160
+ lineNumbers: editorPrefs.lineNumbers || 'on',
161
+ renderWhitespace: editorPrefs.renderWhitespace || 'none',
162
+ scrollBeyondLastLine: !!(editorPrefs.scrollBeyondLastLine),
129
163
  linkedEditing: true, // Enables Auto-Rename Tag natively!
130
164
  fixedOverflowWidgets: true,
131
165
  hover: { above: false },
@@ -142,8 +176,22 @@ var EditorPanel = function EditorPanel(_ref) {
142
176
 
143
177
  monacoRef.current = editor;
144
178
  window.__mbeditorActiveEditor = editor;
179
+ setEditorReady(true);
145
180
 
181
+ // Stash the workspace-relative path on the model so code-action providers
182
+ // can identify which file they are operating on without needing React state.
146
183
  var modelObj = editor.getModel();
184
+ if (modelObj) modelObj._mbeditorPath = tab.path;
185
+
186
+ var formatActionDisposable = editor.addAction({
187
+ id: 'mbeditor.formatDocument',
188
+ label: 'Format Document',
189
+ contextMenuGroupId: '1_modification',
190
+ contextMenuOrder: 1.5,
191
+ run: function() {
192
+ if (onFormatRef.current) onFormatRef.current();
193
+ }
194
+ });
147
195
 
148
196
  var editorPluginDisposable = null;
149
197
  if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.attachEditorFeatures) {
@@ -153,7 +201,7 @@ var EditorPanel = function EditorPanel(_ref) {
153
201
  // Change listener
154
202
  var contentDisposable = modelObj.onDidChangeContent(function (e) {
155
203
  var val = editor.getValue();
156
- var currentContent = monacoRef.current._latestContent || '';
204
+ var currentContent = latestContentRef.current;
157
205
 
158
206
  // Normalize before comparing to prevent false positive dirty edits
159
207
  var vNorm = val.replace(/\r\n/g, '\n');
@@ -165,12 +213,15 @@ var EditorPanel = function EditorPanel(_ref) {
165
213
 
166
214
  return function () {
167
215
  blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
216
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
168
217
  clearBlameZones(editor);
218
+ clearTestZones(editor);
169
219
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
170
220
  if (window.__mbeditorActiveEditor === editor) {
171
221
  window.__mbeditorActiveEditor = null;
172
222
  }
173
223
  if (editorPluginDisposable) editorPluginDisposable.dispose();
224
+ formatActionDisposable.dispose();
174
225
  contentDisposable.dispose();
175
226
  editor.dispose();
176
227
  };
@@ -179,7 +230,7 @@ var EditorPanel = function EditorPanel(_ref) {
179
230
  // Listen for external content changes (e.g. after Format/Save/Load)
180
231
  useEffect(function () {
181
232
  var editor = monacoRef.current;
182
- if (editor) editor._latestContent = tab.content; // update ref for closure
233
+ if (editor) latestContentRef.current = tab.content; // keep ref in sync for closure
183
234
 
184
235
  if (editor && editor.getValue() !== tab.content) {
185
236
  if (typeof tab.content !== 'string') return;
@@ -207,6 +258,45 @@ var EditorPanel = function EditorPanel(_ref) {
207
258
  }
208
259
  }, [tab.content]);
209
260
 
261
+ // Apply editorPrefs changes to a running editor without remounting
262
+ useEffect(function () {
263
+ if (!window.monaco) return;
264
+ var theme = editorPrefs.theme || 'vs-dark';
265
+ window.monaco.editor.setTheme(theme);
266
+ if (monacoRef.current) {
267
+ monacoRef.current.updateOptions({
268
+ fontSize: editorPrefs.fontSize || 13,
269
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
270
+ tabSize: editorPrefs.tabSize || 4,
271
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
272
+ wordWrap: editorPrefs.wordWrap || 'off',
273
+ lineNumbers: editorPrefs.lineNumbers || 'on',
274
+ renderWhitespace: editorPrefs.renderWhitespace || 'none',
275
+ minimap: { enabled: !!(editorPrefs.minimap) },
276
+ scrollBeyondLastLine: !!(editorPrefs.scrollBeyondLastLine),
277
+ bracketPairColorization: { enabled: editorPrefs.bracketPairColorization !== false }
278
+ });
279
+ }
280
+ }, [editorPrefs]);
281
+
282
+ // Ctrl-hold → column selection mode (like Notepad++)
283
+ useEffect(function () {
284
+ var editor = monacoRef.current;
285
+ if (!editor) return;
286
+ var onKeyDown = function(e) {
287
+ if (e.ctrlKey || e.metaKey) editor.updateOptions({ columnSelection: true });
288
+ };
289
+ var onKeyUp = function(e) {
290
+ if (!e.ctrlKey && !e.metaKey) editor.updateOptions({ columnSelection: false });
291
+ };
292
+ var onBlur = editor.onDidBlurEditorText(function() {
293
+ editor.updateOptions({ columnSelection: false });
294
+ });
295
+ var kdDisposable = editor.onKeyDown(onKeyDown);
296
+ var kuDisposable = editor.onKeyUp(onKeyUp);
297
+ return function() { kdDisposable.dispose(); kuDisposable.dispose(); onBlur.dispose(); };
298
+ }, [tab.id]);
299
+
210
300
  // Jump to line if specified
211
301
  useEffect(function () {
212
302
  if (tab.gotoLine && monacoRef.current) {
@@ -230,8 +320,13 @@ var EditorPanel = function EditorPanel(_ref) {
230
320
  var model = monacoRef.current.getModel();
231
321
  if (model) {
232
322
  var monacoMarkers = markers.map(function (m) {
323
+ var sev = m.severity === 'error'
324
+ ? window.monaco.MarkerSeverity.Error
325
+ : window.monaco.MarkerSeverity.Warning;
233
326
  return {
234
- severity: m.severity === 'error' ? window.monaco.MarkerSeverity.Error : window.monaco.MarkerSeverity.Warning,
327
+ severity: sev,
328
+ source: 'rubocop',
329
+ code: m.copName || '',
235
330
  message: m.message,
236
331
  startLineNumber: m.startLine,
237
332
  startColumn: m.startCol,
@@ -240,19 +335,25 @@ var EditorPanel = function EditorPanel(_ref) {
240
335
  };
241
336
  });
242
337
  window.monaco.editor.setModelMarkers(model, 'rubocop', monacoMarkers);
338
+ // Track which cops are autocorrectable so the quick-fix provider can
339
+ // skip lightbulbs for cops that can never be machine-fixed.
340
+ model._mbeditorCorrectableCops = new Set(
341
+ markers.filter(function(m) { return m.correctable && m.copName; }).map(function(m) { return m.copName; })
342
+ );
243
343
  }
244
344
  }
245
345
  }, [markers, tab.id]);
246
346
 
247
- // Reset blame state when file path changes
347
+ // Reset blame + test decorations when file path changes
248
348
  useEffect(function () {
249
349
  setBlameData(null);
250
350
  setIsBlameLoading(false);
251
351
 
252
- // Clear stale blame render when switching files.
253
352
  if (monacoRef.current && monacoRef.current.getModel()) {
254
353
  clearBlameZones(monacoRef.current);
354
+ clearTestZones(monacoRef.current);
255
355
  blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
356
+ testDecorationIdsRef.current = monacoRef.current.deltaDecorations(testDecorationIdsRef.current, []);
256
357
  }
257
358
  }, [tab.path]);
258
359
 
@@ -368,6 +469,144 @@ var EditorPanel = function EditorPanel(_ref) {
368
469
  // Include tab.content so blame re-renders once async file contents finish loading.
369
470
  }, [blameData, isBlameVisible, tab.id, tab.content]);
370
471
 
472
+ // Check whether the current tab is the source file for the test that was run.
473
+ // e.g. testPanelFile = "test/controllers/theme_controller_test.rb"
474
+ // tab.path = "app/controllers/theme_controller.rb"
475
+ var isSourceForTest = function(tabPath, testFilePath) {
476
+ if (!tabPath || !testFilePath) return false;
477
+ var norm = function(p) { return p.replace(/^\/+/, ''); };
478
+ // Direct match (viewing the test file itself)
479
+ if (norm(tabPath) === norm(testFilePath)) return true;
480
+ // Derive the expected source path from the test file path
481
+ var derived = norm(testFilePath)
482
+ .replace(/^test\//, '').replace(/^spec\//, '')
483
+ .replace(/_test\.rb$/, '.rb').replace(/_spec\.rb$/, '.rb');
484
+ var src = norm(tabPath).replace(/^app\//, '');
485
+ return src === derived;
486
+ };
487
+
488
+ // Map a test method name to the best-matching line in the source file.
489
+ // Extracts keywords from the test name and scores each source line.
490
+ var mapTestToSourceLine = function(testName, sourceContent) {
491
+ var name = (testName || '').replace(/^test_/, '').replace(/^(it |should )/, '');
492
+ var tokens = name.split('_').filter(function(t) { return t.length > 1; });
493
+ if (tokens.length === 0) return 1;
494
+
495
+ var lines = sourceContent.split('\n');
496
+ var bestLine = 1;
497
+ var bestScore = 0;
498
+
499
+ lines.forEach(function(line, idx) {
500
+ var lineNum = idx + 1;
501
+ var lower = line.toLowerCase();
502
+ var score = 0;
503
+ tokens.forEach(function(tok) {
504
+ if (lower.indexOf(tok.toLowerCase()) !== -1) score++;
505
+ });
506
+ // Prefer def/attr/constant lines
507
+ if (score > 0 && (/\bdef\b/.test(lower) || /\battr_/.test(lower) || /^ [A-Z]/.test(line))) {
508
+ score += 0.5;
509
+ }
510
+ if (score > bestScore) { bestScore = score; bestLine = lineNum; }
511
+ });
512
+
513
+ return bestLine;
514
+ };
515
+
516
+ // Render test result annotations above source lines (same pattern as blame zones).
517
+ useEffect(function () {
518
+ if (!monacoRef.current || !window.monaco) return;
519
+
520
+ var editor = monacoRef.current;
521
+ var model = editor.getModel();
522
+ var lineCount = model ? model.getLineCount() : 0;
523
+
524
+ var showHere = testPanelFile && tab.path && isSourceForTest(tab.path, testPanelFile);
525
+
526
+ if (!testResult || !testInlineVisible || !showHere) {
527
+ clearTestZones(editor);
528
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
529
+ return;
530
+ }
531
+
532
+ try {
533
+ clearTestZones(editor);
534
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
535
+
536
+ var normPath = function(p) { return p ? p.replace(/^\/+/, '') : ''; };
537
+ var viewingTestFile = normPath(tab.path) === normPath(testPanelFile);
538
+
539
+ var tests = testResult.tests || [];
540
+ var testsWithStatus = tests.filter(function (t) {
541
+ return t.status === 'pass' || t.status === 'fail' || t.status === 'error';
542
+ });
543
+
544
+ if (testsWithStatus.length === 0) return;
545
+
546
+ // Determine line number for each test
547
+ var sourceContent = tab.content || '';
548
+ var mapped = testsWithStatus.map(function(t) {
549
+ var line;
550
+ if (viewingTestFile && t.line && t.line >= 1 && t.line <= lineCount) {
551
+ line = t.line;
552
+ } else {
553
+ line = mapTestToSourceLine(t.name, sourceContent);
554
+ }
555
+ return { name: t.name, status: t.status, message: t.message, line: line };
556
+ });
557
+
558
+ // Sort by line so zones appear in order
559
+ mapped.sort(function(a, b) { return a.line - b.line; });
560
+
561
+ var zoneIds = [];
562
+ var decorations = [];
563
+
564
+ editor.changeViewZones(function(accessor) {
565
+ mapped.forEach(function(t) {
566
+ if (t.line < 1 || t.line > lineCount) return;
567
+
568
+ var isPassing = t.status === 'pass';
569
+ var icon = isPassing ? '\u2713' : '\u2717';
570
+ var label = icon + ' ' + (t.name || 'Test');
571
+ if (!isPassing && t.message) {
572
+ label += ' \u2014 ' + t.message.split('\n')[0];
573
+ }
574
+
575
+ var header = document.createElement('div');
576
+ header.className = isPassing
577
+ ? 'ide-test-zone-header ide-test-zone-pass'
578
+ : 'ide-test-zone-header ide-test-zone-fail';
579
+ header.textContent = label;
580
+
581
+ var zoneId = accessor.addZone({
582
+ afterLineNumber: t.line > 1 ? t.line - 1 : 0,
583
+ heightInLines: 1,
584
+ domNode: header,
585
+ suppressMouseDown: true
586
+ });
587
+ zoneIds.push(zoneId);
588
+
589
+ decorations.push({
590
+ range: new window.monaco.Range(t.line, 1, t.line, 1),
591
+ options: {
592
+ isWholeLine: true,
593
+ className: isPassing ? 'ide-test-line-pass' : 'ide-test-line-fail',
594
+ stickiness: 1
595
+ }
596
+ });
597
+ });
598
+ });
599
+
600
+ testZoneIdsRef.current = zoneIds;
601
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, decorations);
602
+ } catch (err) {
603
+ clearTestZones(editor);
604
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
605
+ }
606
+
607
+ // Include tab.content so zones re-render once async file content loads (same as blame).
608
+ }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, tab.content]);
609
+
371
610
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
372
611
  var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
373
612
 
@@ -393,12 +632,13 @@ var EditorPanel = function EditorPanel(_ref) {
393
632
  }, [markdownContent, isMarkdown]);
394
633
 
395
634
  if (tab.isDiff) {
396
- var isDiffDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches || true;
635
+ var isDiffDark = (editorPrefs.theme || 'vs-dark') !== 'vs' && (editorPrefs.theme || 'vs-dark') !== 'hc-light';
397
636
  return React.createElement(window.DiffViewer || DiffViewer, {
398
637
  path: tab.repoPath || tab.path,
399
638
  original: tab.diffOriginal || "",
400
639
  modified: tab.diffModified || "",
401
- isDark: isDiffDark
640
+ isDark: isDiffDark,
641
+ editorPrefs: editorPrefs
402
642
  });
403
643
  }
404
644
 
@@ -429,10 +669,21 @@ var EditorPanel = function EditorPanel(_ref) {
429
669
  return React.createElement(
430
670
  'div',
431
671
  { className: 'ide-editor-wrapper', style: { display: 'flex', flexDirection: 'column', height: '100%' } },
432
- gitAvailable && React.createElement(
672
+ (gitAvailable || testAvailable) && React.createElement(
433
673
  'div',
434
- { className: 'ide-editor-toolbar', style: { display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', background: '#252526', borderBottom: '1px solid #3c3c3c' } },
435
- React.createElement(
674
+ { className: 'ide-editor-toolbar', style: { display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', background: '#252526', borderBottom: '1px solid #3c3c3c', gap: '4px' } },
675
+ gitAvailable && tab.path && React.createElement(
676
+ 'button',
677
+ {
678
+ className: 'ide-icon-btn',
679
+ onClick: function() { if (onShowHistory) onShowHistory(tab.path); },
680
+ title: 'File History',
681
+ style: { fontSize: '12px', padding: '2px 6px', opacity: 0.6, background: 'transparent', border: 'none', color: '#ccc', cursor: 'pointer', borderRadius: '3px' }
682
+ },
683
+ React.createElement('i', { className: 'fas fa-history', style: { marginRight: '6px' } }),
684
+ 'History'
685
+ ),
686
+ gitAvailable && React.createElement(
436
687
  'button',
437
688
  {
438
689
  className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
@@ -444,6 +695,18 @@ var EditorPanel = function EditorPanel(_ref) {
444
695
  },
445
696
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: '6px' } }),
446
697
  isBlameLoading ? 'Loading...' : 'Blame'
698
+ ),
699
+ testAvailable && tab.path && tab.path.endsWith('.rb') && React.createElement(
700
+ 'button',
701
+ {
702
+ className: 'ide-icon-btn',
703
+ onClick: function() { if (onRunTest) onRunTest(); },
704
+ disabled: testLoading,
705
+ title: 'Run Tests',
706
+ style: { fontSize: '12px', padding: '2px 6px', opacity: testLoading ? 1 : 0.6, background: 'transparent', border: 'none', color: '#ccc', cursor: testLoading ? 'wait' : 'pointer', borderRadius: '3px' }
707
+ },
708
+ React.createElement('i', { className: testLoading ? 'fas fa-spinner fa-spin' : 'fas fa-flask', style: { marginRight: '6px' } }),
709
+ testLoading ? 'Running...' : 'Test'
447
710
  )
448
711
  ),
449
712
  React.createElement('div', { ref: editorRef, className: 'monaco-container', style: { flex: 1, minHeight: 0 } })
@@ -35,8 +35,12 @@ var FileHistoryPanel = function FileHistoryPanel(_ref) {
35
35
  }, [path]);
36
36
 
37
37
  return React.createElement(
38
+ React.Fragment,
39
+ null,
40
+ React.createElement('div', { className: 'ide-modal-backdrop', onClick: onClose }),
41
+ React.createElement(
38
42
  'div',
39
- { className: 'ide-file-history' },
43
+ { className: 'ide-modal-panel' },
40
44
  React.createElement(
41
45
  'div',
42
46
  { className: 'ide-file-history-header' },
@@ -106,6 +110,7 @@ var FileHistoryPanel = function FileHistoryPanel(_ref) {
106
110
  })
107
111
  )
108
112
  )
113
+ )
109
114
  );
110
115
  };
111
116
 
@@ -39,6 +39,17 @@ var FileTree = function FileTree(_ref) {
39
39
 
40
40
  var inlineRef = useRef(null);
41
41
  var committedRef = useRef(false);
42
+ var containerRef = useRef(null);
43
+
44
+ // Scroll the highlighted node into view when selectedPath changes (e.g. Find in Explorer)
45
+ useEffect(function () {
46
+ if (!selectedPath || !containerRef.current) return;
47
+ var timer = setTimeout(function () {
48
+ var el = containerRef.current && containerRef.current.querySelector('.tree-item.selected');
49
+ if (el) el.scrollIntoView({ block: 'nearest' });
50
+ }, 60);
51
+ return function () { clearTimeout(timer); };
52
+ }, [selectedPath]);
42
53
 
43
54
  var renameSelectionEnd = function renameSelectionEnd(name) {
44
55
  var value = String(name || '');
@@ -278,7 +289,7 @@ var FileTree = function FileTree(_ref) {
278
289
 
279
290
  return React.createElement(
280
291
  'div',
281
- { className: 'file-tree-root' },
292
+ { className: 'file-tree-root', ref: containerRef },
282
293
  renderTree(items, '')
283
294
  );
284
295
  };