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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  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 +104 -103
  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 +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  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 +155 -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 +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. 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
- # no-op
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
- transmit({ "type" => "sync", "snapshot" => state[:snapshot], "deltas" => state[:deltas] })
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
- render json: FileTreeService.build(workspace_root)
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
- size = File.size(path)
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: :unprocessable_entity
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
- 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 }
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
- _out, _err, status = Open3.capture3(env, *cmd)
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 || 60,
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
- _out, _err, status = Open3.capture3(env, *cmd)
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
- def broadcast_files_changed(changed_paths = nil)
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
- 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}")
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
- result = ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
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
- path = uri.delete_prefix("file://")
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
- output, _err, _status = Open3.capture3(*AvailabilityProbe.haml_lint_command(workspace_root), "--reporter", "json", "--no-color", f.path)
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
- 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,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
- oldest = rooms.min_by { |_path, room| room[:last_activity] }
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