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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- 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"
|
|
482
|
-
"hover"
|
|
483
|
-
"completion"
|
|
484
|
-
"diagnostics"
|
|
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
|
-
#
|
|
488
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|