mbeditor 0.12.9 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -1
- data/app/assets/javascripts/mbeditor/application.js +6 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +112 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +50 -4
- data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +389 -103
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +155 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +18 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +98 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +73 -66
- data/app/services/mbeditor/search_replace_service.rb +42 -7
- data/app/services/mbeditor/test_runner_service.rb +45 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +4 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +8 -2
|
@@ -23,6 +23,12 @@ module Mbeditor
|
|
|
23
23
|
MAX_RESULTS = 10_000
|
|
24
24
|
RESULT_CACHE_TTL = 30 # seconds; also invalidated on mbeditor file mutations
|
|
25
25
|
RESULT_CACHE_MAX_ENTRIES = 3
|
|
26
|
+
# A user-supplied regex is matched in-process, and Timeout.timeout cannot
|
|
27
|
+
# interrupt a CRuby regex — only Regexp's own timeout (Ruby >= 3.2) can.
|
|
28
|
+
# Below that the gemspec floor (3.0) leaves a catastrophic pattern
|
|
29
|
+
# unguarded, which is the pre-existing behaviour.
|
|
30
|
+
REGEXP_TIMEOUT = 1 # seconds
|
|
31
|
+
REGEXP_TIMEOUT_SUPPORTED = Regexp.method_defined?(:timeout)
|
|
26
32
|
|
|
27
33
|
STATE_MUTEX = Mutex.new
|
|
28
34
|
private_constant :STATE_MUTEX
|
|
@@ -112,6 +118,10 @@ module Mbeditor
|
|
|
112
118
|
else
|
|
113
119
|
@result_cache = {}
|
|
114
120
|
end
|
|
121
|
+
# Bumped so a scan already in flight can tell its results are stale
|
|
122
|
+
# and skip filling the cache it just emptied. One counter for every
|
|
123
|
+
# root: an unrelated invalidation only costs a cache miss.
|
|
124
|
+
@cache_generation = (@cache_generation || 0) + 1
|
|
115
125
|
end
|
|
116
126
|
end
|
|
117
127
|
|
|
@@ -153,7 +163,14 @@ module Mbeditor
|
|
|
153
163
|
content = File.binread(full_path).force_encoding("UTF-8")
|
|
154
164
|
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
155
165
|
replacements_in_file = content.scan(pattern).length
|
|
156
|
-
|
|
166
|
+
# Block form for a literal search: the two-argument gsub expands
|
|
167
|
+
# \1, \& and \\ in the replacement, which a non-regex replace
|
|
168
|
+
# must insert verbatim.
|
|
169
|
+
new_content = if use_regex
|
|
170
|
+
content.gsub(pattern, replacement)
|
|
171
|
+
else
|
|
172
|
+
content.gsub(pattern) { replacement }
|
|
173
|
+
end
|
|
157
174
|
if new_content != content
|
|
158
175
|
File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
|
|
159
176
|
files_affected << rel_path
|
|
@@ -185,10 +202,12 @@ module Mbeditor
|
|
|
185
202
|
key = [workspace_root.to_s, query, use_regex, match_case, whole_word,
|
|
186
203
|
Array(excluded_paths).map(&:to_s).sort]
|
|
187
204
|
now = monotonic
|
|
188
|
-
STATE_MUTEX.synchronize do
|
|
205
|
+
generation = STATE_MUTEX.synchronize do
|
|
189
206
|
@result_cache ||= {}
|
|
190
207
|
entry = @result_cache[key]
|
|
191
208
|
return entry[:data] if entry && (now - entry[:ts]) < RESULT_CACHE_TTL
|
|
209
|
+
|
|
210
|
+
@cache_generation ||= 0
|
|
192
211
|
end
|
|
193
212
|
|
|
194
213
|
data = scan(workspace_root, query, use_regex: use_regex, match_case: match_case,
|
|
@@ -198,6 +217,10 @@ module Mbeditor
|
|
|
198
217
|
# Don't cache superseded scans — their results are truncated by the kill.
|
|
199
218
|
unless data[:superseded]
|
|
200
219
|
STATE_MUTEX.synchronize do
|
|
220
|
+
# A save landed while this scan ran: these rows predate it, and
|
|
221
|
+
# storing them now would serve pre-save matches for a full TTL.
|
|
222
|
+
next if (@cache_generation ||= 0) != generation
|
|
223
|
+
|
|
201
224
|
@result_cache[key] = { ts: monotonic, data: data }
|
|
202
225
|
@result_cache.delete(@result_cache.keys.first) while @result_cache.length > RESULT_CACHE_MAX_ENTRIES
|
|
203
226
|
end
|
|
@@ -370,7 +393,8 @@ module Mbeditor
|
|
|
370
393
|
return {
|
|
371
394
|
file: relative_path(md.dig("path", "text").to_s, root),
|
|
372
395
|
line: md.dig("line_number"),
|
|
373
|
-
text: raw_text.strip
|
|
396
|
+
text: raw_text.strip,
|
|
397
|
+
lead: leading_ws(raw_text)
|
|
374
398
|
}.merge(cols)
|
|
375
399
|
end
|
|
376
400
|
|
|
@@ -389,10 +413,18 @@ module Mbeditor
|
|
|
389
413
|
end
|
|
390
414
|
|
|
391
415
|
raw_text = Regexp.last_match(3)
|
|
392
|
-
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
|
|
416
|
+
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip, lead: leading_ws(raw_text) }
|
|
393
417
|
.merge(match_columns(raw_text, pattern))
|
|
394
418
|
end
|
|
395
419
|
|
|
420
|
+
# How many characters `strip` took off the FRONT of the line. `col`/
|
|
421
|
+
# `end_col` are measured against the raw line (see match_columns), so
|
|
422
|
+
# without this the client cannot say where the match sits inside the
|
|
423
|
+
# trimmed `text` it renders — which is what highlighting needs.
|
|
424
|
+
def leading_ws(raw_text)
|
|
425
|
+
raw_text.length - raw_text.lstrip.length
|
|
426
|
+
end
|
|
427
|
+
|
|
396
428
|
# 1-based Monaco columns for the first match on a hit line. Returns an
|
|
397
429
|
# empty hash when there is no usable pattern or it doesn't match — the
|
|
398
430
|
# row is still a valid result, it just opens at the start of the line.
|
|
@@ -446,11 +478,14 @@ module Mbeditor
|
|
|
446
478
|
|
|
447
479
|
def build_pattern(query, use_regex:, match_case:, whole_word:)
|
|
448
480
|
flags = match_case ? 0 : Regexp::IGNORECASE
|
|
449
|
-
if use_regex
|
|
450
|
-
|
|
481
|
+
source = if use_regex
|
|
482
|
+
whole_word ? "\\b(?:#{query})\\b" : query
|
|
451
483
|
else
|
|
452
|
-
|
|
484
|
+
whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query)
|
|
453
485
|
end
|
|
486
|
+
return Regexp.new(source, flags, timeout: REGEXP_TIMEOUT) if REGEXP_TIMEOUT_SUPPORTED
|
|
487
|
+
|
|
488
|
+
Regexp.new(source, flags)
|
|
454
489
|
end
|
|
455
490
|
|
|
456
491
|
def relative_path(absolute_path, workspace_root)
|
|
@@ -10,6 +10,11 @@ module Mbeditor
|
|
|
10
10
|
# Follows the same process-group kill pattern used by the lint endpoint to
|
|
11
11
|
# enforce a configurable timeout.
|
|
12
12
|
module TestRunnerService
|
|
13
|
+
# Cap on the output shipped to the browser. A verbose run emits megabytes
|
|
14
|
+
# of it, and the tail is the part that matters (the failure list and the
|
|
15
|
+
# summary). Parsing still sees the full output.
|
|
16
|
+
MAX_RAW_BYTES = 256_000
|
|
17
|
+
|
|
13
18
|
module_function
|
|
14
19
|
|
|
15
20
|
# Run the test file at +test_path+ inside +repo_path+.
|
|
@@ -26,13 +31,13 @@ module Mbeditor
|
|
|
26
31
|
|
|
27
32
|
cmd = build_command(repo_path, test_path, framework, command, line: line)
|
|
28
33
|
raw = execute_with_timeout(repo_path, cmd, timeout)
|
|
29
|
-
tests, summary = parse_output(raw, framework)
|
|
34
|
+
tests, summary = parse_output(raw, framework, repo_path: repo_path)
|
|
30
35
|
{
|
|
31
36
|
ok: true,
|
|
32
37
|
framework: framework.to_s,
|
|
33
38
|
summary: summary,
|
|
34
39
|
tests: tests,
|
|
35
|
-
raw: raw
|
|
40
|
+
raw: truncate_raw(raw)
|
|
36
41
|
}
|
|
37
42
|
rescue ProcessRunner::TimeoutError
|
|
38
43
|
error_result("Test run timed out after #{timeout} seconds")
|
|
@@ -176,18 +181,44 @@ module Mbeditor
|
|
|
176
181
|
result[:stdout] + result[:stderr]
|
|
177
182
|
end
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
# Keeps the tail. byteslice can cut a multi-byte character in half, so the
|
|
185
|
+
# result is scrubbed before it goes anywhere near JSON.
|
|
186
|
+
def truncate_raw(raw)
|
|
187
|
+
return raw if raw.bytesize <= MAX_RAW_BYTES
|
|
188
|
+
|
|
189
|
+
raw.byteslice(-MAX_RAW_BYTES, MAX_RAW_BYTES).scrub
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_output(raw, framework, repo_path: nil)
|
|
180
193
|
case framework.to_sym
|
|
181
194
|
when :rspec
|
|
182
|
-
parse_rspec_output(raw)
|
|
195
|
+
parse_rspec_output(raw, repo_path: repo_path)
|
|
183
196
|
when :minitest
|
|
184
|
-
parse_minitest_output(raw)
|
|
197
|
+
parse_minitest_output(raw, repo_path: repo_path)
|
|
185
198
|
else
|
|
186
199
|
[[], empty_summary]
|
|
187
200
|
end
|
|
188
201
|
end
|
|
189
202
|
|
|
190
|
-
|
|
203
|
+
# Failure blocks name an absolute path in some runners and a repo-relative
|
|
204
|
+
# one in others. The editor can only open the relative form, so both are
|
|
205
|
+
# normalized here; anything that escapes the workspace is dropped rather
|
|
206
|
+
# than handed to the client as an unopenable path.
|
|
207
|
+
def relativize(path, repo_path)
|
|
208
|
+
return nil if path.nil? || path.empty?
|
|
209
|
+
|
|
210
|
+
rel = path.to_s
|
|
211
|
+
rel = rel.delete_prefix("./")
|
|
212
|
+
if repo_path
|
|
213
|
+
root = File.join(repo_path.to_s.chomp("/"), "")
|
|
214
|
+
rel = rel.delete_prefix(root)
|
|
215
|
+
end
|
|
216
|
+
return nil if rel.start_with?("/", "../")
|
|
217
|
+
|
|
218
|
+
rel
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def parse_rspec_output(raw, repo_path: nil)
|
|
191
222
|
# RSpec with --format json embeds JSON in the output
|
|
192
223
|
json_match = raw.match(/(\{.*"summary_line".*\})/m)
|
|
193
224
|
if json_match
|
|
@@ -205,18 +236,19 @@ module Mbeditor
|
|
|
205
236
|
name: ex["full_description"] || ex["description"],
|
|
206
237
|
status: ex["status"] == "passed" ? "pass" : (ex["status"] == "pending" ? "skip" : "fail"),
|
|
207
238
|
line: ex.dig("line_number"),
|
|
239
|
+
file: relativize(ex["file_path"], repo_path),
|
|
208
240
|
message: ex.dig("exception", "message")
|
|
209
241
|
}
|
|
210
242
|
end
|
|
211
243
|
[tests, summary]
|
|
212
244
|
else
|
|
213
|
-
parse_minitest_output(raw) # fallback to text parsing
|
|
245
|
+
parse_minitest_output(raw, repo_path: repo_path) # fallback to text parsing
|
|
214
246
|
end
|
|
215
247
|
rescue JSON::ParserError
|
|
216
|
-
parse_minitest_output(raw)
|
|
248
|
+
parse_minitest_output(raw, repo_path: repo_path)
|
|
217
249
|
end
|
|
218
250
|
|
|
219
|
-
def parse_minitest_output(raw)
|
|
251
|
+
def parse_minitest_output(raw, repo_path: nil)
|
|
220
252
|
lines = raw.lines
|
|
221
253
|
|
|
222
254
|
# First pass: collect per-test results from verbose output.
|
|
@@ -232,7 +264,7 @@ module Mbeditor
|
|
|
232
264
|
when "E" then "error"
|
|
233
265
|
when "S" then "skip"
|
|
234
266
|
end
|
|
235
|
-
verbose_results[m[1]] = { name: m[1], status: status, line: nil, message: nil }
|
|
267
|
+
verbose_results[m[1]] = { name: m[1], status: status, line: nil, file: nil, message: nil }
|
|
236
268
|
end
|
|
237
269
|
|
|
238
270
|
# Second pass: parse failure/error blocks for messages and line numbers.
|
|
@@ -246,6 +278,7 @@ module Mbeditor
|
|
|
246
278
|
|
|
247
279
|
name = name_line.strip.split(" [").first.chomp(":")
|
|
248
280
|
line_num = name_line[/:(\d+)\]/, 1]&.to_i
|
|
281
|
+
file = relativize(name_line[/\[([^\]]+):\d+\]/, 1], repo_path)
|
|
249
282
|
msg_lines = []
|
|
250
283
|
(idx + 2...lines.length).each do |j|
|
|
251
284
|
break if lines[j].strip.empty? || lines[j].match?(/^\s+\d+\)\s+/)
|
|
@@ -256,11 +289,13 @@ module Mbeditor
|
|
|
256
289
|
name: name,
|
|
257
290
|
status: line.include?("Error") ? "error" : "fail",
|
|
258
291
|
line: line_num,
|
|
292
|
+
file: file,
|
|
259
293
|
message: msg_lines.join("\n")
|
|
260
294
|
}
|
|
261
295
|
|
|
262
296
|
if verbose_results.key?(name)
|
|
263
297
|
verbose_results[name][:line] = line_num
|
|
298
|
+
verbose_results[name][:file] = file
|
|
264
299
|
verbose_results[name][:message] = msg_lines.join("\n")
|
|
265
300
|
else
|
|
266
301
|
failure_entries << entry
|
|
@@ -62,8 +62,14 @@ module Mbeditor
|
|
|
62
62
|
|
|
63
63
|
%w[debug info warn error fatal unknown].each do |level|
|
|
64
64
|
define_method(level) do |message = nil, &block|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
# Evaluate the block exactly once. It used to be called for the
|
|
66
|
+
# suppression check and then handed to super as well, so every message
|
|
67
|
+
# that got through was built twice.
|
|
68
|
+
if message.nil? && block
|
|
69
|
+
message = block.call
|
|
70
|
+
block = nil
|
|
71
|
+
end
|
|
72
|
+
return if suppress_message?(message.to_s)
|
|
67
73
|
|
|
68
74
|
super(message, &block)
|
|
69
75
|
end
|
|
@@ -27,7 +27,10 @@ module Mbeditor
|
|
|
27
27
|
@redmine_ticket_source = :commit # :commit (scan commit messages) or :branch (leading digits of branch name)
|
|
28
28
|
@test_framework = nil # :minitest or :rspec — auto-detected when nil
|
|
29
29
|
@test_command = nil # e.g. "bundle exec ruby -Itest" or "bundle exec rspec"
|
|
30
|
-
|
|
30
|
+
# Seconds. 60 was not enough for a real app: `bin/rails test` spends most
|
|
31
|
+
# of it booting Rails before the first assertion runs, so a perfectly
|
|
32
|
+
# healthy single-file run reported "timed out".
|
|
33
|
+
@test_timeout = 180
|
|
31
34
|
@lint_timeout = 15 # seconds for RuboCop/haml-lint subprocesses
|
|
32
35
|
@base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
|
|
33
36
|
@git_timeout = 10 # seconds; nil disables (no timeout on git subprocesses)
|
|
@@ -55,27 +55,33 @@ module Mbeditor
|
|
|
55
55
|
|
|
56
56
|
window.loadPrettierPlugins = function() {
|
|
57
57
|
if (window._prettierLoadPromise) return window._prettierLoadPromise;
|
|
58
|
-
|
|
58
|
+
var promise = new Promise(function(resolve, reject) {
|
|
59
59
|
var savedDefine = window.define;
|
|
60
60
|
window.define = undefined;
|
|
61
61
|
var pending = prettierScripts.length;
|
|
62
|
+
var failed = null;
|
|
63
|
+
// Restore window.define only once every script has settled: an
|
|
64
|
+
// early restore let a sibling still in flight register as an AMD
|
|
65
|
+
// module instead of setting its window global.
|
|
66
|
+
function settle() {
|
|
67
|
+
if (--pending > 0) return;
|
|
68
|
+
window.define = savedDefine;
|
|
69
|
+
if (failed) reject(new Error('Failed to load Prettier: ' + failed));
|
|
70
|
+
else resolve();
|
|
71
|
+
}
|
|
62
72
|
prettierScripts.forEach(function(src) {
|
|
63
73
|
var s = document.createElement('script');
|
|
64
74
|
s.src = src;
|
|
65
|
-
s.onload =
|
|
66
|
-
|
|
67
|
-
window.define = savedDefine;
|
|
68
|
-
resolve();
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
s.onerror = function() {
|
|
72
|
-
window.define = savedDefine;
|
|
73
|
-
reject(new Error('Failed to load Prettier: ' + src));
|
|
74
|
-
};
|
|
75
|
+
s.onload = settle;
|
|
76
|
+
s.onerror = function() { failed = failed || src; settle(); };
|
|
75
77
|
document.head.appendChild(s);
|
|
76
78
|
});
|
|
77
79
|
});
|
|
78
|
-
|
|
80
|
+
window._prettierLoadPromise = promise;
|
|
81
|
+
// Clear the cached promise so the next Format action retries the
|
|
82
|
+
// load; caching a rejection disabled Format for the whole session.
|
|
83
|
+
promise.catch(function() { window._prettierLoadPromise = null; });
|
|
84
|
+
return promise;
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
// Lazy-load monaco-vim on first use. Drop-in for the old AMD
|
data/lib/mbeditor/engine.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "mbeditor/rack/silence_ping_request"
|
|
4
4
|
require "mbeditor/rack/handle_pending_migrations"
|
|
5
|
+
require "mbeditor/rack/pending_migration_bypass"
|
|
5
6
|
require "mbeditor/rack/resilient_router"
|
|
6
7
|
require "mbeditor/cable_log_filter"
|
|
7
8
|
|
|
@@ -71,7 +72,24 @@ module Mbeditor
|
|
|
71
72
|
app.config.active_record[:migration_error]
|
|
72
73
|
|
|
73
74
|
if defined?(ActiveRecord::Migration::CheckPending) && migration_error == :page_load
|
|
74
|
-
|
|
75
|
+
# Two layers, and both earn their place.
|
|
76
|
+
#
|
|
77
|
+
# The swap is the actual fix: CheckPending is replaced by a wrapper that
|
|
78
|
+
# runs the identical check for the host's own traffic, but records
|
|
79
|
+
# rather than raises for the editor's — so a pending migration no longer
|
|
80
|
+
# blocks the tree, opening a file, saving one, or loading the assets the
|
|
81
|
+
# page needs. Without it the editor was unusable exactly when it was
|
|
82
|
+
# needed most.
|
|
83
|
+
app.middleware.swap ActiveRecord::Migration::CheckPending,
|
|
84
|
+
Mbeditor::Rack::PendingMigrationBypass,
|
|
85
|
+
file_watcher: app.config.file_watcher
|
|
86
|
+
|
|
87
|
+
# HandlePendingMigrations stays above it as a backstop for a
|
|
88
|
+
# PendingMigrationError raised from anywhere else in the stack (an
|
|
89
|
+
# engine or initializer that checks on its own). It should now be
|
|
90
|
+
# unreachable for the ordinary case; it is cheap, and being locked out
|
|
91
|
+
# of the editor is the failure worth two guards.
|
|
92
|
+
app.middleware.insert_before Mbeditor::Rack::PendingMigrationBypass,
|
|
75
93
|
Mbeditor::Rack::HandlePendingMigrations
|
|
76
94
|
end
|
|
77
95
|
end
|
|
@@ -156,8 +174,9 @@ module Mbeditor
|
|
|
156
174
|
|
|
157
175
|
@exception_subscriber = ActiveSupport::Notifications.subscribe(
|
|
158
176
|
"process_action.action_controller"
|
|
159
|
-
) do
|
|
160
|
-
|
|
177
|
+
) do |_name, _started, _finished, _id, payload|
|
|
178
|
+
# 5-arity: the block runs for every host request, and building an Event
|
|
179
|
+
# just to read its payload allocated one per request for nothing.
|
|
161
180
|
exception = payload[:exception_object]
|
|
162
181
|
next unless exception
|
|
163
182
|
|
|
@@ -48,10 +48,10 @@ module Mbeditor
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
50
|
def build(exception, payload, workspace_root)
|
|
51
|
-
@seq = (@seq || 0) + 1
|
|
51
|
+
seq = MUTEX.synchronize { @seq = (@seq || 0) + 1 }
|
|
52
52
|
{
|
|
53
53
|
type: "exception",
|
|
54
|
-
id:
|
|
54
|
+
id: seq,
|
|
55
55
|
at: Time.now.utc.iso8601,
|
|
56
56
|
klass: exception.class.name,
|
|
57
57
|
message: exception.message.to_s[0, 2000],
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# The last pending-migration message seen, so the editor can warn about it
|
|
5
|
+
# without being blocked by it.
|
|
6
|
+
#
|
|
7
|
+
# Lives in lib/ (not autoloaded) for the same reason ExceptionLog does: a
|
|
8
|
+
# Zeitwerk reload must not wipe it, and a pending migration is exactly the
|
|
9
|
+
# state in which reloads are happening.
|
|
10
|
+
module PendingMigrations
|
|
11
|
+
MUTEX = Mutex.new
|
|
12
|
+
private_constant :MUTEX
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# message: the PendingMigrationError's message, or nil once it clears.
|
|
17
|
+
def note(message)
|
|
18
|
+
MUTEX.synchronize { @message = message && message.to_s.strip }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clear
|
|
22
|
+
MUTEX.synchronize { @message = nil }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def message
|
|
26
|
+
MUTEX.synchronize { @message }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pending?
|
|
30
|
+
!message.nil?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -18,7 +18,7 @@ module Mbeditor
|
|
|
18
18
|
raise unless defined?(ActiveRecord::PendingMigrationError) && e.is_a?(ActiveRecord::PendingMigrationError)
|
|
19
19
|
|
|
20
20
|
path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
|
|
21
|
-
raise unless path.start_with?(
|
|
21
|
+
raise unless path.start_with?(Mbeditor::MountPath.resolve)
|
|
22
22
|
|
|
23
23
|
if env["HTTP_X_MBEDITOR_CLIENT"] == "1"
|
|
24
24
|
# XHR from the editor frontend — structured JSON error.
|
|
@@ -30,13 +30,23 @@ module Mbeditor
|
|
|
30
30
|
# files. Assets are referenced by their unfingerprinted paths, which
|
|
31
31
|
# Sprockets resolves in development (the only env mbeditor runs in).
|
|
32
32
|
# The banner appears as soon as the first XHR fires.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
#
|
|
34
|
+
# The base comes from MountPath, not SCRIPT_NAME: this middleware runs
|
|
35
|
+
# above the router, so SCRIPT_NAME is still "" and the engine-served
|
|
36
|
+
# URLs in the shell (/monaco-editor/...) were emitted without the
|
|
37
|
+
# mount prefix and 404'd.
|
|
38
|
+
[200, { "Content-Type" => "text/html; charset=utf-8" }, [editor_shell_html(mount_base)]]
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
private
|
|
39
43
|
|
|
44
|
+
def mount_base
|
|
45
|
+
Mbeditor::MountPath.resolve.to_s.sub(%r{/$}, "")
|
|
46
|
+
rescue StandardError
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def editor_shell_html(base)
|
|
41
51
|
prettier_script_urls = %w[
|
|
42
52
|
prettier-standalone.js
|
|
@@ -45,14 +55,14 @@ module Mbeditor
|
|
|
45
55
|
prettier-plugin-html.js
|
|
46
56
|
prettier-plugin-postcss.js
|
|
47
57
|
prettier-plugin-markdown.js
|
|
48
|
-
].map { |f| "
|
|
58
|
+
].map { |f| "/assets/#{f}" }
|
|
49
59
|
|
|
50
60
|
# Bootstrap JS shared with the standard layout — see Mbeditor::EditorBootstrap
|
|
51
61
|
setup_js = Mbeditor::EditorBootstrap.setup_js(base)
|
|
52
62
|
boot_js = Mbeditor::EditorBootstrap.boot_js(
|
|
53
63
|
base: base,
|
|
54
64
|
prettier_script_urls: prettier_script_urls,
|
|
55
|
-
application_js_url: "
|
|
65
|
+
application_js_url: "/assets/mbeditor/application.js"
|
|
56
66
|
)
|
|
57
67
|
|
|
58
68
|
<<~HTML
|
|
@@ -62,16 +72,16 @@ module Mbeditor
|
|
|
62
72
|
<meta charset="UTF-8" />
|
|
63
73
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
64
74
|
<title>Mbeditor</title>
|
|
65
|
-
<link rel="stylesheet" href="
|
|
66
|
-
<link rel="stylesheet" href="
|
|
67
|
-
<script defer src="
|
|
68
|
-
<script defer src="
|
|
69
|
-
<script defer src="
|
|
70
|
-
<script defer src="
|
|
71
|
-
<script defer src="
|
|
72
|
-
<script defer src="
|
|
73
|
-
<script defer src="
|
|
74
|
-
<script defer src="
|
|
75
|
+
<link rel="stylesheet" href="/assets/fontawesome.min.css" />
|
|
76
|
+
<link rel="stylesheet" href="/assets/mbeditor/application.css" />
|
|
77
|
+
<script defer src="/assets/react.min.js"></script>
|
|
78
|
+
<script defer src="/assets/react-dom.min.js"></script>
|
|
79
|
+
<script defer src="/assets/axios.min.js"></script>
|
|
80
|
+
<script defer src="/assets/lodash.min.js"></script>
|
|
81
|
+
<script defer src="/assets/minisearch.min.js"></script>
|
|
82
|
+
<script defer src="/assets/marked.min.js"></script>
|
|
83
|
+
<script defer src="/assets/emmet.js"></script>
|
|
84
|
+
<script defer src="/assets/monaco-themes-bundle.js"></script>
|
|
75
85
|
<link rel="stylesheet" href="#{base}/monaco-editor/monaco.css" />
|
|
76
86
|
</head>
|
|
77
87
|
<body>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mbeditor/pending_migrations"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
module Rack
|
|
7
|
+
# Wraps ActiveRecord::Migration::CheckPending so a pending migration cannot
|
|
8
|
+
# lock the developer out of the one tool they need to fix it.
|
|
9
|
+
#
|
|
10
|
+
# Rails inserts CheckPending after ActionDispatch::Callbacks whenever
|
|
11
|
+
# config.active_record.migration_error is :page_load — the default a real
|
|
12
|
+
# `rails new` app carries in development. From then until the migration is
|
|
13
|
+
# run, EVERY request raises PendingMigrationError, and that included all of
|
|
14
|
+
# mbeditor's: the file tree, opening a file, and saving one. The editor was
|
|
15
|
+
# unusable in precisely the situation it exists to get you out of, and the
|
|
16
|
+
# migration could not be edited to fix it.
|
|
17
|
+
#
|
|
18
|
+
# An earlier attempt rescued the error above CheckPending and answered
|
|
19
|
+
# mbeditor's own paths with a fallback shell and a 503 for every XHR. That
|
|
20
|
+
# left the shell without its stylesheets or JavaScript — those live under
|
|
21
|
+
# /assets, which is not an mbeditor path, so they still 500'd — and even
|
|
22
|
+
# once loaded every API call was a 503. Rescuing after the fact was the
|
|
23
|
+
# wrong shape; the check simply must not apply to this traffic.
|
|
24
|
+
#
|
|
25
|
+
# So the check still runs (it is file-watcher backed and cheap after the
|
|
26
|
+
# first call, and non-editor traffic must keep Rails' behaviour exactly),
|
|
27
|
+
# but for editor traffic the error is recorded and swallowed rather than
|
|
28
|
+
# raised, and the request proceeds. CheckPending raises before it calls
|
|
29
|
+
# anything downstream, so dispatching afterwards runs the request once, not
|
|
30
|
+
# twice.
|
|
31
|
+
#
|
|
32
|
+
# The recorded message is what surfaces the warning banner, so the developer
|
|
33
|
+
# is still told to migrate — they are just no longer prevented from working.
|
|
34
|
+
class PendingMigrationBypass
|
|
35
|
+
DEFAULT_ASSET_PREFIX = "/assets"
|
|
36
|
+
|
|
37
|
+
def initialize(app, file_watcher: nil)
|
|
38
|
+
@app = app
|
|
39
|
+
@checked =
|
|
40
|
+
if file_watcher
|
|
41
|
+
ActiveRecord::Migration::CheckPending.new(app, file_watcher: file_watcher)
|
|
42
|
+
else
|
|
43
|
+
ActiveRecord::Migration::CheckPending.new(app)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Announced on every bypassed response so the editor can warn about a
|
|
48
|
+
# migration created mid-session, not only one that existed at page load.
|
|
49
|
+
PENDING_HEADER = "X-Mbeditor-Pending-Migration"
|
|
50
|
+
|
|
51
|
+
def call(env)
|
|
52
|
+
return @checked.call(env) unless editor_traffic?(env)
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
result = @checked.call(env)
|
|
56
|
+
# The check passed, so anything recorded earlier is stale.
|
|
57
|
+
Mbeditor::PendingMigrations.clear
|
|
58
|
+
result
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
raise unless pending_migration_error?(e)
|
|
61
|
+
|
|
62
|
+
Mbeditor::PendingMigrations.note(e.message)
|
|
63
|
+
status, headers, body = @app.call(env)
|
|
64
|
+
headers[PENDING_HEADER] = header_safe(e.message)
|
|
65
|
+
[status, headers, body]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# A header cannot carry the newlines the error message has, and it does
|
|
72
|
+
# not need the whole thing — the banner supplies the instructions.
|
|
73
|
+
def header_safe(message)
|
|
74
|
+
message.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def pending_migration_error?(error)
|
|
78
|
+
defined?(ActiveRecord::PendingMigrationError) &&
|
|
79
|
+
error.is_a?(ActiveRecord::PendingMigrationError)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Everything the editor needs in order to load and function: its own mount
|
|
83
|
+
# and the assets its page pulls in. Asset requests are included because
|
|
84
|
+
# the shell is inert without them — and because serving a stylesheet has
|
|
85
|
+
# never depended on the schema being current.
|
|
86
|
+
def editor_traffic?(env)
|
|
87
|
+
path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
|
|
88
|
+
prefix = Mbeditor::MountPath.resolve
|
|
89
|
+
return true if path == prefix || path.start_with?("#{prefix}/")
|
|
90
|
+
|
|
91
|
+
path.start_with?("#{asset_prefix}/")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def asset_prefix
|
|
95
|
+
@asset_prefix ||= begin
|
|
96
|
+
configured = Rails.application.config.assets.prefix if Rails.application.config.respond_to?(:assets)
|
|
97
|
+
(configured.presence || DEFAULT_ASSET_PREFIX).to_s.chomp("/")
|
|
98
|
+
rescue StandardError
|
|
99
|
+
DEFAULT_ASSET_PREFIX
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -35,7 +35,15 @@ module Mbeditor
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
def mbeditor_request?(path)
|
|
38
|
-
path.start_with?("/
|
|
38
|
+
path.start_with?("#{mount_prefix}/")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The mount is configurable, so the prefix cannot be hardcoded — a host
|
|
42
|
+
# that mounts the engine elsewhere logged every editor request in full.
|
|
43
|
+
def mount_prefix
|
|
44
|
+
Mbeditor::MountPath.resolve.chomp("/")
|
|
45
|
+
rescue StandardError
|
|
46
|
+
Mbeditor::MountPath::DEFAULT
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def cable_request?(path)
|
|
@@ -53,7 +61,7 @@ module Mbeditor
|
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def root_request?(env, path)
|
|
56
|
-
env["REQUEST_METHOD"] == "GET" && path ==
|
|
64
|
+
env["REQUEST_METHOD"] == "GET" && path == mount_prefix
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
def normalized_request_path(env)
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Mbeditor
|
|
|
55
55
|
post 'lint', to: 'editors#lint'
|
|
56
56
|
post 'quick_fix', to: 'editors#quick_fix'
|
|
57
57
|
post 'format', to: 'editors#format_file'
|
|
58
|
+
post 'rubocop', to: 'editors#rubocop_run'
|
|
58
59
|
post 'test', to: 'editors#run_test'
|
|
59
60
|
get 'logs/tail', to: 'logs#tail'
|
|
60
61
|
|