mbeditor 0.11.0 → 0.12.1

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 +193 -0
  3. data/README.md +190 -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 +916 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -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 +365 -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 +492 -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 +37 -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
@@ -45,7 +45,11 @@ module Mbeditor
45
45
  return nil unless SafePath.within?(root, full)
46
46
 
47
47
  full
48
- rescue Errno::EACCES
48
+ rescue Errno::EACCES, ArgumentError
49
+ # ArgumentError: File.expand_path rejects a null byte in the path. Every
50
+ # caller already treats nil as "refuse this path", so a rejected path
51
+ # becomes a clean 4xx instead of escaping as a 500 — and in a batch
52
+ # operation like #import it stays scoped to the one bad entry.
49
53
  nil
50
54
  end
51
55
 
@@ -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,48 @@ 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
+ # Display name for the collaboration caret. An explicit user_name_callback
1149
+ # wins; otherwise fall back to current_user, when the host's auth library put
1150
+ # it within reach of an ActionController::Base subclass (Devise, Sorcery and
1151
+ # friends do; a current_user hand-written on the host's own
1152
+ # ApplicationController does not — see default_user_name).
1153
+ def resolved_user_name
1154
+ cb = Mbeditor.configuration.user_name_callback
1155
+ name = cb ? instance_exec(&cb) : default_user_name
1156
+ name = name.to_s.strip
1157
+ name.empty? ? nil : name
1158
+ rescue StandardError => e
1159
+ # Was a bare `nil`, which made a broken callback indistinguishable from an
1160
+ # unconfigured one: you got the generated name and no clue why. The usual
1161
+ # cause is a NameError on current_user, and silently swallowing it sent
1162
+ # people looking in the wrong place.
1163
+ Rails.logger.warn("[mbeditor] user name lookup failed: #{e.class}: #{e.message}")
1164
+ nil
1165
+ end
1166
+
1167
+ # Read the display name straight off current_user, trying each configured
1168
+ # attribute in turn. This is what makes an authenticated editor show real
1169
+ # names with no extra wiring; user_name_methods exists so an app whose column
1170
+ # isn't in the default list can name it instead of writing a callback.
1171
+ def default_user_name
1172
+ return nil unless respond_to?(:current_user, true)
1173
+
1174
+ user = current_user
1175
+ return nil unless user
1176
+
1177
+ Array(Mbeditor.configuration.user_name_methods).each do |m|
1178
+ next unless user.respond_to?(m)
1179
+
1180
+ value = user.public_send(m).to_s.strip
1181
+ return value unless value.empty?
1182
+ end
1183
+ nil
1184
+ end
1185
+
938
1186
  # Normalized base prefix mbeditor renders URLs against. Sourced from
939
1187
  # MountPath (not the engine `root_path` helper) so it still resolves when a
940
1188
  # broken host config/routes.rb has wiped Mbeditor::Engine.routes — the exact
@@ -996,6 +1244,18 @@ module Mbeditor
996
1244
  # Never let a broadcast failure affect the HTTP response
997
1245
  end
998
1246
 
1247
+ # Tells every peer that this file's shared buffer was just saved to disk so
1248
+ # they can reset that tab's clean baseline and clear its dirty indicator.
1249
+ # Rides the same global stream as broadcast_files_changed and is equally
1250
+ # resilient: a relay failure must never affect the HTTP response.
1251
+ def broadcast_file_saved(path)
1252
+ return unless defined?(ActionCable.server)
1253
+
1254
+ ActionCable.server.broadcast("mbeditor_editor", { type: "file_saved", path: relative_path(path) })
1255
+ rescue StandardError
1256
+ # Never let a broadcast failure affect the HTTP response
1257
+ end
1258
+
999
1259
  def editor_state_service
1000
1260
  @editor_state_service ||= EditorStateService.new(workspace_root)
1001
1261
  end
@@ -1117,6 +1377,29 @@ module Mbeditor
1117
1377
  ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root).excluded?(rel)
1118
1378
  end
1119
1379
 
1380
+ # Returns a message when the import batch is structurally unusable, nil
1381
+ # when it is worth handing to FileImportService.
1382
+ def import_batch_error(files, paths, mode)
1383
+ return "Nothing to import" if files.empty?
1384
+ return "files and paths must be the same length" unless files.length == paths.length
1385
+ unless FileImportService::CONFLICT_MODES.include?(mode.to_s.to_sym)
1386
+ return "Unknown on_conflict: #{mode}"
1387
+ end
1388
+ # A String responds to #size but not #read, so this also rejects a batch
1389
+ # that smuggles plain params in through files[].
1390
+ unless files.all? { |f| f.respond_to?(:read) && f.respond_to?(:size) }
1391
+ return "files must be uploaded files"
1392
+ end
1393
+ return "Too many files — #{IMPORT_MAX_FILES} maximum per drop." if files.length > IMPORT_MAX_FILES
1394
+
1395
+ total = files.sum { |f| f.size.to_i }
1396
+ if total > IMPORT_MAX_TOTAL_BYTES
1397
+ return "Drop is too large (#{human_size(total)}). Limit is #{human_size(IMPORT_MAX_TOTAL_BYTES)}."
1398
+ end
1399
+
1400
+ nil
1401
+ end
1402
+
1120
1403
  def ruby_def_include_dirs
1121
1404
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1122
1405
  end
@@ -1128,15 +1411,169 @@ module Mbeditor
1128
1411
  result[:stdout]
1129
1412
  end
1130
1413
 
1131
- def translate_ruby_lsp_result(kind, result)
1414
+ def apply_rename_changes(result, open_paths)
1415
+ changes = result.is_a?(Hash) ? (result["changes"] || {}) : {}
1416
+ open = open_paths.to_set
1417
+ written = []
1418
+ rejected = []
1419
+ edits_for_open = {}
1420
+
1421
+ changes.each do |uri, raw_edits|
1422
+ rel = workspace_relative_uri(uri.to_s)
1423
+ # Outside the workspace, or a path we refuse to write: record it so a
1424
+ # partial rename is visibly partial rather than silently so.
1425
+ target = rel && resolve_path(rel)
1426
+ if rel.nil? || target.nil? || path_blocked_for_operations?(target)
1427
+ rejected << (rel || uri.to_s)
1428
+ next
1429
+ end
1430
+
1431
+ edits = Array(raw_edits).filter_map { |e| LspDiagnosticsTranslator.sanitize_edit(e) }
1432
+ next if edits.empty?
1433
+
1434
+ if open.include?(rel)
1435
+ edits_for_open[rel] = edits
1436
+ else
1437
+ # ponytail: no transaction. A failure part-way leaves some files
1438
+ # renamed and some not; `written` says which. Upgrade path is
1439
+ # write-to-temp then rename-all if that ever bites.
1440
+ FileOperationService.new(workspace_root).save(target, apply_edits(File.read(target), edits))
1441
+ written << rel
1442
+ end
1443
+ end
1444
+
1445
+ broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }) if written.any?
1446
+ { ok: true, written: written, rejected: rejected, edits: edits_for_open }
1447
+ end
1448
+
1449
+ # Applies 1-based text edits to a source string. Sorted descending so an
1450
+ # earlier edit never invalidates a later one's offsets.
1451
+ def apply_edits(source, edits)
1452
+ lines = source.split("\n", -1)
1453
+ edits.sort_by { |e| [-e[:startLine], -e[:startCol]] }.each do |edit|
1454
+ next unless edit[:startLine] == edit[:endLine] # ruby-lsp's renames are single-line
1455
+
1456
+ line = lines[edit[:startLine] - 1]
1457
+ next if line.nil?
1458
+
1459
+ lines[edit[:startLine] - 1] =
1460
+ line[0, edit[:startCol] - 1].to_s + edit[:text].to_s + line[(edit[:endCol] - 1)..].to_s
1461
+ end
1462
+ lines.join("\n")
1463
+ end
1464
+
1465
+ def ruby_lsp_unavailable_reason
1466
+ return "Disabled by configuration (config.mbeditor.ruby_lsp = false)" if Mbeditor.configuration.ruby_lsp == false
1467
+
1468
+ "ruby-lsp is not installed in this workspace"
1469
+ end
1470
+
1471
+ # Status for the editor's ruby-lsp indicator, and the recovery path behind
1472
+ # clicking it. Never returns the resolved command — that would leak absolute
1473
+ # host paths to the browser.
1474
+ def ruby_lsp_health(restart: false)
1475
+ if restart
1476
+ # ponytail: reset! clears every probe, not just ruby-lsp. Harmless (they
1477
+ # all re-probe on next use) and the alternative is a per-key API nothing
1478
+ # else wants.
1479
+ AvailabilityProbe.reset!
1480
+ RubyLspClient.for(workspace_root.to_s).reset!
1481
+ end
1482
+
1483
+ available = AvailabilityProbe.ruby_lsp(workspace_root)
1484
+ health = available ? RubyLspClient.for(workspace_root.to_s).health : {}
1485
+
1486
+ payload = {
1487
+ available: available,
1488
+ disabled: Mbeditor.configuration.ruby_lsp == false,
1489
+ state: health[:state] || :stopped,
1490
+ restarts: health[:restarts] || 0,
1491
+ error: health[:error]
1492
+ }
1493
+ payload[:reason] = ruby_lsp_unavailable_reason unless available
1494
+ payload
1495
+ rescue StandardError => e
1496
+ { available: false, disabled: false, state: :failed, restarts: 0, error: e.message }
1497
+ end
1498
+
1499
+ def translate_ruby_lsp_result(kind, result, uri = nil)
1132
1500
  case kind
1133
1501
  when "definition" then { results: translate_lsp_locations(result) }
1134
1502
  when "hover" then { markdown: translate_lsp_hover(result) }
1135
1503
  when "completion" then { suggestions: translate_lsp_completions(result) }
1136
- when "diagnostics" then LspDiagnosticsTranslator.call(result)
1504
+ when "diagnostics" then LspDiagnosticsTranslator.call(result, uri)
1505
+ when *RUBY_LSP_RAW then { result: sanitize_lsp_uris(result) }
1506
+ end
1507
+ end
1508
+
1509
+ # The one trust boundary for every raw-passthrough method. Walks the LSP
1510
+ # response and rewrites each file:// URI to a workspace-relative path,
1511
+ # dropping any object that points outside the workspace — a reference in a
1512
+ # gem is not something this editor can open, and a path outside the root is
1513
+ # not something it should hand to the browser at all.
1514
+ #
1515
+ # Recursion is bounded by MAX_LSP_DEPTH: the payload comes from a
1516
+ # subprocess, and a cyclic or pathologically nested one must not take the
1517
+ # request thread down with it.
1518
+ MAX_LSP_DEPTH = 32
1519
+
1520
+ URI_KEYS = %w[uri targetUri].freeze
1521
+
1522
+ def sanitize_lsp_uris(node, depth = 0)
1523
+ return nil if depth > MAX_LSP_DEPTH
1524
+
1525
+ case node
1526
+ when Array
1527
+ node.filter_map { |child| sanitize_lsp_uris(child, depth + 1) }
1528
+ when Hash
1529
+ sanitized = {}
1530
+ node.each do |key, value|
1531
+ if URI_KEYS.include?(key) && value.is_a?(String)
1532
+ rel = workspace_relative_uri(value)
1533
+ # A URI we can't place inside the workspace disqualifies its object.
1534
+ return nil unless rel
1535
+
1536
+ sanitized[key] = rel
1537
+ else
1538
+ child = sanitize_lsp_uris(value, depth + 1)
1539
+ sanitized[key] = child unless child.nil? && !value.nil?
1540
+ end
1541
+ end
1542
+ sanitized
1543
+ when String
1544
+ sanitize_lsp_markdown(node)
1545
+ else
1546
+ node
1137
1547
  end
1138
1548
  end
1139
1549
 
1550
+ # URIs also turn up *inside* strings: ruby-lsp's signatureHelp and hover
1551
+ # documentation embed a "Definitions" line of file:// markdown links. Those
1552
+ # would leak absolute host paths and render as links that go nowhere, so
1553
+ # they get the same treatment hover already gives them.
1554
+ def sanitize_lsp_markdown(text)
1555
+ return text unless text.include?("file://")
1556
+
1557
+ rewritten = rewrite_lsp_hover_links(text)
1558
+ return rewritten unless rewritten.include?("file://")
1559
+
1560
+ # Backstop for any file:// URI that wasn't in markdown-link form. An
1561
+ # absolute host path must never reach the browser, linkable or not.
1562
+ rewritten.gsub(%r{file://\S*}) do |raw|
1563
+ workspace_relative_uri(raw.sub(/[)\]\s].*\z/m, "")) || "(external)"
1564
+ end
1565
+ end
1566
+
1567
+ def workspace_relative_uri(uri)
1568
+ return nil unless uri.start_with?("file://")
1569
+
1570
+ path = uri.delete_prefix("file://")
1571
+ prefix = "#{workspace_root}/"
1572
+ return nil unless path.start_with?(prefix)
1573
+
1574
+ path.delete_prefix(prefix)
1575
+ end
1576
+
1140
1577
  def translate_lsp_locations(result)
1141
1578
  items = result.is_a?(Array) ? result : [result].compact
1142
1579
  root = workspace_root.to_s
@@ -1152,7 +1589,17 @@ module Mbeditor
1152
1589
  # the frontend fall back to the legacy services (ri covers stdlib).
1153
1590
  next unless fpath.start_with?("#{root}/")
1154
1591
 
1155
- { file: fpath.delete_prefix("#{root}/"), line: (range&.dig("start", "line") || 0) + 1 }
1592
+ start_line = (range&.dig("start", "line") || 0) + 1
1593
+ {
1594
+ file: fpath.delete_prefix("#{root}/"),
1595
+ line: start_line,
1596
+ # Columns and the end of the range are additive: existing consumers
1597
+ # only read :file and :line, but peek-definition needs a real range
1598
+ # to highlight rather than the start of the line.
1599
+ col: (range&.dig("start", "character") || 0) + 1,
1600
+ endLine: (range&.dig("end", "line") || range&.dig("start", "line") || 0) + 1,
1601
+ endCol: (range&.dig("end", "character") || range&.dig("start", "character") || 0) + 1
1602
+ }
1156
1603
  end
1157
1604
  end
1158
1605
 
@@ -1167,7 +1614,28 @@ module Mbeditor
1167
1614
  else contents.to_s
1168
1615
  end
1169
1616
 
1170
- rewrite_lsp_hover_links(markdown)
1617
+ neutralize_comment_headings(rewrite_lsp_hover_links(markdown))
1618
+ end
1619
+
1620
+ # ruby-lsp renders a doc comment by stripping exactly one leading "# " from
1621
+ # each line, then hands the result to the editor as markdown. A `##`-opened
1622
+ # doc block — a very common Ruby convention — therefore arrives as
1623
+ # "# Title" and renders as an <h1> filling the hover.
1624
+ #
1625
+ # Ruby comments are not markdown, so escape a `#` that opens a line and let
1626
+ # it render as the text it is. Fenced code blocks are left alone: `#` inside
1627
+ # them is Ruby source, not a heading, and needs no escaping.
1628
+ #
1629
+ # This deliberately diverges from other ruby-lsp clients, which show the
1630
+ # heading.
1631
+ def neutralize_comment_headings(markdown)
1632
+ in_fence = false
1633
+ markdown.lines.map do |line|
1634
+ in_fence = !in_fence if line.start_with?("```")
1635
+ next line if in_fence || line.start_with?("```")
1636
+
1637
+ line.sub(/\A(\s*)(#+)(?=\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
1638
+ end.join
1171
1639
  end
1172
1640
 
1173
1641
  # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
@@ -1221,10 +1689,15 @@ module Mbeditor
1221
1689
  LSP_COMPLETION_KINDS[kind] || "Text"
1222
1690
  end
1223
1691
 
1692
+ # Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
1693
+ # through ruby-lsp and the same file linted through `rubocop --stdin` grade
1694
+ # their offenses identically. rubocop's own `info` is the weakest level and
1695
+ # maps to hint; convention/refactor fall through to info.
1224
1696
  def cop_severity(severity)
1225
1697
  case severity
1226
1698
  when "error", "fatal" then "error"
1227
1699
  when "warning" then "warning"
1700
+ when "info" then "hint"
1228
1701
  else "info"
1229
1702
  end
1230
1703
  end