mbeditor 0.4.5 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +205 -18
- data/app/assets/javascripts/mbeditor/components/FileTree.js +23 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +324 -48
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +226 -0
- data/app/assets/javascripts/mbeditor/file_service.js +93 -1
- data/app/assets/javascripts/mbeditor/git_service.js +7 -3
- data/app/assets/javascripts/mbeditor/search_service.js +91 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +78 -2
- data/app/assets/stylesheets/mbeditor/editor.css +29 -0
- data/app/controllers/mbeditor/editors_controller.rb +318 -41
- data/app/services/mbeditor/ruby_definition_service.rb +163 -21
- data/app/services/mbeditor/unused_methods_service.rb +139 -0
- data/app/views/layouts/mbeditor/application.html.erb +86 -56
- data/config/routes.rb +4 -0
- data/lib/mbeditor/version.rb +1 -1
- metadata +3 -2
|
@@ -837,6 +837,12 @@ html, body, #mbeditor-root {
|
|
|
837
837
|
padding: 0 6px;
|
|
838
838
|
}
|
|
839
839
|
|
|
840
|
+
.statusbar-zen-btn {
|
|
841
|
+
font-size: 10px;
|
|
842
|
+
font-weight: 600;
|
|
843
|
+
letter-spacing: 0.04em;
|
|
844
|
+
}
|
|
845
|
+
|
|
840
846
|
/* ── Search Panel ─────────────────────────────────────────── */
|
|
841
847
|
.search-panel {
|
|
842
848
|
padding: 8px;
|
|
@@ -1432,6 +1438,29 @@ html, body, #mbeditor-root {
|
|
|
1432
1438
|
gap: 8px;
|
|
1433
1439
|
}
|
|
1434
1440
|
|
|
1441
|
+
/* Open-editors panel: height is controlled by the --open-editors-height CSS variable
|
|
1442
|
+
set inline from React state (default 140px ≈ 5-6 items), drag-resizable via the
|
|
1443
|
+
.open-editors-resize-handle strip below. */
|
|
1444
|
+
.ide-sidebar-fixed .collapsible-content {
|
|
1445
|
+
max-height: var(--open-editors-height, 140px);
|
|
1446
|
+
overflow-y: auto;
|
|
1447
|
+
overflow-x: hidden;
|
|
1448
|
+
}
|
|
1449
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar { width: 6px; }
|
|
1450
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar-track { background: transparent; }
|
|
1451
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar-thumb { background: var(--ide-scrollbar); border-radius: 3px; }
|
|
1452
|
+
|
|
1453
|
+
.open-editors-resize-handle {
|
|
1454
|
+
height: 4px;
|
|
1455
|
+
flex-shrink: 0;
|
|
1456
|
+
cursor: row-resize;
|
|
1457
|
+
background: transparent;
|
|
1458
|
+
transition: background 0.15s;
|
|
1459
|
+
}
|
|
1460
|
+
.open-editors-resize-handle:hover {
|
|
1461
|
+
background: var(--ide-border);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1435
1464
|
.open-editors-group-header {
|
|
1436
1465
|
font-size: 10px;
|
|
1437
1466
|
padding-left: 8px;
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "open3"
|
|
5
5
|
require "shellwords"
|
|
6
6
|
require "tempfile"
|
|
7
|
+
require "timeout"
|
|
7
8
|
require "tmpdir"
|
|
8
9
|
|
|
9
10
|
module Mbeditor
|
|
@@ -45,7 +46,12 @@ module Mbeditor
|
|
|
45
46
|
|
|
46
47
|
# GET /mbeditor/files — recursive file tree
|
|
47
48
|
def files
|
|
48
|
-
|
|
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)
|
|
49
55
|
render json: tree
|
|
50
56
|
end
|
|
51
57
|
|
|
@@ -69,7 +75,9 @@ module Mbeditor
|
|
|
69
75
|
|
|
70
76
|
# POST /mbeditor/state — save workspace state
|
|
71
77
|
def save_state
|
|
72
|
-
|
|
78
|
+
raw = params[:state]
|
|
79
|
+
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
80
|
+
payload = raw.to_json
|
|
73
81
|
return render json: { error: "State payload too large" }, status: :content_too_large if payload.bytesize > STATE_MAX_BYTES
|
|
74
82
|
|
|
75
83
|
path = workspace_root.join("tmp", "mbeditor_workspace.json")
|
|
@@ -162,6 +170,29 @@ module Mbeditor
|
|
|
162
170
|
return render json: { error: "Not found" }, status: :not_found
|
|
163
171
|
end
|
|
164
172
|
|
|
173
|
+
start_line = params[:start_line] ? params[:start_line].to_i : nil
|
|
174
|
+
line_count = params.key?(:line_count) ? params[:line_count].to_i : 500
|
|
175
|
+
line_count = [line_count, 5000].min
|
|
176
|
+
|
|
177
|
+
if start_line
|
|
178
|
+
total_bytes = File.size(path)
|
|
179
|
+
chunk = []
|
|
180
|
+
total_lines = 0
|
|
181
|
+
File.foreach(path, encoding: "UTF-8", invalid: :replace, undef: :replace) do |line|
|
|
182
|
+
chunk << line if total_lines >= start_line && chunk.length < line_count
|
|
183
|
+
total_lines += 1
|
|
184
|
+
end
|
|
185
|
+
return render json: {
|
|
186
|
+
path: relative_path(path),
|
|
187
|
+
content: chunk.join,
|
|
188
|
+
truncated: total_lines > start_line + chunk.length,
|
|
189
|
+
start_line: start_line,
|
|
190
|
+
line_count: chunk.length,
|
|
191
|
+
total_lines: total_lines,
|
|
192
|
+
total_bytes: total_bytes
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
165
196
|
size = File.size(path)
|
|
166
197
|
return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
|
|
167
198
|
|
|
@@ -319,6 +350,77 @@ module Mbeditor
|
|
|
319
350
|
render json: { error: e.message }, status: :unprocessable_content
|
|
320
351
|
end
|
|
321
352
|
|
|
353
|
+
# GET /mbeditor/module_members?name=ArticlesHelper
|
|
354
|
+
# Returns methods defined in the workspace file that defines the named module/class.
|
|
355
|
+
def module_members
|
|
356
|
+
name = params[:name].to_s.strip
|
|
357
|
+
return render json: { error: "Invalid name" }, status: :bad_request \
|
|
358
|
+
unless name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
|
359
|
+
|
|
360
|
+
file = RubyDefinitionService.module_defined_in(
|
|
361
|
+
workspace_root, name,
|
|
362
|
+
excluded_dirnames: excluded_dirnames,
|
|
363
|
+
excluded_paths: excluded_paths
|
|
364
|
+
)
|
|
365
|
+
return render json: { name: name, methods: [] } unless file
|
|
366
|
+
|
|
367
|
+
defs = RubyDefinitionService.defs_in_file(file)
|
|
368
|
+
methods = defs.flat_map do |method_name, entries|
|
|
369
|
+
entries.map { |e| { name: method_name, line: e[:line], signature: e[:signature], file: relative_path(file) } }
|
|
370
|
+
end
|
|
371
|
+
render json: { name: name, file: relative_path(file), methods: methods }
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# GET /mbeditor/file_includes?path=app/models/article.rb
|
|
377
|
+
# Returns included/extended/prepended module names and their methods.
|
|
378
|
+
def file_includes
|
|
379
|
+
path = resolve_path(params[:path])
|
|
380
|
+
return render json: { error: "Forbidden" }, status: :forbidden unless path
|
|
381
|
+
|
|
382
|
+
# Ensure workspace is scanned so include_calls are populated in the cache.
|
|
383
|
+
# Fast no-op on subsequent calls (mtime checks only).
|
|
384
|
+
RubyDefinitionService.scan(workspace_root,
|
|
385
|
+
excluded_dirnames: excluded_dirnames,
|
|
386
|
+
excluded_paths: excluded_paths)
|
|
387
|
+
|
|
388
|
+
module_names = RubyDefinitionService.includes_in_file(path)
|
|
389
|
+
includes = module_names.filter_map do |mod_name|
|
|
390
|
+
mod_file = RubyDefinitionService.module_defined_in(
|
|
391
|
+
workspace_root, mod_name,
|
|
392
|
+
excluded_dirnames: excluded_dirnames,
|
|
393
|
+
excluded_paths: excluded_paths
|
|
394
|
+
)
|
|
395
|
+
next unless mod_file
|
|
396
|
+
|
|
397
|
+
defs = RubyDefinitionService.defs_in_file(mod_file)
|
|
398
|
+
methods = defs.flat_map do |method_name, entries|
|
|
399
|
+
entries.map { |e| { name: method_name, line: e[:line], signature: e[:signature] } }
|
|
400
|
+
end
|
|
401
|
+
{ name: mod_name, file: relative_path(mod_file), methods: methods }
|
|
402
|
+
end
|
|
403
|
+
render json: { includes: includes }
|
|
404
|
+
rescue StandardError => e
|
|
405
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# GET /mbeditor/unused_methods?path=app/models/article.rb
|
|
409
|
+
# Returns method names defined in the file that have no call-sites in the workspace.
|
|
410
|
+
def unused_methods
|
|
411
|
+
path = resolve_path(params[:path])
|
|
412
|
+
return render json: { error: "Forbidden" }, status: :forbidden unless path
|
|
413
|
+
|
|
414
|
+
unused = UnusedMethodsService.call(
|
|
415
|
+
workspace_root, path,
|
|
416
|
+
excluded_dirnames: excluded_dirnames,
|
|
417
|
+
excluded_paths: excluded_paths
|
|
418
|
+
)
|
|
419
|
+
render json: { unused: unused }
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
422
|
+
end
|
|
423
|
+
|
|
322
424
|
# GET /mbeditor/search?q=...&offset=0&limit=50®ex=false&match_case=false&whole_word=false
|
|
323
425
|
def search
|
|
324
426
|
query = params[:q].to_s.strip
|
|
@@ -350,6 +452,104 @@ module Mbeditor
|
|
|
350
452
|
render json: { error: e.message }, status: :unprocessable_content
|
|
351
453
|
end
|
|
352
454
|
|
|
455
|
+
MAX_REPLACE_FILES = 500
|
|
456
|
+
|
|
457
|
+
# POST /mbeditor/replace_in_files
|
|
458
|
+
# Replaces a string/pattern across all matching files in the workspace.
|
|
459
|
+
# Returns { replaced_count:, files_affected:[], errors:[], partial: }
|
|
460
|
+
def replace_in_files
|
|
461
|
+
query = params[:query].to_s.strip
|
|
462
|
+
replacement = params[:replacement].to_s
|
|
463
|
+
use_regex = params[:regex] == 'true'
|
|
464
|
+
match_case = params[:match_case] == 'true'
|
|
465
|
+
whole_word = params[:whole_word] == 'true'
|
|
466
|
+
|
|
467
|
+
return render json: { error: "Query is required" }, status: :bad_request if query.blank?
|
|
468
|
+
return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
|
|
469
|
+
|
|
470
|
+
# Collect all unique file paths that have at least one match.
|
|
471
|
+
# Use a large limit to get all matching files; stream_search_results handles deduplication by file internally.
|
|
472
|
+
raw_results = stream_search_results(query, 10_000, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
|
|
473
|
+
file_paths = raw_results.map { |r| r[:file] }.uniq
|
|
474
|
+
|
|
475
|
+
# Fix 3: Cap the number of files to process
|
|
476
|
+
if file_paths.length > MAX_REPLACE_FILES
|
|
477
|
+
return render json: { error: "Too many files matched (#{file_paths.length}). Narrow your search." }, status: :unprocessable_entity
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
replaced_count = 0
|
|
481
|
+
files_affected = []
|
|
482
|
+
errors = []
|
|
483
|
+
|
|
484
|
+
# Build the Ruby Regexp to use for gsub
|
|
485
|
+
begin
|
|
486
|
+
pattern = if use_regex
|
|
487
|
+
flags = match_case ? 0 : Regexp::IGNORECASE
|
|
488
|
+
Regexp.new(whole_word ? "\\b(?:#{query})\\b" : query, flags)
|
|
489
|
+
else
|
|
490
|
+
flags = match_case ? 0 : Regexp::IGNORECASE
|
|
491
|
+
Regexp.new(whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query), flags)
|
|
492
|
+
end
|
|
493
|
+
rescue RegexpError => e
|
|
494
|
+
return render json: { error: "Invalid regex: #{e.message}" }, status: :bad_request
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
file_paths.each do |rel_path|
|
|
498
|
+
full_path = resolve_path(rel_path)
|
|
499
|
+
unless full_path
|
|
500
|
+
errors << { file: rel_path, error: "Forbidden" }
|
|
501
|
+
next
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Fix 2: Check path_blocked_for_operations?
|
|
505
|
+
if path_blocked_for_operations?(full_path)
|
|
506
|
+
errors << { file: rel_path, error: "Forbidden" }
|
|
507
|
+
next
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
unless File.file?(full_path)
|
|
511
|
+
errors << { file: rel_path, error: "File not found" }
|
|
512
|
+
next
|
|
513
|
+
end
|
|
514
|
+
if File.size(full_path) > MAX_OPEN_FILE_SIZE_BYTES
|
|
515
|
+
errors << { file: rel_path, error: "File too large" }
|
|
516
|
+
next
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Fix 1: Wrap per-file gsub/scan in a timeout to prevent ReDoS
|
|
520
|
+
begin
|
|
521
|
+
Timeout::timeout(5) do
|
|
522
|
+
content = File.binread(full_path).force_encoding("UTF-8")
|
|
523
|
+
replacements_in_file = content.scan(pattern).length
|
|
524
|
+
new_content = content.gsub(pattern, replacement)
|
|
525
|
+
|
|
526
|
+
# Fix 4: Use new_content != content instead of delta logic
|
|
527
|
+
if new_content != content
|
|
528
|
+
File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
|
|
529
|
+
files_affected << rel_path
|
|
530
|
+
replaced_count += replacements_in_file
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
rescue Timeout::Error
|
|
534
|
+
errors << { file: rel_path, error: "Timed out processing file" }
|
|
535
|
+
next
|
|
536
|
+
rescue StandardError => e
|
|
537
|
+
errors << { file: rel_path, error: e.message }
|
|
538
|
+
next
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Fix 5: Surface partial failure
|
|
543
|
+
render json: {
|
|
544
|
+
replaced_count: replaced_count,
|
|
545
|
+
files_affected: files_affected,
|
|
546
|
+
errors: errors,
|
|
547
|
+
partial: errors.any? && files_affected.any?
|
|
548
|
+
}
|
|
549
|
+
rescue StandardError => e
|
|
550
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
551
|
+
end
|
|
552
|
+
|
|
353
553
|
# GET /mbeditor/git_status
|
|
354
554
|
def git_status
|
|
355
555
|
output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
|
|
@@ -363,63 +563,84 @@ module Mbeditor
|
|
|
363
563
|
# GET /mbeditor/git_info
|
|
364
564
|
def git_info
|
|
365
565
|
repo = workspace_root.to_s
|
|
566
|
+
cached = cached_git_info(repo)
|
|
567
|
+
return render json: cached if cached
|
|
568
|
+
|
|
366
569
|
branch = GitService.current_branch(repo)
|
|
367
570
|
unless branch
|
|
368
571
|
return render json: { ok: false, error: "Unable to determine current branch" }, status: :unprocessable_content
|
|
369
572
|
end
|
|
370
|
-
|
|
573
|
+
|
|
574
|
+
# Wave 1: all independent git reads run in parallel
|
|
575
|
+
status_t = Thread.new { Open3.capture3("git", "-C", repo, "status", "--porcelain") }
|
|
576
|
+
numstat_t = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD") }
|
|
577
|
+
upstream_t = Thread.new { Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
|
|
578
|
+
base_t = Thread.new { GitService.find_branch_base(repo, branch) }
|
|
579
|
+
|
|
580
|
+
working_output, _err, working_status = status_t.value
|
|
371
581
|
working_tree = working_status.success? ? parse_porcelain_status(working_output) : []
|
|
372
582
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
numstat_map = GitService.parse_numstat(numstat_out)
|
|
583
|
+
numstat_out = numstat_t.value.first
|
|
584
|
+
numstat_map = GitService.parse_numstat(numstat_out)
|
|
376
585
|
working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
|
|
377
586
|
|
|
378
|
-
upstream_output, _err, upstream_status =
|
|
587
|
+
upstream_output, _err, upstream_status = upstream_t.value
|
|
379
588
|
upstream_branch = upstream_status.success? ? upstream_output.strip : nil
|
|
380
589
|
upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
|
|
381
590
|
|
|
382
|
-
|
|
383
|
-
|
|
591
|
+
# Determine the branch's fork point relative to a base branch (develop/main/master).
|
|
592
|
+
# This ensures History and Changes only show work unique to this branch.
|
|
593
|
+
base_sha, base_ref = base_t.value
|
|
594
|
+
|
|
595
|
+
ahead_count = 0
|
|
596
|
+
behind_count = 0
|
|
384
597
|
unpushed_files = []
|
|
385
598
|
unpushed_commits = []
|
|
599
|
+
diff_base = base_sha || upstream_branch
|
|
386
600
|
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
601
|
+
# Wave 2: conditional parallel reads that depend on Wave 1 results
|
|
602
|
+
wave2 = {}
|
|
603
|
+
wave2[:counts] = Thread.new { Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}") } if upstream_branch.present?
|
|
604
|
+
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?
|
|
605
|
+
wave2[:diff_name] = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--name-status", "#{diff_base}..HEAD") } if diff_base.present?
|
|
606
|
+
wave2[:diff_num] = Thread.new { Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD") } if diff_base.present?
|
|
607
|
+
wave2[:branch_log] = Thread.new do
|
|
608
|
+
if base_sha
|
|
609
|
+
Open3.capture3("git", "-C", repo, "log", "--first-parent", "#{base_sha}..HEAD",
|
|
610
|
+
"--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
|
|
611
|
+
else
|
|
612
|
+
Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
|
|
613
|
+
"--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
wave2.each_value(&:join)
|
|
390
618
|
|
|
391
|
-
if
|
|
392
|
-
counts_output, _err, counts_status =
|
|
619
|
+
if (ct = wave2[:counts])
|
|
620
|
+
counts_output, _err, counts_status = ct.value
|
|
393
621
|
if counts_status.success?
|
|
394
622
|
ahead_str, behind_str = counts_output.strip.split("\t", 2)
|
|
395
|
-
ahead_count
|
|
623
|
+
ahead_count = ahead_str.to_i
|
|
396
624
|
behind_count = behind_str.to_i
|
|
397
625
|
end
|
|
626
|
+
end
|
|
398
627
|
|
|
399
|
-
|
|
628
|
+
if (ul = wave2[:unp_log])
|
|
629
|
+
unpushed_log_output, _err, unpushed_log_status = ul.value
|
|
400
630
|
unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
|
|
401
631
|
end
|
|
402
632
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
unpushed_files
|
|
410
|
-
unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD")
|
|
411
|
-
unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
|
|
412
|
-
unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
|
|
633
|
+
if (dn = wave2[:diff_name]) && (dnum = wave2[:diff_num])
|
|
634
|
+
diff_name_out, _err, diff_name_status = dn.value
|
|
635
|
+
if diff_name_status.success?
|
|
636
|
+
unpushed_files = parse_name_status(diff_name_out)
|
|
637
|
+
unp_numstat_out = dnum.value.first
|
|
638
|
+
unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
|
|
639
|
+
unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
|
|
413
640
|
end
|
|
414
641
|
end
|
|
415
642
|
|
|
416
|
-
branch_log_output, _err, branch_log_status =
|
|
417
|
-
Open3.capture3("git", "-C", repo, "log", "--first-parent", "#{base_sha}..HEAD",
|
|
418
|
-
"--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
|
|
419
|
-
else
|
|
420
|
-
Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
|
|
421
|
-
"--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
|
|
422
|
-
end
|
|
643
|
+
branch_log_output, _err, branch_log_status = wave2[:branch_log].value
|
|
423
644
|
branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
|
|
424
645
|
|
|
425
646
|
redmine_ticket_id = nil
|
|
@@ -438,7 +659,7 @@ module Mbeditor
|
|
|
438
659
|
end
|
|
439
660
|
end
|
|
440
661
|
|
|
441
|
-
|
|
662
|
+
payload = {
|
|
442
663
|
ok: true,
|
|
443
664
|
branch: branch,
|
|
444
665
|
upstreamBranch: upstream_branch,
|
|
@@ -451,6 +672,8 @@ module Mbeditor
|
|
|
451
672
|
branchBaseRef: base_ref,
|
|
452
673
|
redmineTicketId: redmine_ticket_id
|
|
453
674
|
}
|
|
675
|
+
store_git_info(repo, payload)
|
|
676
|
+
render json: payload
|
|
454
677
|
rescue StandardError => e
|
|
455
678
|
render json: { ok: false, error: e.message }, status: :unprocessable_content
|
|
456
679
|
end
|
|
@@ -536,7 +759,7 @@ module Mbeditor
|
|
|
536
759
|
return render json: { markers: markers }
|
|
537
760
|
end
|
|
538
761
|
|
|
539
|
-
cmd = rubocop_command + ["--no-server", "--
|
|
762
|
+
cmd = rubocop_command + ["--no-server", "--stdin", filename, "--format", "json", "--no-color", "--force-exclusion"]
|
|
540
763
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
541
764
|
output = run_with_timeout(env, cmd, stdin_data: code)
|
|
542
765
|
|
|
@@ -597,7 +820,7 @@ module Mbeditor
|
|
|
597
820
|
f.flush
|
|
598
821
|
tmpfile = f.path
|
|
599
822
|
|
|
600
|
-
cmd = rubocop_command + ["--no-server", "
|
|
823
|
+
cmd = rubocop_command + ["--no-server", "-A", "--no-color", tmpfile]
|
|
601
824
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
602
825
|
_out, _err, status = Open3.capture3(env, *cmd)
|
|
603
826
|
|
|
@@ -660,7 +883,7 @@ module Mbeditor
|
|
|
660
883
|
f.flush
|
|
661
884
|
tmpfile = f.path
|
|
662
885
|
|
|
663
|
-
cmd = rubocop_command + ["--no-server", "
|
|
886
|
+
cmd = rubocop_command + ["--no-server", "-A", "--no-color", tmpfile]
|
|
664
887
|
env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
|
|
665
888
|
_out, _err, status = Open3.capture3(env, *cmd)
|
|
666
889
|
unless status.success? || status.exitstatus == 1
|
|
@@ -677,6 +900,10 @@ module Mbeditor
|
|
|
677
900
|
private
|
|
678
901
|
|
|
679
902
|
def broadcast_files_changed
|
|
903
|
+
root = workspace_root.to_s
|
|
904
|
+
invalidate_file_tree_cache(root)
|
|
905
|
+
invalidate_git_info_cache(root)
|
|
906
|
+
|
|
680
907
|
return unless defined?(ActionCable.server)
|
|
681
908
|
|
|
682
909
|
ActionCable.server.broadcast("mbeditor_editor", { type: "files_changed" })
|
|
@@ -838,8 +1065,6 @@ module Mbeditor
|
|
|
838
1065
|
full = File.join(dir, name)
|
|
839
1066
|
rel = relative_path(full)
|
|
840
1067
|
|
|
841
|
-
next if excluded_path?(rel, name)
|
|
842
|
-
|
|
843
1068
|
if File.directory?(full)
|
|
844
1069
|
{ name: name, type: "folder", path: rel, children: build_tree(full, depth: depth + 1) }
|
|
845
1070
|
else
|
|
@@ -966,8 +1191,10 @@ module Mbeditor
|
|
|
966
1191
|
candidate.exist? ? ".rubocop.yml" : nil
|
|
967
1192
|
end
|
|
968
1193
|
|
|
969
|
-
PROBE_MUTEX
|
|
970
|
-
|
|
1194
|
+
PROBE_MUTEX = Mutex.new
|
|
1195
|
+
GIT_INFO_MUTEX = Mutex.new
|
|
1196
|
+
FILE_TREE_MUTEX = Mutex.new
|
|
1197
|
+
private_constant :PROBE_MUTEX, :GIT_INFO_MUTEX, :FILE_TREE_MUTEX
|
|
971
1198
|
|
|
972
1199
|
def rubocop_available?
|
|
973
1200
|
key = Mbeditor.configuration.rubocop_command.to_s
|
|
@@ -994,6 +1221,56 @@ module Mbeditor
|
|
|
994
1221
|
end
|
|
995
1222
|
end
|
|
996
1223
|
|
|
1224
|
+
def cached_git_info(repo, ttl: 5)
|
|
1225
|
+
GIT_INFO_MUTEX.synchronize do
|
|
1226
|
+
cache = self.class.instance_variable_get(:@git_info_cache) || {}
|
|
1227
|
+
entry = cache[repo]
|
|
1228
|
+
return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < ttl
|
|
1229
|
+
end
|
|
1230
|
+
nil
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
def store_git_info(repo, data)
|
|
1234
|
+
GIT_INFO_MUTEX.synchronize do
|
|
1235
|
+
cache = self.class.instance_variable_get(:@git_info_cache) || {}
|
|
1236
|
+
cache[repo] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
|
|
1237
|
+
self.class.instance_variable_set(:@git_info_cache, cache)
|
|
1238
|
+
end
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
def invalidate_git_info_cache(repo)
|
|
1242
|
+
GIT_INFO_MUTEX.synchronize do
|
|
1243
|
+
cache = self.class.instance_variable_get(:@git_info_cache) || {}
|
|
1244
|
+
cache.delete(repo)
|
|
1245
|
+
self.class.instance_variable_set(:@git_info_cache, cache)
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def cached_file_tree(root, ttl: 15)
|
|
1250
|
+
FILE_TREE_MUTEX.synchronize do
|
|
1251
|
+
cache = self.class.instance_variable_get(:@file_tree_cache) || {}
|
|
1252
|
+
entry = cache[root]
|
|
1253
|
+
return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < ttl
|
|
1254
|
+
end
|
|
1255
|
+
nil
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def store_file_tree(root, data)
|
|
1259
|
+
FILE_TREE_MUTEX.synchronize do
|
|
1260
|
+
cache = self.class.instance_variable_get(:@file_tree_cache) || {}
|
|
1261
|
+
cache[root] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
|
|
1262
|
+
self.class.instance_variable_set(:@file_tree_cache, cache)
|
|
1263
|
+
end
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def invalidate_file_tree_cache(root)
|
|
1267
|
+
FILE_TREE_MUTEX.synchronize do
|
|
1268
|
+
cache = self.class.instance_variable_get(:@file_tree_cache) || {}
|
|
1269
|
+
cache.delete(root)
|
|
1270
|
+
self.class.instance_variable_set(:@file_tree_cache, cache)
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
997
1274
|
def probe_cached(ivar, key, &block)
|
|
998
1275
|
PROBE_MUTEX.synchronize do
|
|
999
1276
|
cache = self.class.instance_variable_get(ivar) ||
|