mbeditor 0.13.0 → 0.14.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -3,7 +3,6 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "open3"
6
- require "shellwords"
7
6
  require "tempfile"
8
7
  require "timeout"
9
8
  require "tmpdir"
@@ -13,6 +12,11 @@ module Mbeditor
13
12
  class EditorsController < ApplicationController
14
13
  skip_before_action :verify_authenticity_token
15
14
  before_action :verify_mbeditor_client, unless: -> { request.get? || request.head? }
15
+ # The audit plumbing does not audit itself. Otherwise every ring flush and
16
+ # every download writes a :request row about the flush, and Clear log ends
17
+ # with the DELETE's own row already in the freshly emptied ring, so the
18
+ # clean trace it exists to give you is never clean.
19
+ skip_around_action :audit_request, only: %i[audit_log ingest_audit_log clear_audit_log]
16
20
 
17
21
  IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
18
22
  helper_method :mbeditor_base_path
@@ -74,6 +78,13 @@ module Mbeditor
74
78
 
75
79
  # GET /mbeditor — renders the IDE shell
76
80
  def index
81
+ # Theme and glass go on <html> before the first paint; the React effect
82
+ # that normally sets them runs after mount, which flashed the default
83
+ # theme on every load.
84
+ prefs = (editor_state_service.read_state["editorPrefs"] rescue nil) || {}
85
+ theme = prefs["theme"].to_s
86
+ @initial_theme = theme.match?(/\A[a-z][a-z0-9-]*\z/) ? theme : "vs-dark"
87
+ @initial_glass = prefs["glass"] == true
77
88
  render layout: "mbeditor/application"
78
89
  end
79
90
 
@@ -96,12 +107,11 @@ module Mbeditor
96
107
  blameAvailable: AvailabilityProbe.git(workspace_root),
97
108
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
98
109
  testAvailable: test_available?,
99
- # The client derives its request timeouts from these. Two independently
110
+ # The client derives its request timeout from this. Two independently
100
111
  # hard-coded numbers is how you get the browser aborting a run the
101
112
  # server is still happily executing, reported as a generic network
102
113
  # error instead of the server's own message.
103
114
  testTimeout: (Mbeditor.configuration.test_timeout || 180).to_i,
104
- testAllTimeout: (Mbeditor.configuration.test_all_timeout || 1800).to_i,
105
115
  actionCableEnabled: action_cable_enabled?,
106
116
  jsSyntaxCheckAvailable: JsSyntaxCheckService.available?,
107
117
  rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root),
@@ -148,10 +158,6 @@ module Mbeditor
148
158
  render json: { error: e.message }, status: :unprocessable_content
149
159
  end
150
160
 
151
- HISTORY_MAX_OPS = 10_000
152
- HISTORY_COMPACT_TARGET = 5_000
153
-
154
-
155
161
  # GET /mbeditor/branch_state?branch=... — load per-branch pane state
156
162
  def branch_state
157
163
  branch = sanitize_branch_name(params[:branch])
@@ -182,20 +188,7 @@ module Mbeditor
182
188
 
183
189
  local_branches = out.split("\n").map(&:strip).reject(&:empty?)
184
190
  pruned = editor_state_service.prune_branch_states(active_branches: local_branches)
185
-
186
- hist_dir = workspace_root.join('tmp', 'mbeditor_history')
187
- if File.directory?(hist_dir)
188
- Dir.glob(File.join(hist_dir, '*.json')) do |hist_file|
189
- data = begin
190
- JSON.parse(File.read(hist_file))
191
- rescue JSON::ParserError => e
192
- Rails.logger.error("[mbeditor] prune_branch_states: skipping corrupt history file #{hist_file}: #{e.message}")
193
- nil
194
- end
195
- next unless data.is_a?(Hash) && data['branch']
196
- FileUtils.rm_f(hist_file) unless local_branches.include?(data['branch'])
197
- end
198
- end
191
+ file_history_service.prune(active_branches: local_branches)
199
192
 
200
193
  render json: { pruned: pruned }
201
194
  rescue StandardError => e
@@ -210,24 +203,8 @@ module Mbeditor
210
203
  path = resolve_path(params[:path])
211
204
  return render json: {}, status: :forbidden unless path
212
205
 
213
- rel = relative_path(path)
214
- hist = history_file_path(branch, rel)
215
- return render json: {} unless File.exist?(hist)
216
-
217
- data = JSON.parse(File.read(hist))
218
-
219
- if data['t']
220
- age = Time.now.utc - Time.parse(data['t'])
221
- if age > 7 * 24 * 3600
222
- FileUtils.rm_f(hist)
223
- return render json: {}
224
- end
225
- end
226
-
227
- render json: { base: data['base'], ops: data['ops'] || [] }
228
- rescue JSON::ParserError
229
- FileUtils.rm_f(hist) rescue nil
230
- render json: {}
206
+ rel = relative_path(path)
207
+ render json: file_history_service.read(branch, rel) || {}
231
208
  rescue StandardError
232
209
  render json: {}
233
210
  end
@@ -240,50 +217,19 @@ module Mbeditor
240
217
  path = resolve_path(params[:path])
241
218
  return render json: { error: 'Forbidden' }, status: :forbidden unless path
242
219
 
243
- rel = relative_path(path)
244
- new_ops = params[:ops]
220
+ rel = relative_path(path)
221
+ new_ops = params[:ops]
245
222
  return render json: { error: 'ops must be an array' }, status: :bad_request unless new_ops.is_a?(Array)
246
223
  return head :no_content if new_ops.empty?
247
224
 
248
225
  new_ops_clean = new_ops.map { |op| Array(op).first(5) }
249
226
 
250
- hist = history_file_path(branch, rel)
251
- FileUtils.mkdir_p(File.dirname(hist))
252
-
253
- File.open(hist, File::RDWR | File::CREAT) do |f|
254
- flock_exclusive_with_timeout!(f)
255
- existing = f.size > 0 ? (JSON.parse(f.read) rescue {}) : {}
256
-
257
- if existing.empty?
258
- # An empty base is a legitimate one, and in practice the usual one:
259
- # the client starts tracking when the editor mounts, which is before
260
- # the file content has arrived, so the load itself is recorded as the
261
- # first op against an empty document. Rejecting "" meant the initial
262
- # POST for every file 400'd and no history was ever written at all.
263
- # Only an absent base is an error.
264
- unless params.key?(:base)
265
- return render json: { error: 'base required for initial history' }, status: :bad_request
266
- end
267
-
268
- base = params[:base].to_s
269
- return render json: { error: 'base too large' }, status: :content_too_large if base.bytesize > STATE_MAX_BYTES
270
- existing = { 'branch' => branch, 'path' => rel, 'base' => base, 'ops' => [], 't' => Time.now.utc.iso8601 }
271
- end
272
-
273
- existing['ops'] = (existing['ops'] || []) + new_ops_clean
274
- existing['t'] = Time.now.utc.iso8601
275
-
276
- if existing['ops'].length > HISTORY_MAX_OPS
277
- to_compact = existing['ops'].shift(HISTORY_COMPACT_TARGET)
278
- existing['base'] = compact_history_ops(existing['base'], to_compact)
279
- end
280
-
281
- f.truncate(0)
282
- f.rewind
283
- f.write(existing.to_json)
284
- end
285
-
227
+ file_history_service.append(branch, rel, ops: new_ops_clean, base: params[:base], base_given: params.key?(:base), version: params[:v])
286
228
  head :no_content
229
+ rescue FileHistoryService::BaseRequiredError
230
+ render json: { error: 'base required for initial history' }, status: :bad_request
231
+ rescue FileHistoryService::BaseTooLargeError
232
+ render json: { error: 'base too large' }, status: :content_too_large
287
233
  rescue StandardError => e
288
234
  render json: { error: e.message }, status: :unprocessable_content
289
235
  end
@@ -371,6 +317,31 @@ module Mbeditor
371
317
  send_file path, disposition: params[:download].present? ? "attachment" : "inline"
372
318
  end
373
319
 
320
+ # GET /mbeditor/archive?paths[]=a&paths[]=b — bundle a multi-selection of
321
+ # files and directories into one .tar.gz.
322
+ #
323
+ # GET, and so exempt from verify_mbeditor_client, for the same reason #raw
324
+ # is: the client fires it with a synthetic <a download> click, which is a
325
+ # real browser navigation and cannot carry the X-Mbeditor-Client header.
326
+ def archive
327
+ requested = Array(params[:paths]).map(&:to_s).reject(&:blank?)
328
+ return render json: { error: "No paths given" }, status: :bad_request if requested.empty?
329
+
330
+ resolved = requested.map { |p| resolve_path(p) }
331
+ return render json: { error: "Forbidden" }, status: :forbidden if resolved.any?(&:nil?)
332
+ unless resolved.all? { |p| File.exist?(p) || File.symlink?(p) }
333
+ return render json: { error: "Not found" }, status: :not_found
334
+ end
335
+
336
+ name = File.basename(resolved.length == 1 ? resolved.first : workspace_root.to_s)
337
+ send_data ArchiveService.new(workspace_root).build(resolved),
338
+ type: "application/gzip", disposition: "attachment", filename: "#{name}.tar.gz"
339
+ rescue ArchiveService::LimitExceededError => e
340
+ render json: { error: e.message }, status: :content_too_large
341
+ rescue StandardError => e
342
+ render json: { error: e.message }, status: :unprocessable_content
343
+ end
344
+
374
345
  # POST /mbeditor/file — save file
375
346
  def save
376
347
  path = resolve_path(params[:path])
@@ -604,6 +575,10 @@ module Mbeditor
604
575
  "restart" => :restart
605
576
  }.freeze
606
577
 
578
+ # Symbol counterpart of RUBY_LSP_METHODS' keys, for the audit log. Every
579
+ # value is interned at class-load time, never derived from the request.
580
+ RUBY_LSP_METHOD_SYMBOLS = RUBY_LSP_METHODS.keys.to_h { |k| [k, k.to_sym] }.freeze
581
+
607
582
  # Passed through as raw LSP JSON rather than translated. Ranges stay 0-based
608
583
  # on the wire and are converted by one helper at the Monaco provider.
609
584
  RUBY_LSP_RAW = %w[
@@ -682,7 +657,22 @@ module Mbeditor
682
657
  timeout = RUBY_LSP_RUBOCOP_METHODS.include?(lsp_method) ? RUBY_LSP_DIAGNOSTICS_TIMEOUT : nil
683
658
 
684
659
  client = RubyLspClient.for(workspace_root.to_s)
685
- result = client.request_with_document(lsp_method, path, content, extra, timeout: timeout)
660
+ lsp_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
661
+ lsp_ok = true
662
+ begin
663
+ result = client.request_with_document(lsp_method, path, content, extra, timeout: timeout)
664
+ rescue StandardError
665
+ lsp_ok = false
666
+ raise
667
+ ensure
668
+ # One record per ruby-lsp round trip: which allowlisted LSP method ran,
669
+ # how long it took, and whether it returned without raising (a timeout
670
+ # or a client error is ok: false).
671
+ AuditLog.record(:ruby_lsp,
672
+ method: RUBY_LSP_METHOD_SYMBOLS[params[:lsp_method].to_s],
673
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - lsp_started) * 1000).round,
674
+ ok: lsp_ok)
675
+ end
686
676
  # The URI is what the diagnostics translator checks embedded code-action
687
677
  # edits against, so it must be the same string the client sent.
688
678
  render json: translate_ruby_lsp_result(params[:lsp_method].to_s, result, "file://#{path}")
@@ -783,6 +773,36 @@ module Mbeditor
783
773
  render json: { ok: true }
784
774
  end
785
775
 
776
+ # GET /mbeditor/audit_log — the merged client + server telemetry log, as a
777
+ # download to hand to an AI. It carries numbers only; see AuditLog.
778
+ def audit_log
779
+ send_data JSON.pretty_generate(AuditLog.payload),
780
+ type: "application/json", disposition: "attachment",
781
+ filename: "mbeditor-audit-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}.json"
782
+ end
783
+
784
+ # POST /mbeditor/audit_log — a batch from the browser ring.
785
+ def ingest_audit_log
786
+ raw = request.raw_post.to_s
787
+ # Cap before parsing, not after: MAX_INGEST bounds what is kept, but the
788
+ # parse of an oversized body has already happened by then.
789
+ return head(:payload_too_large) if raw.bytesize > AuditLog::MAX_POST_BYTES
790
+
791
+ body = begin
792
+ JSON.parse(raw)
793
+ rescue JSON::ParserError
794
+ nil
795
+ end
796
+ AuditLog.ingest(body["events"]) if body.is_a?(Hash)
797
+ head :no_content
798
+ end
799
+
800
+ # DELETE /mbeditor/audit_log — start a clean trace.
801
+ def clear_audit_log
802
+ AuditLog.clear!
803
+ render json: { ok: true }
804
+ end
805
+
786
806
  # GET /mbeditor/module_members?name=ArticlesHelper
787
807
  # Returns methods defined in the workspace file that defines the named module/class.
788
808
  def module_members
@@ -1016,37 +1036,11 @@ module Mbeditor
1016
1036
  return render json: { error: "haml-lint not available", markers: [] }, status: :unprocessable_content
1017
1037
  end
1018
1038
 
1019
- markers = run_haml_lint(code)
1020
- return render json: { markers: markers }
1021
- end
1022
-
1023
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "--stdin", filename, "--format", "json", "--no-color"]
1024
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1025
- output = run_with_timeout(env, cmd, stdin_data: code)
1026
-
1027
- idx = output.index("{")
1028
- result = idx ? JSON.parse(output[idx..]) : {}
1029
- result = {} unless result.is_a?(Hash)
1030
- offenses = result.dig("files", 0, "offenses") || []
1031
-
1032
- markers = offenses.map do |offense|
1033
- {
1034
- severity: cop_severity(offense["severity"]),
1035
- copName: offense["cop_name"],
1036
- correctable: offense["correctable"] == true,
1037
- message: "[#{offense['cop_name']}] #{offense['message']}",
1038
- startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
1039
- startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
1040
- endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
1041
- endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1,
1042
- # Same predicate the ruby-lsp path uses, so dead code fades whichever
1043
- # linter produced the offense. Plain rubocop JSON carries no
1044
- # code_description, so there's no codeHref to pass on here.
1045
- unnecessary: LspDiagnosticsTranslator.unnecessary?(offense["cop_name"])
1046
- }
1039
+ return render json: { markers: LintService.haml_diagnostics(workspace_root, code) }
1047
1040
  end
1048
1041
 
1049
- render json: { markers: markers, summary: result["summary"] }
1042
+ result = LintService.rubocop_diagnostics(workspace_root, path, code)
1043
+ render json: { markers: result[:markers], summary: result[:summary] }
1050
1044
  rescue StandardError => e
1051
1045
  render json: { error: e.message, markers: [] }, status: :unprocessable_content
1052
1046
  end
@@ -1076,28 +1070,9 @@ module Mbeditor
1076
1070
  return render json: { error: "Invalid cop name" }, status: :unprocessable_content unless cop_name.match?(/\A[\w\/]+\z/)
1077
1071
 
1078
1072
  code = params[:code].to_s
1079
- ext = File.extname(File.basename(path))
1080
-
1081
- # Use a workspace-local tempfile so RuboCop's config discovery walks up
1082
- # from the source file's directory and finds the host app's .rubocop.yml.
1083
- Tempfile.create([".mbeditor_fix_", ext], File.dirname(path)) do |f|
1084
- f.write(code)
1085
- f.flush
1086
- tmpfile = f.path
1087
-
1088
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
1089
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1090
- status = run_lint_command(env, cmd)[:exit_status]
1091
-
1092
- # exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
1093
- unless status.success? || status.exitstatus == 1
1094
- return render json: { fix: nil }
1095
- end
1096
-
1097
- corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
1098
- fix = compute_text_edit(code, corrected)
1099
- render json: { fix: fix }
1100
- end
1073
+ result = LintService.autocorrect(workspace_root, path, code)
1074
+ fix = result[:ok] ? compute_text_edit(code, result[:content]) : nil
1075
+ render json: { fix: fix }
1101
1076
  rescue StandardError => e
1102
1077
  render json: { error: e.message }, status: :unprocessable_content
1103
1078
  end
@@ -1122,25 +1097,6 @@ module Mbeditor
1122
1097
  render json: { ok: false, error: e.message, files: [] }, status: :unprocessable_content
1123
1098
  end
1124
1099
 
1125
- # POST /mbeditor/test_all — run the whole suite
1126
- #
1127
- # No path: the framework's own default target applies. Deliberately a plain
1128
- # blocking request like /test, just with the suite ceiling — streaming would
1129
- # need a channel, a buffer and a cancel protocol for a button most people
1130
- # press a handful of times a day.
1131
- def run_all_tests
1132
- config = Mbeditor.configuration
1133
- result = TestRunnerService.run_all(
1134
- workspace_root.to_s,
1135
- framework: config.test_framework&.to_sym,
1136
- command: config.test_all_command,
1137
- timeout: (config.test_all_timeout || 1800).to_i
1138
- )
1139
- render json: result
1140
- rescue StandardError => e
1141
- render json: { ok: false, error: e.message }, status: :unprocessable_content
1142
- end
1143
-
1144
1100
  # POST /mbeditor/test — run tests for the given file
1145
1101
  def run_test
1146
1102
  path = resolve_path(params[:path])
@@ -1222,11 +1178,13 @@ module Mbeditor
1222
1178
  def model_schema
1223
1179
  model_name = params[:model].to_s.strip
1224
1180
  return render json: { error: "model required" }, status: :bad_request if model_name.blank?
1225
- # SchemaService derives a file path from this name, so it is validated the
1226
- # same way #module_members validates its own: a constant name, nothing that
1227
- # could carry a "..".
1228
- return render json: { error: "Invalid model" }, status: :bad_request \
1229
- unless model_name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
1181
+ # SchemaService interpolates the underscored name into app/models/<x>.rb,
1182
+ # and Inflector.underscore leaves "../" intact. Same guard as
1183
+ # module_members, widened to the "::" a namespaced model needs. Spaces
1184
+ # are allowed because SchemaService strips them ("Order Item" → OrderItem).
1185
+ unless model_name.delete(" ").match?(RUBY_CONSTANT_PATH)
1186
+ return render json: { error: "Invalid model name" }, status: :bad_request
1187
+ end
1230
1188
 
1231
1189
  schema = SchemaService.new(model_name, workspace_root.to_s).call
1232
1190
  if schema
@@ -1251,22 +1209,8 @@ module Mbeditor
1251
1209
  code = params[:code].to_s
1252
1210
  return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
1253
1211
 
1254
- ext = File.extname(File.basename(path))
1255
- Tempfile.create([".mbeditor_fmt_", ext], File.dirname(path)) do |f|
1256
- f.write(code)
1257
- f.flush
1258
- tmpfile = f.path
1259
-
1260
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
1261
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1262
- status = run_lint_command(env, cmd)[:exit_status]
1263
- unless status.success? || status.exitstatus == 1
1264
- return render json: { ok: false, content: code }
1265
- end
1266
-
1267
- corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
1268
- render json: { ok: true, content: corrected }
1269
- end
1212
+ result = LintService.autocorrect(workspace_root, path, code)
1213
+ render json: { ok: result[:ok], content: result[:content] }
1270
1214
  rescue StandardError => e
1271
1215
  render json: { error: e.message }, status: :unprocessable_content
1272
1216
  end
@@ -1324,36 +1268,6 @@ module Mbeditor
1324
1268
  raw.start_with?("/") || raw.empty? ? raw : "/#{raw}"
1325
1269
  end
1326
1270
 
1327
- def history_file_path(branch, rel_path)
1328
- branch_hash = Digest::SHA256.hexdigest(branch.to_s)[0, 16]
1329
- file_hash = Digest::SHA256.hexdigest(rel_path.to_s)[0, 16]
1330
- workspace_root.join('tmp', 'mbeditor_history', "#{branch_hash}_#{file_hash}.json")
1331
- end
1332
-
1333
- def compact_history_ops(base, ops)
1334
- text = base.to_s
1335
- ops.each do |op|
1336
- sl, sc, el, ec, ins = op[0].to_i, op[1].to_i, op[2].to_i, op[3].to_i, op[4].to_s
1337
- lines = text.split("\n", -1)
1338
- sl0 = [[sl - 1, 0].max, [lines.length - 1, 0].max].min
1339
- el0 = [[el - 1, 0].max, [lines.length - 1, 0].max].min
1340
- sc0 = sc - 1
1341
- ec0 = ec - 1
1342
- prefix = (lines[sl0] || '')[0, sc0] || ''
1343
- suffix = (lines[el0] || '')[ec0..] || ''
1344
- ins_lines = ins.split("\n", -1)
1345
- new_seg = if ins_lines.length <= 1
1346
- [prefix + (ins_lines[0] || '') + suffix]
1347
- else
1348
- [prefix + ins_lines[0]] + ins_lines[1..-2] + [ins_lines[-1] + suffix]
1349
- end
1350
- text = (lines[0...sl0] + new_seg + lines[(el0 + 1)..]).join("\n")
1351
- end
1352
- text
1353
- rescue StandardError
1354
- base.to_s
1355
- end
1356
-
1357
1271
  # structural: false means "these files' contents changed, the tree did not"
1358
1272
  # — a save or a replace-in-files. The client uses it to skip re-walking the
1359
1273
  # whole workspace and rebuilding its quick-open index on every save, which
@@ -1415,17 +1329,8 @@ module Mbeditor
1415
1329
  @editor_state_service ||= EditorStateService.new(workspace_root)
1416
1330
  end
1417
1331
 
1418
- # Acquire an exclusive lock without blocking forever, so a stuck holder (e.g.
1419
- # a request paused at a breakpoint mid-write) cannot wedge history saves and
1420
- # pin a worker indefinitely. Raises on timeout; the caller's rescue turns it
1421
- # into a fast error response instead of a hang.
1422
- HISTORY_LOCK_TIMEOUT = 5.0
1423
- def flock_exclusive_with_timeout!(file, timeout: HISTORY_LOCK_TIMEOUT)
1424
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
1425
- until file.flock(File::LOCK_EX | File::LOCK_NB)
1426
- raise "could not acquire history lock within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1427
- sleep 0.01
1428
- end
1332
+ def file_history_service
1333
+ @file_history_service ||= FileHistoryService.new(workspace_root)
1429
1334
  end
1430
1335
 
1431
1336
  def sanitize_branch_name(branch)
@@ -1559,19 +1464,6 @@ module Mbeditor
1559
1464
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1560
1465
  end
1561
1466
 
1562
- def run_with_timeout(env, cmd, stdin_data:)
1563
- run_lint_command(env, cmd, stdin_data: stdin_data)[:stdout]
1564
- end
1565
-
1566
- # Same lint_timeout ceiling as #lint. quick_fix, format_file and haml-lint
1567
- # used to spawn with Open3.capture3 and no timeout at all, so a wedged
1568
- # rubocop held a request thread until the client gave up.
1569
- def run_lint_command(env, cmd, stdin_data: nil)
1570
- timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
1571
- timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
1572
- ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
1573
- end
1574
-
1575
1467
  def apply_rename_changes(result, open_paths)
1576
1468
  changes = result.is_a?(Hash) ? (result["changes"] || {}) : {}
1577
1469
  open = open_paths.to_set
@@ -1580,7 +1472,7 @@ module Mbeditor
1580
1472
  edits_for_open = {}
1581
1473
 
1582
1474
  changes.each do |uri, raw_edits|
1583
- rel = workspace_relative_uri(uri.to_s)
1475
+ rel = RubyLspResultTranslator.workspace_relative_uri(uri.to_s, workspace_root)
1584
1476
  # Outside the workspace, or a path we refuse to write: record it so a
1585
1477
  # partial rename is visibly partial rather than silently so.
1586
1478
  target = rel && resolve_path(rel)
@@ -1657,217 +1549,18 @@ module Mbeditor
1657
1549
  { available: false, disabled: false, state: :failed, restarts: 0, error: e.message }
1658
1550
  end
1659
1551
 
1552
+ # Dispatches to RubyLspResultTranslator (definition/hover/completion/raw —
1553
+ # sanitizes every file:// URI, see that module for the trust-boundary
1554
+ # rationale) or to LspDiagnosticsTranslator for diagnostics, which has its
1555
+ # own interface (no workspace_root) and is shared with the plain-rubocop
1556
+ # /lint path.
1660
1557
  def translate_ruby_lsp_result(kind, result, uri = nil)
1661
1558
  case kind
1662
- when "definition" then { results: translate_lsp_locations(result) }
1663
- when "hover" then { markdown: translate_lsp_hover(result) }
1664
- when "completion" then { suggestions: translate_lsp_completions(result) }
1559
+ when "definition" then RubyLspResultTranslator.definition(result, workspace_root: workspace_root)
1560
+ when "hover" then RubyLspResultTranslator.hover(result, workspace_root: workspace_root)
1561
+ when "completion" then RubyLspResultTranslator.completion(result)
1665
1562
  when "diagnostics" then LspDiagnosticsTranslator.call(result, uri)
1666
- when *RUBY_LSP_RAW then { result: sanitize_lsp_uris(result) }
1667
- end
1668
- end
1669
-
1670
- # The one trust boundary for every raw-passthrough method. Walks the LSP
1671
- # response and rewrites each file:// URI to a workspace-relative path,
1672
- # dropping any object that points outside the workspace — a reference in a
1673
- # gem is not something this editor can open, and a path outside the root is
1674
- # not something it should hand to the browser at all.
1675
- #
1676
- # Recursion is bounded by MAX_LSP_DEPTH: the payload comes from a
1677
- # subprocess, and a cyclic or pathologically nested one must not take the
1678
- # request thread down with it.
1679
- MAX_LSP_DEPTH = 32
1680
-
1681
- URI_KEYS = %w[uri targetUri].freeze
1682
-
1683
- # URI::DEFAULT_PARSER became the RFC3986 parser in Ruby 4.0, where #unescape
1684
- # is deprecated; URI::RFC2396_PARSER only exists from Ruby 3.4. The gem
1685
- # supports >= 3.0, so take whichever this Ruby has.
1686
- URI_UNESCAPER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
1687
-
1688
- def sanitize_lsp_uris(node, depth = 0)
1689
- return nil if depth > MAX_LSP_DEPTH
1690
-
1691
- case node
1692
- when Array
1693
- node.filter_map { |child| sanitize_lsp_uris(child, depth + 1) }
1694
- when Hash
1695
- sanitized = {}
1696
- node.each do |key, value|
1697
- if URI_KEYS.include?(key) && value.is_a?(String)
1698
- rel = workspace_relative_uri(value)
1699
- # A URI we can't place inside the workspace disqualifies its object.
1700
- return nil unless rel
1701
-
1702
- sanitized[key] = rel
1703
- else
1704
- child = sanitize_lsp_uris(value, depth + 1)
1705
- sanitized[key] = child unless child.nil? && !value.nil?
1706
- end
1707
- end
1708
- sanitized
1709
- when String
1710
- sanitize_lsp_markdown(node)
1711
- else
1712
- node
1713
- end
1714
- end
1715
-
1716
- # URIs also turn up *inside* strings: ruby-lsp's signatureHelp and hover
1717
- # documentation embed a "Definitions" line of file:// markdown links. Those
1718
- # would leak absolute host paths and render as links that go nowhere, so
1719
- # they get the same treatment hover already gives them.
1720
- def sanitize_lsp_markdown(text)
1721
- return text unless text.include?("file://")
1722
-
1723
- rewritten = rewrite_lsp_hover_links(text)
1724
- return rewritten unless rewritten.include?("file://")
1725
-
1726
- # Backstop for any file:// URI that wasn't in markdown-link form. An
1727
- # absolute host path must never reach the browser, linkable or not.
1728
- rewritten.gsub(%r{file://\S*}) do |raw|
1729
- workspace_relative_uri(raw.sub(/[)\]\s].*\z/m, "")) || "(external)"
1730
- end
1731
- end
1732
-
1733
- def workspace_relative_uri(uri)
1734
- return nil unless uri.start_with?("file://")
1735
-
1736
- # ruby-lsp percent-escapes its URIs; workspace_root is a raw path. A
1737
- # checkout with a space (or any other escaped character) in its path
1738
- # matched nothing here, so every result was silently dropped.
1739
- path = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
1740
- prefix = "#{workspace_root}/"
1741
- return nil unless path.start_with?(prefix)
1742
-
1743
- path.delete_prefix(prefix)
1744
- end
1745
-
1746
- def translate_lsp_locations(result)
1747
- items = result.is_a?(Array) ? result : [result].compact
1748
- root = workspace_root.to_s
1749
- items.filter_map do |loc|
1750
- next unless loc.is_a?(Hash)
1751
-
1752
- uri = loc["uri"] || loc["targetUri"]
1753
- range = loc["range"] || loc["targetSelectionRange"] || loc["targetRange"]
1754
- next unless uri.to_s.start_with?("file://")
1755
-
1756
- fpath = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
1757
- # Drop gem/stdlib locations the editor can't open; an empty list makes
1758
- # the frontend fall back to the legacy services (ri covers stdlib).
1759
- next unless fpath.start_with?("#{root}/")
1760
-
1761
- start_line = (range&.dig("start", "line") || 0) + 1
1762
- {
1763
- file: fpath.delete_prefix("#{root}/"),
1764
- line: start_line,
1765
- # Columns and the end of the range are additive: existing consumers
1766
- # only read :file and :line, but peek-definition needs a real range
1767
- # to highlight rather than the start of the line.
1768
- col: (range&.dig("start", "character") || 0) + 1,
1769
- endLine: (range&.dig("end", "line") || range&.dig("start", "line") || 0) + 1,
1770
- endCol: (range&.dig("end", "character") || range&.dig("start", "character") || 0) + 1
1771
- }
1772
- end
1773
- end
1774
-
1775
- def translate_lsp_hover(result)
1776
- contents = result.is_a?(Hash) ? result["contents"] : nil
1777
- return nil if contents.nil?
1778
-
1779
- markdown =
1780
- case contents
1781
- when Hash then contents["value"].to_s
1782
- when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
1783
- else contents.to_s
1784
- end
1785
-
1786
- neutralize_comment_headings(rewrite_lsp_hover_links(markdown))
1787
- end
1788
-
1789
- # ruby-lsp renders a doc comment by stripping exactly one leading "# " from
1790
- # each line, then hands the result to the editor as markdown. A `##`-opened
1791
- # doc block — a very common Ruby convention — therefore arrives as
1792
- # "# Title" and renders as an <h1> filling the hover.
1793
- #
1794
- # Ruby comments are not markdown, so escape a `#` that opens a line and let
1795
- # it render as the text it is. Fenced code blocks are left alone: `#` inside
1796
- # them is Ruby source, not a heading, and needs no escaping.
1797
- #
1798
- # This deliberately diverges from other ruby-lsp clients, which show the
1799
- # heading.
1800
- def neutralize_comment_headings(markdown)
1801
- in_fence = false
1802
- markdown.lines.map do |line|
1803
- in_fence = !in_fence if line.start_with?("```")
1804
- next line if in_fence || line.start_with?("```")
1805
-
1806
- line.sub(/\A(\s*)(#+)(?=\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
1807
- end.join
1808
- end
1809
-
1810
- # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
1811
- # `[user.rb](file:///abs/path/user.rb#L3,1-9,4)`. Monaco renders those as
1812
- # links but clicking one does nothing, since nothing can open a file:// URI
1813
- # here. Point in-workspace links at the `mbeditor.openDefinition` Monaco
1814
- # command (registered in editor_plugins.js) and demote gem/stdlib links —
1815
- # which the editor cannot open at all — to plain code spans.
1816
- LSP_HOVER_FILE_LINK = %r{\[([^\]\n]+)\]\(file://([^)\s#]+)(?:\#L(\d+),\d+(?:-\d+,\d+)?)?\)}
1817
-
1818
- def rewrite_lsp_hover_links(markdown)
1819
- prefix = "#{workspace_root}/"
1820
- markdown.gsub(LSP_HOVER_FILE_LINK) do
1821
- label, raw_path, line = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3).to_i
1822
- # Percent-decode by hand: URI's unescape helpers are deprecated on new
1823
- # Rubies and their replacements are missing on the old ones we support.
1824
- # (Must come after reading the other captures — gsub resets last_match.)
1825
- path = raw_path.gsub(/%\h\h/) { |esc| esc[1..].hex.chr }.force_encoding(Encoding::UTF_8)
1826
-
1827
- if path.start_with?(prefix)
1828
- args = [path.delete_prefix(prefix), line.positive? ? line : 1]
1829
- "[#{label}](command:mbeditor.openDefinition?#{ERB::Util.url_encode(args.to_json)})"
1830
- else
1831
- "`#{label}`"
1832
- end
1833
- end
1834
- end
1835
-
1836
- def translate_lsp_completions(result)
1837
- items = result.is_a?(Hash) ? Array(result["items"]) : Array(result)
1838
- items.first(100).filter_map do |item|
1839
- next unless item.is_a?(Hash)
1840
-
1841
- {
1842
- label: item["label"].to_s,
1843
- kind: lsp_completion_kind(item["kind"]),
1844
- insertText: (item.dig("textEdit", "newText") || item["insertText"] || item["label"]).to_s,
1845
- detail: item["detail"].to_s,
1846
- isSnippet: item["insertTextFormat"] == 2
1847
- }
1848
- end
1849
- end
1850
-
1851
- LSP_COMPLETION_KINDS = {
1852
- 2 => "Method", 3 => "Function", 4 => "Constructor", 5 => "Field",
1853
- 6 => "Variable", 7 => "Class", 8 => "Interface", 9 => "Module",
1854
- 10 => "Property", 14 => "Keyword", 15 => "Snippet", 21 => "Constant"
1855
- }.freeze
1856
-
1857
- def lsp_completion_kind(kind)
1858
- LSP_COMPLETION_KINDS[kind] || "Text"
1859
- end
1860
-
1861
- # Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
1862
- # through ruby-lsp and the same file linted through `rubocop --stdin` grade
1863
- # their offenses identically. rubocop's own `info` is the weakest level and
1864
- # maps to hint; convention/refactor fall through to info.
1865
- def cop_severity(severity)
1866
- case severity
1867
- when "error", "fatal" then "error"
1868
- when "warning" then "warning"
1869
- when "info" then "hint"
1870
- else "info"
1563
+ when *RUBY_LSP_RAW then RubyLspResultTranslator.raw(result, workspace_root: workspace_root)
1871
1564
  end
1872
1565
  end
1873
1566
 
@@ -1925,39 +1618,6 @@ module Mbeditor
1925
1618
  File.directory?(File.join(root, "test")) || File.directory?(File.join(root, "spec"))
1926
1619
  end
1927
1620
 
1928
- def run_haml_lint(code)
1929
- markers = []
1930
- Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
1931
- f.write(code)
1932
- f.flush
1933
- cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
1934
- output = run_lint_command({}, cmd)[:stdout]
1935
- idx = output.index("{")
1936
- result = idx ? JSON.parse(output[idx..]) : {}
1937
- result = {} unless result.is_a?(Hash)
1938
- offenses = result.dig("files", 0, "offenses") || []
1939
- markers = offenses.map do |offense|
1940
- {
1941
- severity: haml_lint_severity(offense["severity"]),
1942
- message: "[#{offense['linter_name']}] #{offense['message']}",
1943
- startLine: offense.dig("location", "line"),
1944
- startCol: (offense.dig("location", "column") || 1) - 1,
1945
- endLine: offense.dig("location", "line"),
1946
- endCol: offense.dig("location", "column") || 1
1947
- }
1948
- end
1949
- end
1950
- markers
1951
- end
1952
-
1953
- def haml_lint_severity(severity)
1954
- case severity
1955
- when "error" then "error"
1956
- when "warning" then "warning"
1957
- else "info"
1958
- end
1959
- end
1960
-
1961
1621
  def resolve_monaco_asset_path(asset_path)
1962
1622
  return nil if asset_path.blank?
1963
1623