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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/application_iife_head.js +7 -0
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +213 -11
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +14 -4
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +673 -160
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +41 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +3 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +21 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +10 -2
- data/app/assets/javascripts/mbeditor/file_service.js +29 -23
- data/app/assets/javascripts/mbeditor/git_service.js +7 -11
- data/app/assets/javascripts/mbeditor/search_service.js +51 -14
- data/app/assets/javascripts/mbeditor/tab_manager.js +3 -3
- data/app/assets/javascripts/mbeditor/websocket_service.js +126 -0
- data/app/assets/stylesheets/mbeditor/editor.css +237 -15
- data/app/channels/mbeditor/editor_channel.rb +79 -0
- data/app/controllers/mbeditor/editors_controller.rb +177 -136
- data/app/controllers/mbeditor/git_controller.rb +5 -40
- data/app/services/mbeditor/git_blame_service.rb +6 -0
- data/app/services/mbeditor/git_commit_graph_service.rb +2 -0
- data/app/services/mbeditor/git_service.rb +97 -28
- data/app/services/mbeditor/redmine_service.rb +7 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -2
- data/app/views/layouts/mbeditor/application.html.erb +4 -0
- data/lib/mbeditor/cable_log_filter.rb +28 -0
- data/lib/mbeditor/configuration.rb +7 -1
- data/lib/mbeditor/engine.rb +37 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +4 -1
- data/lib/mbeditor/version.rb +3 -1
- data/lib/mbeditor.rb +2 -0
- 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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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®ex=false&match_case=false&whole_word=false
|
|
288
323
|
def search
|
|
289
|
-
query
|
|
290
|
-
offset
|
|
291
|
-
limit
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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[
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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 =
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
874
|
+
timer&.kill
|
|
782
875
|
end
|
|
783
876
|
|
|
784
|
-
raise "RuboCop timed out after #{
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
892
|
-
key
|
|
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
|
-
|
|
900
|
-
|
|
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.
|
|
956
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|