mbeditor 0.12.9 → 0.13.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 +216 -1
- data/app/assets/javascripts/mbeditor/application.js +6 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +112 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +50 -4
- data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +389 -103
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +155 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +18 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +98 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +73 -66
- data/app/services/mbeditor/search_replace_service.rb +42 -7
- data/app/services/mbeditor/test_runner_service.rb +45 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +4 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +8 -2
|
@@ -19,6 +19,13 @@ module Mbeditor
|
|
|
19
19
|
# True when the connection is allowed (or no hook is configured); otherwise
|
|
20
20
|
# rejects the subscription and returns false.
|
|
21
21
|
def mbeditor_authenticated?
|
|
22
|
+
# The same gate the controllers apply. Without it the cable channels were
|
|
23
|
+
# reachable in any environment, hook or no hook.
|
|
24
|
+
unless Array(Mbeditor.configuration.allowed_environments).map(&:to_sym).include?(Rails.env.to_sym)
|
|
25
|
+
mbeditor_reject_subscription
|
|
26
|
+
return false
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
hook = mbeditor_auth_hook
|
|
23
30
|
return true unless hook
|
|
24
31
|
|
|
@@ -21,11 +21,14 @@ module Mbeditor
|
|
|
21
21
|
return reject if @path.empty? && respond_to?(:reject, true)
|
|
22
22
|
|
|
23
23
|
stream_from stream_name if respond_to?(:stream_from)
|
|
24
|
+
CollaborationDocStore.join(room_key)
|
|
25
|
+
@joined = true
|
|
24
26
|
transmit_initial_state
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def unsubscribed
|
|
28
|
-
|
|
30
|
+
CollaborationDocStore.leave(room_key) if @joined
|
|
31
|
+
@joined = false
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def doc_update(data)
|
|
@@ -66,7 +69,10 @@ module Mbeditor
|
|
|
66
69
|
return unless respond_to?(:transmit, true)
|
|
67
70
|
|
|
68
71
|
state = CollaborationDocStore.state_for(room_key)
|
|
69
|
-
|
|
72
|
+
# Exactly one client per empty room is told to seed it from disk; see
|
|
73
|
+
# CollaborationDocStore.claim_seed. Everyone else waits for that content.
|
|
74
|
+
transmit({ "type" => "sync", "snapshot" => state[:snapshot], "deltas" => state[:deltas],
|
|
75
|
+
"seed" => CollaborationDocStore.claim_seed(room_key) })
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
# A room is identified by workspace *and* relative path. Keying on the path
|
|
@@ -50,8 +50,10 @@ module Mbeditor
|
|
|
50
50
|
def save_state(data)
|
|
51
51
|
state = data["state"] || data
|
|
52
52
|
EditorStateService.new(workspace_root).write_state(state)
|
|
53
|
-
rescue StandardError
|
|
54
|
-
# Never let a state-save failure crash the WebSocket connection
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
# Never let a state-save failure crash the WebSocket connection — but a
|
|
55
|
+
# silently dropped save looked exactly like a working one.
|
|
56
|
+
Rails.logger.warn("[mbeditor] EditorChannel#save_state failed: #{e.class}: #{e.message}")
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def save_branch_state(data)
|
|
@@ -62,8 +64,9 @@ module Mbeditor
|
|
|
62
64
|
# A misbehaving client sent a malformed branch name. Don't crash the
|
|
63
65
|
# connection, but log it so the misconfiguration is observable.
|
|
64
66
|
Rails.logger.warn("[mbeditor] EditorChannel#save_branch_state: rejected invalid branch name #{branch.inspect}")
|
|
65
|
-
rescue StandardError
|
|
67
|
+
rescue StandardError => e
|
|
66
68
|
# Never let a state-save failure crash the WebSocket connection
|
|
69
|
+
Rails.logger.warn("[mbeditor] EditorChannel#save_branch_state failed: #{e.class}: #{e.message}")
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
def start_log_tail(data)
|
|
@@ -6,6 +6,11 @@ require "pathname"
|
|
|
6
6
|
module Mbeditor
|
|
7
7
|
class ApplicationController < ActionController::Base
|
|
8
8
|
protect_from_forgery with: :exception
|
|
9
|
+
# Before the auth hook, and on every controller: a disallowed environment is
|
|
10
|
+
# "this engine is not here", so it must 404 without running the host app's
|
|
11
|
+
# authenticate_with proc. Declaring it per-controller meant LogsController
|
|
12
|
+
# missed it entirely and served the environment's log file anywhere.
|
|
13
|
+
before_action :ensure_allowed_environment!
|
|
9
14
|
before_action :run_authentication
|
|
10
15
|
|
|
11
16
|
private
|
|
@@ -12,7 +12,6 @@ require "uri"
|
|
|
12
12
|
module Mbeditor
|
|
13
13
|
class EditorsController < ApplicationController
|
|
14
14
|
skip_before_action :verify_authenticity_token
|
|
15
|
-
before_action :ensure_allowed_environment!
|
|
16
15
|
before_action :verify_mbeditor_client, unless: -> { request.get? || request.head? }
|
|
17
16
|
|
|
18
17
|
IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
|
|
@@ -21,6 +20,8 @@ module Mbeditor
|
|
|
21
20
|
RUBY_DEFS_WARM_MUTEX = Mutex.new
|
|
22
21
|
TOTAL_LINES_CACHE_MAX = 50
|
|
23
22
|
TOTAL_LINES_MUTEX = Mutex.new
|
|
23
|
+
GIT_STATUS_TTL = 3
|
|
24
|
+
GIT_STATUS_MUTEX = Mutex.new
|
|
24
25
|
# Kept below Rack's multipart_part_limit (128 file parts in Rack 3.2), which
|
|
25
26
|
# is enforced during param parsing — outside the action, where this
|
|
26
27
|
# controller's rescue cannot turn it into a clean 422. A batch larger than
|
|
@@ -47,6 +48,28 @@ module Mbeditor
|
|
|
47
48
|
cache.delete(cache.keys.first) while cache.length > TOTAL_LINES_CACHE_MAX
|
|
48
49
|
end
|
|
49
50
|
end
|
|
51
|
+
|
|
52
|
+
# git_status is polled every 5s by every open tab and costs two git
|
|
53
|
+
# subprocesses. A 3s TTL collapses the duplicates while staying inside one
|
|
54
|
+
# poll interval; any mutation drops it outright (broadcast_files_changed).
|
|
55
|
+
def cached_git_status(root)
|
|
56
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
57
|
+
GIT_STATUS_MUTEX.synchronize do
|
|
58
|
+
entry = (@git_status_cache ||= {})[root]
|
|
59
|
+
return entry[:value] if entry && (now - entry[:ts]) < GIT_STATUS_TTL
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
value = yield
|
|
63
|
+
GIT_STATUS_MUTEX.synchronize do
|
|
64
|
+
(@git_status_cache ||= {})[root] = { ts: now, value: value }
|
|
65
|
+
end
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def invalidate_git_status(root)
|
|
70
|
+
GIT_STATUS_MUTEX.synchronize { (@git_status_cache ||= {}).delete(root) }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
# GET /mbeditor — renders the IDE shell
|
|
@@ -73,6 +96,11 @@ module Mbeditor
|
|
|
73
96
|
blameAvailable: AvailabilityProbe.git(workspace_root),
|
|
74
97
|
redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
|
|
75
98
|
testAvailable: test_available?,
|
|
99
|
+
# The client derives its request timeout from this. Two independently
|
|
100
|
+
# hard-coded numbers is how you get the browser aborting a run the
|
|
101
|
+
# server is still happily executing, reported as a generic network
|
|
102
|
+
# error instead of the server's own message.
|
|
103
|
+
testTimeout: (Mbeditor.configuration.test_timeout || 180).to_i,
|
|
76
104
|
actionCableEnabled: action_cable_enabled?,
|
|
77
105
|
jsSyntaxCheckAvailable: JsSyntaxCheckService.available?,
|
|
78
106
|
rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root),
|
|
@@ -84,8 +112,18 @@ module Mbeditor
|
|
|
84
112
|
end
|
|
85
113
|
|
|
86
114
|
# GET /mbeditor/files — recursive file tree
|
|
115
|
+
# refresh=1 means "my view of the workspace is stale, drop your caches" —
|
|
116
|
+
# the client sends it after an external `git checkout`, which rewrites the
|
|
117
|
+
# tree without going through any mutation endpoint, so nothing here knows
|
|
118
|
+
# to invalidate. Without it the 15s tree TTL kept serving the old branch's
|
|
119
|
+
# file list, and the search cache the old branch's hits.
|
|
87
120
|
def files
|
|
88
|
-
|
|
121
|
+
if params[:refresh].present?
|
|
122
|
+
FileTreeService.invalidate(workspace_root.to_s)
|
|
123
|
+
SearchReplaceService.invalidate_cache(workspace_root.to_s)
|
|
124
|
+
end
|
|
125
|
+
tree = FileTreeService.cached_json(workspace_root)
|
|
126
|
+
render json: tree[:body] if stale?(etag: tree[:digest], public: false)
|
|
89
127
|
end
|
|
90
128
|
|
|
91
129
|
# GET /mbeditor/state — load workspace state
|
|
@@ -216,8 +254,17 @@ module Mbeditor
|
|
|
216
254
|
existing = f.size > 0 ? (JSON.parse(f.read) rescue {}) : {}
|
|
217
255
|
|
|
218
256
|
if existing.empty?
|
|
257
|
+
# An empty base is a legitimate one, and in practice the usual one:
|
|
258
|
+
# the client starts tracking when the editor mounts, which is before
|
|
259
|
+
# the file content has arrived, so the load itself is recorded as the
|
|
260
|
+
# first op against an empty document. Rejecting "" meant the initial
|
|
261
|
+
# POST for every file 400'd and no history was ever written at all.
|
|
262
|
+
# Only an absent base is an error.
|
|
263
|
+
unless params.key?(:base)
|
|
264
|
+
return render json: { error: 'base required for initial history' }, status: :bad_request
|
|
265
|
+
end
|
|
266
|
+
|
|
219
267
|
base = params[:base].to_s
|
|
220
|
-
return render json: { error: 'base required for initial history' }, status: :bad_request if base.empty?
|
|
221
268
|
return render json: { error: 'base too large' }, status: :content_too_large if base.bytesize > STATE_MAX_BYTES
|
|
222
269
|
existing = { 'branch' => branch, 'path' => rel, 'base' => base, 'ops' => [], 't' => Time.now.utc.iso8601 }
|
|
223
270
|
end
|
|
@@ -307,15 +354,20 @@ module Mbeditor
|
|
|
307
354
|
end
|
|
308
355
|
|
|
309
356
|
# GET /mbeditor/raw?path=... — send raw file directly (for images)
|
|
357
|
+
#
|
|
358
|
+
# ?download=1 flips the disposition to attachment, which is the whole of
|
|
359
|
+
# the explorer's "Download" action: the browser's own save dialog does the
|
|
360
|
+
# rest, so there is no separate endpoint and no client-side blob.
|
|
310
361
|
def raw
|
|
311
362
|
path = resolve_path(params[:path])
|
|
312
363
|
return render json: { error: "Forbidden" }, status: :forbidden unless path
|
|
313
364
|
return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
|
|
314
365
|
|
|
315
|
-
|
|
316
|
-
return render_file_too_large(size) if size > FileOperationService::MAX_FILE_SIZE_BYTES
|
|
366
|
+
stat = File.stat(path)
|
|
367
|
+
return render_file_too_large(stat.size) if stat.size > FileOperationService::MAX_FILE_SIZE_BYTES
|
|
368
|
+
return unless stale?(last_modified: stat.mtime, public: false)
|
|
317
369
|
|
|
318
|
-
send_file path, disposition: "inline"
|
|
370
|
+
send_file path, disposition: params[:download].present? ? "attachment" : "inline"
|
|
319
371
|
end
|
|
320
372
|
|
|
321
373
|
# POST /mbeditor/file — save file
|
|
@@ -325,7 +377,7 @@ module Mbeditor
|
|
|
325
377
|
return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
|
|
326
378
|
|
|
327
379
|
result = FileOperationService.new(workspace_root).save(path, params[:code].to_s)
|
|
328
|
-
broadcast_files_changed([path])
|
|
380
|
+
broadcast_files_changed([path], structural: false)
|
|
329
381
|
broadcast_file_saved(path)
|
|
330
382
|
render json: result
|
|
331
383
|
rescue FileOperationService::FileTooLargeError
|
|
@@ -838,7 +890,7 @@ module Mbeditor
|
|
|
838
890
|
)
|
|
839
891
|
|
|
840
892
|
if result.key?(:error)
|
|
841
|
-
render json: { error: result[:error] }, status: :
|
|
893
|
+
render json: { error: result[:error] }, status: :unprocessable_content
|
|
842
894
|
else
|
|
843
895
|
render json: result
|
|
844
896
|
end
|
|
@@ -848,10 +900,14 @@ module Mbeditor
|
|
|
848
900
|
|
|
849
901
|
# GET /mbeditor/git_status
|
|
850
902
|
def git_status
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
903
|
+
root = workspace_root.to_s
|
|
904
|
+
payload = self.class.cached_git_status(root) do
|
|
905
|
+
output, status = GitService.run_git(root, "status", "--porcelain")
|
|
906
|
+
{ ok: status.success?,
|
|
907
|
+
files: GitService.parse_porcelain_status(output),
|
|
908
|
+
branch: GitService.current_branch(root) || "" }
|
|
909
|
+
end
|
|
910
|
+
render json: payload
|
|
855
911
|
rescue StandardError => e
|
|
856
912
|
render json: { error: e.message }, status: :unprocessable_content
|
|
857
913
|
end
|
|
@@ -868,6 +924,9 @@ module Mbeditor
|
|
|
868
924
|
path = resolve_monaco_asset_path(relative)
|
|
869
925
|
return head :not_found unless path
|
|
870
926
|
|
|
927
|
+
# ~14 MB of bundle, re-sent on every page load without this.
|
|
928
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
929
|
+
|
|
871
930
|
send_file path, disposition: "inline", type: Mime::Type.lookup_by_extension(File.extname(path).delete_prefix(".")) || "application/octet-stream"
|
|
872
931
|
end
|
|
873
932
|
|
|
@@ -894,6 +953,7 @@ module Mbeditor
|
|
|
894
953
|
def pwa_sw
|
|
895
954
|
path = Mbeditor::Engine.root.join("public", "sw.js").to_s
|
|
896
955
|
return render plain: "Not found", status: :not_found unless File.file?(path)
|
|
956
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
897
957
|
|
|
898
958
|
send_file path, disposition: "inline", type: "application/javascript"
|
|
899
959
|
end
|
|
@@ -902,6 +962,7 @@ module Mbeditor
|
|
|
902
962
|
def pwa_icon
|
|
903
963
|
path = Mbeditor::Engine.root.join("public", "mbeditor-icon.svg").to_s
|
|
904
964
|
return render plain: "Not found", status: :not_found unless File.file?(path)
|
|
965
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
905
966
|
|
|
906
967
|
send_file path, disposition: "inline", type: "image/svg+xml"
|
|
907
968
|
end
|
|
@@ -1025,7 +1086,7 @@ module Mbeditor
|
|
|
1025
1086
|
|
|
1026
1087
|
cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
|
|
1027
1088
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
1028
|
-
|
|
1089
|
+
status = run_lint_command(env, cmd)[:exit_status]
|
|
1029
1090
|
|
|
1030
1091
|
# exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
|
|
1031
1092
|
unless status.success? || status.exitstatus == 1
|
|
@@ -1040,6 +1101,26 @@ module Mbeditor
|
|
|
1040
1101
|
render json: { error: e.message }, status: :unprocessable_content
|
|
1041
1102
|
end
|
|
1042
1103
|
|
|
1104
|
+
# POST /mbeditor/rubocop — whole-workspace `rubocop` run
|
|
1105
|
+
#
|
|
1106
|
+
# mode=autocorrect adds `-a` (safe corrections only) and writes to disk, so
|
|
1107
|
+
# the response doubles as the post-correction offense list.
|
|
1108
|
+
def rubocop_run
|
|
1109
|
+
unless AvailabilityProbe.rubocop(workspace_root)
|
|
1110
|
+
return render json: { ok: false, error: "RuboCop is not available", files: [] }, status: :unprocessable_content
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
mode = params[:mode].to_s == "autocorrect" ? :autocorrect : :check
|
|
1114
|
+
result = RubocopRunService.run(workspace_root, mode: mode)
|
|
1115
|
+
# `-a` writes to disk behind every open tab's back. No path list: a
|
|
1116
|
+
# whole-project run can touch anything, and the omitted-paths form makes
|
|
1117
|
+
# the client re-check every open tab, which is exactly what's wanted.
|
|
1118
|
+
broadcast_files_changed(nil, structural: false) if mode == :autocorrect && result[:ok]
|
|
1119
|
+
render json: result
|
|
1120
|
+
rescue StandardError => e
|
|
1121
|
+
render json: { ok: false, error: e.message, files: [] }, status: :unprocessable_content
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1043
1124
|
# POST /mbeditor/test — run tests for the given file
|
|
1044
1125
|
def run_test
|
|
1045
1126
|
path = resolve_path(params[:path])
|
|
@@ -1068,7 +1149,7 @@ module Mbeditor
|
|
|
1068
1149
|
test_file,
|
|
1069
1150
|
framework: config.test_framework&.to_sym,
|
|
1070
1151
|
command: config.test_command,
|
|
1071
|
-
timeout: config.test_timeout ||
|
|
1152
|
+
timeout: (config.test_timeout || 180).to_i,
|
|
1072
1153
|
line: line
|
|
1073
1154
|
)
|
|
1074
1155
|
|
|
@@ -1121,6 +1202,13 @@ module Mbeditor
|
|
|
1121
1202
|
def model_schema
|
|
1122
1203
|
model_name = params[:model].to_s.strip
|
|
1123
1204
|
return render json: { error: "model required" }, status: :bad_request if model_name.blank?
|
|
1205
|
+
# SchemaService interpolates the underscored name into app/models/<x>.rb,
|
|
1206
|
+
# and Inflector.underscore leaves "../" intact. Same guard as
|
|
1207
|
+
# module_members, widened to the "::" a namespaced model needs. Spaces
|
|
1208
|
+
# are allowed because SchemaService strips them ("Order Item" → OrderItem).
|
|
1209
|
+
unless model_name.delete(" ").match?(RUBY_CONSTANT_PATH)
|
|
1210
|
+
return render json: { error: "Invalid model name" }, status: :bad_request
|
|
1211
|
+
end
|
|
1124
1212
|
|
|
1125
1213
|
schema = SchemaService.new(model_name, workspace_root.to_s).call
|
|
1126
1214
|
if schema
|
|
@@ -1153,7 +1241,7 @@ module Mbeditor
|
|
|
1153
1241
|
|
|
1154
1242
|
cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
|
|
1155
1243
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
1156
|
-
|
|
1244
|
+
status = run_lint_command(env, cmd)[:exit_status]
|
|
1157
1245
|
unless status.success? || status.exitstatus == 1
|
|
1158
1246
|
return render json: { ok: false, content: code }
|
|
1159
1247
|
end
|
|
@@ -1248,21 +1336,30 @@ module Mbeditor
|
|
|
1248
1336
|
base.to_s
|
|
1249
1337
|
end
|
|
1250
1338
|
|
|
1251
|
-
|
|
1339
|
+
# structural: false means "these files' contents changed, the tree did not"
|
|
1340
|
+
# — a save or a replace-in-files. The client uses it to skip re-walking the
|
|
1341
|
+
# whole workspace and rebuilding its quick-open index on every save, which
|
|
1342
|
+
# is work no content change can invalidate. Defaults to true so a new call
|
|
1343
|
+
# site that says nothing keeps the conservative behaviour.
|
|
1344
|
+
def broadcast_files_changed(changed_paths = nil, structural: true)
|
|
1252
1345
|
root = workspace_root.to_s
|
|
1253
1346
|
FileTreeService.invalidate(root)
|
|
1254
1347
|
SearchReplaceService.invalidate_cache(root)
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
GitInfoService.invalidate(root)
|
|
1259
|
-
rescue => e
|
|
1260
|
-
Rails.logger.warn("[mbeditor] GitInfoService.invalidate failed: #{e}")
|
|
1348
|
+
if js_related_paths?(changed_paths)
|
|
1349
|
+
JsGlobalsService.invalidate(root)
|
|
1350
|
+
JsProgramService.invalidate(root)
|
|
1261
1351
|
end
|
|
1352
|
+
# Cheap, and it means editing config/routes.rb refreshes the inline route
|
|
1353
|
+
# hints on the next look rather than after the TTL.
|
|
1354
|
+
RouteService.invalidate
|
|
1355
|
+
# Both are mutex-guarded Hash#deletes; a thread per save cost more than
|
|
1356
|
+
# the work it deferred.
|
|
1357
|
+
GitInfoService.invalidate(root)
|
|
1358
|
+
self.class.invalidate_git_status(root)
|
|
1262
1359
|
|
|
1263
1360
|
return unless defined?(ActionCable.server)
|
|
1264
1361
|
|
|
1265
|
-
payload = { type: "files_changed" }
|
|
1362
|
+
payload = { type: "files_changed", structural: structural }
|
|
1266
1363
|
rel = Array(changed_paths).compact.map { |p| relative_path(p.to_s) }.reject(&:empty?)
|
|
1267
1364
|
payload[:paths] = rel if rel.any?
|
|
1268
1365
|
ActionCable.server.broadcast("mbeditor_editor", payload)
|
|
@@ -1270,6 +1367,20 @@ module Mbeditor
|
|
|
1270
1367
|
# Never let a broadcast failure affect the HTTP response
|
|
1271
1368
|
end
|
|
1272
1369
|
|
|
1370
|
+
# Everything either JS cache indexes: JsProgramService::SOURCE_EXT plus the
|
|
1371
|
+
# .erb-templated variants CodeSearchService::JS_GLOBS feeds JsGlobalsService.
|
|
1372
|
+
JS_SOURCE_NAME = /\.(js|jsx|mjs|cjs|ts|tsx)(\.erb)?\z/i
|
|
1373
|
+
|
|
1374
|
+
# Rebuilding the JS program costs ~93ms/MB, so saving a .rb file must not
|
|
1375
|
+
# throw it away. A path with no extension at all — a directory, a rename
|
|
1376
|
+
# target — stays conservative, as does an omitted path list.
|
|
1377
|
+
def js_related_paths?(changed_paths)
|
|
1378
|
+
paths = Array(changed_paths).compact.map(&:to_s).reject(&:empty?)
|
|
1379
|
+
return true if paths.empty?
|
|
1380
|
+
|
|
1381
|
+
paths.any? { |p| File.extname(p).empty? || p.match?(JS_SOURCE_NAME) }
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1273
1384
|
# Tells every peer that this file's shared buffer was just saved to disk so
|
|
1274
1385
|
# they can reset that tab's clean baseline and clear its dirty indicator.
|
|
1275
1386
|
# Rides the same global stream as broadcast_files_changed and is equally
|
|
@@ -1431,10 +1542,16 @@ module Mbeditor
|
|
|
1431
1542
|
end
|
|
1432
1543
|
|
|
1433
1544
|
def run_with_timeout(env, cmd, stdin_data:)
|
|
1545
|
+
run_lint_command(env, cmd, stdin_data: stdin_data)[:stdout]
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1548
|
+
# Same lint_timeout ceiling as #lint. quick_fix, format_file and haml-lint
|
|
1549
|
+
# used to spawn with Open3.capture3 and no timeout at all, so a wedged
|
|
1550
|
+
# rubocop held a request thread until the client gave up.
|
|
1551
|
+
def run_lint_command(env, cmd, stdin_data: nil)
|
|
1434
1552
|
timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
|
|
1435
1553
|
timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
|
|
1436
|
-
|
|
1437
|
-
result[:stdout]
|
|
1554
|
+
ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
|
|
1438
1555
|
end
|
|
1439
1556
|
|
|
1440
1557
|
def apply_rename_changes(result, open_paths)
|
|
@@ -1468,7 +1585,7 @@ module Mbeditor
|
|
|
1468
1585
|
end
|
|
1469
1586
|
end
|
|
1470
1587
|
|
|
1471
|
-
broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }) if written.any?
|
|
1588
|
+
broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }, structural: false) if written.any?
|
|
1472
1589
|
{ ok: true, written: written, rejected: rejected, edits: edits_for_open }
|
|
1473
1590
|
end
|
|
1474
1591
|
|
|
@@ -1545,6 +1662,11 @@ module Mbeditor
|
|
|
1545
1662
|
|
|
1546
1663
|
URI_KEYS = %w[uri targetUri].freeze
|
|
1547
1664
|
|
|
1665
|
+
# URI::DEFAULT_PARSER became the RFC3986 parser in Ruby 4.0, where #unescape
|
|
1666
|
+
# is deprecated; URI::RFC2396_PARSER only exists from Ruby 3.4. The gem
|
|
1667
|
+
# supports >= 3.0, so take whichever this Ruby has.
|
|
1668
|
+
URI_UNESCAPER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
|
|
1669
|
+
|
|
1548
1670
|
def sanitize_lsp_uris(node, depth = 0)
|
|
1549
1671
|
return nil if depth > MAX_LSP_DEPTH
|
|
1550
1672
|
|
|
@@ -1593,7 +1715,10 @@ module Mbeditor
|
|
|
1593
1715
|
def workspace_relative_uri(uri)
|
|
1594
1716
|
return nil unless uri.start_with?("file://")
|
|
1595
1717
|
|
|
1596
|
-
|
|
1718
|
+
# ruby-lsp percent-escapes its URIs; workspace_root is a raw path. A
|
|
1719
|
+
# checkout with a space (or any other escaped character) in its path
|
|
1720
|
+
# matched nothing here, so every result was silently dropped.
|
|
1721
|
+
path = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
1597
1722
|
prefix = "#{workspace_root}/"
|
|
1598
1723
|
return nil unless path.start_with?(prefix)
|
|
1599
1724
|
|
|
@@ -1610,7 +1735,7 @@ module Mbeditor
|
|
|
1610
1735
|
range = loc["range"] || loc["targetSelectionRange"] || loc["targetRange"]
|
|
1611
1736
|
next unless uri.to_s.start_with?("file://")
|
|
1612
1737
|
|
|
1613
|
-
fpath = uri.delete_prefix("file://")
|
|
1738
|
+
fpath = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
1614
1739
|
# Drop gem/stdlib locations the editor can't open; an empty list makes
|
|
1615
1740
|
# the frontend fall back to the legacy services (ri covers stdlib).
|
|
1616
1741
|
next unless fpath.start_with?("#{root}/")
|
|
@@ -1787,7 +1912,8 @@ module Mbeditor
|
|
|
1787
1912
|
Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
|
|
1788
1913
|
f.write(code)
|
|
1789
1914
|
f.flush
|
|
1790
|
-
|
|
1915
|
+
cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
|
|
1916
|
+
output = run_lint_command({}, cmd)[:stdout]
|
|
1791
1917
|
idx = output.index("{")
|
|
1792
1918
|
result = idx ? JSON.parse(output[idx..]) : {}
|
|
1793
1919
|
result = {} unless result.is_a?(Hash)
|
|
@@ -14,7 +14,6 @@ module Mbeditor
|
|
|
14
14
|
# GET /mbeditor/redmine/issue/:id
|
|
15
15
|
class GitController < ApplicationController
|
|
16
16
|
skip_before_action :verify_authenticity_token
|
|
17
|
-
before_action :ensure_allowed_environment!
|
|
18
17
|
|
|
19
18
|
# GET /mbeditor/git/diff?file=<path>[&base=<sha>&head=<sha>]
|
|
20
19
|
def diff
|
|
@@ -27,8 +26,9 @@ module Mbeditor
|
|
|
27
26
|
head = nil if head == 'WORKING'
|
|
28
27
|
# Allow full/short SHA hashes plus common git ref formats: branch names,
|
|
29
28
|
# HEAD, remote tracking refs, parent notation (sha^, sha~N) and tags.
|
|
30
|
-
# @ is excluded to block reflog syntax like @{-1} or HEAD@{2}
|
|
31
|
-
|
|
29
|
+
# @ is excluded to block reflog syntax like @{-1} or HEAD@{2}, and a
|
|
30
|
+
# leading - so the ref cannot be read as an option by the git it reaches.
|
|
31
|
+
valid_ref = /\A(?!-)[a-zA-Z0-9._\-\/\^~]+\z/
|
|
32
32
|
if [base, head].any? { |s| s && (s.length > 200 || !s.match?(valid_ref)) }
|
|
33
33
|
return render json: { error: 'Invalid ref' }, status: :bad_request
|
|
34
34
|
end
|
|
@@ -6,7 +6,9 @@ module Mbeditor
|
|
|
6
6
|
# Reads the active environment's log file incrementally. Used for the
|
|
7
7
|
# initial load (no offset) and as the HTTP polling fallback (with offset).
|
|
8
8
|
def tail
|
|
9
|
-
|
|
9
|
+
# Clamped: a negative offset reaches IO#seek and raises Errno::EINVAL,
|
|
10
|
+
# which surfaced as a 500 on a param the client fully controls.
|
|
11
|
+
offset = params[:offset].present? ? [params[:offset].to_i, 0].max : nil
|
|
10
12
|
result = LogTailService.new(log_path).read_since(offset)
|
|
11
13
|
render json: result
|
|
12
14
|
end
|
|
@@ -27,10 +27,55 @@ module Mbeditor
|
|
|
27
27
|
# idle room is reclaimed soon after its grace window elapses.
|
|
28
28
|
SWEEP_INTERVAL = 60
|
|
29
29
|
|
|
30
|
+
# Hard cap on buffered deltas per room; a long editing session on one file
|
|
31
|
+
# would otherwise grow it without limit. Over the cap the oldest go, which
|
|
32
|
+
# costs a late joiner an incomplete replay — it then waits for the next
|
|
33
|
+
# snapshot instead of syncing from the buffer.
|
|
34
|
+
MAX_DELTAS = 500
|
|
35
|
+
|
|
36
|
+
# Grant the right to seed a room's shared document from disk, to exactly one
|
|
37
|
+
# client. Without this every client that finds the room empty inserts the whole
|
|
38
|
+
# file at offset 0, and Yjs merges those inserts into two concatenated copies —
|
|
39
|
+
# the file appended to itself. That happens whenever two editor tabs attach to
|
|
40
|
+
# a room the server has no state for: a fresh room, or one that was evicted
|
|
41
|
+
# while both were idle. The claim is per room, so the loser waits for the
|
|
42
|
+
# winner's content instead of inventing its own.
|
|
43
|
+
def claim_seed(path, now: monotonic)
|
|
44
|
+
MUTEX.synchronize do
|
|
45
|
+
room = touch(path, now)
|
|
46
|
+
next false if room[:seed_claimed] || room[:snapshot] || !room[:deltas].empty?
|
|
47
|
+
|
|
48
|
+
room[:seed_claimed] = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Subscriber accounting. A room with a live subscriber must never be evicted:
|
|
53
|
+
# eviction empties the server's copy while clients still hold content, and the
|
|
54
|
+
# next client to attach would be granted a seed it must not perform.
|
|
55
|
+
def join(path, now: monotonic)
|
|
56
|
+
MUTEX.synchronize { touch(path, now)[:subscribers] += 1 }
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def leave(path, now: monotonic)
|
|
61
|
+
MUTEX.synchronize do
|
|
62
|
+
room = rooms[path]
|
|
63
|
+
next unless room
|
|
64
|
+
|
|
65
|
+
room[:subscribers] -= 1 if room[:subscribers] > 0
|
|
66
|
+
# The claimer can leave before it ever seeds (a tab closed during the
|
|
67
|
+
# handshake). Releasing the claim on an empty, empty-handed room keeps the
|
|
68
|
+
# next opener from deferring to content that will never arrive.
|
|
69
|
+
room[:seed_claimed] = false if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty?
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
30
74
|
def record_update(path, bytes, now: monotonic)
|
|
31
75
|
MUTEX.synchronize do
|
|
32
76
|
room = touch(path, now)
|
|
33
77
|
room[:deltas] << bytes
|
|
78
|
+
room[:deltas].shift while room[:deltas].size > MAX_DELTAS
|
|
34
79
|
end
|
|
35
80
|
nil
|
|
36
81
|
end
|
|
@@ -78,7 +123,7 @@ module Mbeditor
|
|
|
78
123
|
|
|
79
124
|
def touch(path, now)
|
|
80
125
|
new_room = !rooms.key?(path)
|
|
81
|
-
room = (rooms[path] ||= { snapshot: nil, deltas: [] })
|
|
126
|
+
room = (rooms[path] ||= { snapshot: nil, deltas: [], subscribers: 0, seed_claimed: false })
|
|
82
127
|
room[:last_activity] = now
|
|
83
128
|
maybe_sweep(now)
|
|
84
129
|
evict_lru if new_room && rooms.size > ROOM_CAP
|
|
@@ -98,12 +143,13 @@ module Mbeditor
|
|
|
98
143
|
private_class_method :maybe_sweep
|
|
99
144
|
|
|
100
145
|
def evict_idle(now, grace)
|
|
101
|
-
rooms.delete_if { |_path, room| (now - room[:last_activity]) > grace }
|
|
146
|
+
rooms.delete_if { |_path, room| room[:subscribers].zero? && (now - room[:last_activity]) > grace }
|
|
102
147
|
end
|
|
103
148
|
private_class_method :evict_idle
|
|
104
149
|
|
|
105
150
|
def evict_lru
|
|
106
|
-
|
|
151
|
+
idle = rooms.reject { |_path, room| room[:subscribers].positive? }
|
|
152
|
+
oldest = (idle.empty? ? rooms : idle).min_by { |_path, room| room[:last_activity] }
|
|
107
153
|
rooms.delete(oldest.first) if oldest
|
|
108
154
|
end
|
|
109
155
|
private_class_method :evict_lru
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
# Finds files that look like they were written back to disk with their own
|
|
7
|
+
# content appended to themselves.
|
|
8
|
+
#
|
|
9
|
+
# The corruption this detects came from the collaborative buffer: two clients
|
|
10
|
+
# that each found an empty shared document seeded it from disk, and Yjs merged
|
|
11
|
+
# both inserts at offset 0 into two concatenated copies. That bug is fixed
|
|
12
|
+
# (CollaborationDocStore.claim_seed), but files saved while it was live are
|
|
13
|
+
# still on disk, so there has to be a way to find them.
|
|
14
|
+
#
|
|
15
|
+
# Two signals, cheapest first:
|
|
16
|
+
#
|
|
17
|
+
# * :exact — the file is byte-for-byte X + X. What a duplicated save
|
|
18
|
+
# produces before anyone edits it again.
|
|
19
|
+
# * :repeated_block — the first ANCHOR_LINES lines of the file occur again
|
|
20
|
+
# later, verbatim. Survives edits made after the
|
|
21
|
+
# corruption, which is the common case for a file noticed
|
|
22
|
+
# days later.
|
|
23
|
+
#
|
|
24
|
+
# Report-only. Nothing here writes.
|
|
25
|
+
class DuplicateContentScanner
|
|
26
|
+
ANCHOR_LINES = 10
|
|
27
|
+
MIN_LINES = 20 # below this, a repeat is unremarkable
|
|
28
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
Finding = Struct.new(:path, :reason, :line, :lines, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def self.check(content)
|
|
33
|
+
return nil if content.nil? || content.empty?
|
|
34
|
+
|
|
35
|
+
lines = content.lines
|
|
36
|
+
return nil if lines.size < MIN_LINES
|
|
37
|
+
|
|
38
|
+
half = content.bytesize / 2
|
|
39
|
+
if content.bytesize.even? && content.byteslice(0, half) == content.byteslice(half, half)
|
|
40
|
+
return { reason: :exact, line: (lines.size / 2) + 1 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
at = repeated_anchor_line(lines)
|
|
44
|
+
at ? { reason: :repeated_block, line: at } : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Line number (1-based) of a later verbatim recurrence of the file's opening
|
|
48
|
+
# block, or nil. The anchor starts at the first non-blank line so a leading
|
|
49
|
+
# blank does not shift it out of alignment with its copy.
|
|
50
|
+
def self.repeated_anchor_line(lines)
|
|
51
|
+
start = lines.index { |l| !l.strip.empty? }
|
|
52
|
+
return nil if start.nil? || (start + ANCHOR_LINES) > lines.size
|
|
53
|
+
|
|
54
|
+
anchor = lines[start, ANCHOR_LINES]
|
|
55
|
+
# A block of identical lines (padding, a long table) repeats for boring
|
|
56
|
+
# reasons; require the anchor itself to carry some variety.
|
|
57
|
+
return nil if anchor.uniq.size < 3
|
|
58
|
+
|
|
59
|
+
idx = ((start + 1)..(lines.size - ANCHOR_LINES)).find { |i| lines[i, ANCHOR_LINES] == anchor }
|
|
60
|
+
idx && (idx + 1)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :repeated_anchor_line
|
|
63
|
+
|
|
64
|
+
def initialize(root, excluded: Mbeditor.configuration.excluded_paths)
|
|
65
|
+
@root = File.expand_path(root.to_s)
|
|
66
|
+
@matcher = ExclusionMatcher.new(excluded, root: @root)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call
|
|
70
|
+
findings = []
|
|
71
|
+
Find.find(@root) do |path|
|
|
72
|
+
rel = path == @root ? "" : path.delete_prefix("#{@root}/")
|
|
73
|
+
if File.directory?(path)
|
|
74
|
+
Find.prune if !rel.empty? && @matcher.excluded?(rel)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
next if @matcher.excluded?(rel)
|
|
78
|
+
next unless File.file?(path) && File.size(path).between?(1, MAX_FILE_SIZE)
|
|
79
|
+
|
|
80
|
+
content = read_text(path)
|
|
81
|
+
next unless content
|
|
82
|
+
|
|
83
|
+
hit = self.class.check(content)
|
|
84
|
+
next unless hit
|
|
85
|
+
|
|
86
|
+
findings << Finding.new(path: rel, reason: hit[:reason], line: hit[:line], lines: content.lines.size)
|
|
87
|
+
end
|
|
88
|
+
findings.sort_by { |f| [f.reason == :exact ? 0 : 1, f.path] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# nil for anything that is not readable UTF-8 text — a NUL byte is the same
|
|
94
|
+
# binary test the rest of the editor uses.
|
|
95
|
+
def read_text(path)
|
|
96
|
+
content = File.read(path, encoding: "UTF-8")
|
|
97
|
+
return nil unless content.valid_encoding?
|
|
98
|
+
return nil if content.include?("\0")
|
|
99
|
+
|
|
100
|
+
content
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|