mbeditor 0.11.0 → 0.12.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +465 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -21,6 +21,13 @@ module Mbeditor
21
21
  RUBY_DEFS_WARM_MUTEX = Mutex.new
22
22
  TOTAL_LINES_CACHE_MAX = 50
23
23
  TOTAL_LINES_MUTEX = Mutex.new
24
+ # Kept below Rack's multipart_part_limit (128 file parts in Rack 3.2), which
25
+ # is enforced during param parsing — outside the action, where this
26
+ # controller's rescue cannot turn it into a clean 422. A batch larger than
27
+ # the Rack limit raises MultipartPartLimitError and surfaces as a 500, so
28
+ # this guard is only reachable if it trips first.
29
+ IMPORT_MAX_FILES = 100
30
+ IMPORT_MAX_TOTAL_BYTES = 50 * 1024 * 1024
24
31
 
25
32
  class << self
26
33
  # Small (path, mtime) => total-line-count cache so windowed reads of big
@@ -68,7 +75,11 @@ module Mbeditor
68
75
  testAvailable: test_available?,
69
76
  actionCableEnabled: action_cable_enabled?,
70
77
  jsSyntaxCheckAvailable: JsSyntaxCheckService.available?,
71
- rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root)
78
+ rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root),
79
+ # "rg" | "git" | "grep". The tiers differ by 10-30x, and the usual
80
+ # reason for a slow search is ripgrep being installed but absent from
81
+ # the server process's PATH — which is invisible without this.
82
+ searchBackend: SearchReplaceService.backend(workspace_root)
72
83
  }
73
84
  end
74
85
 
@@ -315,6 +326,7 @@ module Mbeditor
315
326
 
316
327
  result = FileOperationService.new(workspace_root).save(path, params[:code].to_s)
317
328
  broadcast_files_changed([path])
329
+ broadcast_file_saved(path)
318
330
  render json: result
319
331
  rescue FileOperationService::FileTooLargeError
320
332
  render_file_too_large(params[:code].to_s.bytesize)
@@ -354,6 +366,42 @@ module Mbeditor
354
366
  render json: { error: e.message }, status: :unprocessable_content
355
367
  end
356
368
 
369
+ # POST /mbeditor/import — write files dragged in from outside the browser.
370
+ #
371
+ # Multipart: files[] carries the bodies, paths[] the parallel list of
372
+ # workspace-relative targets, on_conflict one of ask/overwrite/rename.
373
+ # A structurally invalid batch is a 422; per-entry problems come back in
374
+ # the :errors array so one bad path never sinks the whole drop.
375
+ def import
376
+ files = Array(params[:files])
377
+ paths = Array(params[:paths]).map(&:to_s)
378
+ mode = params[:on_conflict].presence || "ask"
379
+
380
+ error = import_batch_error(files, paths, mode)
381
+ return render json: { error: error }, status: :unprocessable_content if error
382
+
383
+ entries = []
384
+ errors = []
385
+ files.each_with_index do |file, i|
386
+ full = resolve_path(paths[i])
387
+ if full.nil? || path_blocked_for_operations?(full)
388
+ errors << { path: paths[i], error: "Cannot write to this path" }
389
+ else
390
+ entries << { target_path: full, io: file }
391
+ end
392
+ end
393
+
394
+ result = FileImportService.new(workspace_root).import(entries, on_conflict: mode.to_sym)
395
+ result[:errors] = errors + result[:errors]
396
+
397
+ written = result[:imported].map { |e| File.join(workspace_root.to_s, e[:path]) }
398
+ broadcast_files_changed(written) if written.any?
399
+
400
+ render json: result
401
+ rescue StandardError => e
402
+ render json: { error: e.message }, status: :unprocessable_content
403
+ end
404
+
357
405
  # PATCH /mbeditor/rename — rename file or directory
358
406
  def rename
359
407
  old_path = resolve_path(params[:path])
@@ -477,20 +525,68 @@ module Mbeditor
477
525
  render json: { ok: false, error: e.message }, status: :unprocessable_content
478
526
  end
479
527
 
528
+ # The whitelist is the trust boundary: only these reach the language server.
529
+ # Symbol values are handled locally instead of being forwarded; they need no
530
+ # path, no document and no running process.
531
+ #
532
+ # The first four have bespoke translators producing 1-based, editor-shaped
533
+ # payloads, kept for their existing consumers. Everything added since is in
534
+ # RUBY_LSP_RAW below: raw LSP JSON, 0-based, through one URI sanitizer. Eight
535
+ # more hand-written translators would be ~200 lines restating shapes Monaco
536
+ # already understands.
480
537
  RUBY_LSP_METHODS = {
481
- "definition" => "textDocument/definition",
482
- "hover" => "textDocument/hover",
483
- "completion" => "textDocument/completion",
484
- "diagnostics" => "textDocument/diagnostic"
538
+ "definition" => "textDocument/definition",
539
+ "hover" => "textDocument/hover",
540
+ "completion" => "textDocument/completion",
541
+ "diagnostics" => "textDocument/diagnostic",
542
+ "references" => "textDocument/references",
543
+ "document_highlight" => "textDocument/documentHighlight",
544
+ "document_symbol" => "textDocument/documentSymbol",
545
+ "folding_range" => "textDocument/foldingRange",
546
+ "formatting" => "textDocument/formatting",
547
+ "signature_help" => "textDocument/signatureHelp",
548
+ "selection_range" => "textDocument/selectionRange",
549
+ "prepare_rename" => "textDocument/prepareRename",
550
+ "health" => :health,
551
+ "restart" => :restart
485
552
  }.freeze
486
553
 
487
- # Diagnostics are whole-document, not positional.
488
- RUBY_LSP_POSITIONLESS = %w[textDocument/diagnostic].freeze
554
+ # Passed through as raw LSP JSON rather than translated. Ranges stay 0-based
555
+ # on the wire and are converted by one helper at the Monaco provider.
556
+ RUBY_LSP_RAW = %w[
557
+ references document_highlight document_symbol folding_range
558
+ formatting signature_help selection_range prepare_rename
559
+ ].freeze
560
+
561
+ # Whole-document requests, which carry no cursor position.
562
+ RUBY_LSP_POSITIONLESS = %w[
563
+ textDocument/diagnostic textDocument/documentSymbol textDocument/foldingRange
564
+ textDocument/formatting
565
+ ].freeze
566
+
567
+ # Extra params per method, merged into the request alongside the position.
568
+ # Values may be a hash or a callable taking the params hash.
569
+ RUBY_LSP_EXTRA_PARAMS = {
570
+ "textDocument/references" => { context: { includeDeclaration: true } },
571
+ # selectionRange takes a list of positions, not the single `position` the
572
+ # positional branch supplies, so it builds its own from the same params.
573
+ "textDocument/selectionRange" => lambda { |p|
574
+ { positions: [{ line: [p[:line].to_i - 1, 0].max,
575
+ character: [p[:character].to_i - 1, 0].max }] }
576
+ },
577
+ "textDocument/formatting" => lambda { |p|
578
+ { options: { tabSize: (p[:tab_size].presence || 2).to_i,
579
+ insertSpaces: p[:insert_spaces].to_s != "false" } }
580
+ }
581
+ }.freeze
489
582
 
490
583
  # RuboCop's first run inside a freshly booted ruby-lsp is far slower than a
491
- # hover/definition lookup, so diagnostics get their own budget.
584
+ # hover/definition lookup, so the requests that go through RuboCop —
585
+ # diagnostics and formatting — get their own budget.
492
586
  RUBY_LSP_DIAGNOSTICS_TIMEOUT = 10
493
587
 
588
+ RUBY_LSP_RUBOCOP_METHODS = %w[textDocument/diagnostic textDocument/formatting].freeze
589
+
494
590
  # POST /mbeditor/ruby_lsp — bridge to the host's ruby-lsp process.
495
591
  # Body: { path:, content:, line: (1-based), character: (1-based), lsp_method: }
496
592
  # Translates LSP responses into the shapes the frontend providers already
@@ -499,6 +595,9 @@ module Mbeditor
499
595
  lsp_method = RUBY_LSP_METHODS[params[:lsp_method].to_s]
500
596
  return render json: { error: "Invalid lsp_method" }, status: :bad_request unless lsp_method
501
597
 
598
+ # Before resolve_path: health and restart carry no document.
599
+ return render json: ruby_lsp_health(restart: lsp_method == :restart) if lsp_method.is_a?(Symbol)
600
+
502
601
  path = resolve_path(params[:path])
503
602
  return render json: { error: "Invalid path" }, status: :bad_request unless path
504
603
 
@@ -508,27 +607,129 @@ module Mbeditor
508
607
  end
509
608
 
510
609
  unless AvailabilityProbe.ruby_lsp(workspace_root)
511
- return render json: { error: "ruby-lsp unavailable", rubyLspAvailable: false }, status: :unprocessable_content
610
+ return render json: { error: "ruby-lsp unavailable",
611
+ reason: ruby_lsp_unavailable_reason,
612
+ rubyLspAvailable: false }, status: :unprocessable_content
512
613
  end
513
614
 
514
- extra = if RUBY_LSP_POSITIONLESS.include?(lsp_method)
615
+ positionless = RUBY_LSP_POSITIONLESS.include?(lsp_method)
616
+ extra = if positionless
515
617
  {}
516
618
  else
517
619
  # Monaco positions are 1-based; LSP positions are 0-based.
518
620
  { position: { line: [params[:line].to_i - 1, 0].max,
519
621
  character: [params[:character].to_i - 1, 0].max } }
520
622
  end
521
- timeout = RUBY_LSP_POSITIONLESS.include?(lsp_method) ? RUBY_LSP_DIAGNOSTICS_TIMEOUT : nil
623
+ per_method = RUBY_LSP_EXTRA_PARAMS[lsp_method]
624
+ per_method = per_method.call(params) if per_method.respond_to?(:call)
625
+ extra = extra.merge(per_method || {})
626
+ # Only the two RuboCop-backed requests pay its boot cost; documentSymbol,
627
+ # foldingRange and the rest are a single Prism parse and want the normal
628
+ # budget.
629
+ timeout = RUBY_LSP_RUBOCOP_METHODS.include?(lsp_method) ? RUBY_LSP_DIAGNOSTICS_TIMEOUT : nil
522
630
 
523
631
  client = RubyLspClient.for(workspace_root.to_s)
524
632
  result = client.request_with_document(lsp_method, path, content, extra, timeout: timeout)
525
- render json: translate_ruby_lsp_result(params[:lsp_method].to_s, result)
633
+ # The URI is what the diagnostics translator checks embedded code-action
634
+ # edits against, so it must be the same string the client sent.
635
+ render json: translate_ruby_lsp_result(params[:lsp_method].to_s, result, "file://#{path}")
526
636
  rescue RubyLspClient::TimeoutError, RubyLspClient::NotReadyError
527
- render json: { fallback: true }
637
+ # lspState lets the frontend tell "this one request was slow" from "the
638
+ # server is dead", and stop asking in the latter case.
639
+ render json: { fallback: true, lspState: RubyLspClient.for(workspace_root.to_s).state }
528
640
  rescue StandardError => e
529
641
  render json: { error: e.message, fallback: true }
530
642
  end
531
643
 
644
+ # ruby-lsp renames constants only (Rename#perform locates
645
+ # ConstantReadNode | ConstantPathNode | ConstantPathTargetNode and nothing
646
+ # else), so anything that isn't a constant path is rejected before we ask.
647
+ RUBY_CONSTANT_PATH = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
648
+
649
+ # Rename#collect_text_edits globs **/*.rb and Prism-parses every hit, which
650
+ # is seconds on a large app rather than the usual milliseconds.
651
+ RUBY_LSP_RENAME_TIMEOUT = 30
652
+
653
+ # POST /mbeditor/ruby_rename — rename a Ruby constant across the workspace.
654
+ #
655
+ # Body: { path:, content:, line:, character:, new_name:, open_paths: [] }
656
+ #
657
+ # The workspace edit is split by whether the file is open in the editor:
658
+ #
659
+ # - open files -> edits returned for Monaco to apply, so the change is
660
+ # undoable, marks the tab dirty, and respects the
661
+ # buffer the user is actually looking at
662
+ # - closed files -> written here, then announced over the cable
663
+ #
664
+ # That split is what makes unsaved work safe: anything dirty is by
665
+ # definition open, and open files are never written by the server.
666
+ def ruby_rename
667
+ path = resolve_path(params[:path])
668
+ return render json: { error: "Invalid path" }, status: :bad_request unless path
669
+
670
+ new_name = params[:new_name].to_s.strip
671
+ unless new_name.match?(RUBY_CONSTANT_PATH)
672
+ return render json: { error: "Only Ruby constants can be renamed, and #{new_name.inspect} is not one." },
673
+ status: :unprocessable_content
674
+ end
675
+
676
+ content = params[:content].to_s
677
+ if content.bytesize > FileOperationService::MAX_FILE_SIZE_BYTES
678
+ return render json: { error: "Content too large" }, status: :content_too_large
679
+ end
680
+
681
+ unless AvailabilityProbe.ruby_lsp(workspace_root)
682
+ return render json: { error: "ruby-lsp unavailable", reason: ruby_lsp_unavailable_reason,
683
+ rubyLspAvailable: false }, status: :unprocessable_content
684
+ end
685
+
686
+ result = RubyLspClient.for(workspace_root.to_s).request_with_document(
687
+ "textDocument/rename", path, content,
688
+ { position: { line: [params[:line].to_i - 1, 0].max,
689
+ character: [params[:character].to_i - 1, 0].max },
690
+ newName: new_name },
691
+ timeout: RUBY_LSP_RENAME_TIMEOUT
692
+ )
693
+
694
+ render json: apply_rename_changes(result, Array(params[:open_paths]).map(&:to_s))
695
+ rescue RubyLspClient::TimeoutError, RubyLspClient::NotReadyError
696
+ render json: { error: "ruby-lsp did not answer in time", fallback: true,
697
+ lspState: RubyLspClient.for(workspace_root.to_s).state },
698
+ status: :unprocessable_content
699
+ rescue StandardError => e
700
+ # ruby-lsp raises InvalidNameError ("already in use by X") for a clash;
701
+ # its message is the most useful thing we can show.
702
+ render json: { error: e.message }, status: :unprocessable_content
703
+ end
704
+
705
+ # GET /mbeditor/model_graph — ActiveRecord models and their associations.
706
+ #
707
+ # Cached until a model or migration file changes; `refresh=1` forces a
708
+ # rebuild. Generating it eager-loads the host app, which is why this is
709
+ # only requested when the Models tab is opened.
710
+ def model_graph
711
+ ModelGraphService.invalidate(workspace_root) if params[:refresh].present?
712
+ render json: ModelGraphService.call(workspace_root)
713
+ rescue StandardError => e
714
+ render json: { ok: false, error: e.message, models: [], edges: [] },
715
+ status: :unprocessable_content
716
+ end
717
+
718
+ # GET /mbeditor/exceptions — recorded host-app exceptions, newest first.
719
+ #
720
+ # The cable push is the live path; this seeds the panel on load and covers
721
+ # hosts where ActionCable isn't available.
722
+ def exceptions
723
+ render json: { exceptions: ExceptionLog.entries,
724
+ enabled: Mbeditor.configuration.exception_capture != false }
725
+ end
726
+
727
+ # DELETE /mbeditor/exceptions — clear the recorded exceptions.
728
+ def clear_exceptions
729
+ ExceptionLog.clear!
730
+ render json: { ok: true }
731
+ end
732
+
532
733
  # GET /mbeditor/module_members?name=ArticlesHelper
533
734
  # Returns methods defined in the workspace file that defines the named module/class.
534
735
  def module_members
@@ -762,7 +963,11 @@ module Mbeditor
762
963
  startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
763
964
  startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
764
965
  endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
765
- endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1
966
+ endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1,
967
+ # Same predicate the ruby-lsp path uses, so dead code fades whichever
968
+ # linter produced the offense. Plain rubocop JSON carries no
969
+ # code_description, so there's no codeHref to pass on here.
970
+ unnecessary: LspDiagnosticsTranslator.unnecessary?(offense["cop_name"])
766
971
  }
767
972
  end
768
973
 
@@ -862,7 +1067,8 @@ module Mbeditor
862
1067
  # GET /mbeditor/client_config — returns client-side configuration values
863
1068
  def client_config
864
1069
  render json: {
865
- related_files_custom_paths: Array(Mbeditor.configuration.related_files_custom_paths)
1070
+ related_files_custom_paths: Array(Mbeditor.configuration.related_files_custom_paths),
1071
+ user_name: resolved_user_name
866
1072
  }
867
1073
  end
868
1074
 
@@ -935,6 +1141,21 @@ module Mbeditor
935
1141
 
936
1142
  private
937
1143
 
1144
+ # Resolve the optional user_name_callback in controller context (like
1145
+ # authenticate_with) so the host app can supply a collaboration display name.
1146
+ # nil — including any failure or a blank result — falls through to the
1147
+ # client-generated name.
1148
+ def resolved_user_name
1149
+ cb = Mbeditor.configuration.user_name_callback
1150
+ return nil unless cb
1151
+
1152
+ name = instance_exec(&cb)
1153
+ name = name.to_s.strip
1154
+ name.empty? ? nil : name
1155
+ rescue StandardError
1156
+ nil
1157
+ end
1158
+
938
1159
  # Normalized base prefix mbeditor renders URLs against. Sourced from
939
1160
  # MountPath (not the engine `root_path` helper) so it still resolves when a
940
1161
  # broken host config/routes.rb has wiped Mbeditor::Engine.routes — the exact
@@ -996,6 +1217,18 @@ module Mbeditor
996
1217
  # Never let a broadcast failure affect the HTTP response
997
1218
  end
998
1219
 
1220
+ # Tells every peer that this file's shared buffer was just saved to disk so
1221
+ # they can reset that tab's clean baseline and clear its dirty indicator.
1222
+ # Rides the same global stream as broadcast_files_changed and is equally
1223
+ # resilient: a relay failure must never affect the HTTP response.
1224
+ def broadcast_file_saved(path)
1225
+ return unless defined?(ActionCable.server)
1226
+
1227
+ ActionCable.server.broadcast("mbeditor_editor", { type: "file_saved", path: relative_path(path) })
1228
+ rescue StandardError
1229
+ # Never let a broadcast failure affect the HTTP response
1230
+ end
1231
+
999
1232
  def editor_state_service
1000
1233
  @editor_state_service ||= EditorStateService.new(workspace_root)
1001
1234
  end
@@ -1117,6 +1350,29 @@ module Mbeditor
1117
1350
  ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root).excluded?(rel)
1118
1351
  end
1119
1352
 
1353
+ # Returns a message when the import batch is structurally unusable, nil
1354
+ # when it is worth handing to FileImportService.
1355
+ def import_batch_error(files, paths, mode)
1356
+ return "Nothing to import" if files.empty?
1357
+ return "files and paths must be the same length" unless files.length == paths.length
1358
+ unless FileImportService::CONFLICT_MODES.include?(mode.to_s.to_sym)
1359
+ return "Unknown on_conflict: #{mode}"
1360
+ end
1361
+ # A String responds to #size but not #read, so this also rejects a batch
1362
+ # that smuggles plain params in through files[].
1363
+ unless files.all? { |f| f.respond_to?(:read) && f.respond_to?(:size) }
1364
+ return "files must be uploaded files"
1365
+ end
1366
+ return "Too many files — #{IMPORT_MAX_FILES} maximum per drop." if files.length > IMPORT_MAX_FILES
1367
+
1368
+ total = files.sum { |f| f.size.to_i }
1369
+ if total > IMPORT_MAX_TOTAL_BYTES
1370
+ return "Drop is too large (#{human_size(total)}). Limit is #{human_size(IMPORT_MAX_TOTAL_BYTES)}."
1371
+ end
1372
+
1373
+ nil
1374
+ end
1375
+
1120
1376
  def ruby_def_include_dirs
1121
1377
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1122
1378
  end
@@ -1128,15 +1384,169 @@ module Mbeditor
1128
1384
  result[:stdout]
1129
1385
  end
1130
1386
 
1131
- def translate_ruby_lsp_result(kind, result)
1387
+ def apply_rename_changes(result, open_paths)
1388
+ changes = result.is_a?(Hash) ? (result["changes"] || {}) : {}
1389
+ open = open_paths.to_set
1390
+ written = []
1391
+ rejected = []
1392
+ edits_for_open = {}
1393
+
1394
+ changes.each do |uri, raw_edits|
1395
+ rel = workspace_relative_uri(uri.to_s)
1396
+ # Outside the workspace, or a path we refuse to write: record it so a
1397
+ # partial rename is visibly partial rather than silently so.
1398
+ target = rel && resolve_path(rel)
1399
+ if rel.nil? || target.nil? || path_blocked_for_operations?(target)
1400
+ rejected << (rel || uri.to_s)
1401
+ next
1402
+ end
1403
+
1404
+ edits = Array(raw_edits).filter_map { |e| LspDiagnosticsTranslator.sanitize_edit(e) }
1405
+ next if edits.empty?
1406
+
1407
+ if open.include?(rel)
1408
+ edits_for_open[rel] = edits
1409
+ else
1410
+ # ponytail: no transaction. A failure part-way leaves some files
1411
+ # renamed and some not; `written` says which. Upgrade path is
1412
+ # write-to-temp then rename-all if that ever bites.
1413
+ FileOperationService.new(workspace_root).save(target, apply_edits(File.read(target), edits))
1414
+ written << rel
1415
+ end
1416
+ end
1417
+
1418
+ broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }) if written.any?
1419
+ { ok: true, written: written, rejected: rejected, edits: edits_for_open }
1420
+ end
1421
+
1422
+ # Applies 1-based text edits to a source string. Sorted descending so an
1423
+ # earlier edit never invalidates a later one's offsets.
1424
+ def apply_edits(source, edits)
1425
+ lines = source.split("\n", -1)
1426
+ edits.sort_by { |e| [-e[:startLine], -e[:startCol]] }.each do |edit|
1427
+ next unless edit[:startLine] == edit[:endLine] # ruby-lsp's renames are single-line
1428
+
1429
+ line = lines[edit[:startLine] - 1]
1430
+ next if line.nil?
1431
+
1432
+ lines[edit[:startLine] - 1] =
1433
+ line[0, edit[:startCol] - 1].to_s + edit[:text].to_s + line[(edit[:endCol] - 1)..].to_s
1434
+ end
1435
+ lines.join("\n")
1436
+ end
1437
+
1438
+ def ruby_lsp_unavailable_reason
1439
+ return "Disabled by configuration (config.mbeditor.ruby_lsp = false)" if Mbeditor.configuration.ruby_lsp == false
1440
+
1441
+ "ruby-lsp is not installed in this workspace"
1442
+ end
1443
+
1444
+ # Status for the editor's ruby-lsp indicator, and the recovery path behind
1445
+ # clicking it. Never returns the resolved command — that would leak absolute
1446
+ # host paths to the browser.
1447
+ def ruby_lsp_health(restart: false)
1448
+ if restart
1449
+ # ponytail: reset! clears every probe, not just ruby-lsp. Harmless (they
1450
+ # all re-probe on next use) and the alternative is a per-key API nothing
1451
+ # else wants.
1452
+ AvailabilityProbe.reset!
1453
+ RubyLspClient.for(workspace_root.to_s).reset!
1454
+ end
1455
+
1456
+ available = AvailabilityProbe.ruby_lsp(workspace_root)
1457
+ health = available ? RubyLspClient.for(workspace_root.to_s).health : {}
1458
+
1459
+ payload = {
1460
+ available: available,
1461
+ disabled: Mbeditor.configuration.ruby_lsp == false,
1462
+ state: health[:state] || :stopped,
1463
+ restarts: health[:restarts] || 0,
1464
+ error: health[:error]
1465
+ }
1466
+ payload[:reason] = ruby_lsp_unavailable_reason unless available
1467
+ payload
1468
+ rescue StandardError => e
1469
+ { available: false, disabled: false, state: :failed, restarts: 0, error: e.message }
1470
+ end
1471
+
1472
+ def translate_ruby_lsp_result(kind, result, uri = nil)
1132
1473
  case kind
1133
1474
  when "definition" then { results: translate_lsp_locations(result) }
1134
1475
  when "hover" then { markdown: translate_lsp_hover(result) }
1135
1476
  when "completion" then { suggestions: translate_lsp_completions(result) }
1136
- when "diagnostics" then LspDiagnosticsTranslator.call(result)
1477
+ when "diagnostics" then LspDiagnosticsTranslator.call(result, uri)
1478
+ when *RUBY_LSP_RAW then { result: sanitize_lsp_uris(result) }
1479
+ end
1480
+ end
1481
+
1482
+ # The one trust boundary for every raw-passthrough method. Walks the LSP
1483
+ # response and rewrites each file:// URI to a workspace-relative path,
1484
+ # dropping any object that points outside the workspace — a reference in a
1485
+ # gem is not something this editor can open, and a path outside the root is
1486
+ # not something it should hand to the browser at all.
1487
+ #
1488
+ # Recursion is bounded by MAX_LSP_DEPTH: the payload comes from a
1489
+ # subprocess, and a cyclic or pathologically nested one must not take the
1490
+ # request thread down with it.
1491
+ MAX_LSP_DEPTH = 32
1492
+
1493
+ URI_KEYS = %w[uri targetUri].freeze
1494
+
1495
+ def sanitize_lsp_uris(node, depth = 0)
1496
+ return nil if depth > MAX_LSP_DEPTH
1497
+
1498
+ case node
1499
+ when Array
1500
+ node.filter_map { |child| sanitize_lsp_uris(child, depth + 1) }
1501
+ when Hash
1502
+ sanitized = {}
1503
+ node.each do |key, value|
1504
+ if URI_KEYS.include?(key) && value.is_a?(String)
1505
+ rel = workspace_relative_uri(value)
1506
+ # A URI we can't place inside the workspace disqualifies its object.
1507
+ return nil unless rel
1508
+
1509
+ sanitized[key] = rel
1510
+ else
1511
+ child = sanitize_lsp_uris(value, depth + 1)
1512
+ sanitized[key] = child unless child.nil? && !value.nil?
1513
+ end
1514
+ end
1515
+ sanitized
1516
+ when String
1517
+ sanitize_lsp_markdown(node)
1518
+ else
1519
+ node
1520
+ end
1521
+ end
1522
+
1523
+ # URIs also turn up *inside* strings: ruby-lsp's signatureHelp and hover
1524
+ # documentation embed a "Definitions" line of file:// markdown links. Those
1525
+ # would leak absolute host paths and render as links that go nowhere, so
1526
+ # they get the same treatment hover already gives them.
1527
+ def sanitize_lsp_markdown(text)
1528
+ return text unless text.include?("file://")
1529
+
1530
+ rewritten = rewrite_lsp_hover_links(text)
1531
+ return rewritten unless rewritten.include?("file://")
1532
+
1533
+ # Backstop for any file:// URI that wasn't in markdown-link form. An
1534
+ # absolute host path must never reach the browser, linkable or not.
1535
+ rewritten.gsub(%r{file://\S*}) do |raw|
1536
+ workspace_relative_uri(raw.sub(/[)\]\s].*\z/m, "")) || "(external)"
1137
1537
  end
1138
1538
  end
1139
1539
 
1540
+ def workspace_relative_uri(uri)
1541
+ return nil unless uri.start_with?("file://")
1542
+
1543
+ path = uri.delete_prefix("file://")
1544
+ prefix = "#{workspace_root}/"
1545
+ return nil unless path.start_with?(prefix)
1546
+
1547
+ path.delete_prefix(prefix)
1548
+ end
1549
+
1140
1550
  def translate_lsp_locations(result)
1141
1551
  items = result.is_a?(Array) ? result : [result].compact
1142
1552
  root = workspace_root.to_s
@@ -1152,7 +1562,17 @@ module Mbeditor
1152
1562
  # the frontend fall back to the legacy services (ri covers stdlib).
1153
1563
  next unless fpath.start_with?("#{root}/")
1154
1564
 
1155
- { file: fpath.delete_prefix("#{root}/"), line: (range&.dig("start", "line") || 0) + 1 }
1565
+ start_line = (range&.dig("start", "line") || 0) + 1
1566
+ {
1567
+ file: fpath.delete_prefix("#{root}/"),
1568
+ line: start_line,
1569
+ # Columns and the end of the range are additive: existing consumers
1570
+ # only read :file and :line, but peek-definition needs a real range
1571
+ # to highlight rather than the start of the line.
1572
+ col: (range&.dig("start", "character") || 0) + 1,
1573
+ endLine: (range&.dig("end", "line") || range&.dig("start", "line") || 0) + 1,
1574
+ endCol: (range&.dig("end", "character") || range&.dig("start", "character") || 0) + 1
1575
+ }
1156
1576
  end
1157
1577
  end
1158
1578
 
@@ -1167,7 +1587,28 @@ module Mbeditor
1167
1587
  else contents.to_s
1168
1588
  end
1169
1589
 
1170
- rewrite_lsp_hover_links(markdown)
1590
+ neutralize_comment_headings(rewrite_lsp_hover_links(markdown))
1591
+ end
1592
+
1593
+ # ruby-lsp renders a doc comment by stripping exactly one leading "# " from
1594
+ # each line, then hands the result to the editor as markdown. A `##`-opened
1595
+ # doc block — a very common Ruby convention — therefore arrives as
1596
+ # "# Title" and renders as an <h1> filling the hover.
1597
+ #
1598
+ # Ruby comments are not markdown, so escape a `#` that opens a line and let
1599
+ # it render as the text it is. Fenced code blocks are left alone: `#` inside
1600
+ # them is Ruby source, not a heading, and needs no escaping.
1601
+ #
1602
+ # This deliberately diverges from other ruby-lsp clients, which show the
1603
+ # heading.
1604
+ def neutralize_comment_headings(markdown)
1605
+ in_fence = false
1606
+ markdown.lines.map do |line|
1607
+ in_fence = !in_fence if line.start_with?("```")
1608
+ next line if in_fence || line.start_with?("```")
1609
+
1610
+ line.sub(/\A(\s*)(#+)(?=\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
1611
+ end.join
1171
1612
  end
1172
1613
 
1173
1614
  # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
@@ -1221,10 +1662,15 @@ module Mbeditor
1221
1662
  LSP_COMPLETION_KINDS[kind] || "Text"
1222
1663
  end
1223
1664
 
1665
+ # Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
1666
+ # through ruby-lsp and the same file linted through `rubocop --stdin` grade
1667
+ # their offenses identically. rubocop's own `info` is the weakest level and
1668
+ # maps to hint; convention/refactor fall through to info.
1224
1669
  def cop_severity(severity)
1225
1670
  case severity
1226
1671
  when "error", "fatal" then "error"
1227
1672
  when "warning" then "warning"
1673
+ when "info" then "hint"
1228
1674
  else "info"
1229
1675
  end
1230
1676
  end
@@ -100,11 +100,18 @@ module Mbeditor
100
100
  # Returns the raw unified diff text for all files in the given scope.
101
101
  # scope=local → git diff HEAD (working tree vs HEAD)
102
102
  # scope=branch → git diff <branch-base>..HEAD (same baseline as git_info)
103
+ # The body stays raw diff text for the viewer; what it was compared against
104
+ # (and why it couldn't be) rides along in headers, so an empty diff is no
105
+ # longer indistinguishable from "we had no base and gave up".
103
106
  def combined_diff
104
107
  scope = params[:scope] == 'branch' ? :branch : :local
105
- out = GitCombinedDiffService.new(repo_path: workspace_root, scope: scope).call
108
+ service = GitCombinedDiffService.new(repo_path: workspace_root, scope: scope)
109
+ out = service.call
110
+ response.set_header("X-Mbeditor-Diff-Base", service.base_ref.to_s) if service.base_ref
111
+ response.set_header("X-Mbeditor-Diff-Error", service.error.to_s) if service.error
106
112
  render plain: out, content_type: "text/plain"
107
- rescue StandardError
113
+ rescue StandardError => e
114
+ response.set_header("X-Mbeditor-Diff-Error", e.message.to_s)
108
115
  render plain: "", content_type: "text/plain"
109
116
  end
110
117