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.
- checksums.yaml +4 -4
- data/README.md +46 -2
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +14 -2
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +281 -18
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -1
- data/app/assets/javascripts/mbeditor/components/FileTree.js +12 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +94 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +403 -17
- data/app/assets/javascripts/mbeditor/components/TabBar.js +72 -3
- data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +49 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
- data/app/assets/javascripts/mbeditor/file_service.js +11 -1
- data/app/assets/javascripts/mbeditor/search_service.js +8 -4
- data/app/assets/stylesheets/mbeditor/application.css +148 -11
- data/app/assets/stylesheets/mbeditor/editor.css +281 -0
- data/app/controllers/mbeditor/application_controller.rb +1 -1
- data/app/controllers/mbeditor/editors_controller.rb +164 -22
- data/app/controllers/mbeditor/git_controller.rb +24 -10
- data/app/services/mbeditor/git_diff_service.rb +3 -0
- data/app/services/mbeditor/git_service.rb +15 -3
- data/app/services/mbeditor/test_runner_service.rb +259 -0
- data/app/views/layouts/mbeditor/application.html.erb +5 -1
- data/config/routes.rb +2 -0
- data/lib/mbeditor/configuration.rb +5 -1
- data/lib/mbeditor/rack/silence_ping_request.rb +32 -6
- data/lib/mbeditor/version.rb +1 -1
- data/public/ts_worker.js +5 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f9731ab7001f6b719f901bfa718ca47ae3ea9d38134a03a3602bdf31bcb6100
|
|
4
|
+
data.tar.gz: 8883cc001b712a8ce976a3bdd4b639e6c92fab9db54b1e1f0950204c104a2975
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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:
|
|
152
|
+
minimap: { enabled: !!(editorPrefs.minimap) },
|
|
122
153
|
renderLineHighlight: 'none',
|
|
123
|
-
bracketPairColorization: { enabled:
|
|
124
|
-
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
|
|
125
|
-
fontSize: 13,
|
|
126
|
-
tabSize: 4,
|
|
127
|
-
insertSpaces:
|
|
128
|
-
wordWrap: '
|
|
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 =
|
|
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)
|
|
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:
|
|
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
|
|
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 =
|
|
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-
|
|
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
|
};
|