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