mbeditor 0.12.8 → 0.13.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +135 -40
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +302 -55
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +75 -10
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
@@ -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 whole-suite run emits
14
+ # megabytes of it, and the tail is the part that matters (the failure list
15
+ # and the 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")
@@ -40,6 +45,59 @@ module Mbeditor
40
45
  error_result(e.message)
41
46
  end
42
47
 
48
+ # Run the whole suite in +repo_path+ — no file argument, so the framework's
49
+ # own default target applies (test/ for Rails, spec/ for RSpec).
50
+ #
51
+ # Same return shape as +run+, so the panel renders one result type. The
52
+ # framework is detected from the project rather than from a filename,
53
+ # since there isn't one.
54
+ def run_all(repo_path, framework: nil, command: nil, timeout: 1800)
55
+ framework = detect_suite_framework(repo_path) if framework.nil?
56
+ return error_result("Could not detect test framework") unless framework
57
+
58
+ cmd = build_suite_command(repo_path, framework, command)
59
+ raw = execute_with_timeout(repo_path, cmd, timeout)
60
+ tests, summary = parse_output(raw, framework, repo_path: repo_path)
61
+ {
62
+ ok: true,
63
+ framework: framework.to_s,
64
+ summary: summary,
65
+ tests: tests,
66
+ raw: truncate_raw(raw)
67
+ }
68
+ rescue ProcessRunner::TimeoutError
69
+ error_result("Test run timed out after #{timeout} seconds")
70
+ rescue StandardError => e
71
+ error_result(e.message)
72
+ end
73
+
74
+ # No test_path to go on, so this reads the project layout only. RSpec wins
75
+ # a tie: a project with both usually keeps `test/` for legacy fixtures.
76
+ def detect_suite_framework(repo_path)
77
+ return :rspec if File.exist?(File.join(repo_path, ".rspec"))
78
+ return :rspec if File.directory?(File.join(repo_path, "spec"))
79
+ return :minitest if File.directory?(File.join(repo_path, "test"))
80
+
81
+ nil
82
+ end
83
+
84
+ def build_suite_command(repo_path, framework, custom_command)
85
+ return Shellwords.split(custom_command) if custom_command.present?
86
+
87
+ case framework.to_sym
88
+ when :rspec
89
+ bin = File.join(repo_path, "bin", "rspec")
90
+ (File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"]) + ["--format", "json"]
91
+ else
92
+ bin = File.join(repo_path, "bin", "rails")
93
+ # `bin/rails test` with no path runs the default suite. Without it,
94
+ # `rake test` is the portable fallback for a non-Rails project.
95
+ return [bin, "test", "--verbose"] if File.exist?(bin)
96
+
97
+ ["bundle", "exec", "rake", "test"]
98
+ end
99
+ end
100
+
43
101
  # Given a source file path, resolve it to its matching test/spec file.
44
102
  # If the file is already a test/spec file, return it as-is.
45
103
  def resolve_test_file(repo_path, relative_path)
@@ -176,18 +234,44 @@ module Mbeditor
176
234
  result[:stdout] + result[:stderr]
177
235
  end
178
236
 
179
- def parse_output(raw, framework)
237
+ # Keeps the tail. byteslice can cut a multi-byte character in half, so the
238
+ # result is scrubbed before it goes anywhere near JSON.
239
+ def truncate_raw(raw)
240
+ return raw if raw.bytesize <= MAX_RAW_BYTES
241
+
242
+ raw.byteslice(-MAX_RAW_BYTES, MAX_RAW_BYTES).scrub
243
+ end
244
+
245
+ def parse_output(raw, framework, repo_path: nil)
180
246
  case framework.to_sym
181
247
  when :rspec
182
- parse_rspec_output(raw)
248
+ parse_rspec_output(raw, repo_path: repo_path)
183
249
  when :minitest
184
- parse_minitest_output(raw)
250
+ parse_minitest_output(raw, repo_path: repo_path)
185
251
  else
186
252
  [[], empty_summary]
187
253
  end
188
254
  end
189
255
 
190
- def parse_rspec_output(raw)
256
+ # Failure blocks name an absolute path in some runners and a repo-relative
257
+ # one in others. The editor can only open the relative form, so both are
258
+ # normalized here; anything that escapes the workspace is dropped rather
259
+ # than handed to the client as an unopenable path.
260
+ def relativize(path, repo_path)
261
+ return nil if path.nil? || path.empty?
262
+
263
+ rel = path.to_s
264
+ rel = rel.delete_prefix("./")
265
+ if repo_path
266
+ root = File.join(repo_path.to_s.chomp("/"), "")
267
+ rel = rel.delete_prefix(root)
268
+ end
269
+ return nil if rel.start_with?("/", "../")
270
+
271
+ rel
272
+ end
273
+
274
+ def parse_rspec_output(raw, repo_path: nil)
191
275
  # RSpec with --format json embeds JSON in the output
192
276
  json_match = raw.match(/(\{.*"summary_line".*\})/m)
193
277
  if json_match
@@ -205,18 +289,19 @@ module Mbeditor
205
289
  name: ex["full_description"] || ex["description"],
206
290
  status: ex["status"] == "passed" ? "pass" : (ex["status"] == "pending" ? "skip" : "fail"),
207
291
  line: ex.dig("line_number"),
292
+ file: relativize(ex["file_path"], repo_path),
208
293
  message: ex.dig("exception", "message")
209
294
  }
210
295
  end
211
296
  [tests, summary]
212
297
  else
213
- parse_minitest_output(raw) # fallback to text parsing
298
+ parse_minitest_output(raw, repo_path: repo_path) # fallback to text parsing
214
299
  end
215
300
  rescue JSON::ParserError
216
- parse_minitest_output(raw)
301
+ parse_minitest_output(raw, repo_path: repo_path)
217
302
  end
218
303
 
219
- def parse_minitest_output(raw)
304
+ def parse_minitest_output(raw, repo_path: nil)
220
305
  lines = raw.lines
221
306
 
222
307
  # First pass: collect per-test results from verbose output.
@@ -232,7 +317,7 @@ module Mbeditor
232
317
  when "E" then "error"
233
318
  when "S" then "skip"
234
319
  end
235
- verbose_results[m[1]] = { name: m[1], status: status, line: nil, message: nil }
320
+ verbose_results[m[1]] = { name: m[1], status: status, line: nil, file: nil, message: nil }
236
321
  end
237
322
 
238
323
  # Second pass: parse failure/error blocks for messages and line numbers.
@@ -246,6 +331,7 @@ module Mbeditor
246
331
 
247
332
  name = name_line.strip.split(" [").first.chomp(":")
248
333
  line_num = name_line[/:(\d+)\]/, 1]&.to_i
334
+ file = relativize(name_line[/\[([^\]]+):\d+\]/, 1], repo_path)
249
335
  msg_lines = []
250
336
  (idx + 2...lines.length).each do |j|
251
337
  break if lines[j].strip.empty? || lines[j].match?(/^\s+\d+\)\s+/)
@@ -256,11 +342,13 @@ module Mbeditor
256
342
  name: name,
257
343
  status: line.include?("Error") ? "error" : "fail",
258
344
  line: line_num,
345
+ file: file,
259
346
  message: msg_lines.join("\n")
260
347
  }
261
348
 
262
349
  if verbose_results.key?(name)
263
350
  verbose_results[name][:line] = line_num
351
+ verbose_results[name][:file] = file
264
352
  verbose_results[name][:message] = msg_lines.join("\n")
265
353
  else
266
354
  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
- msg = message.nil? && block ? block.call : message.to_s
66
- return if suppress_message?(msg)
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
@@ -5,6 +5,7 @@ module Mbeditor
5
5
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
6
6
  :redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
7
7
  :test_framework, :test_command, :test_timeout,
8
+ :test_all_command, :test_all_timeout,
8
9
  :authenticate_with, :cable_authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
9
10
  :lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
10
11
  :ruby_def_include_dirs, :related_files_custom_paths,
@@ -27,7 +28,17 @@ module Mbeditor
27
28
  @redmine_ticket_source = :commit # :commit (scan commit messages) or :branch (leading digits of branch name)
28
29
  @test_framework = nil # :minitest or :rspec — auto-detected when nil
29
30
  @test_command = nil # e.g. "bundle exec ruby -Itest" or "bundle exec rspec"
30
- @test_timeout = 60 # seconds
31
+ # Seconds. 60 was not enough for a real app: `bin/rails test` spends most
32
+ # of it booting Rails before the first assertion runs, so a perfectly
33
+ # healthy single-file run reported "timed out".
34
+ @test_timeout = 180
35
+ # A whole-suite run is a different order of magnitude — minutes, not
36
+ # seconds — so it gets its own ceiling rather than forcing test_timeout
37
+ # up to a value that lets one hung example block for half an hour.
38
+ @test_all_timeout = 1800
39
+ # Sibling of test_command, for the same reason: a project that needs a
40
+ # custom runner for one file needs one for the suite too. nil auto-detects.
41
+ @test_all_command = nil
31
42
  @lint_timeout = 15 # seconds for RuboCop/haml-lint subprocesses
32
43
  @base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
33
44
  @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
- window._prettierLoadPromise = new Promise(function(resolve, reject) {
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 = function() {
66
- if (--pending === 0) {
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
- return window._prettierLoadPromise;
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
@@ -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
- app.middleware.insert_before ActiveRecord::Migration::CheckPending,
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 |*args|
160
- payload = ActiveSupport::Notifications::Event.new(*args).payload
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: @seq,
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?("/mbeditor")
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
- base = env["SCRIPT_NAME"].to_s.sub(%r{/$}, "")
34
- [200, { "Content-Type" => "text/html; charset=utf-8" }, [editor_shell_html(base)]]
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| "#{base}/assets/#{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: "#{base}/assets/mbeditor/application.js"
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="#{base}/assets/fontawesome.min.css" />
66
- <link rel="stylesheet" href="#{base}/assets/mbeditor/application.css" />
67
- <script defer src="#{base}/assets/react.min.js"></script>
68
- <script defer src="#{base}/assets/react-dom.min.js"></script>
69
- <script defer src="#{base}/assets/axios.min.js"></script>
70
- <script defer src="#{base}/assets/lodash.min.js"></script>
71
- <script defer src="#{base}/assets/minisearch.min.js"></script>
72
- <script defer src="#{base}/assets/marked.min.js"></script>
73
- <script defer src="#{base}/assets/emmet.js"></script>
74
- <script defer src="#{base}/assets/monaco-themes-bundle.js"></script>
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?("/mbeditor/")
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 == "/mbeditor"
64
+ env["REQUEST_METHOD"] == "GET" && path == mount_prefix
57
65
  end
58
66
 
59
67
  def normalized_request_path(env)
@@ -55,7 +55,9 @@ 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'
60
+ post 'test_all', to: 'editors#run_all_tests'
59
61
  get 'logs/tail', to: 'logs#tail'
60
62
 
61
63
  # ── Git & Code Review ──────────────────────────────────────────────────────