mbeditor 0.3.8 → 0.4.2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/app/assets/javascripts/mbeditor/application.js +1 -0
  4. data/app/assets/javascripts/mbeditor/application_iife_head.js +7 -0
  5. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +1 -1
  6. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +213 -11
  7. data/app/assets/javascripts/mbeditor/components/GitPanel.js +14 -4
  8. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +673 -160
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +41 -1
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +3 -2
  11. data/app/assets/javascripts/mbeditor/editor_plugins.js +21 -0
  12. data/app/assets/javascripts/mbeditor/editor_store.js +10 -2
  13. data/app/assets/javascripts/mbeditor/file_service.js +29 -23
  14. data/app/assets/javascripts/mbeditor/git_service.js +7 -11
  15. data/app/assets/javascripts/mbeditor/search_service.js +51 -14
  16. data/app/assets/javascripts/mbeditor/tab_manager.js +3 -3
  17. data/app/assets/javascripts/mbeditor/websocket_service.js +126 -0
  18. data/app/assets/stylesheets/mbeditor/editor.css +237 -15
  19. data/app/channels/mbeditor/editor_channel.rb +79 -0
  20. data/app/controllers/mbeditor/editors_controller.rb +177 -136
  21. data/app/controllers/mbeditor/git_controller.rb +5 -40
  22. data/app/services/mbeditor/git_blame_service.rb +6 -0
  23. data/app/services/mbeditor/git_commit_graph_service.rb +2 -0
  24. data/app/services/mbeditor/git_service.rb +97 -28
  25. data/app/services/mbeditor/redmine_service.rb +7 -0
  26. data/app/services/mbeditor/ruby_definition_service.rb +23 -2
  27. data/app/views/layouts/mbeditor/application.html.erb +4 -0
  28. data/lib/mbeditor/cable_log_filter.rb +28 -0
  29. data/lib/mbeditor/configuration.rb +7 -1
  30. data/lib/mbeditor/engine.rb +37 -0
  31. data/lib/mbeditor/rack/silence_ping_request.rb +4 -1
  32. data/lib/mbeditor/version.rb +3 -1
  33. data/lib/mbeditor.rb +2 -0
  34. metadata +5 -2
@@ -15,7 +15,6 @@ module Mbeditor
15
15
  IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
16
16
  MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
17
17
  RG_AVAILABLE = system("which rg > /dev/null 2>&1")
18
- RUBOCOP_TIMEOUT_SECONDS = 15
19
18
 
20
19
  # GET /mbeditor — renders the IDE shell
21
20
  def index
@@ -39,7 +38,8 @@ module Mbeditor
39
38
  gitAvailable: git_available?,
40
39
  blameAvailable: git_blame_available?,
41
40
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
42
- testAvailable: test_available?
41
+ testAvailable: test_available?,
42
+ actionCableEnabled: defined?(ActionCable::Channel::Base) ? true : false
43
43
  }
44
44
  end
45
45
 
@@ -59,14 +59,27 @@ module Mbeditor
59
59
  end
60
60
  rescue Errno::ENOENT
61
61
  render json: {}
62
+ rescue JSON::ParserError
63
+ render json: {}
62
64
  rescue StandardError => e
63
65
  render json: { error: e.message }, status: :unprocessable_content
64
66
  end
65
67
 
68
+ STATE_MAX_BYTES = 1 * 1024 * 1024
69
+
66
70
  # POST /mbeditor/state — save workspace state
67
71
  def save_state
72
+ payload = params[:state].to_json
73
+ return render json: { error: "State payload too large" }, status: :content_too_large if payload.bytesize > STATE_MAX_BYTES
74
+
68
75
  path = workspace_root.join("tmp", "mbeditor_workspace.json")
69
- File.write(path, params[:state].to_json)
76
+ FileUtils.mkdir_p(workspace_root.join("tmp"))
77
+ File.open(path, File::RDWR | File::CREAT) do |f|
78
+ f.flock(File::LOCK_EX)
79
+ f.truncate(0)
80
+ f.rewind
81
+ f.write(payload)
82
+ end
70
83
  render json: { ok: true }
71
84
  rescue StandardError => e
72
85
  render json: { error: e.message }, status: :unprocessable_content
@@ -93,11 +106,19 @@ module Mbeditor
93
106
  branch = sanitize_branch_name(params[:branch])
94
107
  return render json: { error: "Invalid branch name" }, status: :bad_request unless branch
95
108
 
109
+ payload = params[:state].to_unsafe_h
110
+ return render json: { error: "State payload too large" }, status: :content_too_large if payload.to_json.bytesize > STATE_MAX_BYTES
111
+
96
112
  path = workspace_root.join("tmp", "mbeditor_branch_states.json")
97
113
  FileUtils.mkdir_p(workspace_root.join("tmp"))
98
- all = File.exist?(path) ? JSON.parse(File.read(path)) : {}
99
- all[branch] = params[:state].to_unsafe_h
100
- File.write(path, all.to_json)
114
+ File.open(path, File::RDWR | File::CREAT) do |f|
115
+ f.flock(File::LOCK_EX)
116
+ existing = f.size > 0 ? JSON.parse(f.read) : {}
117
+ existing[branch] = payload
118
+ f.truncate(0)
119
+ f.rewind
120
+ f.write(existing.to_json)
121
+ end
101
122
  render json: { ok: true }
102
123
  rescue StandardError => e
103
124
  render json: { error: e.message }, status: :unprocessable_content
@@ -113,10 +134,18 @@ module Mbeditor
113
134
  return render json: { pruned: [] } unless status.success?
114
135
 
115
136
  local_branches = out.split("\n").map(&:strip).reject(&:empty?)
116
- all = JSON.parse(File.read(state_path))
117
- pruned = all.keys - local_branches
118
- pruned.each { |b| all.delete(b) }
119
- File.write(state_path, all.to_json)
137
+ pruned = []
138
+ File.open(state_path, File::RDWR) do |f|
139
+ f.flock(File::LOCK_EX)
140
+ all = JSON.parse(f.read) rescue {}
141
+ pruned = all.keys - local_branches
142
+ if pruned.any?
143
+ pruned.each { |b| all.delete(b) }
144
+ f.truncate(0)
145
+ f.rewind
146
+ f.write(all.to_json)
147
+ end
148
+ end
120
149
  render json: { pruned: pruned }
121
150
  rescue StandardError => e
122
151
  render json: { error: e.message }, status: :unprocessable_content
@@ -178,6 +207,7 @@ module Mbeditor
178
207
  return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
179
208
 
180
209
  File.write(path, content)
210
+ broadcast_files_changed
181
211
  render json: { ok: true, path: relative_path(path) }
182
212
  rescue StandardError => e
183
213
  render json: { error: e.message }, status: :unprocessable_content
@@ -195,6 +225,7 @@ module Mbeditor
195
225
 
196
226
  FileUtils.mkdir_p(File.dirname(path))
197
227
  File.write(path, content)
228
+ broadcast_files_changed
198
229
 
199
230
  render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
200
231
  rescue StandardError => e
@@ -209,6 +240,7 @@ module Mbeditor
209
240
  return render json: { error: "Path already exists" }, status: :unprocessable_content if File.exist?(path)
210
241
 
211
242
  FileUtils.mkdir_p(path)
243
+ broadcast_files_changed
212
244
  render json: { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
213
245
  rescue StandardError => e
214
246
  render json: { error: e.message }, status: :unprocessable_content
@@ -225,6 +257,7 @@ module Mbeditor
225
257
 
226
258
  FileUtils.mkdir_p(File.dirname(new_path))
227
259
  FileUtils.mv(old_path, new_path)
260
+ broadcast_files_changed
228
261
 
229
262
  render json: {
230
263
  ok: true,
@@ -246,9 +279,11 @@ module Mbeditor
246
279
 
247
280
  if File.directory?(path)
248
281
  FileUtils.rm_rf(path)
282
+ broadcast_files_changed
249
283
  render json: { ok: true, type: "folder", path: relative_path(path) }
250
284
  else
251
285
  File.delete(path)
286
+ broadcast_files_changed
252
287
  render json: { ok: true, type: "file", path: relative_path(path) }
253
288
  end
254
289
  rescue StandardError => e
@@ -284,19 +319,33 @@ module Mbeditor
284
319
  render json: { error: e.message }, status: :unprocessable_content
285
320
  end
286
321
 
287
- # GET /mbeditor/search?q=...&offset=0&limit=50
322
+ # GET /mbeditor/search?q=...&offset=0&limit=50&regex=false&match_case=false&whole_word=false
288
323
  def search
289
- query = params[:q].to_s.strip
290
- offset = [params[:offset].to_i, 0].max
291
- limit = [[params[:limit].to_i > 0 ? params[:limit].to_i : 50, 200].min, 1].max
292
- needed = offset + limit + 1 # collect one extra to detect has_more
324
+ query = params[:q].to_s.strip
325
+ offset = [params[:offset].to_i, 0].max
326
+ limit = [[params[:limit].to_i > 0 ? params[:limit].to_i : 50, 200].min, 1].max
327
+ use_regex = params[:regex] == 'true'
328
+ match_case = params[:match_case] == 'true'
329
+ whole_word = params[:whole_word] == 'true'
330
+ needed = offset + limit + 1 # collect one extra to detect has_more
293
331
 
294
332
  return render json: [] if query.blank?
295
333
  return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
296
334
 
297
- results = stream_search_results(query, needed)
298
- has_more = results.length > offset + limit
299
- render json: { results: results[offset, limit] || [], has_more: has_more }
335
+ # On first page, count total matches in parallel with fetching results.
336
+ count_thread = offset == 0 ? Thread.new { count_search_results(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word) } : nil
337
+
338
+ results = stream_search_results(query, needed, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
339
+ has_more = results.length > offset + limit
340
+ response = { results: results[offset, limit] || [], has_more: has_more }
341
+ if count_thread
342
+ # Give the count thread up to 100 ms; omit total_count when it hasn't finished yet
343
+ # so the first page is never blocked by the counting subprocess.
344
+ count_thread.join(0.1)
345
+ response[:total_count] = count_thread.value unless count_thread.alive?
346
+ end
347
+
348
+ render json: response
300
349
  rescue StandardError => e
301
350
  render json: { error: e.message }, status: :unprocessable_content
302
351
  end
@@ -305,9 +354,7 @@ module Mbeditor
305
354
  def git_status
306
355
  output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
307
356
  branch = GitService.current_branch(workspace_root.to_s) || ""
308
- files = output.lines.map do |line|
309
- { status: line[0..1].strip, path: line[3..].strip }
310
- end
357
+ files = parse_porcelain_status(output)
311
358
  render json: { ok: status.success?, files: files, branch: branch }
312
359
  rescue StandardError => e
313
360
  render json: { error: e.message }, status: :unprocessable_content
@@ -325,7 +372,7 @@ module Mbeditor
325
372
 
326
373
  # Annotate each working-tree file with added/removed line counts
327
374
  numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
328
- numstat_map = parse_numstat(numstat_out)
375
+ numstat_map = GitService.parse_numstat(numstat_out)
329
376
  working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
330
377
 
331
378
  upstream_output, _err, upstream_status = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
@@ -339,7 +386,7 @@ module Mbeditor
339
386
 
340
387
  # Determine the branch's fork point relative to a base branch (develop/main/master).
341
388
  # This ensures History and Changes only show work unique to this branch.
342
- base_sha, base_ref = find_branch_base(repo, branch)
389
+ base_sha, base_ref = GitService.find_branch_base(repo, branch)
343
390
 
344
391
  if upstream_branch.present?
345
392
  counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
@@ -350,7 +397,7 @@ module Mbeditor
350
397
  end
351
398
 
352
399
  unpushed_log_output, _err, unpushed_log_status = Open3.capture3("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
353
- unpushed_commits = parse_git_log(unpushed_log_output) if unpushed_log_status.success?
400
+ unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
354
401
  end
355
402
 
356
403
  # "Changes in Branch" — use the merge-base against the base branch when available
@@ -361,7 +408,7 @@ module Mbeditor
361
408
  if unpushed_status.success?
362
409
  unpushed_files = parse_name_status(unpushed_output)
363
410
  unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD")
364
- unp_numstat_map = parse_numstat(unp_numstat_out)
411
+ unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
365
412
  unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
366
413
  end
367
414
  end
@@ -373,7 +420,7 @@ module Mbeditor
373
420
  Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
374
421
  "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
375
422
  end
376
- branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
423
+ branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
377
424
 
378
425
  redmine_ticket_id = nil
379
426
  if Mbeditor.configuration.redmine_enabled
@@ -382,7 +429,7 @@ module Mbeditor
382
429
  redmine_ticket_id = m[1] if m
383
430
  else
384
431
  branch_commits.each do |commit|
385
- m = commit[:title]&.match(/#(\d+)/)
432
+ m = commit["title"]&.match(/#(\d+)/)
386
433
  if m
387
434
  redmine_ticket_id = m[1]
388
435
  break
@@ -545,9 +592,10 @@ module Mbeditor
545
592
 
546
593
  # Use a workspace-local tempfile so RuboCop's config discovery walks up
547
594
  # from the source file's directory and finds the host app's .rubocop.yml.
548
- tmpfile = File.join(File.dirname(path), ".mbeditor_fix_#{SecureRandom.hex(8)}#{ext}")
549
- begin
550
- File.write(tmpfile, code)
595
+ Tempfile.create([".mbeditor_fix_", ext], File.dirname(path)) do |f|
596
+ f.write(code)
597
+ f.flush
598
+ tmpfile = f.path
551
599
 
552
600
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
553
601
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
@@ -561,8 +609,6 @@ module Mbeditor
561
609
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
562
610
  fix = compute_text_edit(code, corrected)
563
611
  render json: { fix: fix }
564
- ensure
565
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
566
612
  end
567
613
  rescue StandardError => e
568
614
  render json: { error: e.message }, status: :unprocessable_content
@@ -608,10 +654,12 @@ module Mbeditor
608
654
  code = params[:code].to_s
609
655
  return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
610
656
 
611
- ext = File.extname(File.basename(path))
612
- tmpfile = File.join(File.dirname(path), ".mbeditor_fmt_#{SecureRandom.hex(8)}#{ext}")
613
- begin
614
- File.write(tmpfile, code)
657
+ ext = File.extname(File.basename(path))
658
+ Tempfile.create([".mbeditor_fmt_", ext], File.dirname(path)) do |f|
659
+ f.write(code)
660
+ f.flush
661
+ tmpfile = f.path
662
+
615
663
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
616
664
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
617
665
  _out, _err, status = Open3.capture3(env, *cmd)
@@ -621,8 +669,6 @@ module Mbeditor
621
669
 
622
670
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
623
671
  render json: { ok: true, content: corrected }
624
- ensure
625
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
626
672
  end
627
673
  rescue StandardError => e
628
674
  render json: { error: e.message }, status: :unprocessable_content
@@ -630,6 +676,14 @@ module Mbeditor
630
676
 
631
677
  private
632
678
 
679
+ def broadcast_files_changed
680
+ return unless defined?(ActionCable.server)
681
+
682
+ ActionCable.server.broadcast("mbeditor_editor", { type: "files_changed" })
683
+ rescue StandardError
684
+ # Never let a broadcast failure affect the HTTP response
685
+ end
686
+
633
687
  def sanitize_branch_name(branch)
634
688
  return nil if branch.blank?
635
689
  str = branch.to_s.strip
@@ -665,11 +719,14 @@ module Mbeditor
665
719
  # Stream search results using popen so we can stop reading early once we
666
720
  # have collected `limit` matches (avoids buffering the entire rg/grep output
667
721
  # in memory when searching large codebases for common tokens).
668
- def stream_search_results(query, limit)
722
+ def stream_search_results(query, limit, use_regex: false, match_case: false, whole_word: false)
669
723
  results = []
670
724
 
671
725
  if RG_AVAILABLE
672
726
  args = ["rg", "--json", "--no-ignore"]
727
+ args << "-F" unless use_regex
728
+ args << "--ignore-case" unless match_case
729
+ args << "--word-regexp" if whole_word
673
730
  excluded_paths.each { |p| args << "--glob=!#{p}" }
674
731
  args += ["--", query, workspace_root.to_s]
675
732
 
@@ -679,7 +736,8 @@ module Mbeditor
679
736
 
680
737
  begin
681
738
  data = JSON.parse(raw)
682
- rescue JSON::ParserError
739
+ rescue JSON::ParserError => e
740
+ Rails.logger.warn("[mbeditor] search: malformed rg JSON line: #{e.message}")
683
741
  next
684
742
  end
685
743
  next unless data["type"] == "match"
@@ -693,8 +751,11 @@ module Mbeditor
693
751
  end
694
752
  end
695
753
  else
696
- args = ["grep", "-rn", "-F"]
697
- excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
754
+ base_flags = use_regex ? "-E" : "-F"
755
+ args = ["grep", "-rn", base_flags]
756
+ args << "-i" unless match_case
757
+ args << "-w" if whole_word
758
+ excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
698
759
  args += [query, workspace_root.to_s]
699
760
 
700
761
  IO.popen(args, err: File::NULL) do |io|
@@ -722,6 +783,36 @@ module Mbeditor
722
783
  results
723
784
  end
724
785
 
786
+ # Count total matching lines across the workspace using rg --count (or grep -c).
787
+ # Fast: rg just counts without extracting context. Runs in a background thread.
788
+ def count_search_results(query, use_regex: false, match_case: false, whole_word: false)
789
+ total = 0
790
+ if RG_AVAILABLE
791
+ args = ["rg", "--count", "--no-ignore"]
792
+ args << "-F" unless use_regex
793
+ args << "--ignore-case" unless match_case
794
+ args << "--word-regexp" if whole_word
795
+ excluded_paths.each { |p| args << "--glob=!#{p}" }
796
+ args += ["--", query, workspace_root.to_s]
797
+ IO.popen(args, err: File::NULL) do |io|
798
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
799
+ end
800
+ else
801
+ base_flags = use_regex ? "-E" : "-F"
802
+ args = ["grep", "-rc", base_flags]
803
+ args << "-i" unless match_case
804
+ args << "-w" if whole_word
805
+ excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
806
+ args += [query, workspace_root.to_s]
807
+ IO.popen(args, err: File::NULL) do |io|
808
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
809
+ end
810
+ end
811
+ total
812
+ rescue StandardError
813
+ 0
814
+ end
815
+
725
816
  def build_tree(dir, max_depth: 10, depth: 0)
726
817
  return [] if depth >= max_depth
727
818
 
@@ -761,27 +852,29 @@ module Mbeditor
761
852
  end
762
853
 
763
854
  def run_with_timeout(env, cmd, stdin_data:)
764
- output = +""
765
- timed_out = false
855
+ timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
856
+ output = +""; timed_out = false
766
857
 
767
858
  Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
768
859
  stdin.write(stdin_data)
769
860
  stdin.close
770
861
 
771
- timer = Thread.new do
772
- sleep RUBOCOP_TIMEOUT_SECONDS
773
- timed_out = true
774
- Process.kill('-KILL', wait_thr.pid)
775
- rescue Errno::ESRCH
776
- nil
862
+ timer = if timeout_seconds && timeout_seconds > 0
863
+ Thread.new do
864
+ sleep timeout_seconds
865
+ timed_out = true
866
+ Process.kill('-KILL', wait_thr.pid)
867
+ rescue Errno::ESRCH
868
+ nil
869
+ end
777
870
  end
778
871
 
779
872
  output = stdout.read
780
873
  wait_thr.value
781
- timer.kill
874
+ timer&.kill
782
875
  end
783
876
 
784
- raise "RuboCop timed out after #{RUBOCOP_TIMEOUT_SECONDS} seconds" if timed_out
877
+ raise "RuboCop timed out after #{timeout_seconds} seconds" if timed_out
785
878
 
786
879
  output
787
880
  end
@@ -856,48 +949,46 @@ module Mbeditor
856
949
  candidate.exist? ? ".rubocop.yml" : nil
857
950
  end
858
951
 
952
+ PROBE_MUTEX = Mutex.new
953
+ private_constant :PROBE_MUTEX
954
+
859
955
  def rubocop_available?
860
- # Key on the configured rubocop command so the cache is invalidated if the
861
- # command changes (e.g., between test cases or after reconfiguration).
862
- key = Mbeditor.configuration.rubocop_command.to_s
863
- cache = self.class.instance_variable_get(:@rubocop_available_cache) ||
864
- self.class.instance_variable_set(:@rubocop_available_cache, {})
865
- return cache[key] if cache.key?(key)
866
- cache[key] = begin
956
+ key = Mbeditor.configuration.rubocop_command.to_s
957
+ probe_cached(:@rubocop_available_cache, key) do
867
958
  _out, _err, status = Open3.capture3(*rubocop_command, "--version")
868
959
  status.success?
869
- rescue StandardError
870
- false
871
960
  end
872
961
  end
873
962
 
874
963
  def haml_lint_available?
875
- # Key on the resolved command array so workspace-local bin/haml-lint is
876
- # respected without re-running the probe on every request.
877
- cmd = haml_lint_command
878
- key = cmd.join(" ")
879
- cache = self.class.instance_variable_get(:@haml_lint_available_cache) ||
880
- self.class.instance_variable_set(:@haml_lint_available_cache, {})
881
- return cache[key] if cache.key?(key)
882
- cache[key] = begin
964
+ cmd = haml_lint_command
965
+ key = cmd.join(" ")
966
+ probe_cached(:@haml_lint_available_cache, key) do
883
967
  _out, _err, status = Open3.capture3(*cmd, "--version")
884
968
  status.success?
885
- rescue StandardError
886
- false
887
969
  end
888
970
  end
889
971
 
890
972
  def git_available?
891
- # Key on workspace path so different directories get their own probe result.
892
- key = workspace_root.to_s
893
- cache = self.class.instance_variable_get(:@git_available_cache) ||
894
- self.class.instance_variable_set(:@git_available_cache, {})
895
- return cache[key] if cache.key?(key)
896
- cache[key] = begin
973
+ key = workspace_root.to_s
974
+ probe_cached(:@git_available_cache, key) do
897
975
  _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
898
976
  status.success?
899
- rescue StandardError
900
- false
977
+ end
978
+ end
979
+
980
+ def probe_cached(ivar, key, &block)
981
+ PROBE_MUTEX.synchronize do
982
+ cache = self.class.instance_variable_get(ivar) ||
983
+ self.class.instance_variable_set(ivar, {})
984
+ unless cache.key?(key)
985
+ cache[key] = begin
986
+ block.call
987
+ rescue StandardError
988
+ false
989
+ end
990
+ end
991
+ cache[key]
901
992
  end
902
993
  end
903
994
 
@@ -952,8 +1043,13 @@ module Mbeditor
952
1043
  end
953
1044
 
954
1045
  def parse_porcelain_status(output)
955
- output.lines.map do |line|
956
- { status: line[0..1].strip, path: line[3..].to_s.strip }
1046
+ output.lines.filter_map do |line|
1047
+ next if line.length < 4
1048
+
1049
+ path = line[3..].to_s.strip
1050
+ next if path.blank?
1051
+
1052
+ { status: line[0..1].strip, path: path }
957
1053
  end
958
1054
  end
959
1055
 
@@ -970,29 +1066,6 @@ module Mbeditor
970
1066
  end
971
1067
  end
972
1068
 
973
- def parse_numstat(output)
974
- (output || "").lines.each_with_object({}) do |line, map|
975
- parts = line.strip.split("\t", 3)
976
- next if parts.length < 3 || parts[0] == "-"
977
-
978
- map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
979
- end
980
- end
981
-
982
- def parse_git_log(output)
983
- output.split("\x1e").filter_map do |entry|
984
- fields = entry.strip.split("\x1f", 4)
985
- next unless fields.length == 4
986
-
987
- {
988
- hash: fields[0],
989
- title: fields[1],
990
- author: fields[2],
991
- date: fields[3]
992
- }
993
- end
994
- end
995
-
996
1069
  def monaco_worker_file
997
1070
  engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
998
1071
  return engine_path if engine_path.file?
@@ -1000,38 +1073,6 @@ module Mbeditor
1000
1073
  Rails.root.join("public", "monaco_worker.js")
1001
1074
  end
1002
1075
 
1003
- # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
1004
- # or [nil, nil] if none can be determined. Candidates are tried in preference order:
1005
- # origin/develop → origin/main → origin/master → develop → main → master.
1006
- # Skips refs that ARE the current branch and refs where the merge-base equals HEAD
1007
- # (meaning the current branch is behind or at the same point as that ref).
1008
- def find_branch_base(repo, current_branch)
1009
- candidates = %w[origin/develop origin/main origin/master develop main master]
1010
- head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
1011
- head_sha = head_sha_out.strip
1012
-
1013
- candidates.each do |ref|
1014
- short = ref.delete_prefix("origin/")
1015
- next if short == current_branch || ref == current_branch
1016
-
1017
- _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
1018
- next unless st.success?
1019
-
1020
- base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
1021
- next unless base_st.success?
1022
-
1023
- sha = base_out.strip
1024
- next unless sha.match?(/\A[0-9a-f]{40}\z/)
1025
- next if sha == head_sha # branch is at/behind this ref — no unique commits
1026
-
1027
- return [sha, ref]
1028
- end
1029
-
1030
- [nil, nil]
1031
- rescue StandardError
1032
- [nil, nil]
1033
- end
1034
-
1035
1076
  def resolve_monaco_asset_path(asset_path)
1036
1077
  return nil if asset_path.blank?
1037
1078
 
@@ -26,7 +26,8 @@ module Mbeditor
26
26
  head = nil if head == 'WORKING'
27
27
  # Allow full/short SHA hashes plus common git ref formats: branch names,
28
28
  # HEAD, remote tracking refs, parent notation (sha^, sha~N) and tags.
29
- valid_ref = /\A[a-zA-Z0-9._\-\/\^~@]+\z/
29
+ # @ is excluded to block reflog syntax like @{-1} or HEAD@{2}.
30
+ valid_ref = /\A[a-zA-Z0-9._\-\/\^~]+\z/
30
31
  if [base, head].any? { |s| s && (s.length > 200 || !s.match?(valid_ref)) }
31
32
  return render json: { error: 'Invalid ref' }, status: :bad_request
32
33
  end
@@ -88,15 +89,7 @@ module Mbeditor
88
89
  "diff-tree", "--no-commit-id", "-r", "--numstat", sha
89
90
  )
90
91
 
91
- numstat_map = {}
92
- if numstat_status.success?
93
- numstat_output.lines.each do |line|
94
- parts = line.strip.split("\t", 3)
95
- next if parts.length < 3 || parts[0] == "-"
96
-
97
- numstat_map[parts[2].strip] = { "added" => parts[0].to_i, "removed" => parts[1].to_i }
98
- end
99
- end
92
+ numstat_map = numstat_status.success? ? GitService.parse_numstat(numstat_output) : {}
100
93
 
101
94
  files = []
102
95
  if files_status.success?
@@ -136,7 +129,7 @@ module Mbeditor
136
129
  else
137
130
  repo = workspace_root.to_s
138
131
  branch = GitService.current_branch(repo)
139
- base_sha, = find_branch_base(repo, branch)
132
+ base_sha, = GitService.find_branch_base(repo, branch)
140
133
 
141
134
  if base_sha.present?
142
135
  out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{base_sha}..HEAD")
@@ -153,7 +146,7 @@ module Mbeditor
153
146
  out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{upstream}..HEAD")
154
147
  out = status.success? ? out : ""
155
148
  else
156
- out = ""
149
+ return render plain: "", content_type: "text/plain"
157
150
  end
158
151
  end
159
152
  end
@@ -202,33 +195,5 @@ module Mbeditor
202
195
  relative_path(full)
203
196
  end
204
197
 
205
- # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
206
- # or [nil, nil] if none can be determined.
207
- def find_branch_base(repo, current_branch)
208
- candidates = %w[origin/develop origin/main origin/master develop main master]
209
- head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
210
- head_sha = head_sha_out.strip
211
-
212
- candidates.each do |ref|
213
- short = ref.delete_prefix("origin/")
214
- next if short == current_branch || ref == current_branch
215
-
216
- _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
217
- next unless st.success?
218
-
219
- base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
220
- next unless base_st.success?
221
-
222
- sha = base_out.strip
223
- next unless sha.match?(/\A[0-9a-f]{40}\z/)
224
- next if sha == head_sha
225
-
226
- return [sha, ref]
227
- end
228
-
229
- [nil, nil]
230
- rescue StandardError
231
- [nil, nil]
232
- end
233
198
  end
234
199
  end
@@ -92,6 +92,12 @@ module Mbeditor
92
92
  end
93
93
  end
94
94
 
95
+ # Guard against a truncated final block (no TAB content line emitted).
96
+ if current["sha"] && !current.key?("content")
97
+ current["content"] = ""
98
+ results << current.dup
99
+ end
100
+
95
101
  results
96
102
  end
97
103
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Mbeditor
4
6
  # Builds commit graph data for rendering a VSCode-style commit graph.
5
7
  #