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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +102 -25
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +268 -53
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +31 -5
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. 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
- render json: FileTreeService.build(workspace_root)
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
- size = File.size(path)
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: :unprocessable_entity
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
- output, status = GitService.run_git(workspace_root.to_s, "status", "--porcelain")
852
- branch = GitService.current_branch(workspace_root.to_s) || ""
853
- files = GitService.parse_porcelain_status(output)
854
- render json: { ok: status.success?, files: files, branch: branch }
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
- _out, _err, status = Open3.capture3(env, *cmd)
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 || 60,
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
- _out, _err, status = Open3.capture3(env, *cmd)
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
- def broadcast_files_changed(changed_paths = nil)
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
- JsGlobalsService.invalidate(root)
1256
- JsProgramService.invalidate(root)
1257
- Thread.new do
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
- result = ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
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
- path = uri.delete_prefix("file://")
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
- output, _err, _status = Open3.capture3(*AvailabilityProbe.haml_lint_command(workspace_root), "--reporter", "json", "--no-color", f.path)
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
- valid_ref = /\A[a-zA-Z0-9._\-\/\^~]+\z/
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
- offset = params[:offset].present? ? params[:offset].to_i : nil
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
- path = workspace_path
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
- path = branch_states_path
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
- File.open(path, File::RDWR | File::CREAT) do |f|
48
- lock_exclusive!(f)
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
- break if existing[branch] == JSON.parse(payload_json)
46
+ next if existing[branch] == JSON.parse(payload_json)
53
47
 
54
48
  existing[branch] = state
55
- f.truncate(0)
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
- File.open(path, File::RDWR) do |f|
67
- lock_exclusive!(f)
58
+ with_lock(path) do
68
59
  all = begin
69
- JSON.parse(f.read)
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
- f.truncate(0)
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
- File.open(path, File::RDWR | File::CREAT) do |f|
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
- @patterns = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
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
- @patterns.any? { |pattern| matches?(pattern, rel) }
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)