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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/README.md +190 -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 +916 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -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 +365 -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 +492 -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 +37 -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
|
@@ -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"
|
|
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,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
|
|
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
|
-
|
|
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
|