mbeditor 0.1.6 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 706d2307424a25e3a1b19683326d62de74d98568f755971d6eeb592ad5bae1a4
4
- data.tar.gz: 8cfb289f2cc0fd18eb2e8c55ae3a2f766712d68231e695b2e02a1e7778207c2d
3
+ metadata.gz: 2f9731ab7001f6b719f901bfa718ca47ae3ea9d38134a03a3602bdf31bcb6100
4
+ data.tar.gz: 8883cc001b712a8ce976a3bdd4b639e6c92fab9db54b1e1f0950204c104a2975
5
5
  SHA512:
6
- metadata.gz: 8b691b32511249206e9af0e7ceeb6b26f9cf5bf63cd4b035e8b7fa9411ba05480b4c8795cdcf7a20ffefd536803a61e79c84edb7874fad7d7308aa4dca622b2f
7
- data.tar.gz: 41c25d7ad6113629fd50267ceff4e9f07490627f9fc2a8eba2fc2804743e8ba7e3c3c6bedc30c4a225e6a177c3354b1282a88df874086bf2e969bced2d20ea65
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.
@@ -53,6 +54,11 @@ Mbeditor.configure do |config|
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,10 +72,32 @@ 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
+
73
101
  ## Keyboard Shortcuts
74
102
 
75
103
  | Shortcut | Action |
@@ -86,8 +114,9 @@ The gem keeps host/tooling responsibilities in the host app:
86
114
  - `rubocop` and `rubocop-rails` gems (optional, required for Ruby lint/format endpoints)
87
115
  - `haml_lint` gem (optional, required for HAML lint — add to your app's Gemfile if needed)
88
116
  - `git` installed in environment (for Git panel data)
117
+ - `minitest` or `rspec` in the host app's bundle (required for the test runner)
89
118
 
90
- 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.
91
120
 
92
121
  ### Syntax Highlighting Support
93
122
  Monaco runtime assets are served from the engine route namespace (`/mbeditor/monaco-editor/*` and `/mbeditor/monaco_worker.js`).
@@ -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,7 +15,14 @@ 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;
18
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;
19
26
  var editorPrefs = _ref.editorPrefs || {};
20
27
 
21
28
  var editorRef = useRef(null);
@@ -44,10 +51,28 @@ var EditorPanel = function EditorPanel(_ref) {
44
51
 
45
52
  var blameDecorationsRef = useRef([]);
46
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];
47
61
 
48
62
  var onFormatRef = useRef(onFormat);
49
63
  onFormatRef.current = onFormat;
50
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
+ };
75
+
51
76
  var clearBlameZones = function clearBlameZones(editor) {
52
77
  if (!editor) return;
53
78
  if (blameZoneIdsRef.current.length === 0) return;
@@ -124,14 +149,17 @@ var EditorPanel = function EditorPanel(_ref) {
124
149
  language: language,
125
150
  theme: editorPrefs.theme || 'vs-dark',
126
151
  automaticLayout: true,
127
- minimap: { enabled: false },
152
+ minimap: { enabled: !!(editorPrefs.minimap) },
128
153
  renderLineHighlight: 'none',
129
- bracketPairColorization: { enabled: true },
154
+ bracketPairColorization: { enabled: editorPrefs.bracketPairColorization !== false },
130
155
  fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
131
156
  fontSize: editorPrefs.fontSize || 13,
132
157
  tabSize: editorPrefs.tabSize || 4,
133
158
  insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
134
- wordWrap: 'on',
159
+ wordWrap: editorPrefs.wordWrap || 'off',
160
+ lineNumbers: editorPrefs.lineNumbers || 'on',
161
+ renderWhitespace: editorPrefs.renderWhitespace || 'none',
162
+ scrollBeyondLastLine: !!(editorPrefs.scrollBeyondLastLine),
135
163
  linkedEditing: true, // Enables Auto-Rename Tag natively!
136
164
  fixedOverflowWidgets: true,
137
165
  hover: { above: false },
@@ -148,6 +176,7 @@ var EditorPanel = function EditorPanel(_ref) {
148
176
 
149
177
  monacoRef.current = editor;
150
178
  window.__mbeditorActiveEditor = editor;
179
+ setEditorReady(true);
151
180
 
152
181
  // Stash the workspace-relative path on the model so code-action providers
153
182
  // can identify which file they are operating on without needing React state.
@@ -184,7 +213,9 @@ var EditorPanel = function EditorPanel(_ref) {
184
213
 
185
214
  return function () {
186
215
  blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
216
+ testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
187
217
  clearBlameZones(editor);
218
+ clearTestZones(editor);
188
219
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
189
220
  if (window.__mbeditorActiveEditor === editor) {
190
221
  window.__mbeditorActiveEditor = null;
@@ -237,11 +268,35 @@ var EditorPanel = function EditorPanel(_ref) {
237
268
  fontSize: editorPrefs.fontSize || 13,
238
269
  fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
239
270
  tabSize: editorPrefs.tabSize || 4,
240
- insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false
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 }
241
278
  });
242
279
  }
243
280
  }, [editorPrefs]);
244
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
+
245
300
  // Jump to line if specified
246
301
  useEffect(function () {
247
302
  if (tab.gotoLine && monacoRef.current) {
@@ -289,15 +344,16 @@ var EditorPanel = function EditorPanel(_ref) {
289
344
  }
290
345
  }, [markers, tab.id]);
291
346
 
292
- // Reset blame state when file path changes
347
+ // Reset blame + test decorations when file path changes
293
348
  useEffect(function () {
294
349
  setBlameData(null);
295
350
  setIsBlameLoading(false);
296
351
 
297
- // Clear stale blame render when switching files.
298
352
  if (monacoRef.current && monacoRef.current.getModel()) {
299
353
  clearBlameZones(monacoRef.current);
354
+ clearTestZones(monacoRef.current);
300
355
  blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
356
+ testDecorationIdsRef.current = monacoRef.current.deltaDecorations(testDecorationIdsRef.current, []);
301
357
  }
302
358
  }, [tab.path]);
303
359
 
@@ -413,6 +469,144 @@ var EditorPanel = function EditorPanel(_ref) {
413
469
  // Include tab.content so blame re-renders once async file contents finish loading.
414
470
  }, [blameData, isBlameVisible, tab.id, tab.content]);
415
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
+
416
610
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
417
611
  var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
418
612
 
@@ -443,7 +637,8 @@ var EditorPanel = function EditorPanel(_ref) {
443
637
  path: tab.repoPath || tab.path,
444
638
  original: tab.diffOriginal || "",
445
639
  modified: tab.diffModified || "",
446
- isDark: isDiffDark
640
+ isDark: isDiffDark,
641
+ editorPrefs: editorPrefs
447
642
  });
448
643
  }
449
644
 
@@ -474,10 +669,21 @@ var EditorPanel = function EditorPanel(_ref) {
474
669
  return React.createElement(
475
670
  'div',
476
671
  { className: 'ide-editor-wrapper', style: { display: 'flex', flexDirection: 'column', height: '100%' } },
477
- gitAvailable && React.createElement(
672
+ (gitAvailable || testAvailable) && React.createElement(
478
673
  'div',
479
- { className: 'ide-editor-toolbar', style: { display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', background: '#252526', borderBottom: '1px solid #3c3c3c' } },
480
- 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(
481
687
  'button',
482
688
  {
483
689
  className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
@@ -489,6 +695,18 @@ var EditorPanel = function EditorPanel(_ref) {
489
695
  },
490
696
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: '6px' } }),
491
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'
492
710
  )
493
711
  ),
494
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
  };