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.
@@ -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
- tree = build_tree(workspace_root.to_s)
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
- payload = params[:state].to_json
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&regex=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
- working_output, _err, working_status = Open3.capture3("git", "-C", repo, "status", "--porcelain")
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
- # Annotate each working-tree file with added/removed line counts
374
- numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
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 = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
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
- ahead_count = 0
383
- behind_count = 0
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
- # Determine the branch's fork point relative to a base branch (develop/main/master).
388
- # This ensures History and Changes only show work unique to this branch.
389
- base_sha, base_ref = GitService.find_branch_base(repo, branch)
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 upstream_branch.present?
392
- counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
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 = ahead_str.to_i
623
+ ahead_count = ahead_str.to_i
396
624
  behind_count = behind_str.to_i
397
625
  end
626
+ end
398
627
 
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")
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
- # "Changes in Branch" use the merge-base against the base branch when available
404
- # so that files changed in develop (and merged into this branch) are excluded.
405
- diff_base = base_sha || upstream_branch
406
- if diff_base.present?
407
- unpushed_output, _err, unpushed_status = Open3.capture3("git", "-C", repo, "diff", "--name-status", "#{diff_base}..HEAD")
408
- if unpushed_status.success?
409
- unpushed_files = parse_name_status(unpushed_output)
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 = if base_sha
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
- render json: {
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", "--cache", "false", "--stdin", filename, "--format", "json", "--no-color", "--force-exclusion"]
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", "--cache", "false", "-A", "--no-color", tmpfile]
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", "--cache", "false", "-A", "--no-color", tmpfile]
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 = Mutex.new
970
- private_constant :PROBE_MUTEX
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) ||