mbeditor 0.13.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  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 +429 -247
  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/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. metadata +12 -2
@@ -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
 
@@ -147,10 +158,6 @@ module Mbeditor
147
158
  render json: { error: e.message }, status: :unprocessable_content
148
159
  end
149
160
 
150
- HISTORY_MAX_OPS = 10_000
151
- HISTORY_COMPACT_TARGET = 5_000
152
-
153
-
154
161
  # GET /mbeditor/branch_state?branch=... — load per-branch pane state
155
162
  def branch_state
156
163
  branch = sanitize_branch_name(params[:branch])
@@ -181,20 +188,7 @@ module Mbeditor
181
188
 
182
189
  local_branches = out.split("\n").map(&:strip).reject(&:empty?)
183
190
  pruned = editor_state_service.prune_branch_states(active_branches: local_branches)
184
-
185
- hist_dir = workspace_root.join('tmp', 'mbeditor_history')
186
- if File.directory?(hist_dir)
187
- Dir.glob(File.join(hist_dir, '*.json')) do |hist_file|
188
- data = begin
189
- JSON.parse(File.read(hist_file))
190
- rescue JSON::ParserError => e
191
- Rails.logger.error("[mbeditor] prune_branch_states: skipping corrupt history file #{hist_file}: #{e.message}")
192
- nil
193
- end
194
- next unless data.is_a?(Hash) && data['branch']
195
- FileUtils.rm_f(hist_file) unless local_branches.include?(data['branch'])
196
- end
197
- end
191
+ file_history_service.prune(active_branches: local_branches)
198
192
 
199
193
  render json: { pruned: pruned }
200
194
  rescue StandardError => e
@@ -209,24 +203,8 @@ module Mbeditor
209
203
  path = resolve_path(params[:path])
210
204
  return render json: {}, status: :forbidden unless path
211
205
 
212
- rel = relative_path(path)
213
- hist = history_file_path(branch, rel)
214
- return render json: {} unless File.exist?(hist)
215
-
216
- data = JSON.parse(File.read(hist))
217
-
218
- if data['t']
219
- age = Time.now.utc - Time.parse(data['t'])
220
- if age > 7 * 24 * 3600
221
- FileUtils.rm_f(hist)
222
- return render json: {}
223
- end
224
- end
225
-
226
- render json: { base: data['base'], ops: data['ops'] || [] }
227
- rescue JSON::ParserError
228
- FileUtils.rm_f(hist) rescue nil
229
- render json: {}
206
+ rel = relative_path(path)
207
+ render json: file_history_service.read(branch, rel) || {}
230
208
  rescue StandardError
231
209
  render json: {}
232
210
  end
@@ -239,50 +217,19 @@ module Mbeditor
239
217
  path = resolve_path(params[:path])
240
218
  return render json: { error: 'Forbidden' }, status: :forbidden unless path
241
219
 
242
- rel = relative_path(path)
243
- new_ops = params[:ops]
220
+ rel = relative_path(path)
221
+ new_ops = params[:ops]
244
222
  return render json: { error: 'ops must be an array' }, status: :bad_request unless new_ops.is_a?(Array)
245
223
  return head :no_content if new_ops.empty?
246
224
 
247
225
  new_ops_clean = new_ops.map { |op| Array(op).first(5) }
248
226
 
249
- hist = history_file_path(branch, rel)
250
- FileUtils.mkdir_p(File.dirname(hist))
251
-
252
- File.open(hist, File::RDWR | File::CREAT) do |f|
253
- flock_exclusive_with_timeout!(f)
254
- existing = f.size > 0 ? (JSON.parse(f.read) rescue {}) : {}
255
-
256
- if existing.empty?
257
- # An empty base is a legitimate one, and in practice the usual one:
258
- # the client starts tracking when the editor mounts, which is before
259
- # the file content has arrived, so the load itself is recorded as the
260
- # first op against an empty document. Rejecting "" meant the initial
261
- # POST for every file 400'd and no history was ever written at all.
262
- # Only an absent base is an error.
263
- unless params.key?(:base)
264
- return render json: { error: 'base required for initial history' }, status: :bad_request
265
- end
266
-
267
- base = params[:base].to_s
268
- return render json: { error: 'base too large' }, status: :content_too_large if base.bytesize > STATE_MAX_BYTES
269
- existing = { 'branch' => branch, 'path' => rel, 'base' => base, 'ops' => [], 't' => Time.now.utc.iso8601 }
270
- end
271
-
272
- existing['ops'] = (existing['ops'] || []) + new_ops_clean
273
- existing['t'] = Time.now.utc.iso8601
274
-
275
- if existing['ops'].length > HISTORY_MAX_OPS
276
- to_compact = existing['ops'].shift(HISTORY_COMPACT_TARGET)
277
- existing['base'] = compact_history_ops(existing['base'], to_compact)
278
- end
279
-
280
- f.truncate(0)
281
- f.rewind
282
- f.write(existing.to_json)
283
- end
284
-
227
+ file_history_service.append(branch, rel, ops: new_ops_clean, base: params[:base], base_given: params.key?(:base), version: params[:v])
285
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
286
233
  rescue StandardError => e
287
234
  render json: { error: e.message }, status: :unprocessable_content
288
235
  end
@@ -370,6 +317,31 @@ module Mbeditor
370
317
  send_file path, disposition: params[:download].present? ? "attachment" : "inline"
371
318
  end
372
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
+
373
345
  # POST /mbeditor/file — save file
374
346
  def save
375
347
  path = resolve_path(params[:path])
@@ -603,6 +575,10 @@ module Mbeditor
603
575
  "restart" => :restart
604
576
  }.freeze
605
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
+
606
582
  # Passed through as raw LSP JSON rather than translated. Ranges stay 0-based
607
583
  # on the wire and are converted by one helper at the Monaco provider.
608
584
  RUBY_LSP_RAW = %w[
@@ -681,7 +657,22 @@ module Mbeditor
681
657
  timeout = RUBY_LSP_RUBOCOP_METHODS.include?(lsp_method) ? RUBY_LSP_DIAGNOSTICS_TIMEOUT : nil
682
658
 
683
659
  client = RubyLspClient.for(workspace_root.to_s)
684
- 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
685
676
  # The URI is what the diagnostics translator checks embedded code-action
686
677
  # edits against, so it must be the same string the client sent.
687
678
  render json: translate_ruby_lsp_result(params[:lsp_method].to_s, result, "file://#{path}")
@@ -782,6 +773,36 @@ module Mbeditor
782
773
  render json: { ok: true }
783
774
  end
784
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
+
785
806
  # GET /mbeditor/module_members?name=ArticlesHelper
786
807
  # Returns methods defined in the workspace file that defines the named module/class.
787
808
  def module_members
@@ -1015,37 +1036,11 @@ module Mbeditor
1015
1036
  return render json: { error: "haml-lint not available", markers: [] }, status: :unprocessable_content
1016
1037
  end
1017
1038
 
1018
- markers = run_haml_lint(code)
1019
- return render json: { markers: markers }
1039
+ return render json: { markers: LintService.haml_diagnostics(workspace_root, code) }
1020
1040
  end
1021
1041
 
1022
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "--stdin", filename, "--format", "json", "--no-color"]
1023
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1024
- output = run_with_timeout(env, cmd, stdin_data: code)
1025
-
1026
- idx = output.index("{")
1027
- result = idx ? JSON.parse(output[idx..]) : {}
1028
- result = {} unless result.is_a?(Hash)
1029
- offenses = result.dig("files", 0, "offenses") || []
1030
-
1031
- markers = offenses.map do |offense|
1032
- {
1033
- severity: cop_severity(offense["severity"]),
1034
- copName: offense["cop_name"],
1035
- correctable: offense["correctable"] == true,
1036
- message: "[#{offense['cop_name']}] #{offense['message']}",
1037
- startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
1038
- startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
1039
- endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
1040
- endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1,
1041
- # Same predicate the ruby-lsp path uses, so dead code fades whichever
1042
- # linter produced the offense. Plain rubocop JSON carries no
1043
- # code_description, so there's no codeHref to pass on here.
1044
- unnecessary: LspDiagnosticsTranslator.unnecessary?(offense["cop_name"])
1045
- }
1046
- end
1047
-
1048
- 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] }
1049
1044
  rescue StandardError => e
1050
1045
  render json: { error: e.message, markers: [] }, status: :unprocessable_content
1051
1046
  end
@@ -1075,28 +1070,9 @@ module Mbeditor
1075
1070
  return render json: { error: "Invalid cop name" }, status: :unprocessable_content unless cop_name.match?(/\A[\w\/]+\z/)
1076
1071
 
1077
1072
  code = params[:code].to_s
1078
- ext = File.extname(File.basename(path))
1079
-
1080
- # Use a workspace-local tempfile so RuboCop's config discovery walks up
1081
- # from the source file's directory and finds the host app's .rubocop.yml.
1082
- Tempfile.create([".mbeditor_fix_", ext], File.dirname(path)) do |f|
1083
- f.write(code)
1084
- f.flush
1085
- tmpfile = f.path
1086
-
1087
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
1088
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1089
- status = run_lint_command(env, cmd)[:exit_status]
1090
-
1091
- # exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
1092
- unless status.success? || status.exitstatus == 1
1093
- return render json: { fix: nil }
1094
- end
1095
-
1096
- corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
1097
- fix = compute_text_edit(code, corrected)
1098
- render json: { fix: fix }
1099
- 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 }
1100
1076
  rescue StandardError => e
1101
1077
  render json: { error: e.message }, status: :unprocessable_content
1102
1078
  end
@@ -1233,22 +1209,8 @@ module Mbeditor
1233
1209
  code = params[:code].to_s
1234
1210
  return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
1235
1211
 
1236
- ext = File.extname(File.basename(path))
1237
- Tempfile.create([".mbeditor_fmt_", ext], File.dirname(path)) do |f|
1238
- f.write(code)
1239
- f.flush
1240
- tmpfile = f.path
1241
-
1242
- cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
1243
- env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
1244
- status = run_lint_command(env, cmd)[:exit_status]
1245
- unless status.success? || status.exitstatus == 1
1246
- return render json: { ok: false, content: code }
1247
- end
1248
-
1249
- corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
1250
- render json: { ok: true, content: corrected }
1251
- end
1212
+ result = LintService.autocorrect(workspace_root, path, code)
1213
+ render json: { ok: result[:ok], content: result[:content] }
1252
1214
  rescue StandardError => e
1253
1215
  render json: { error: e.message }, status: :unprocessable_content
1254
1216
  end
@@ -1306,36 +1268,6 @@ module Mbeditor
1306
1268
  raw.start_with?("/") || raw.empty? ? raw : "/#{raw}"
1307
1269
  end
1308
1270
 
1309
- def history_file_path(branch, rel_path)
1310
- branch_hash = Digest::SHA256.hexdigest(branch.to_s)[0, 16]
1311
- file_hash = Digest::SHA256.hexdigest(rel_path.to_s)[0, 16]
1312
- workspace_root.join('tmp', 'mbeditor_history', "#{branch_hash}_#{file_hash}.json")
1313
- end
1314
-
1315
- def compact_history_ops(base, ops)
1316
- text = base.to_s
1317
- ops.each do |op|
1318
- 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
1319
- lines = text.split("\n", -1)
1320
- sl0 = [[sl - 1, 0].max, [lines.length - 1, 0].max].min
1321
- el0 = [[el - 1, 0].max, [lines.length - 1, 0].max].min
1322
- sc0 = sc - 1
1323
- ec0 = ec - 1
1324
- prefix = (lines[sl0] || '')[0, sc0] || ''
1325
- suffix = (lines[el0] || '')[ec0..] || ''
1326
- ins_lines = ins.split("\n", -1)
1327
- new_seg = if ins_lines.length <= 1
1328
- [prefix + (ins_lines[0] || '') + suffix]
1329
- else
1330
- [prefix + ins_lines[0]] + ins_lines[1..-2] + [ins_lines[-1] + suffix]
1331
- end
1332
- text = (lines[0...sl0] + new_seg + lines[(el0 + 1)..]).join("\n")
1333
- end
1334
- text
1335
- rescue StandardError
1336
- base.to_s
1337
- end
1338
-
1339
1271
  # structural: false means "these files' contents changed, the tree did not"
1340
1272
  # — a save or a replace-in-files. The client uses it to skip re-walking the
1341
1273
  # whole workspace and rebuilding its quick-open index on every save, which
@@ -1397,17 +1329,8 @@ module Mbeditor
1397
1329
  @editor_state_service ||= EditorStateService.new(workspace_root)
1398
1330
  end
1399
1331
 
1400
- # Acquire an exclusive lock without blocking forever, so a stuck holder (e.g.
1401
- # a request paused at a breakpoint mid-write) cannot wedge history saves and
1402
- # pin a worker indefinitely. Raises on timeout; the caller's rescue turns it
1403
- # into a fast error response instead of a hang.
1404
- HISTORY_LOCK_TIMEOUT = 5.0
1405
- def flock_exclusive_with_timeout!(file, timeout: HISTORY_LOCK_TIMEOUT)
1406
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
1407
- until file.flock(File::LOCK_EX | File::LOCK_NB)
1408
- raise "could not acquire history lock within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1409
- sleep 0.01
1410
- end
1332
+ def file_history_service
1333
+ @file_history_service ||= FileHistoryService.new(workspace_root)
1411
1334
  end
1412
1335
 
1413
1336
  def sanitize_branch_name(branch)
@@ -1541,19 +1464,6 @@ module Mbeditor
1541
1464
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1542
1465
  end
1543
1466
 
1544
- def run_with_timeout(env, cmd, stdin_data:)
1545
- run_lint_command(env, cmd, stdin_data: stdin_data)[:stdout]
1546
- end
1547
-
1548
- # Same lint_timeout ceiling as #lint. quick_fix, format_file and haml-lint
1549
- # used to spawn with Open3.capture3 and no timeout at all, so a wedged
1550
- # rubocop held a request thread until the client gave up.
1551
- def run_lint_command(env, cmd, stdin_data: nil)
1552
- timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
1553
- timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
1554
- ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
1555
- end
1556
-
1557
1467
  def apply_rename_changes(result, open_paths)
1558
1468
  changes = result.is_a?(Hash) ? (result["changes"] || {}) : {}
1559
1469
  open = open_paths.to_set
@@ -1562,7 +1472,7 @@ module Mbeditor
1562
1472
  edits_for_open = {}
1563
1473
 
1564
1474
  changes.each do |uri, raw_edits|
1565
- rel = workspace_relative_uri(uri.to_s)
1475
+ rel = RubyLspResultTranslator.workspace_relative_uri(uri.to_s, workspace_root)
1566
1476
  # Outside the workspace, or a path we refuse to write: record it so a
1567
1477
  # partial rename is visibly partial rather than silently so.
1568
1478
  target = rel && resolve_path(rel)
@@ -1639,217 +1549,18 @@ module Mbeditor
1639
1549
  { available: false, disabled: false, state: :failed, restarts: 0, error: e.message }
1640
1550
  end
1641
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.
1642
1557
  def translate_ruby_lsp_result(kind, result, uri = nil)
1643
1558
  case kind
1644
- when "definition" then { results: translate_lsp_locations(result) }
1645
- when "hover" then { markdown: translate_lsp_hover(result) }
1646
- 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)
1647
1562
  when "diagnostics" then LspDiagnosticsTranslator.call(result, uri)
1648
- when *RUBY_LSP_RAW then { result: sanitize_lsp_uris(result) }
1649
- end
1650
- end
1651
-
1652
- # The one trust boundary for every raw-passthrough method. Walks the LSP
1653
- # response and rewrites each file:// URI to a workspace-relative path,
1654
- # dropping any object that points outside the workspace — a reference in a
1655
- # gem is not something this editor can open, and a path outside the root is
1656
- # not something it should hand to the browser at all.
1657
- #
1658
- # Recursion is bounded by MAX_LSP_DEPTH: the payload comes from a
1659
- # subprocess, and a cyclic or pathologically nested one must not take the
1660
- # request thread down with it.
1661
- MAX_LSP_DEPTH = 32
1662
-
1663
- URI_KEYS = %w[uri targetUri].freeze
1664
-
1665
- # URI::DEFAULT_PARSER became the RFC3986 parser in Ruby 4.0, where #unescape
1666
- # is deprecated; URI::RFC2396_PARSER only exists from Ruby 3.4. The gem
1667
- # supports >= 3.0, so take whichever this Ruby has.
1668
- URI_UNESCAPER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
1669
-
1670
- def sanitize_lsp_uris(node, depth = 0)
1671
- return nil if depth > MAX_LSP_DEPTH
1672
-
1673
- case node
1674
- when Array
1675
- node.filter_map { |child| sanitize_lsp_uris(child, depth + 1) }
1676
- when Hash
1677
- sanitized = {}
1678
- node.each do |key, value|
1679
- if URI_KEYS.include?(key) && value.is_a?(String)
1680
- rel = workspace_relative_uri(value)
1681
- # A URI we can't place inside the workspace disqualifies its object.
1682
- return nil unless rel
1683
-
1684
- sanitized[key] = rel
1685
- else
1686
- child = sanitize_lsp_uris(value, depth + 1)
1687
- sanitized[key] = child unless child.nil? && !value.nil?
1688
- end
1689
- end
1690
- sanitized
1691
- when String
1692
- sanitize_lsp_markdown(node)
1693
- else
1694
- node
1695
- end
1696
- end
1697
-
1698
- # URIs also turn up *inside* strings: ruby-lsp's signatureHelp and hover
1699
- # documentation embed a "Definitions" line of file:// markdown links. Those
1700
- # would leak absolute host paths and render as links that go nowhere, so
1701
- # they get the same treatment hover already gives them.
1702
- def sanitize_lsp_markdown(text)
1703
- return text unless text.include?("file://")
1704
-
1705
- rewritten = rewrite_lsp_hover_links(text)
1706
- return rewritten unless rewritten.include?("file://")
1707
-
1708
- # Backstop for any file:// URI that wasn't in markdown-link form. An
1709
- # absolute host path must never reach the browser, linkable or not.
1710
- rewritten.gsub(%r{file://\S*}) do |raw|
1711
- workspace_relative_uri(raw.sub(/[)\]\s].*\z/m, "")) || "(external)"
1712
- end
1713
- end
1714
-
1715
- def workspace_relative_uri(uri)
1716
- return nil unless uri.start_with?("file://")
1717
-
1718
- # ruby-lsp percent-escapes its URIs; workspace_root is a raw path. A
1719
- # checkout with a space (or any other escaped character) in its path
1720
- # matched nothing here, so every result was silently dropped.
1721
- path = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
1722
- prefix = "#{workspace_root}/"
1723
- return nil unless path.start_with?(prefix)
1724
-
1725
- path.delete_prefix(prefix)
1726
- end
1727
-
1728
- def translate_lsp_locations(result)
1729
- items = result.is_a?(Array) ? result : [result].compact
1730
- root = workspace_root.to_s
1731
- items.filter_map do |loc|
1732
- next unless loc.is_a?(Hash)
1733
-
1734
- uri = loc["uri"] || loc["targetUri"]
1735
- range = loc["range"] || loc["targetSelectionRange"] || loc["targetRange"]
1736
- next unless uri.to_s.start_with?("file://")
1737
-
1738
- fpath = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
1739
- # Drop gem/stdlib locations the editor can't open; an empty list makes
1740
- # the frontend fall back to the legacy services (ri covers stdlib).
1741
- next unless fpath.start_with?("#{root}/")
1742
-
1743
- start_line = (range&.dig("start", "line") || 0) + 1
1744
- {
1745
- file: fpath.delete_prefix("#{root}/"),
1746
- line: start_line,
1747
- # Columns and the end of the range are additive: existing consumers
1748
- # only read :file and :line, but peek-definition needs a real range
1749
- # to highlight rather than the start of the line.
1750
- col: (range&.dig("start", "character") || 0) + 1,
1751
- endLine: (range&.dig("end", "line") || range&.dig("start", "line") || 0) + 1,
1752
- endCol: (range&.dig("end", "character") || range&.dig("start", "character") || 0) + 1
1753
- }
1754
- end
1755
- end
1756
-
1757
- def translate_lsp_hover(result)
1758
- contents = result.is_a?(Hash) ? result["contents"] : nil
1759
- return nil if contents.nil?
1760
-
1761
- markdown =
1762
- case contents
1763
- when Hash then contents["value"].to_s
1764
- when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
1765
- else contents.to_s
1766
- end
1767
-
1768
- neutralize_comment_headings(rewrite_lsp_hover_links(markdown))
1769
- end
1770
-
1771
- # ruby-lsp renders a doc comment by stripping exactly one leading "# " from
1772
- # each line, then hands the result to the editor as markdown. A `##`-opened
1773
- # doc block — a very common Ruby convention — therefore arrives as
1774
- # "# Title" and renders as an <h1> filling the hover.
1775
- #
1776
- # Ruby comments are not markdown, so escape a `#` that opens a line and let
1777
- # it render as the text it is. Fenced code blocks are left alone: `#` inside
1778
- # them is Ruby source, not a heading, and needs no escaping.
1779
- #
1780
- # This deliberately diverges from other ruby-lsp clients, which show the
1781
- # heading.
1782
- def neutralize_comment_headings(markdown)
1783
- in_fence = false
1784
- markdown.lines.map do |line|
1785
- in_fence = !in_fence if line.start_with?("```")
1786
- next line if in_fence || line.start_with?("```")
1787
-
1788
- line.sub(/\A(\s*)(#+)(?=\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
1789
- end.join
1790
- end
1791
-
1792
- # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
1793
- # `[user.rb](file:///abs/path/user.rb#L3,1-9,4)`. Monaco renders those as
1794
- # links but clicking one does nothing, since nothing can open a file:// URI
1795
- # here. Point in-workspace links at the `mbeditor.openDefinition` Monaco
1796
- # command (registered in editor_plugins.js) and demote gem/stdlib links —
1797
- # which the editor cannot open at all — to plain code spans.
1798
- LSP_HOVER_FILE_LINK = %r{\[([^\]\n]+)\]\(file://([^)\s#]+)(?:\#L(\d+),\d+(?:-\d+,\d+)?)?\)}
1799
-
1800
- def rewrite_lsp_hover_links(markdown)
1801
- prefix = "#{workspace_root}/"
1802
- markdown.gsub(LSP_HOVER_FILE_LINK) do
1803
- label, raw_path, line = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3).to_i
1804
- # Percent-decode by hand: URI's unescape helpers are deprecated on new
1805
- # Rubies and their replacements are missing on the old ones we support.
1806
- # (Must come after reading the other captures — gsub resets last_match.)
1807
- path = raw_path.gsub(/%\h\h/) { |esc| esc[1..].hex.chr }.force_encoding(Encoding::UTF_8)
1808
-
1809
- if path.start_with?(prefix)
1810
- args = [path.delete_prefix(prefix), line.positive? ? line : 1]
1811
- "[#{label}](command:mbeditor.openDefinition?#{ERB::Util.url_encode(args.to_json)})"
1812
- else
1813
- "`#{label}`"
1814
- end
1815
- end
1816
- end
1817
-
1818
- def translate_lsp_completions(result)
1819
- items = result.is_a?(Hash) ? Array(result["items"]) : Array(result)
1820
- items.first(100).filter_map do |item|
1821
- next unless item.is_a?(Hash)
1822
-
1823
- {
1824
- label: item["label"].to_s,
1825
- kind: lsp_completion_kind(item["kind"]),
1826
- insertText: (item.dig("textEdit", "newText") || item["insertText"] || item["label"]).to_s,
1827
- detail: item["detail"].to_s,
1828
- isSnippet: item["insertTextFormat"] == 2
1829
- }
1830
- end
1831
- end
1832
-
1833
- LSP_COMPLETION_KINDS = {
1834
- 2 => "Method", 3 => "Function", 4 => "Constructor", 5 => "Field",
1835
- 6 => "Variable", 7 => "Class", 8 => "Interface", 9 => "Module",
1836
- 10 => "Property", 14 => "Keyword", 15 => "Snippet", 21 => "Constant"
1837
- }.freeze
1838
-
1839
- def lsp_completion_kind(kind)
1840
- LSP_COMPLETION_KINDS[kind] || "Text"
1841
- end
1842
-
1843
- # Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
1844
- # through ruby-lsp and the same file linted through `rubocop --stdin` grade
1845
- # their offenses identically. rubocop's own `info` is the weakest level and
1846
- # maps to hint; convention/refactor fall through to info.
1847
- def cop_severity(severity)
1848
- case severity
1849
- when "error", "fatal" then "error"
1850
- when "warning" then "warning"
1851
- when "info" then "hint"
1852
- else "info"
1563
+ when *RUBY_LSP_RAW then RubyLspResultTranslator.raw(result, workspace_root: workspace_root)
1853
1564
  end
1854
1565
  end
1855
1566
 
@@ -1907,39 +1618,6 @@ module Mbeditor
1907
1618
  File.directory?(File.join(root, "test")) || File.directory?(File.join(root, "spec"))
1908
1619
  end
1909
1620
 
1910
- def run_haml_lint(code)
1911
- markers = []
1912
- Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
1913
- f.write(code)
1914
- f.flush
1915
- cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
1916
- output = run_lint_command({}, cmd)[:stdout]
1917
- idx = output.index("{")
1918
- result = idx ? JSON.parse(output[idx..]) : {}
1919
- result = {} unless result.is_a?(Hash)
1920
- offenses = result.dig("files", 0, "offenses") || []
1921
- markers = offenses.map do |offense|
1922
- {
1923
- severity: haml_lint_severity(offense["severity"]),
1924
- message: "[#{offense['linter_name']}] #{offense['message']}",
1925
- startLine: offense.dig("location", "line"),
1926
- startCol: (offense.dig("location", "column") || 1) - 1,
1927
- endLine: offense.dig("location", "line"),
1928
- endCol: offense.dig("location", "column") || 1
1929
- }
1930
- end
1931
- end
1932
- markers
1933
- end
1934
-
1935
- def haml_lint_severity(severity)
1936
- case severity
1937
- when "error" then "error"
1938
- when "warning" then "warning"
1939
- else "info"
1940
- end
1941
- end
1942
-
1943
1621
  def resolve_monaco_asset_path(asset_path)
1944
1622
  return nil if asset_path.blank?
1945
1623