mbeditor 0.10.1 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -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 +948 -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 +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -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 +481 -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/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -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])
@@ -462,20 +510,83 @@ module Mbeditor
462
510
  render json: { ok: false, error: e.message }, status: :unprocessable_content
463
511
  end
464
512
 
513
+ # GET /mbeditor/js_program
514
+ # The workspace's own JS source, for Monaco's TypeScript program. With
515
+ # ?path= it returns just that one file, which is how the editor refreshes
516
+ # after a change without re-sending the whole tree.
517
+ def js_program
518
+ if params[:path].present?
519
+ entry = JsProgramService.file(workspace_root, params[:path])
520
+ return render json: { ok: true, file: entry }
521
+ end
522
+
523
+ render json: JsProgramService.call(workspace_root)
524
+ rescue StandardError => e
525
+ render json: { ok: false, error: e.message }, status: :unprocessable_content
526
+ end
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.
465
537
  RUBY_LSP_METHODS = {
466
- "definition" => "textDocument/definition",
467
- "hover" => "textDocument/hover",
468
- "completion" => "textDocument/completion",
469
- "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
470
552
  }.freeze
471
553
 
472
- # Diagnostics are whole-document, not positional.
473
- 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
474
582
 
475
583
  # RuboCop's first run inside a freshly booted ruby-lsp is far slower than a
476
- # 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.
477
586
  RUBY_LSP_DIAGNOSTICS_TIMEOUT = 10
478
587
 
588
+ RUBY_LSP_RUBOCOP_METHODS = %w[textDocument/diagnostic textDocument/formatting].freeze
589
+
479
590
  # POST /mbeditor/ruby_lsp — bridge to the host's ruby-lsp process.
480
591
  # Body: { path:, content:, line: (1-based), character: (1-based), lsp_method: }
481
592
  # Translates LSP responses into the shapes the frontend providers already
@@ -484,6 +595,9 @@ module Mbeditor
484
595
  lsp_method = RUBY_LSP_METHODS[params[:lsp_method].to_s]
485
596
  return render json: { error: "Invalid lsp_method" }, status: :bad_request unless lsp_method
486
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
+
487
601
  path = resolve_path(params[:path])
488
602
  return render json: { error: "Invalid path" }, status: :bad_request unless path
489
603
 
@@ -493,27 +607,129 @@ module Mbeditor
493
607
  end
494
608
 
495
609
  unless AvailabilityProbe.ruby_lsp(workspace_root)
496
- 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
497
613
  end
498
614
 
499
- extra = if RUBY_LSP_POSITIONLESS.include?(lsp_method)
615
+ positionless = RUBY_LSP_POSITIONLESS.include?(lsp_method)
616
+ extra = if positionless
500
617
  {}
501
618
  else
502
619
  # Monaco positions are 1-based; LSP positions are 0-based.
503
620
  { position: { line: [params[:line].to_i - 1, 0].max,
504
621
  character: [params[:character].to_i - 1, 0].max } }
505
622
  end
506
- 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
507
630
 
508
631
  client = RubyLspClient.for(workspace_root.to_s)
509
632
  result = client.request_with_document(lsp_method, path, content, extra, timeout: timeout)
510
- 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}")
511
636
  rescue RubyLspClient::TimeoutError, RubyLspClient::NotReadyError
512
- 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 }
513
640
  rescue StandardError => e
514
641
  render json: { error: e.message, fallback: true }
515
642
  end
516
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
+
517
733
  # GET /mbeditor/module_members?name=ArticlesHelper
518
734
  # Returns methods defined in the workspace file that defines the named module/class.
519
735
  def module_members
@@ -747,7 +963,11 @@ module Mbeditor
747
963
  startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
748
964
  startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
749
965
  endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
750
- 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"])
751
971
  }
752
972
  end
753
973
 
@@ -847,7 +1067,8 @@ module Mbeditor
847
1067
  # GET /mbeditor/client_config — returns client-side configuration values
848
1068
  def client_config
849
1069
  render json: {
850
- 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
851
1072
  }
852
1073
  end
853
1074
 
@@ -920,6 +1141,21 @@ module Mbeditor
920
1141
 
921
1142
  private
922
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
+
923
1159
  # Normalized base prefix mbeditor renders URLs against. Sourced from
924
1160
  # MountPath (not the engine `root_path` helper) so it still resolves when a
925
1161
  # broken host config/routes.rb has wiped Mbeditor::Engine.routes — the exact
@@ -964,6 +1200,7 @@ module Mbeditor
964
1200
  FileTreeService.invalidate(root)
965
1201
  SearchReplaceService.invalidate_cache(root)
966
1202
  JsGlobalsService.invalidate(root)
1203
+ JsProgramService.invalidate(root)
967
1204
  Thread.new do
968
1205
  GitInfoService.invalidate(root)
969
1206
  rescue => e
@@ -980,6 +1217,18 @@ module Mbeditor
980
1217
  # Never let a broadcast failure affect the HTTP response
981
1218
  end
982
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
+
983
1232
  def editor_state_service
984
1233
  @editor_state_service ||= EditorStateService.new(workspace_root)
985
1234
  end
@@ -1101,6 +1350,29 @@ module Mbeditor
1101
1350
  ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root).excluded?(rel)
1102
1351
  end
1103
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
+
1104
1376
  def ruby_def_include_dirs
1105
1377
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1106
1378
  end
@@ -1112,15 +1384,169 @@ module Mbeditor
1112
1384
  result[:stdout]
1113
1385
  end
1114
1386
 
1115
- 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)
1116
1473
  case kind
1117
1474
  when "definition" then { results: translate_lsp_locations(result) }
1118
1475
  when "hover" then { markdown: translate_lsp_hover(result) }
1119
1476
  when "completion" then { suggestions: translate_lsp_completions(result) }
1120
- 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
1121
1520
  end
1122
1521
  end
1123
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)"
1537
+ end
1538
+ end
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
+
1124
1550
  def translate_lsp_locations(result)
1125
1551
  items = result.is_a?(Array) ? result : [result].compact
1126
1552
  root = workspace_root.to_s
@@ -1136,7 +1562,17 @@ module Mbeditor
1136
1562
  # the frontend fall back to the legacy services (ri covers stdlib).
1137
1563
  next unless fpath.start_with?("#{root}/")
1138
1564
 
1139
- { 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
+ }
1140
1576
  end
1141
1577
  end
1142
1578
 
@@ -1151,7 +1587,28 @@ module Mbeditor
1151
1587
  else contents.to_s
1152
1588
  end
1153
1589
 
1154
- 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
1155
1612
  end
1156
1613
 
1157
1614
  # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
@@ -1205,10 +1662,15 @@ module Mbeditor
1205
1662
  LSP_COMPLETION_KINDS[kind] || "Text"
1206
1663
  end
1207
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.
1208
1669
  def cop_severity(severity)
1209
1670
  case severity
1210
1671
  when "error", "fatal" then "error"
1211
1672
  when "warning" then "warning"
1673
+ when "info" then "hint"
1212
1674
  else "info"
1213
1675
  end
1214
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