mbeditor 0.5.3 → 0.7.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +7 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
  6. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
  9. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
  11. data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
  12. data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
  13. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +34 -6
  15. data/app/assets/javascripts/mbeditor/git_service.js +2 -1
  16. data/app/assets/javascripts/mbeditor/history_service.js +177 -0
  17. data/app/assets/javascripts/mbeditor/search_service.js +1 -0
  18. data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
  19. data/app/assets/stylesheets/mbeditor/application.css +112 -0
  20. data/app/assets/stylesheets/mbeditor/editor.css +443 -78
  21. data/app/channels/mbeditor/editor_channel.rb +5 -41
  22. data/app/controllers/mbeditor/application_controller.rb +8 -1
  23. data/app/controllers/mbeditor/editors_controller.rb +276 -654
  24. data/app/controllers/mbeditor/git_controller.rb +2 -61
  25. data/app/services/mbeditor/availability_probe.rb +83 -0
  26. data/app/services/mbeditor/code_search_service.rb +42 -0
  27. data/app/services/mbeditor/editor_state_service.rb +91 -0
  28. data/app/services/mbeditor/exclusion_matcher.rb +23 -0
  29. data/app/services/mbeditor/file_operation_service.rb +68 -0
  30. data/app/services/mbeditor/file_tree_service.rb +69 -0
  31. data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
  32. data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
  33. data/app/services/mbeditor/git_info_service.rb +151 -0
  34. data/app/services/mbeditor/git_service.rb +36 -26
  35. data/app/services/mbeditor/js_definition_service.rb +59 -0
  36. data/app/services/mbeditor/js_members_service.rb +62 -0
  37. data/app/services/mbeditor/process_runner.rb +48 -0
  38. data/app/services/mbeditor/rails_related_files_service.rb +282 -0
  39. data/app/services/mbeditor/ruby_definition_service.rb +77 -101
  40. data/app/services/mbeditor/schema_service.rb +270 -0
  41. data/app/services/mbeditor/search_replace_service.rb +184 -0
  42. data/app/services/mbeditor/test_runner_service.rb +5 -27
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -2
  44. data/config/routes.rb +8 -1
  45. data/lib/mbeditor/configuration.rb +4 -2
  46. data/lib/mbeditor/version.rb +1 -1
  47. data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
  48. data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
  49. data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
  50. data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
  51. data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
  52. data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
  53. metadata +26 -3
  54. data/app/services/mbeditor/unused_methods_service.rb +0 -139
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
4
5
  require "open3"
5
6
  require "shellwords"
@@ -14,8 +15,6 @@ module Mbeditor
14
15
  before_action :verify_mbeditor_client, unless: -> { request.get? || request.head? }
15
16
 
16
17
  IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
17
- MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
18
- RG_AVAILABLE = system("which rg > /dev/null 2>&1")
19
18
 
20
19
  # GET /mbeditor — renders the IDE shell
21
20
  def index
@@ -33,11 +32,11 @@ module Mbeditor
33
32
  render json: {
34
33
  rootName: workspace_root.basename.to_s,
35
34
  rootPath: workspace_root.to_s,
36
- rubocopAvailable: rubocop_available?,
35
+ rubocopAvailable: AvailabilityProbe.rubocop(workspace_root),
37
36
  rubocopConfigPath: rubocop_config_path,
38
- hamlLintAvailable: haml_lint_available?,
39
- gitAvailable: git_available?,
40
- blameAvailable: git_blame_available?,
37
+ hamlLintAvailable: AvailabilityProbe.haml_lint(workspace_root),
38
+ gitAvailable: AvailabilityProbe.git(workspace_root),
39
+ blameAvailable: AvailabilityProbe.git(workspace_root),
41
40
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
42
41
  testAvailable: test_available?,
43
42
  actionCableEnabled: action_cable_enabled?
@@ -46,67 +45,40 @@ module Mbeditor
46
45
 
47
46
  # GET /mbeditor/files — recursive file tree
48
47
  def files
49
- root = workspace_root.to_s
50
- cached = cached_file_tree(root)
51
- return render json: cached if cached
52
-
53
- tree = build_tree(root)
54
- store_file_tree(root, tree)
55
- render json: tree
48
+ render json: FileTreeService.build(workspace_root)
56
49
  end
57
50
 
58
51
  # GET /mbeditor/state — load workspace state
59
52
  def state
60
- path = workspace_root.join("tmp", "mbeditor_workspace.json")
61
- if File.exist?(path)
62
- render json: JSON.parse(File.read(path))
63
- else
64
- render json: {}
65
- end
66
- rescue Errno::ENOENT
67
- render json: {}
68
- rescue JSON::ParserError
69
- render json: {}
53
+ render json: editor_state_service.read_state
70
54
  rescue StandardError => e
71
55
  render json: { error: e.message }, status: :unprocessable_content
72
56
  end
73
57
 
74
- STATE_MAX_BYTES = 1 * 1024 * 1024
58
+ STATE_MAX_BYTES = EditorStateService::STATE_MAX_BYTES
75
59
 
76
60
  # POST /mbeditor/state — save workspace state
77
61
  def save_state
78
62
  raw = params[:state]
79
63
  raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
80
- payload = raw.to_json
81
- return render json: { error: "State payload too large" }, status: :content_too_large if payload.bytesize > STATE_MAX_BYTES
82
-
83
- path = workspace_root.join("tmp", "mbeditor_workspace.json")
84
- FileUtils.mkdir_p(workspace_root.join("tmp"))
85
- File.open(path, File::RDWR | File::CREAT) do |f|
86
- f.flock(File::LOCK_EX)
87
- f.truncate(0)
88
- f.rewind
89
- f.write(payload)
90
- end
64
+ editor_state_service.write_state(raw)
91
65
  render json: { ok: true }
66
+ rescue EditorStateService::PayloadTooLargeError
67
+ render json: { error: "State payload too large" }, status: :content_too_large
92
68
  rescue StandardError => e
93
69
  render json: { error: e.message }, status: :unprocessable_content
94
70
  end
95
71
 
96
- SAFE_BRANCH_NAME = /\A[a-zA-Z0-9._\-\/]+\z/
72
+ HISTORY_MAX_OPS = 10_000
73
+ HISTORY_COMPACT_TARGET = 5_000
74
+
97
75
 
98
76
  # GET /mbeditor/branch_state?branch=... — load per-branch pane state
99
77
  def branch_state
100
78
  branch = sanitize_branch_name(params[:branch])
101
79
  return render json: {}, status: :bad_request unless branch
102
80
 
103
- path = workspace_root.join("tmp", "mbeditor_branch_states.json")
104
- if File.exist?(path)
105
- all = JSON.parse(File.read(path))
106
- render json: (all[branch] || {})
107
- else
108
- render json: {}
109
- end
81
+ render json: editor_state_service.read_branch_state(branch)
110
82
  rescue StandardError
111
83
  render json: {}
112
84
  end
@@ -115,50 +87,114 @@ module Mbeditor
115
87
  return render json: { error: "Invalid branch name" }, status: :bad_request unless branch
116
88
 
117
89
  payload = params[:state].to_unsafe_h
118
- return render json: { error: "State payload too large" }, status: :content_too_large if payload.to_json.bytesize > STATE_MAX_BYTES
119
-
120
- path = workspace_root.join("tmp", "mbeditor_branch_states.json")
121
- FileUtils.mkdir_p(workspace_root.join("tmp"))
122
- File.open(path, File::RDWR | File::CREAT) do |f|
123
- f.flock(File::LOCK_EX)
124
- existing = f.size > 0 ? JSON.parse(f.read) : {}
125
- existing[branch] = payload
126
- f.truncate(0)
127
- f.rewind
128
- f.write(existing.to_json)
129
- end
90
+ editor_state_service.write_branch_state(branch, payload)
130
91
  render json: { ok: true }
92
+ rescue EditorStateService::PayloadTooLargeError
93
+ render json: { error: "State payload too large" }, status: :content_too_large
131
94
  rescue StandardError => e
132
95
  render json: { error: e.message }, status: :unprocessable_content
133
96
  end
134
97
 
135
98
  # POST /mbeditor/prune_branch_states — remove states for deleted branches
136
99
  def prune_branch_states
137
- state_path = workspace_root.join("tmp", "mbeditor_branch_states.json")
138
- return render json: { pruned: [] } unless File.exist?(state_path)
139
-
140
100
  root = workspace_root.to_s
141
101
  out, _err, status = Open3.capture3("git", "-C", root, "branch", "--format=%(refname:short)")
142
102
  return render json: { pruned: [] } unless status.success?
143
103
 
144
104
  local_branches = out.split("\n").map(&:strip).reject(&:empty?)
145
- pruned = []
146
- File.open(state_path, File::RDWR) do |f|
147
- f.flock(File::LOCK_EX)
148
- all = JSON.parse(f.read) rescue {}
149
- pruned = all.keys - local_branches
150
- if pruned.any?
151
- pruned.each { |b| all.delete(b) }
152
- f.truncate(0)
153
- f.rewind
154
- f.write(all.to_json)
105
+ pruned = editor_state_service.prune_branch_states(active_branches: local_branches)
106
+
107
+ hist_dir = workspace_root.join('tmp', 'mbeditor_history')
108
+ if File.directory?(hist_dir)
109
+ Dir.glob(File.join(hist_dir, '*.json')) do |hist_file|
110
+ data = JSON.parse(File.read(hist_file)) rescue nil
111
+ next unless data.is_a?(Hash) && data['branch']
112
+ FileUtils.rm_f(hist_file) unless local_branches.include?(data['branch'])
155
113
  end
156
114
  end
115
+
157
116
  render json: { pruned: pruned }
158
117
  rescue StandardError => e
159
118
  render json: { error: e.message }, status: :unprocessable_content
160
119
  end
161
120
 
121
+ # GET /mbeditor/file_history?branch=X&path=Y
122
+ def file_history
123
+ branch = sanitize_branch_name(params[:branch])
124
+ return render json: {}, status: :bad_request unless branch
125
+
126
+ path = resolve_path(params[:path])
127
+ return render json: {}, status: :forbidden unless path
128
+
129
+ rel = relative_path(path)
130
+ hist = history_file_path(branch, rel)
131
+ return render json: {} unless File.exist?(hist)
132
+
133
+ data = JSON.parse(File.read(hist))
134
+
135
+ if data['t']
136
+ age = Time.now.utc - Time.parse(data['t'])
137
+ if age > 7 * 24 * 3600
138
+ FileUtils.rm_f(hist)
139
+ return render json: {}
140
+ end
141
+ end
142
+
143
+ render json: { base: data['base'], ops: data['ops'] || [] }
144
+ rescue JSON::ParserError
145
+ FileUtils.rm_f(hist) rescue nil
146
+ render json: {}
147
+ rescue StandardError
148
+ render json: {}
149
+ end
150
+
151
+ # POST /mbeditor/file_history
152
+ def save_file_history
153
+ branch = sanitize_branch_name(params[:branch])
154
+ return render json: { error: 'Invalid branch name' }, status: :bad_request unless branch
155
+
156
+ path = resolve_path(params[:path])
157
+ return render json: { error: 'Forbidden' }, status: :forbidden unless path
158
+
159
+ rel = relative_path(path)
160
+ new_ops = params[:ops]
161
+ return render json: { error: 'ops must be an array' }, status: :bad_request unless new_ops.is_a?(Array)
162
+ return head :no_content if new_ops.empty?
163
+
164
+ new_ops_clean = new_ops.map { |op| Array(op).first(5) }
165
+
166
+ hist = history_file_path(branch, rel)
167
+ FileUtils.mkdir_p(File.dirname(hist))
168
+
169
+ File.open(hist, File::RDWR | File::CREAT) do |f|
170
+ f.flock(File::LOCK_EX)
171
+ existing = f.size > 0 ? (JSON.parse(f.read) rescue {}) : {}
172
+
173
+ if existing.empty?
174
+ base = params[:base].to_s
175
+ return render json: { error: 'base required for initial history' }, status: :bad_request if base.empty?
176
+ return render json: { error: 'base too large' }, status: :content_too_large if base.bytesize > STATE_MAX_BYTES
177
+ existing = { 'branch' => branch, 'path' => rel, 'base' => base, 'ops' => [], 't' => Time.now.utc.iso8601 }
178
+ end
179
+
180
+ existing['ops'] = (existing['ops'] || []) + new_ops_clean
181
+ existing['t'] = Time.now.utc.iso8601
182
+
183
+ if existing['ops'].length > HISTORY_MAX_OPS
184
+ to_compact = existing['ops'].shift(HISTORY_COMPACT_TARGET)
185
+ existing['base'] = compact_history_ops(existing['base'], to_compact)
186
+ end
187
+
188
+ f.truncate(0)
189
+ f.rewind
190
+ f.write(existing.to_json)
191
+ end
192
+
193
+ head :no_content
194
+ rescue StandardError => e
195
+ render json: { error: e.message }, status: :unprocessable_content
196
+ end
197
+
162
198
  # GET /mbeditor/file?path=...
163
199
  def show
164
200
  path = resolve_path(params[:path])
@@ -194,7 +230,7 @@ module Mbeditor
194
230
  end
195
231
 
196
232
  size = File.size(path)
197
- return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
233
+ return render_file_too_large(size) if size > FileOperationService::MAX_FILE_SIZE_BYTES
198
234
 
199
235
  if image_path?(path)
200
236
  return render json: {
@@ -223,7 +259,7 @@ module Mbeditor
223
259
  return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
224
260
 
225
261
  size = File.size(path)
226
- return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
262
+ return render_file_too_large(size) if size > FileOperationService::MAX_FILE_SIZE_BYTES
227
263
 
228
264
  send_file path, disposition: "inline"
229
265
  end
@@ -234,12 +270,11 @@ module Mbeditor
234
270
  return render json: { error: "Forbidden" }, status: :forbidden unless path
235
271
  return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
236
272
 
237
- content = params[:code].to_s
238
- return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
239
-
240
- File.write(path, content)
273
+ result = FileOperationService.new(workspace_root).save(path, params[:code].to_s)
241
274
  broadcast_files_changed
242
- render json: { ok: true, path: relative_path(path) }
275
+ render json: result
276
+ rescue FileOperationService::FileTooLargeError
277
+ render_file_too_large(params[:code].to_s.bytesize)
243
278
  rescue StandardError => e
244
279
  render json: { error: e.message }, status: :unprocessable_content
245
280
  end
@@ -249,16 +284,14 @@ module Mbeditor
249
284
  path = resolve_path(params[:path])
250
285
  return render json: { error: "Forbidden" }, status: :forbidden unless path
251
286
  return render json: { error: "Cannot create file in this path" }, status: :forbidden if path_blocked_for_operations?(path)
252
- return render json: { error: "File already exists" }, status: :unprocessable_content if File.exist?(path)
253
-
254
- content = params[:code].to_s
255
- return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
256
287
 
257
- FileUtils.mkdir_p(File.dirname(path))
258
- File.write(path, content)
288
+ result = FileOperationService.new(workspace_root).create_file(path, params[:code].to_s)
259
289
  broadcast_files_changed
260
-
261
- render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
290
+ render json: result
291
+ rescue FileOperationService::FileExistsError
292
+ render json: { error: "File already exists" }, status: :unprocessable_content
293
+ rescue FileOperationService::FileTooLargeError
294
+ render_file_too_large(params[:code].to_s.bytesize)
262
295
  rescue StandardError => e
263
296
  render json: { error: e.message }, status: :unprocessable_content
264
297
  end
@@ -268,11 +301,12 @@ module Mbeditor
268
301
  path = resolve_path(params[:path])
269
302
  return render json: { error: "Forbidden" }, status: :forbidden unless path
270
303
  return render json: { error: "Cannot create directory in this path" }, status: :forbidden if path_blocked_for_operations?(path)
271
- return render json: { error: "Path already exists" }, status: :unprocessable_content if File.exist?(path)
272
304
 
273
- FileUtils.mkdir_p(path)
305
+ result = FileOperationService.new(workspace_root).create_dir(path)
274
306
  broadcast_files_changed
275
- render json: { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
307
+ render json: result
308
+ rescue FileOperationService::FileExistsError
309
+ render json: { error: "Path already exists" }, status: :unprocessable_content
276
310
  rescue StandardError => e
277
311
  render json: { error: e.message }, status: :unprocessable_content
278
312
  end
@@ -282,20 +316,15 @@ module Mbeditor
282
316
  old_path = resolve_path(params[:path])
283
317
  new_path = resolve_path(params[:new_path])
284
318
  return render json: { error: "Forbidden" }, status: :forbidden unless old_path && new_path
285
- return render json: { error: "Path not found" }, status: :not_found unless File.exist?(old_path)
286
- return render json: { error: "Target path already exists" }, status: :unprocessable_content if File.exist?(new_path)
287
319
  return render json: { error: "Cannot rename this path" }, status: :forbidden if path_blocked_for_operations?(old_path) || path_blocked_for_operations?(new_path)
288
320
 
289
- FileUtils.mkdir_p(File.dirname(new_path))
290
- FileUtils.mv(old_path, new_path)
321
+ result = FileOperationService.new(workspace_root).rename(old_path, new_path)
291
322
  broadcast_files_changed
292
-
293
- render json: {
294
- ok: true,
295
- oldPath: relative_path(old_path),
296
- path: relative_path(new_path),
297
- name: File.basename(new_path)
298
- }
323
+ render json: result
324
+ rescue FileOperationService::PathNotFoundError
325
+ render json: { error: "Path not found" }, status: :not_found
326
+ rescue FileOperationService::TargetExistsError
327
+ render json: { error: "Target path already exists" }, status: :unprocessable_content
299
328
  rescue StandardError => e
300
329
  render json: { error: e.message }, status: :unprocessable_content
301
330
  end
@@ -304,19 +333,11 @@ module Mbeditor
304
333
  def destroy_path
305
334
  path = resolve_path(params[:path])
306
335
  return render json: { error: "Forbidden" }, status: :forbidden unless path
307
- # Idempotent: if already gone the desired state is already achieved.
308
- return render json: { ok: true } unless File.exist?(path)
309
- return render json: { error: "Cannot delete this path" }, status: :forbidden if path_blocked_for_operations?(path)
310
-
311
- if File.directory?(path)
312
- FileUtils.rm_rf(path)
313
- broadcast_files_changed
314
- render json: { ok: true, type: "folder", path: relative_path(path) }
315
- else
316
- File.delete(path)
317
- broadcast_files_changed
318
- render json: { ok: true, type: "file", path: relative_path(path) }
319
- end
336
+ return render json: { error: "Cannot delete this path" }, status: :forbidden if File.exist?(path) && path_blocked_for_operations?(path)
337
+
338
+ result = FileOperationService.new(workspace_root).destroy_path(path)
339
+ broadcast_files_changed if result.key?(:type)
340
+ render json: result
320
341
  rescue StandardError => e
321
342
  render json: { error: e.message }, status: :unprocessable_content
322
343
  end
@@ -333,12 +354,12 @@ module Mbeditor
333
354
 
334
355
  results = case language
335
356
  when "ruby"
357
+ excl_patterns = Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
336
358
  workspace = RubyDefinitionService.call(
337
359
  workspace_root,
338
360
  symbol,
339
- excluded_dirnames: excluded_dirnames,
340
- excluded_paths: excluded_paths,
341
- included_dirs: ruby_def_include_dirs
361
+ excluded_paths: excl_patterns,
362
+ included_dirs: ruby_def_include_dirs
342
363
  )
343
364
  ri = RiDefinitionService.call(symbol)
344
365
  workspace + ri
@@ -351,6 +372,34 @@ module Mbeditor
351
372
  render json: { error: e.message }, status: :unprocessable_content
352
373
  end
353
374
 
375
+ # GET /mbeditor/js_definition?symbol=ReactWindow
376
+ # Searches workspace JS/JSX/TS/TSX files for global definitions of the named symbol.
377
+ def js_definition
378
+ symbol = params[:symbol].to_s.strip
379
+ return render json: { results: [] } if symbol.blank?
380
+ return render json: { error: "Invalid symbol" }, status: :bad_request \
381
+ unless symbol.match?(/\A[a-zA-Z_$][a-zA-Z0-9_$]{0,59}\z/)
382
+
383
+ results = JsDefinitionService.new(symbol, workspace_root).call
384
+ render json: { results: results }
385
+ rescue StandardError => e
386
+ render json: { error: e.message }, status: :unprocessable_content
387
+ end
388
+
389
+ # GET /mbeditor/js_members?symbol=ReactWindow
390
+ # Searches workspace JS/JSX/TS/TSX files for properties/methods of the named global.
391
+ def js_members
392
+ symbol = params[:symbol].to_s.strip
393
+ return render json: { members: [] } if symbol.blank?
394
+ return render json: { error: "Invalid symbol" }, status: :bad_request \
395
+ unless symbol.match?(/\A[a-zA-Z_$][a-zA-Z0-9_$]{0,59}\z/)
396
+
397
+ members = JsMembersService.new(symbol, workspace_root).call
398
+ render json: { symbol: symbol, members: members }
399
+ rescue StandardError => e
400
+ render json: { error: e.message }, status: :unprocessable_content
401
+ end
402
+
354
403
  # GET /mbeditor/module_members?name=ArticlesHelper
355
404
  # Returns methods defined in the workspace file that defines the named module/class.
356
405
  def module_members
@@ -358,11 +407,11 @@ module Mbeditor
358
407
  return render json: { error: "Invalid name" }, status: :bad_request \
359
408
  unless name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
360
409
 
410
+ excl_patterns = Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
361
411
  file = RubyDefinitionService.module_defined_in(
362
412
  workspace_root, name,
363
- excluded_dirnames: excluded_dirnames,
364
- excluded_paths: excluded_paths,
365
- included_dirs: ruby_def_include_dirs
413
+ excluded_paths: excl_patterns,
414
+ included_dirs: ruby_def_include_dirs
366
415
  )
367
416
  return render json: { name: name, methods: [] } unless file
368
417
 
@@ -381,20 +430,13 @@ module Mbeditor
381
430
  path = resolve_path(params[:path])
382
431
  return render json: { error: "Forbidden" }, status: :forbidden unless path
383
432
 
384
- # Ensure workspace is scanned so include_calls are populated in the cache.
385
- # Fast no-op on subsequent calls (mtime checks only).
386
- RubyDefinitionService.scan(workspace_root,
387
- excluded_dirnames: excluded_dirnames,
388
- excluded_paths: excluded_paths,
389
- included_dirs: ruby_def_include_dirs)
390
-
433
+ excl_patterns = Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
391
434
  module_names = RubyDefinitionService.includes_in_file(path)
392
435
  includes = module_names.filter_map do |mod_name|
393
436
  mod_file = RubyDefinitionService.module_defined_in(
394
437
  workspace_root, mod_name,
395
- excluded_dirnames: excluded_dirnames,
396
- excluded_paths: excluded_paths,
397
- included_dirs: ruby_def_include_dirs
438
+ excluded_paths: excl_patterns,
439
+ included_dirs: ruby_def_include_dirs
398
440
  )
399
441
  next unless mod_file
400
442
 
@@ -409,22 +451,6 @@ module Mbeditor
409
451
  render json: { error: e.message }, status: :unprocessable_content
410
452
  end
411
453
 
412
- # GET /mbeditor/unused_methods?path=app/models/article.rb
413
- # Returns method names defined in the file that have no call-sites in the workspace.
414
- def unused_methods
415
- path = resolve_path(params[:path])
416
- return render json: { error: "Forbidden" }, status: :forbidden unless path
417
-
418
- unused = UnusedMethodsService.call(
419
- workspace_root, path,
420
- excluded_dirnames: excluded_dirnames,
421
- excluded_paths: excluded_paths
422
- )
423
- render json: { unused: unused }
424
- rescue StandardError => e
425
- render json: { error: e.message }, status: :unprocessable_content
426
- end
427
-
428
454
  # GET /mbeditor/search?q=...&offset=0&limit=50&regex=false&match_case=false&whole_word=false
429
455
  def search
430
456
  query = params[:q].to_s.strip
@@ -439,9 +465,9 @@ module Mbeditor
439
465
  return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
440
466
 
441
467
  # On first page, count total matches in parallel with fetching results.
442
- count_thread = offset == 0 ? Thread.new { count_search_results(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word) } : nil
468
+ count_thread = offset == 0 ? Thread.new { SearchReplaceService.count(workspace_root, query, use_regex: use_regex, match_case: match_case, whole_word: whole_word, excluded_paths: Mbeditor.configuration.excluded_paths) } : nil
443
469
 
444
- results = stream_search_results(query, needed, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
470
+ results = SearchReplaceService.search(workspace_root, query, limit: needed, use_regex: use_regex, match_case: match_case, whole_word: whole_word, excluded_paths: Mbeditor.configuration.excluded_paths)
445
471
  has_more = results.length > offset + limit
446
472
  response = { results: results[offset, limit] || [], has_more: has_more }
447
473
  if count_thread
@@ -456,8 +482,6 @@ module Mbeditor
456
482
  render json: { error: e.message }, status: :unprocessable_content
457
483
  end
458
484
 
459
- MAX_REPLACE_FILES = 500
460
-
461
485
  # POST /mbeditor/replace_in_files
462
486
  # Replaces a string/pattern across all matching files in the workspace.
463
487
  # Returns { replaced_count:, files_affected:[], errors:[], partial: }
@@ -471,85 +495,17 @@ module Mbeditor
471
495
  return render json: { error: "Query is required" }, status: :bad_request if query.blank?
472
496
  return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
473
497
 
474
- # Collect all unique file paths that have at least one match.
475
- # Use a large limit to get all matching files; stream_search_results handles deduplication by file internally.
476
- raw_results = stream_search_results(query, 10_000, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
477
- file_paths = raw_results.map { |r| r[:file] }.uniq
478
-
479
- # Fix 3: Cap the number of files to process
480
- if file_paths.length > MAX_REPLACE_FILES
481
- return render json: { error: "Too many files matched (#{file_paths.length}). Narrow your search." }, status: :unprocessable_entity
482
- end
483
-
484
- replaced_count = 0
485
- files_affected = []
486
- errors = []
487
-
488
- # Build the Ruby Regexp to use for gsub
489
- begin
490
- pattern = if use_regex
491
- flags = match_case ? 0 : Regexp::IGNORECASE
492
- Regexp.new(whole_word ? "\\b(?:#{query})\\b" : query, flags)
493
- else
494
- flags = match_case ? 0 : Regexp::IGNORECASE
495
- Regexp.new(whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query), flags)
496
- end
497
- rescue RegexpError => e
498
- return render json: { error: "Invalid regex: #{e.message}" }, status: :bad_request
499
- end
500
-
501
- file_paths.each do |rel_path|
502
- full_path = resolve_path(rel_path)
503
- unless full_path
504
- errors << { file: rel_path, error: "Forbidden" }
505
- next
506
- end
507
-
508
- # Fix 2: Check path_blocked_for_operations?
509
- if path_blocked_for_operations?(full_path)
510
- errors << { file: rel_path, error: "Forbidden" }
511
- next
512
- end
513
-
514
- unless File.file?(full_path)
515
- errors << { file: rel_path, error: "File not found" }
516
- next
517
- end
518
- if File.size(full_path) > MAX_OPEN_FILE_SIZE_BYTES
519
- errors << { file: rel_path, error: "File too large" }
520
- next
521
- end
498
+ result = SearchReplaceService.replace(
499
+ workspace_root, query, replacement,
500
+ use_regex: use_regex, match_case: match_case, whole_word: whole_word,
501
+ excluded_paths: Mbeditor.configuration.excluded_paths
502
+ )
522
503
 
523
- # Fix 1: Wrap per-file gsub/scan in a timeout to prevent ReDoS
524
- begin
525
- Timeout::timeout(5) do
526
- content = File.binread(full_path).force_encoding("UTF-8")
527
- replacements_in_file = content.scan(pattern).length
528
- new_content = content.gsub(pattern, replacement)
529
-
530
- # Fix 4: Use new_content != content instead of delta logic
531
- if new_content != content
532
- File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
533
- files_affected << rel_path
534
- replaced_count += replacements_in_file
535
- end
536
- end
537
- rescue Timeout::Error
538
- errors << { file: rel_path, error: "Timed out processing file" }
539
- next
540
- rescue StandardError => e
541
- errors << { file: rel_path, error: e.message }
542
- next
543
- end
504
+ if result.key?(:error)
505
+ render json: { error: result[:error] }, status: :unprocessable_entity
506
+ else
507
+ render json: result
544
508
  end
545
-
546
- # Fix 5: Surface partial failure
547
- render json: {
548
- replaced_count: replaced_count,
549
- files_affected: files_affected,
550
- errors: errors,
551
- partial: errors.any? && files_affected.any?
552
- }
553
509
  rescue StandardError => e
554
510
  render json: { error: e.message }, status: :unprocessable_content
555
511
  end
@@ -558,7 +514,7 @@ module Mbeditor
558
514
  def git_status
559
515
  output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
560
516
  branch = GitService.current_branch(workspace_root.to_s) || ""
561
- files = parse_porcelain_status(output)
517
+ files = GitService.parse_porcelain_status(output)
562
518
  render json: { ok: status.success?, files: files, branch: branch }
563
519
  rescue StandardError => e
564
520
  render json: { error: e.message }, status: :unprocessable_content
@@ -566,120 +522,7 @@ module Mbeditor
566
522
 
567
523
  # GET /mbeditor/git_info
568
524
  def git_info
569
- repo = workspace_root.to_s
570
- cached = cached_git_info(repo)
571
- return render json: cached if cached
572
-
573
- branch = GitService.current_branch(repo)
574
- unless branch
575
- return render json: { ok: false, error: "Unable to determine current branch" }, status: :unprocessable_content
576
- end
577
-
578
- # Wave 1: all independent git reads run in parallel
579
- status_t = Thread.new { Open3.capture3("git", "-C", repo, "status", "--porcelain") }
580
- numstat_t = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD") }
581
- upstream_t = Thread.new { Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
582
- base_t = Thread.new { GitService.find_branch_base(repo, branch) }
583
-
584
- working_output, _err, working_status = status_t.value
585
- working_tree = working_status.success? ? parse_porcelain_status(working_output) : []
586
-
587
- numstat_out = numstat_t.value.first
588
- numstat_map = GitService.parse_numstat(numstat_out)
589
- working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
590
-
591
- upstream_output, _err, upstream_status = upstream_t.value
592
- upstream_branch = upstream_status.success? ? upstream_output.strip : nil
593
- upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
594
-
595
- # Determine the branch's fork point relative to a base branch (develop/main/master).
596
- # This ensures History and Changes only show work unique to this branch.
597
- base_sha, base_ref = base_t.value
598
-
599
- ahead_count = 0
600
- behind_count = 0
601
- unpushed_files = []
602
- unpushed_commits = []
603
- diff_base = base_sha || upstream_branch
604
-
605
- # Wave 2: conditional parallel reads that depend on Wave 1 results
606
- wave2 = {}
607
- wave2[:counts] = Thread.new { Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}") } if upstream_branch.present?
608
- wave2[:unp_log] = Thread.new { Open3.capture3("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e") } if upstream_branch.present?
609
- wave2[:diff_name] = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--name-status", "#{diff_base}..HEAD") } if diff_base.present?
610
- wave2[:diff_num] = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD") } if diff_base.present?
611
- wave2[:branch_log] = Thread.new do
612
- if base_sha
613
- Open3.capture3("git", "-C", repo, "log", "--first-parent", "#{base_sha}..HEAD",
614
- "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
615
- else
616
- Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
617
- "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
618
- end
619
- end
620
-
621
- wave2.each_value(&:join)
622
-
623
- if (ct = wave2[:counts])
624
- counts_output, _err, counts_status = ct.value
625
- if counts_status.success?
626
- ahead_str, behind_str = counts_output.strip.split("\t", 2)
627
- ahead_count = ahead_str.to_i
628
- behind_count = behind_str.to_i
629
- end
630
- end
631
-
632
- if (ul = wave2[:unp_log])
633
- unpushed_log_output, _err, unpushed_log_status = ul.value
634
- unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
635
- end
636
-
637
- if (dn = wave2[:diff_name]) && (dnum = wave2[:diff_num])
638
- diff_name_out, _err, diff_name_status = dn.value
639
- if diff_name_status.success?
640
- unpushed_files = parse_name_status(diff_name_out)
641
- unp_numstat_out = dnum.value.first
642
- unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
643
- unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
644
- end
645
- end
646
-
647
- branch_log_output, _err, branch_log_status = wave2[:branch_log].value
648
- branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
649
-
650
- redmine_ticket_id = nil
651
- if Mbeditor.configuration.redmine_enabled
652
- if Mbeditor.configuration.redmine_ticket_source == :branch
653
- m = branch.match(/\A(\d+)/)
654
- redmine_ticket_id = m[1] if m
655
- else
656
- branch_commits.each do |commit|
657
- m = commit["title"]&.match(/#(\d+)/)
658
- if m
659
- redmine_ticket_id = m[1]
660
- break
661
- end
662
- end
663
- end
664
- end
665
-
666
- payload = {
667
- ok: true,
668
- branch: branch,
669
- upstreamBranch: upstream_branch,
670
- ahead: ahead_count,
671
- behind: behind_count,
672
- workingTree: working_tree,
673
- unpushedFiles: unpushed_files,
674
- unpushedCommits: unpushed_commits,
675
- branchCommits: branch_commits,
676
- branchBaseRef: base_ref,
677
- redmineTicketId: redmine_ticket_id
678
- }
679
- store_git_info(repo, payload)
680
- render json: payload
681
- rescue StandardError => e
682
- render json: { ok: false, error: e.message }, status: :unprocessable_content
525
+ render json: GitInfoService.call(workspace_root.to_s)
683
526
  end
684
527
 
685
528
  # GET /mbeditor/monaco-editor/*asset_path — serve packaged Monaco files
@@ -702,7 +545,8 @@ module Mbeditor
702
545
 
703
546
  # GET /mbeditor/manifest.webmanifest — PWA manifest
704
547
  def pwa_manifest
705
- base = request.script_name.to_s.sub(%r{/$}, "")
548
+ raw = root_path.chomp("/")
549
+ base = raw.start_with?("/") || raw.empty? ? raw : "/#{raw}"
706
550
  manifest = {
707
551
  name: "Mbeditor — #{Rails.root.basename}",
708
552
  short_name: "Mbeditor",
@@ -751,11 +595,11 @@ module Mbeditor
751
595
  path = resolve_path(params[:path])
752
596
  return render json: { error: "Forbidden" }, status: :forbidden unless path
753
597
 
754
- filename = File.basename(path)
598
+ filename = path.to_s
755
599
  code = params[:code] || File.read(path)
756
600
 
757
- if filename.end_with?('.haml')
758
- unless haml_lint_available?
601
+ if File.basename(filename).end_with?('.haml')
602
+ unless AvailabilityProbe.haml_lint(workspace_root)
759
603
  return render json: { error: "haml-lint not available", markers: [] }, status: :unprocessable_content
760
604
  end
761
605
 
@@ -763,7 +607,7 @@ module Mbeditor
763
607
  return render json: { markers: markers }
764
608
  end
765
609
 
766
- cmd = rubocop_command + ["--no-server", "--stdin", filename, "--format", "json", "--no-color", "--force-exclusion"]
610
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) + ["--no-server", "--stdin", filename, "--format", "json", "--no-color"]
767
611
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
768
612
  output = run_with_timeout(env, cmd, stdin_data: code)
769
613
 
@@ -824,7 +668,7 @@ module Mbeditor
824
668
  f.flush
825
669
  tmpfile = f.path
826
670
 
827
- cmd = rubocop_command + ["--no-server", "-A", "--no-color", tmpfile]
671
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) + ["--no-server", "-A", "--no-color", tmpfile]
828
672
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
829
673
  _out, _err, status = Open3.capture3(env, *cmd)
830
674
 
@@ -867,6 +711,46 @@ module Mbeditor
867
711
  render json: { error: e.message, ok: false }, status: :unprocessable_content
868
712
  end
869
713
 
714
+ # GET /mbeditor/client_config — returns client-side configuration values
715
+ def client_config
716
+ render json: {
717
+ related_files_custom_paths: Array(Mbeditor.configuration.related_files_custom_paths)
718
+ }
719
+ end
720
+
721
+ # GET /mbeditor/related_files?path=...
722
+ def related_files
723
+ path = resolve_path(params[:path])
724
+ return render json: {}, status: :bad_request unless path
725
+ rel = relative_path(path)
726
+ custom = Array(Mbeditor.configuration.related_files_custom_paths)
727
+ result = RailsRelatedFilesService.find(workspace_root.to_s, rel, custom_paths: custom)
728
+ render json: result
729
+ end
730
+
731
+ # GET /mbeditor/changelog — serves the gem's CHANGELOG.md for the in-editor changelog tab.
732
+ def changelog
733
+ path = Mbeditor::Engine.root.join("CHANGELOG.md")
734
+ content = path.exist? ? path.read(encoding: "utf-8") : ""
735
+ render json: { content: content, version: Mbeditor::VERSION }
736
+ end
737
+
738
+ # GET /mbeditor/model_schema?model=User
739
+ # Returns the db/schema.rb table definition for the named model as JSON.
740
+ # 404 when the schema file is missing or the table is not found.
741
+ def model_schema
742
+ model_name = params[:model].to_s.strip
743
+ return render json: { error: "model required" }, status: :bad_request if model_name.blank?
744
+
745
+ schema = SchemaService.new(model_name, workspace_root.to_s).call
746
+ if schema
747
+ render json: schema
748
+ else
749
+ hint = " (Check if the model has a custom table_name or if db/schema.rb exists)"
750
+ render json: { error: "No schema found for '#{model_name}'#{hint}" }, status: :not_found
751
+ end
752
+ end
753
+
870
754
  # POST /mbeditor/format — rubocop -A on buffer content; returns corrected content WITHOUT saving to disk
871
755
  #
872
756
  # Accepts the current buffer content as `code` and formats it using a
@@ -887,7 +771,7 @@ module Mbeditor
887
771
  f.flush
888
772
  tmpfile = f.path
889
773
 
890
- cmd = rubocop_command + ["--no-server", "-A", "--no-color", tmpfile]
774
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) + ["--no-server", "-A", "--no-color", tmpfile]
891
775
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
892
776
  _out, _err, status = Open3.capture3(env, *cmd)
893
777
  unless status.success? || status.exitstatus == 1
@@ -903,10 +787,44 @@ module Mbeditor
903
787
 
904
788
  private
905
789
 
790
+ def history_file_path(branch, rel_path)
791
+ branch_hash = Digest::SHA256.hexdigest(branch.to_s)[0, 16]
792
+ file_hash = Digest::SHA256.hexdigest(rel_path.to_s)[0, 16]
793
+ workspace_root.join('tmp', 'mbeditor_history', "#{branch_hash}_#{file_hash}.json")
794
+ end
795
+
796
+ def compact_history_ops(base, ops)
797
+ text = base.to_s
798
+ ops.each do |op|
799
+ sl, sc, el, ec, ins = op[0].to_i, op[1].to_i, op[2].to_i, op[3].to_i, op[4].to_s
800
+ lines = text.split("\n", -1)
801
+ sl0 = [[sl - 1, 0].max, [lines.length - 1, 0].max].min
802
+ el0 = [[el - 1, 0].max, [lines.length - 1, 0].max].min
803
+ sc0 = sc - 1
804
+ ec0 = ec - 1
805
+ prefix = (lines[sl0] || '')[0, sc0] || ''
806
+ suffix = (lines[el0] || '')[ec0..] || ''
807
+ ins_lines = ins.split("\n", -1)
808
+ new_seg = if ins_lines.length <= 1
809
+ [prefix + (ins_lines[0] || '') + suffix]
810
+ else
811
+ [prefix + ins_lines[0]] + ins_lines[1..-2] + [ins_lines[-1] + suffix]
812
+ end
813
+ text = (lines[0...sl0] + new_seg + lines[(el0 + 1)..]).join("\n")
814
+ end
815
+ text
816
+ rescue StandardError
817
+ base.to_s
818
+ end
819
+
906
820
  def broadcast_files_changed
907
821
  root = workspace_root.to_s
908
- invalidate_file_tree_cache(root)
909
- invalidate_git_info_cache(root)
822
+ FileTreeService.invalidate(root)
823
+ Thread.new do
824
+ GitInfoService.invalidate(root)
825
+ rescue => e
826
+ Rails.logger.warn("[mbeditor] GitInfoService.invalidate failed: #{e}")
827
+ end
910
828
 
911
829
  return unless defined?(ActionCable.server)
912
830
 
@@ -915,11 +833,15 @@ module Mbeditor
915
833
  # Never let a broadcast failure affect the HTTP response
916
834
  end
917
835
 
836
+ def editor_state_service
837
+ @editor_state_service ||= EditorStateService.new(workspace_root)
838
+ end
839
+
918
840
  def sanitize_branch_name(branch)
919
841
  return nil if branch.blank?
920
842
  str = branch.to_s.strip
921
843
  return nil if str.include?('..')
922
- str.match?(SAFE_BRANCH_NAME) ? str : nil
844
+ str.match?(EditorStateService::SAFE_BRANCH_NAME) ? str : nil
923
845
  end
924
846
 
925
847
  def action_cable_enabled?
@@ -961,173 +883,18 @@ module Mbeditor
961
883
  rel = relative_path(full_path)
962
884
  return true if rel.blank?
963
885
 
964
- excluded_path?(rel, File.basename(full_path))
965
- end
966
-
967
- # Stream search results using popen so we can stop reading early once we
968
- # have collected `limit` matches (avoids buffering the entire rg/grep output
969
- # in memory when searching large codebases for common tokens).
970
- def stream_search_results(query, limit, use_regex: false, match_case: false, whole_word: false)
971
- results = []
972
-
973
- if RG_AVAILABLE
974
- args = ["rg", "--json", "--no-ignore"]
975
- args << "-F" unless use_regex
976
- args << "--ignore-case" unless match_case
977
- args << "--word-regexp" if whole_word
978
- excluded_paths.each { |p| args << "--glob=!#{p}" }
979
- args += ["--", query, workspace_root.to_s]
980
-
981
- IO.popen(args, err: File::NULL) do |io|
982
- io.each_line do |raw|
983
- break if results.length >= limit
984
-
985
- begin
986
- data = JSON.parse(raw)
987
- rescue JSON::ParserError => e
988
- Rails.logger.warn("[mbeditor] search: malformed rg JSON line: #{e.message}")
989
- next
990
- end
991
- next unless data["type"] == "match"
992
-
993
- md = data["data"]
994
- results << {
995
- file: relative_path(md.dig("path", "text").to_s),
996
- line: md.dig("line_number"),
997
- text: md.dig("lines", "text").to_s.strip
998
- }
999
- end
1000
- end
1001
- else
1002
- base_flags = use_regex ? "-E" : "-F"
1003
- args = ["grep", "-rn", base_flags]
1004
- args << "-i" unless match_case
1005
- args << "-w" if whole_word
1006
- excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
1007
- args += [query, workspace_root.to_s]
1008
-
1009
- IO.popen(args, err: File::NULL) do |io|
1010
- io.each_line do |raw|
1011
- break if results.length >= limit
1012
-
1013
- raw.chomp!
1014
- next unless raw =~ /\A(.+?):(\d+):(.*)\z/
1015
-
1016
- file_path = Regexp.last_match(1)
1017
- next unless file_path.start_with?(workspace_root.to_s)
1018
-
1019
- rel = relative_path(file_path)
1020
- next if excluded_path?(rel, File.basename(file_path))
1021
-
1022
- results << {
1023
- file: rel,
1024
- line: Regexp.last_match(2).to_i,
1025
- text: Regexp.last_match(3).strip
1026
- }
1027
- end
1028
- end
1029
- end
1030
-
1031
- results
1032
- end
1033
-
1034
- # Count total matching lines across the workspace using rg --count (or grep -c).
1035
- # Fast: rg just counts without extracting context. Runs in a background thread.
1036
- def count_search_results(query, use_regex: false, match_case: false, whole_word: false)
1037
- total = 0
1038
- if RG_AVAILABLE
1039
- args = ["rg", "--count", "--no-ignore"]
1040
- args << "-F" unless use_regex
1041
- args << "--ignore-case" unless match_case
1042
- args << "--word-regexp" if whole_word
1043
- excluded_paths.each { |p| args << "--glob=!#{p}" }
1044
- args += ["--", query, workspace_root.to_s]
1045
- IO.popen(args, err: File::NULL) do |io|
1046
- io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
1047
- end
1048
- else
1049
- base_flags = use_regex ? "-E" : "-F"
1050
- args = ["grep", "-rc", base_flags]
1051
- args << "-i" unless match_case
1052
- args << "-w" if whole_word
1053
- excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
1054
- args += [query, workspace_root.to_s]
1055
- IO.popen(args, err: File::NULL) do |io|
1056
- io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
1057
- end
1058
- end
1059
- total
1060
- rescue StandardError
1061
- 0
1062
- end
1063
-
1064
- def build_tree(dir, max_depth: 10, depth: 0)
1065
- return [] if depth >= max_depth
1066
-
1067
- entries = Dir.entries(dir).sort.reject { |entry| entry == "." || entry == ".." }
1068
- entries.filter_map do |name|
1069
- full = File.join(dir, name)
1070
- rel = relative_path(full)
1071
-
1072
- if File.directory?(full)
1073
- { name: name, type: "folder", path: rel, children: build_tree(full, depth: depth + 1) }
1074
- else
1075
- size = File.size(full) rescue nil
1076
- { name: name, type: "file", path: rel, size: size }
1077
- end
1078
- end
1079
- rescue Errno::EACCES
1080
- []
1081
- end
1082
-
1083
- def excluded_paths
1084
- Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
1085
- end
1086
-
1087
- def excluded_dirnames
1088
- excluded_paths.filter { |path| !path.include?("/") }
886
+ ExclusionMatcher.new(Mbeditor.configuration.excluded_paths).excluded?(rel)
1089
887
  end
1090
888
 
1091
889
  def ruby_def_include_dirs
1092
890
  Array(Mbeditor.configuration.ruby_def_include_dirs).map(&:to_s).reject(&:blank?)
1093
891
  end
1094
892
 
1095
- def excluded_path?(relative_path, name)
1096
- excluded_paths.any? do |pattern|
1097
- if pattern.include?("/")
1098
- relative_path == pattern || relative_path.start_with?("#{pattern}/")
1099
- else
1100
- name == pattern || relative_path.split("/").include?(pattern)
1101
- end
1102
- end
1103
- end
1104
-
1105
893
  def run_with_timeout(env, cmd, stdin_data:)
1106
894
  timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
1107
- output = +""; timed_out = false
1108
-
1109
- Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
1110
- stdin.write(stdin_data)
1111
- stdin.close
1112
-
1113
- timer = if timeout_seconds && timeout_seconds > 0
1114
- Thread.new do
1115
- sleep timeout_seconds
1116
- timed_out = true
1117
- Process.kill('-KILL', wait_thr.pid)
1118
- rescue Errno::ESRCH
1119
- nil
1120
- end
1121
- end
1122
-
1123
- output = stdout.read
1124
- wait_thr.value
1125
- timer&.kill
1126
- end
1127
-
1128
- raise "RuboCop timed out after #{timeout_seconds} seconds" if timed_out
1129
-
1130
- output
895
+ timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
896
+ result = ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
897
+ result[:stdout]
1131
898
  end
1132
899
 
1133
900
  def cop_severity(severity)
@@ -1182,143 +949,22 @@ module Mbeditor
1182
949
  }
1183
950
  end
1184
951
 
1185
- def rubocop_command
1186
- command = Mbeditor.configuration.rubocop_command.to_s.strip
1187
- command = "rubocop" if command.empty?
1188
- tokens = Shellwords.split(command)
1189
-
1190
- local_bin = workspace_root.join("bin", "rubocop")
1191
- return [local_bin.to_s] if tokens == ["rubocop"] && local_bin.exist?
1192
-
1193
- tokens
1194
- rescue ArgumentError
1195
- ["rubocop"]
1196
- end
1197
-
1198
952
  def rubocop_config_path
1199
953
  candidate = workspace_root.join(".rubocop.yml")
1200
954
  candidate.exist? ? ".rubocop.yml" : nil
1201
955
  end
1202
956
 
1203
- PROBE_MUTEX = Mutex.new
1204
- GIT_INFO_MUTEX = Mutex.new
1205
- FILE_TREE_MUTEX = Mutex.new
1206
- private_constant :PROBE_MUTEX, :GIT_INFO_MUTEX, :FILE_TREE_MUTEX
1207
-
1208
- def rubocop_available?
1209
- key = Mbeditor.configuration.rubocop_command.to_s
1210
- probe_cached(:@rubocop_available_cache, key) do
1211
- _out, _err, status = Open3.capture3(*rubocop_command, "--version")
1212
- status.success?
1213
- end
1214
- end
1215
-
1216
- def haml_lint_available?
1217
- cmd = haml_lint_command
1218
- key = cmd.join(" ")
1219
- probe_cached(:@haml_lint_available_cache, key) do
1220
- _out, _err, status = Open3.capture3(*cmd, "--version")
1221
- status.success?
1222
- end
1223
- end
1224
-
1225
- def git_available?
1226
- key = workspace_root.to_s
1227
- probe_cached(:@git_available_cache, key) do
1228
- _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
1229
- status.success?
1230
- end
1231
- end
1232
-
1233
- def cached_git_info(repo, ttl: 5)
1234
- GIT_INFO_MUTEX.synchronize do
1235
- cache = self.class.instance_variable_get(:@git_info_cache) || {}
1236
- entry = cache[repo]
1237
- return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < ttl
1238
- end
1239
- nil
1240
- end
1241
-
1242
- def store_git_info(repo, data)
1243
- GIT_INFO_MUTEX.synchronize do
1244
- cache = self.class.instance_variable_get(:@git_info_cache) || {}
1245
- cache[repo] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
1246
- self.class.instance_variable_set(:@git_info_cache, cache)
1247
- end
1248
- end
1249
-
1250
- def invalidate_git_info_cache(repo)
1251
- GIT_INFO_MUTEX.synchronize do
1252
- cache = self.class.instance_variable_get(:@git_info_cache) || {}
1253
- cache.delete(repo)
1254
- self.class.instance_variable_set(:@git_info_cache, cache)
1255
- end
1256
- end
1257
-
1258
- def cached_file_tree(root, ttl: 15)
1259
- FILE_TREE_MUTEX.synchronize do
1260
- cache = self.class.instance_variable_get(:@file_tree_cache) || {}
1261
- entry = cache[root]
1262
- return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < ttl
1263
- end
1264
- nil
1265
- end
1266
-
1267
- def store_file_tree(root, data)
1268
- FILE_TREE_MUTEX.synchronize do
1269
- cache = self.class.instance_variable_get(:@file_tree_cache) || {}
1270
- cache[root] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
1271
- self.class.instance_variable_set(:@file_tree_cache, cache)
1272
- end
1273
- end
1274
-
1275
- def invalidate_file_tree_cache(root)
1276
- FILE_TREE_MUTEX.synchronize do
1277
- cache = self.class.instance_variable_get(:@file_tree_cache) || {}
1278
- cache.delete(root)
1279
- self.class.instance_variable_set(:@file_tree_cache, cache)
1280
- end
1281
- end
1282
-
1283
- def probe_cached(ivar, key, &block)
1284
- PROBE_MUTEX.synchronize do
1285
- cache = self.class.instance_variable_get(ivar) ||
1286
- self.class.instance_variable_set(ivar, {})
1287
- unless cache.key?(key)
1288
- cache[key] = begin
1289
- block.call
1290
- rescue StandardError
1291
- false
1292
- end
1293
- end
1294
- cache[key]
1295
- end
1296
- end
1297
-
1298
- alias git_blame_available? git_available?
1299
-
1300
957
  def test_available?
1301
958
  root = workspace_root.to_s
1302
959
  File.directory?(File.join(root, "test")) || File.directory?(File.join(root, "spec"))
1303
960
  end
1304
961
 
1305
- def haml_lint_command
1306
- workspace_bin = workspace_root.join("bin", "haml-lint")
1307
- return [workspace_bin.to_s] if workspace_bin.exist?
1308
-
1309
- begin
1310
- [Gem.bin_path("haml_lint", "haml-lint")]
1311
- rescue Gem::Exception, Gem::GemNotFoundException
1312
- ["haml-lint"]
1313
- end
1314
- end
1315
-
1316
962
  def run_haml_lint(code)
1317
963
  markers = []
1318
964
  Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
1319
965
  f.write(code)
1320
966
  f.flush
1321
- output, _err, _status = Open3.capture3(*haml_lint_command, "--reporter", "json", "--no-color", f.path)
967
+ output, _err, _status = Open3.capture3(*AvailabilityProbe.haml_lint_command(workspace_root), "--reporter", "json", "--no-color", f.path)
1322
968
  idx = output.index("{")
1323
969
  result = idx ? JSON.parse(output[idx..]) : {}
1324
970
  result = {} unless result.is_a?(Hash)
@@ -1345,30 +991,6 @@ module Mbeditor
1345
991
  end
1346
992
  end
1347
993
 
1348
- def parse_porcelain_status(output)
1349
- output.lines.filter_map do |line|
1350
- next if line.length < 4
1351
-
1352
- path = line[3..].to_s.strip
1353
- next if path.blank?
1354
-
1355
- { status: line[0..1].strip, path: path }
1356
- end
1357
- end
1358
-
1359
- def parse_name_status(output)
1360
- output.lines.filter_map do |line|
1361
- parts = line.strip.split("\t")
1362
- next if parts.empty?
1363
-
1364
- status = parts[0].to_s.strip
1365
- path = parts.last.to_s.strip
1366
- next if path.blank?
1367
-
1368
- { status: status, path: path }
1369
- end
1370
- end
1371
-
1372
994
  def monaco_worker_file
1373
995
  engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
1374
996
  return engine_path if engine_path.file?
@@ -1399,7 +1021,7 @@ module Mbeditor
1399
1021
 
1400
1022
  def render_file_too_large(size)
1401
1023
  render json: {
1402
- error: "File is too large to open (#{human_size(size)}). Limit is #{human_size(MAX_OPEN_FILE_SIZE_BYTES)}."
1024
+ error: "File is too large to open (#{human_size(size)}). Limit is #{human_size(FileOperationService::MAX_FILE_SIZE_BYTES)}."
1403
1025
  }, status: :content_too_large
1404
1026
  end
1405
1027