mbeditor 0.12.9 → 0.13.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 +150 -0
- data/app/assets/javascripts/mbeditor/application.js +7 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
- 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 +102 -25
- 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 +216 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +122 -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 +30 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +268 -53
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- 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 +173 -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 +7 -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 +7 -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 +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +65 -64
- data/app/services/mbeditor/search_replace_service.rb +31 -5
- data/app/services/mbeditor/test_runner_service.rb +98 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +12 -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 +2 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- metadata +7 -2
|
@@ -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,12 @@ 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 timeouts from these. 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,
|
|
104
|
+
testAllTimeout: (Mbeditor.configuration.test_all_timeout || 1800).to_i,
|
|
76
105
|
actionCableEnabled: action_cable_enabled?,
|
|
77
106
|
jsSyntaxCheckAvailable: JsSyntaxCheckService.available?,
|
|
78
107
|
rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root),
|
|
@@ -84,8 +113,18 @@ module Mbeditor
|
|
|
84
113
|
end
|
|
85
114
|
|
|
86
115
|
# GET /mbeditor/files — recursive file tree
|
|
116
|
+
# refresh=1 means "my view of the workspace is stale, drop your caches" —
|
|
117
|
+
# the client sends it after an external `git checkout`, which rewrites the
|
|
118
|
+
# tree without going through any mutation endpoint, so nothing here knows
|
|
119
|
+
# to invalidate. Without it the 15s tree TTL kept serving the old branch's
|
|
120
|
+
# file list, and the search cache the old branch's hits.
|
|
87
121
|
def files
|
|
88
|
-
|
|
122
|
+
if params[:refresh].present?
|
|
123
|
+
FileTreeService.invalidate(workspace_root.to_s)
|
|
124
|
+
SearchReplaceService.invalidate_cache(workspace_root.to_s)
|
|
125
|
+
end
|
|
126
|
+
tree = FileTreeService.cached_json(workspace_root)
|
|
127
|
+
render json: tree[:body] if stale?(etag: tree[:digest], public: false)
|
|
89
128
|
end
|
|
90
129
|
|
|
91
130
|
# GET /mbeditor/state — load workspace state
|
|
@@ -216,8 +255,17 @@ module Mbeditor
|
|
|
216
255
|
existing = f.size > 0 ? (JSON.parse(f.read) rescue {}) : {}
|
|
217
256
|
|
|
218
257
|
if existing.empty?
|
|
258
|
+
# An empty base is a legitimate one, and in practice the usual one:
|
|
259
|
+
# the client starts tracking when the editor mounts, which is before
|
|
260
|
+
# the file content has arrived, so the load itself is recorded as the
|
|
261
|
+
# first op against an empty document. Rejecting "" meant the initial
|
|
262
|
+
# POST for every file 400'd and no history was ever written at all.
|
|
263
|
+
# Only an absent base is an error.
|
|
264
|
+
unless params.key?(:base)
|
|
265
|
+
return render json: { error: 'base required for initial history' }, status: :bad_request
|
|
266
|
+
end
|
|
267
|
+
|
|
219
268
|
base = params[:base].to_s
|
|
220
|
-
return render json: { error: 'base required for initial history' }, status: :bad_request if base.empty?
|
|
221
269
|
return render json: { error: 'base too large' }, status: :content_too_large if base.bytesize > STATE_MAX_BYTES
|
|
222
270
|
existing = { 'branch' => branch, 'path' => rel, 'base' => base, 'ops' => [], 't' => Time.now.utc.iso8601 }
|
|
223
271
|
end
|
|
@@ -307,15 +355,20 @@ module Mbeditor
|
|
|
307
355
|
end
|
|
308
356
|
|
|
309
357
|
# GET /mbeditor/raw?path=... — send raw file directly (for images)
|
|
358
|
+
#
|
|
359
|
+
# ?download=1 flips the disposition to attachment, which is the whole of
|
|
360
|
+
# the explorer's "Download" action: the browser's own save dialog does the
|
|
361
|
+
# rest, so there is no separate endpoint and no client-side blob.
|
|
310
362
|
def raw
|
|
311
363
|
path = resolve_path(params[:path])
|
|
312
364
|
return render json: { error: "Forbidden" }, status: :forbidden unless path
|
|
313
365
|
return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
|
|
314
366
|
|
|
315
|
-
|
|
316
|
-
return render_file_too_large(size) if size > FileOperationService::MAX_FILE_SIZE_BYTES
|
|
367
|
+
stat = File.stat(path)
|
|
368
|
+
return render_file_too_large(stat.size) if stat.size > FileOperationService::MAX_FILE_SIZE_BYTES
|
|
369
|
+
return unless stale?(last_modified: stat.mtime, public: false)
|
|
317
370
|
|
|
318
|
-
send_file path, disposition: "inline"
|
|
371
|
+
send_file path, disposition: params[:download].present? ? "attachment" : "inline"
|
|
319
372
|
end
|
|
320
373
|
|
|
321
374
|
# POST /mbeditor/file — save file
|
|
@@ -325,7 +378,7 @@ module Mbeditor
|
|
|
325
378
|
return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
|
|
326
379
|
|
|
327
380
|
result = FileOperationService.new(workspace_root).save(path, params[:code].to_s)
|
|
328
|
-
broadcast_files_changed([path])
|
|
381
|
+
broadcast_files_changed([path], structural: false)
|
|
329
382
|
broadcast_file_saved(path)
|
|
330
383
|
render json: result
|
|
331
384
|
rescue FileOperationService::FileTooLargeError
|
|
@@ -838,7 +891,7 @@ module Mbeditor
|
|
|
838
891
|
)
|
|
839
892
|
|
|
840
893
|
if result.key?(:error)
|
|
841
|
-
render json: { error: result[:error] }, status: :
|
|
894
|
+
render json: { error: result[:error] }, status: :unprocessable_content
|
|
842
895
|
else
|
|
843
896
|
render json: result
|
|
844
897
|
end
|
|
@@ -848,10 +901,14 @@ module Mbeditor
|
|
|
848
901
|
|
|
849
902
|
# GET /mbeditor/git_status
|
|
850
903
|
def git_status
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
904
|
+
root = workspace_root.to_s
|
|
905
|
+
payload = self.class.cached_git_status(root) do
|
|
906
|
+
output, status = GitService.run_git(root, "status", "--porcelain")
|
|
907
|
+
{ ok: status.success?,
|
|
908
|
+
files: GitService.parse_porcelain_status(output),
|
|
909
|
+
branch: GitService.current_branch(root) || "" }
|
|
910
|
+
end
|
|
911
|
+
render json: payload
|
|
855
912
|
rescue StandardError => e
|
|
856
913
|
render json: { error: e.message }, status: :unprocessable_content
|
|
857
914
|
end
|
|
@@ -868,6 +925,9 @@ module Mbeditor
|
|
|
868
925
|
path = resolve_monaco_asset_path(relative)
|
|
869
926
|
return head :not_found unless path
|
|
870
927
|
|
|
928
|
+
# ~14 MB of bundle, re-sent on every page load without this.
|
|
929
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
930
|
+
|
|
871
931
|
send_file path, disposition: "inline", type: Mime::Type.lookup_by_extension(File.extname(path).delete_prefix(".")) || "application/octet-stream"
|
|
872
932
|
end
|
|
873
933
|
|
|
@@ -894,6 +954,7 @@ module Mbeditor
|
|
|
894
954
|
def pwa_sw
|
|
895
955
|
path = Mbeditor::Engine.root.join("public", "sw.js").to_s
|
|
896
956
|
return render plain: "Not found", status: :not_found unless File.file?(path)
|
|
957
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
897
958
|
|
|
898
959
|
send_file path, disposition: "inline", type: "application/javascript"
|
|
899
960
|
end
|
|
@@ -902,6 +963,7 @@ module Mbeditor
|
|
|
902
963
|
def pwa_icon
|
|
903
964
|
path = Mbeditor::Engine.root.join("public", "mbeditor-icon.svg").to_s
|
|
904
965
|
return render plain: "Not found", status: :not_found unless File.file?(path)
|
|
966
|
+
return unless stale?(last_modified: File.mtime(path), public: false)
|
|
905
967
|
|
|
906
968
|
send_file path, disposition: "inline", type: "image/svg+xml"
|
|
907
969
|
end
|
|
@@ -1025,7 +1087,7 @@ module Mbeditor
|
|
|
1025
1087
|
|
|
1026
1088
|
cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
|
|
1027
1089
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
1028
|
-
|
|
1090
|
+
status = run_lint_command(env, cmd)[:exit_status]
|
|
1029
1091
|
|
|
1030
1092
|
# exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
|
|
1031
1093
|
unless status.success? || status.exitstatus == 1
|
|
@@ -1040,6 +1102,45 @@ module Mbeditor
|
|
|
1040
1102
|
render json: { error: e.message }, status: :unprocessable_content
|
|
1041
1103
|
end
|
|
1042
1104
|
|
|
1105
|
+
# POST /mbeditor/rubocop — whole-workspace `rubocop` run
|
|
1106
|
+
#
|
|
1107
|
+
# mode=autocorrect adds `-a` (safe corrections only) and writes to disk, so
|
|
1108
|
+
# the response doubles as the post-correction offense list.
|
|
1109
|
+
def rubocop_run
|
|
1110
|
+
unless AvailabilityProbe.rubocop(workspace_root)
|
|
1111
|
+
return render json: { ok: false, error: "RuboCop is not available", files: [] }, status: :unprocessable_content
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
mode = params[:mode].to_s == "autocorrect" ? :autocorrect : :check
|
|
1115
|
+
result = RubocopRunService.run(workspace_root, mode: mode)
|
|
1116
|
+
# `-a` writes to disk behind every open tab's back. No path list: a
|
|
1117
|
+
# whole-project run can touch anything, and the omitted-paths form makes
|
|
1118
|
+
# the client re-check every open tab, which is exactly what's wanted.
|
|
1119
|
+
broadcast_files_changed(nil, structural: false) if mode == :autocorrect && result[:ok]
|
|
1120
|
+
render json: result
|
|
1121
|
+
rescue StandardError => e
|
|
1122
|
+
render json: { ok: false, error: e.message, files: [] }, status: :unprocessable_content
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# POST /mbeditor/test_all — run the whole suite
|
|
1126
|
+
#
|
|
1127
|
+
# No path: the framework's own default target applies. Deliberately a plain
|
|
1128
|
+
# blocking request like /test, just with the suite ceiling — streaming would
|
|
1129
|
+
# need a channel, a buffer and a cancel protocol for a button most people
|
|
1130
|
+
# press a handful of times a day.
|
|
1131
|
+
def run_all_tests
|
|
1132
|
+
config = Mbeditor.configuration
|
|
1133
|
+
result = TestRunnerService.run_all(
|
|
1134
|
+
workspace_root.to_s,
|
|
1135
|
+
framework: config.test_framework&.to_sym,
|
|
1136
|
+
command: config.test_all_command,
|
|
1137
|
+
timeout: (config.test_all_timeout || 1800).to_i
|
|
1138
|
+
)
|
|
1139
|
+
render json: result
|
|
1140
|
+
rescue StandardError => e
|
|
1141
|
+
render json: { ok: false, error: e.message }, status: :unprocessable_content
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1043
1144
|
# POST /mbeditor/test — run tests for the given file
|
|
1044
1145
|
def run_test
|
|
1045
1146
|
path = resolve_path(params[:path])
|
|
@@ -1068,7 +1169,7 @@ module Mbeditor
|
|
|
1068
1169
|
test_file,
|
|
1069
1170
|
framework: config.test_framework&.to_sym,
|
|
1070
1171
|
command: config.test_command,
|
|
1071
|
-
timeout: config.test_timeout ||
|
|
1172
|
+
timeout: (config.test_timeout || 180).to_i,
|
|
1072
1173
|
line: line
|
|
1073
1174
|
)
|
|
1074
1175
|
|
|
@@ -1121,6 +1222,11 @@ module Mbeditor
|
|
|
1121
1222
|
def model_schema
|
|
1122
1223
|
model_name = params[:model].to_s.strip
|
|
1123
1224
|
return render json: { error: "model required" }, status: :bad_request if model_name.blank?
|
|
1225
|
+
# SchemaService derives a file path from this name, so it is validated the
|
|
1226
|
+
# same way #module_members validates its own: a constant name, nothing that
|
|
1227
|
+
# could carry a "..".
|
|
1228
|
+
return render json: { error: "Invalid model" }, status: :bad_request \
|
|
1229
|
+
unless model_name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
|
|
1124
1230
|
|
|
1125
1231
|
schema = SchemaService.new(model_name, workspace_root.to_s).call
|
|
1126
1232
|
if schema
|
|
@@ -1153,7 +1259,7 @@ module Mbeditor
|
|
|
1153
1259
|
|
|
1154
1260
|
cmd = AvailabilityProbe.rubocop_command(workspace_root) + [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
|
|
1155
1261
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
1156
|
-
|
|
1262
|
+
status = run_lint_command(env, cmd)[:exit_status]
|
|
1157
1263
|
unless status.success? || status.exitstatus == 1
|
|
1158
1264
|
return render json: { ok: false, content: code }
|
|
1159
1265
|
end
|
|
@@ -1248,21 +1354,30 @@ module Mbeditor
|
|
|
1248
1354
|
base.to_s
|
|
1249
1355
|
end
|
|
1250
1356
|
|
|
1251
|
-
|
|
1357
|
+
# structural: false means "these files' contents changed, the tree did not"
|
|
1358
|
+
# — a save or a replace-in-files. The client uses it to skip re-walking the
|
|
1359
|
+
# whole workspace and rebuilding its quick-open index on every save, which
|
|
1360
|
+
# is work no content change can invalidate. Defaults to true so a new call
|
|
1361
|
+
# site that says nothing keeps the conservative behaviour.
|
|
1362
|
+
def broadcast_files_changed(changed_paths = nil, structural: true)
|
|
1252
1363
|
root = workspace_root.to_s
|
|
1253
1364
|
FileTreeService.invalidate(root)
|
|
1254
1365
|
SearchReplaceService.invalidate_cache(root)
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
GitInfoService.invalidate(root)
|
|
1259
|
-
rescue => e
|
|
1260
|
-
Rails.logger.warn("[mbeditor] GitInfoService.invalidate failed: #{e}")
|
|
1366
|
+
if js_related_paths?(changed_paths)
|
|
1367
|
+
JsGlobalsService.invalidate(root)
|
|
1368
|
+
JsProgramService.invalidate(root)
|
|
1261
1369
|
end
|
|
1370
|
+
# Cheap, and it means editing config/routes.rb refreshes the inline route
|
|
1371
|
+
# hints on the next look rather than after the TTL.
|
|
1372
|
+
RouteService.invalidate
|
|
1373
|
+
# Both are mutex-guarded Hash#deletes; a thread per save cost more than
|
|
1374
|
+
# the work it deferred.
|
|
1375
|
+
GitInfoService.invalidate(root)
|
|
1376
|
+
self.class.invalidate_git_status(root)
|
|
1262
1377
|
|
|
1263
1378
|
return unless defined?(ActionCable.server)
|
|
1264
1379
|
|
|
1265
|
-
payload = { type: "files_changed" }
|
|
1380
|
+
payload = { type: "files_changed", structural: structural }
|
|
1266
1381
|
rel = Array(changed_paths).compact.map { |p| relative_path(p.to_s) }.reject(&:empty?)
|
|
1267
1382
|
payload[:paths] = rel if rel.any?
|
|
1268
1383
|
ActionCable.server.broadcast("mbeditor_editor", payload)
|
|
@@ -1270,6 +1385,20 @@ module Mbeditor
|
|
|
1270
1385
|
# Never let a broadcast failure affect the HTTP response
|
|
1271
1386
|
end
|
|
1272
1387
|
|
|
1388
|
+
# Everything either JS cache indexes: JsProgramService::SOURCE_EXT plus the
|
|
1389
|
+
# .erb-templated variants CodeSearchService::JS_GLOBS feeds JsGlobalsService.
|
|
1390
|
+
JS_SOURCE_NAME = /\.(js|jsx|mjs|cjs|ts|tsx)(\.erb)?\z/i
|
|
1391
|
+
|
|
1392
|
+
# Rebuilding the JS program costs ~93ms/MB, so saving a .rb file must not
|
|
1393
|
+
# throw it away. A path with no extension at all — a directory, a rename
|
|
1394
|
+
# target — stays conservative, as does an omitted path list.
|
|
1395
|
+
def js_related_paths?(changed_paths)
|
|
1396
|
+
paths = Array(changed_paths).compact.map(&:to_s).reject(&:empty?)
|
|
1397
|
+
return true if paths.empty?
|
|
1398
|
+
|
|
1399
|
+
paths.any? { |p| File.extname(p).empty? || p.match?(JS_SOURCE_NAME) }
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1273
1402
|
# Tells every peer that this file's shared buffer was just saved to disk so
|
|
1274
1403
|
# they can reset that tab's clean baseline and clear its dirty indicator.
|
|
1275
1404
|
# Rides the same global stream as broadcast_files_changed and is equally
|
|
@@ -1431,10 +1560,16 @@ module Mbeditor
|
|
|
1431
1560
|
end
|
|
1432
1561
|
|
|
1433
1562
|
def run_with_timeout(env, cmd, stdin_data:)
|
|
1563
|
+
run_lint_command(env, cmd, stdin_data: stdin_data)[:stdout]
|
|
1564
|
+
end
|
|
1565
|
+
|
|
1566
|
+
# Same lint_timeout ceiling as #lint. quick_fix, format_file and haml-lint
|
|
1567
|
+
# used to spawn with Open3.capture3 and no timeout at all, so a wedged
|
|
1568
|
+
# rubocop held a request thread until the client gave up.
|
|
1569
|
+
def run_lint_command(env, cmd, stdin_data: nil)
|
|
1434
1570
|
timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
|
|
1435
1571
|
timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
|
|
1436
|
-
|
|
1437
|
-
result[:stdout]
|
|
1572
|
+
ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
|
|
1438
1573
|
end
|
|
1439
1574
|
|
|
1440
1575
|
def apply_rename_changes(result, open_paths)
|
|
@@ -1468,7 +1603,7 @@ module Mbeditor
|
|
|
1468
1603
|
end
|
|
1469
1604
|
end
|
|
1470
1605
|
|
|
1471
|
-
broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }) if written.any?
|
|
1606
|
+
broadcast_files_changed(written.map { |rel| File.join(workspace_root, rel) }, structural: false) if written.any?
|
|
1472
1607
|
{ ok: true, written: written, rejected: rejected, edits: edits_for_open }
|
|
1473
1608
|
end
|
|
1474
1609
|
|
|
@@ -1545,6 +1680,11 @@ module Mbeditor
|
|
|
1545
1680
|
|
|
1546
1681
|
URI_KEYS = %w[uri targetUri].freeze
|
|
1547
1682
|
|
|
1683
|
+
# URI::DEFAULT_PARSER became the RFC3986 parser in Ruby 4.0, where #unescape
|
|
1684
|
+
# is deprecated; URI::RFC2396_PARSER only exists from Ruby 3.4. The gem
|
|
1685
|
+
# supports >= 3.0, so take whichever this Ruby has.
|
|
1686
|
+
URI_UNESCAPER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
|
|
1687
|
+
|
|
1548
1688
|
def sanitize_lsp_uris(node, depth = 0)
|
|
1549
1689
|
return nil if depth > MAX_LSP_DEPTH
|
|
1550
1690
|
|
|
@@ -1593,7 +1733,10 @@ module Mbeditor
|
|
|
1593
1733
|
def workspace_relative_uri(uri)
|
|
1594
1734
|
return nil unless uri.start_with?("file://")
|
|
1595
1735
|
|
|
1596
|
-
|
|
1736
|
+
# ruby-lsp percent-escapes its URIs; workspace_root is a raw path. A
|
|
1737
|
+
# checkout with a space (or any other escaped character) in its path
|
|
1738
|
+
# matched nothing here, so every result was silently dropped.
|
|
1739
|
+
path = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
1597
1740
|
prefix = "#{workspace_root}/"
|
|
1598
1741
|
return nil unless path.start_with?(prefix)
|
|
1599
1742
|
|
|
@@ -1610,7 +1753,7 @@ module Mbeditor
|
|
|
1610
1753
|
range = loc["range"] || loc["targetSelectionRange"] || loc["targetRange"]
|
|
1611
1754
|
next unless uri.to_s.start_with?("file://")
|
|
1612
1755
|
|
|
1613
|
-
fpath = uri.delete_prefix("file://")
|
|
1756
|
+
fpath = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
1614
1757
|
# Drop gem/stdlib locations the editor can't open; an empty list makes
|
|
1615
1758
|
# the frontend fall back to the legacy services (ri covers stdlib).
|
|
1616
1759
|
next unless fpath.start_with?("#{root}/")
|
|
@@ -1787,7 +1930,8 @@ module Mbeditor
|
|
|
1787
1930
|
Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
|
|
1788
1931
|
f.write(code)
|
|
1789
1932
|
f.flush
|
|
1790
|
-
|
|
1933
|
+
cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
|
|
1934
|
+
output = run_lint_command({}, cmd)[:stdout]
|
|
1791
1935
|
idx = output.index("{")
|
|
1792
1936
|
result = idx ? JSON.parse(output[idx..]) : {}
|
|
1793
1937
|
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,17 @@ 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
|
+
|
|
30
36
|
def record_update(path, bytes, now: monotonic)
|
|
31
37
|
MUTEX.synchronize do
|
|
32
38
|
room = touch(path, now)
|
|
33
39
|
room[:deltas] << bytes
|
|
40
|
+
room[:deltas].shift while room[:deltas].size > MAX_DELTAS
|
|
34
41
|
end
|
|
35
42
|
nil
|
|
36
43
|
end
|
|
@@ -22,18 +22,13 @@ module Mbeditor
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def read_state
|
|
25
|
-
|
|
26
|
-
return {} unless File.exist?(path)
|
|
27
|
-
JSON.parse(File.read(path))
|
|
25
|
+
read_json(workspace_path)
|
|
28
26
|
rescue JSON::ParserError, Errno::ENOENT
|
|
29
27
|
{}
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def read_branch_state(branch)
|
|
33
|
-
|
|
34
|
-
return {} unless File.exist?(path)
|
|
35
|
-
all = JSON.parse(File.read(path))
|
|
36
|
-
all[branch] || {}
|
|
31
|
+
read_json(branch_states_path)[branch] || {}
|
|
37
32
|
rescue JSON::ParserError, Errno::ENOENT
|
|
38
33
|
{}
|
|
39
34
|
end
|
|
@@ -44,17 +39,14 @@ module Mbeditor
|
|
|
44
39
|
raise PayloadTooLargeError, "State payload too large" if payload_json.bytesize > STATE_MAX_BYTES
|
|
45
40
|
path = branch_states_path
|
|
46
41
|
FileUtils.mkdir_p(path.dirname)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
existing = f.size > 0 ? JSON.parse(f.read) : {}
|
|
42
|
+
with_lock(path) do
|
|
43
|
+
existing = read_json(path)
|
|
50
44
|
# Auto-save fires on a timer even with no changes; skip the full-file
|
|
51
45
|
# rewrite when this branch's entry is already identical.
|
|
52
|
-
|
|
46
|
+
next if existing[branch] == JSON.parse(payload_json)
|
|
53
47
|
|
|
54
48
|
existing[branch] = state
|
|
55
|
-
|
|
56
|
-
f.rewind
|
|
57
|
-
f.write(existing.to_json)
|
|
49
|
+
atomic_write(path, existing.to_json)
|
|
58
50
|
end
|
|
59
51
|
nil
|
|
60
52
|
end
|
|
@@ -63,10 +55,9 @@ module Mbeditor
|
|
|
63
55
|
path = branch_states_path
|
|
64
56
|
return [] unless File.exist?(path)
|
|
65
57
|
pruned = []
|
|
66
|
-
|
|
67
|
-
lock_exclusive!(f)
|
|
58
|
+
with_lock(path) do
|
|
68
59
|
all = begin
|
|
69
|
-
|
|
60
|
+
read_json(path)
|
|
70
61
|
rescue JSON::ParserError => e
|
|
71
62
|
Rails.logger.error("[mbeditor] EditorStateService#prune_branch_states: discarding corrupt branch_states JSON at #{path}: #{e.message}")
|
|
72
63
|
{}
|
|
@@ -74,9 +65,7 @@ module Mbeditor
|
|
|
74
65
|
pruned = all.keys - active_branches
|
|
75
66
|
if pruned.any?
|
|
76
67
|
pruned.each { |b| all.delete(b) }
|
|
77
|
-
|
|
78
|
-
f.rewind
|
|
79
|
-
f.write(all.to_json)
|
|
68
|
+
atomic_write(path, all.to_json)
|
|
80
69
|
end
|
|
81
70
|
end
|
|
82
71
|
pruned
|
|
@@ -87,17 +76,38 @@ module Mbeditor
|
|
|
87
76
|
raise PayloadTooLargeError, "State payload too large" if payload.bytesize > STATE_MAX_BYTES
|
|
88
77
|
path = workspace_path
|
|
89
78
|
FileUtils.mkdir_p(path.dirname)
|
|
90
|
-
|
|
91
|
-
lock_exclusive!(f)
|
|
92
|
-
f.truncate(0)
|
|
93
|
-
f.rewind
|
|
94
|
-
f.write(payload)
|
|
95
|
-
end
|
|
79
|
+
with_lock(path) { atomic_write(path, payload) }
|
|
96
80
|
nil
|
|
97
81
|
end
|
|
98
82
|
|
|
99
83
|
private
|
|
100
84
|
|
|
85
|
+
# Readers take no lock at all: every write lands by rename, so a read sees
|
|
86
|
+
# either the whole previous file or the whole new one — never the empty
|
|
87
|
+
# window a truncate-then-write leaves, which readers swallowed as {}.
|
|
88
|
+
def read_json(path)
|
|
89
|
+
return {} unless File.exist?(path)
|
|
90
|
+
|
|
91
|
+
raw = File.read(path)
|
|
92
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def atomic_write(path, payload)
|
|
96
|
+
tmp = "#{path}.tmp"
|
|
97
|
+
File.write(tmp, payload)
|
|
98
|
+
File.rename(tmp, path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The lock sits on a sidecar file, not on the state file: the state file is
|
|
102
|
+
# replaced by rename, so a lock held on the inode it had before the write
|
|
103
|
+
# would exclude nobody afterwards.
|
|
104
|
+
def with_lock(path)
|
|
105
|
+
File.open("#{path}.lock", File::RDWR | File::CREAT) do |f|
|
|
106
|
+
lock_exclusive!(f)
|
|
107
|
+
yield
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
101
111
|
# Acquire an exclusive lock without blocking forever. Retries the
|
|
102
112
|
# non-blocking flock until @lock_timeout elapses, then raises so the caller
|
|
103
113
|
# fails fast instead of pinning a worker on a stuck holder.
|
|
@@ -87,24 +87,26 @@ module Mbeditor
|
|
|
87
87
|
# case is folded. Defaults to the resolved workspace root.
|
|
88
88
|
def initialize(patterns, root: nil)
|
|
89
89
|
@fold_case = self.class.case_insensitive_filesystem?(root || WorkspaceRootResolver.call)
|
|
90
|
-
|
|
90
|
+
normalized = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
|
|
91
|
+
# Split by shape once: a path pattern is compared against the whole
|
|
92
|
+
# relative path, a bare name against its segments. This runs per file of
|
|
93
|
+
# a workspace walk, so neither the split nor the basename belongs in the
|
|
94
|
+
# per-pattern loop.
|
|
95
|
+
@path_patterns, @name_patterns = normalized.partition { |pattern| pattern.include?("/") }
|
|
91
96
|
end
|
|
92
97
|
|
|
93
98
|
def excluded?(relative_path)
|
|
94
99
|
rel = normalize(relative_path.to_s)
|
|
95
|
-
@
|
|
100
|
+
return true if @path_patterns.any? { |pattern| rel == pattern || rel.start_with?("#{pattern}/") }
|
|
101
|
+
return false if @name_patterns.empty?
|
|
102
|
+
|
|
103
|
+
segments = rel.split("/")
|
|
104
|
+
basename = File.basename(rel)
|
|
105
|
+
@name_patterns.any? { |pattern| basename == pattern || segments.include?(pattern) }
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
private
|
|
99
109
|
|
|
100
|
-
def matches?(pattern, rel)
|
|
101
|
-
if pattern.include?("/")
|
|
102
|
-
rel == pattern || rel.start_with?("#{pattern}/")
|
|
103
|
-
else
|
|
104
|
-
File.basename(rel) == pattern || rel.split("/").include?(pattern)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
110
|
# "/" is ASCII and survives both steps, so the caller can normalize a whole
|
|
109
111
|
# path in one pass and split it afterwards.
|
|
110
112
|
def normalize(str)
|