mbeditor 0.12.9 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +155 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. metadata +8 -2
@@ -22,18 +22,13 @@ module Mbeditor
22
22
  end
23
23
 
24
24
  def read_state
25
- path = workspace_path
26
- return {} unless File.exist?(path)
27
- JSON.parse(File.read(path))
25
+ read_json(workspace_path)
28
26
  rescue JSON::ParserError, Errno::ENOENT
29
27
  {}
30
28
  end
31
29
 
32
30
  def read_branch_state(branch)
33
- path = branch_states_path
34
- return {} unless File.exist?(path)
35
- all = JSON.parse(File.read(path))
36
- all[branch] || {}
31
+ read_json(branch_states_path)[branch] || {}
37
32
  rescue JSON::ParserError, Errno::ENOENT
38
33
  {}
39
34
  end
@@ -44,17 +39,14 @@ module Mbeditor
44
39
  raise PayloadTooLargeError, "State payload too large" if payload_json.bytesize > STATE_MAX_BYTES
45
40
  path = branch_states_path
46
41
  FileUtils.mkdir_p(path.dirname)
47
- File.open(path, File::RDWR | File::CREAT) do |f|
48
- lock_exclusive!(f)
49
- existing = f.size > 0 ? JSON.parse(f.read) : {}
42
+ with_lock(path) do
43
+ existing = read_json(path)
50
44
  # Auto-save fires on a timer even with no changes; skip the full-file
51
45
  # rewrite when this branch's entry is already identical.
52
- break if existing[branch] == JSON.parse(payload_json)
46
+ next if existing[branch] == JSON.parse(payload_json)
53
47
 
54
48
  existing[branch] = state
55
- f.truncate(0)
56
- f.rewind
57
- f.write(existing.to_json)
49
+ atomic_write(path, existing.to_json)
58
50
  end
59
51
  nil
60
52
  end
@@ -63,10 +55,9 @@ module Mbeditor
63
55
  path = branch_states_path
64
56
  return [] unless File.exist?(path)
65
57
  pruned = []
66
- File.open(path, File::RDWR) do |f|
67
- lock_exclusive!(f)
58
+ with_lock(path) do
68
59
  all = begin
69
- JSON.parse(f.read)
60
+ read_json(path)
70
61
  rescue JSON::ParserError => e
71
62
  Rails.logger.error("[mbeditor] EditorStateService#prune_branch_states: discarding corrupt branch_states JSON at #{path}: #{e.message}")
72
63
  {}
@@ -74,9 +65,7 @@ module Mbeditor
74
65
  pruned = all.keys - active_branches
75
66
  if pruned.any?
76
67
  pruned.each { |b| all.delete(b) }
77
- f.truncate(0)
78
- f.rewind
79
- f.write(all.to_json)
68
+ atomic_write(path, all.to_json)
80
69
  end
81
70
  end
82
71
  pruned
@@ -87,17 +76,38 @@ module Mbeditor
87
76
  raise PayloadTooLargeError, "State payload too large" if payload.bytesize > STATE_MAX_BYTES
88
77
  path = workspace_path
89
78
  FileUtils.mkdir_p(path.dirname)
90
- File.open(path, File::RDWR | File::CREAT) do |f|
91
- lock_exclusive!(f)
92
- f.truncate(0)
93
- f.rewind
94
- f.write(payload)
95
- end
79
+ with_lock(path) { atomic_write(path, payload) }
96
80
  nil
97
81
  end
98
82
 
99
83
  private
100
84
 
85
+ # Readers take no lock at all: every write lands by rename, so a read sees
86
+ # either the whole previous file or the whole new one — never the empty
87
+ # window a truncate-then-write leaves, which readers swallowed as {}.
88
+ def read_json(path)
89
+ return {} unless File.exist?(path)
90
+
91
+ raw = File.read(path)
92
+ raw.empty? ? {} : JSON.parse(raw)
93
+ end
94
+
95
+ def atomic_write(path, payload)
96
+ tmp = "#{path}.tmp"
97
+ File.write(tmp, payload)
98
+ File.rename(tmp, path)
99
+ end
100
+
101
+ # The lock sits on a sidecar file, not on the state file: the state file is
102
+ # replaced by rename, so a lock held on the inode it had before the write
103
+ # would exclude nobody afterwards.
104
+ def with_lock(path)
105
+ File.open("#{path}.lock", File::RDWR | File::CREAT) do |f|
106
+ lock_exclusive!(f)
107
+ yield
108
+ end
109
+ end
110
+
101
111
  # Acquire an exclusive lock without blocking forever. Retries the
102
112
  # non-blocking flock until @lock_timeout elapses, then raises so the caller
103
113
  # fails fast instead of pinning a worker on a stuck holder.
@@ -87,24 +87,26 @@ module Mbeditor
87
87
  # case is folded. Defaults to the resolved workspace root.
88
88
  def initialize(patterns, root: nil)
89
89
  @fold_case = self.class.case_insensitive_filesystem?(root || WorkspaceRootResolver.call)
90
- @patterns = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
90
+ normalized = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
91
+ # Split by shape once: a path pattern is compared against the whole
92
+ # relative path, a bare name against its segments. This runs per file of
93
+ # a workspace walk, so neither the split nor the basename belongs in the
94
+ # per-pattern loop.
95
+ @path_patterns, @name_patterns = normalized.partition { |pattern| pattern.include?("/") }
91
96
  end
92
97
 
93
98
  def excluded?(relative_path)
94
99
  rel = normalize(relative_path.to_s)
95
- @patterns.any? { |pattern| matches?(pattern, rel) }
100
+ return true if @path_patterns.any? { |pattern| rel == pattern || rel.start_with?("#{pattern}/") }
101
+ return false if @name_patterns.empty?
102
+
103
+ segments = rel.split("/")
104
+ basename = File.basename(rel)
105
+ @name_patterns.any? { |pattern| basename == pattern || segments.include?(pattern) }
96
106
  end
97
107
 
98
108
  private
99
109
 
100
- def matches?(pattern, rel)
101
- if pattern.include?("/")
102
- rel == pattern || rel.start_with?("#{pattern}/")
103
- else
104
- File.basename(rel) == pattern || rel.split("/").include?(pattern)
105
- end
106
- end
107
-
108
110
  # "/" is ASCII and survives both steps, so the caller can normalize a whole
109
111
  # path in one pass and split it afterwards.
110
112
  def normalize(str)
@@ -18,7 +18,7 @@ module Mbeditor
18
18
  def save(path, content)
19
19
  raise FileTooLargeError if content.bytesize > MAX_FILE_SIZE_BYTES
20
20
 
21
- File.write(path, content)
21
+ atomic_write(path, content)
22
22
  { ok: true, path: relative_path(path) }
23
23
  end
24
24
 
@@ -36,10 +36,15 @@ module Mbeditor
36
36
 
37
37
  def rename(old_path, new_path)
38
38
  raise PathNotFoundError unless File.exist?(old_path)
39
- raise TargetExistsError if File.exist?(new_path)
39
+ # A case-only rename on a case-insensitive filesystem (macOS) reports the
40
+ # target as existing because it IS the source.
41
+ same_file = File.identical?(old_path, new_path)
42
+ raise TargetExistsError if File.exist?(new_path) && !same_file
40
43
 
41
44
  FileUtils.mkdir_p(File.dirname(new_path))
42
- FileUtils.mv(old_path, new_path)
45
+ # FileUtils.mv refuses a "same file" move, so the case-only rename goes
46
+ # straight to rename(2); everything else keeps mv's cross-device fallback.
47
+ same_file ? File.rename(old_path, new_path) : FileUtils.mv(old_path, new_path)
43
48
  { ok: true, oldPath: relative_path(old_path), path: relative_path(new_path), name: File.basename(new_path) }
44
49
  end
45
50
 
@@ -61,6 +66,36 @@ module Mbeditor
61
66
 
62
67
  private
63
68
 
69
+ # Write through a temp file in the same directory and rename over the
70
+ # target: a truncate-then-write loses the file outright if the process dies
71
+ # mid-save. The symlink is resolved first — renaming onto the link itself
72
+ # would replace it with a regular file, and linked-in files are supported.
73
+ def atomic_write(path, content)
74
+ target = link_target(path)
75
+ tmp = File.join(File.dirname(target), ".#{File.basename(target)}.mbeditor-tmp")
76
+ mode = File.stat(target).mode & 0o7777 if File.exist?(target)
77
+ File.write(tmp, content)
78
+ File.chmod(mode, tmp) if mode
79
+ File.rename(tmp, target)
80
+ rescue StandardError
81
+ FileUtils.rm_f(tmp) if tmp
82
+ raise
83
+ end
84
+
85
+ def link_target(path)
86
+ return path.to_s unless File.symlink?(path)
87
+
88
+ begin
89
+ File.realpath(path)
90
+ rescue SystemCallError
91
+ # realpath refuses a dangling link, but a save through one still has to
92
+ # create the file it names — the same thing File.write did.
93
+ File.expand_path(File.readlink(path), File.dirname(path))
94
+ end
95
+ rescue SystemCallError
96
+ path.to_s
97
+ end
98
+
64
99
  def relative_path(path)
65
100
  Pathname(path).relative_path_from(@workspace_root).to_s
66
101
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Mbeditor
4
6
  class FileTreeService
5
7
  MUTEX = Mutex.new
@@ -26,6 +28,26 @@ module Mbeditor
26
28
  data
27
29
  end
28
30
 
31
+ # The tree already serialized, plus a digest of that body, memoized inside
32
+ # the same cache entry. /files is polled every 10s and the payload is ~1 MB,
33
+ # so the digest is what turns an unchanged poll into a 304.
34
+ def self.cached_json(workspace_root)
35
+ root = workspace_root.to_s
36
+ data = build(root)
37
+
38
+ MUTEX.synchronize do
39
+ entry = (@cache ||= {})[root]
40
+ return entry[:json] if entry && entry[:json] && entry[:data].equal?(data)
41
+
42
+ # to_json, not JSON.generate: `render json:` used ActiveSupport's
43
+ # encoder, and the body must stay byte-identical.
44
+ body = data.to_json
45
+ payload = { body: body, digest: Digest::SHA1.hexdigest(body) }
46
+ entry[:json] = payload if entry
47
+ payload
48
+ end
49
+ end
50
+
29
51
  def self.invalidate(workspace_root)
30
52
  MUTEX.synchronize do
31
53
  @cache ||= {}
@@ -49,14 +71,20 @@ module Mbeditor
49
71
  rel = "" if rel == workspace_root
50
72
  is_excl = matcher.excluded?(rel)
51
73
 
52
- if File.directory?(full) && !File.symlink?(full)
74
+ # One lstat instead of directory?/symlink?/size three stats per entry
75
+ # over a whole tree. lstat does not follow, so a symlink is never a
76
+ # folder here, exactly as the directory?-and-not-symlink? test intended;
77
+ # a symlinked file still reports its *target's* size, as it always did.
78
+ st = (File.lstat(full) rescue nil)
79
+
80
+ if st&.directory?
53
81
  children = is_excl ? [] : traverse(full, workspace_root, matcher, max_depth: max_depth, depth: depth + 1)
54
82
  node = { name: name, type: "folder", path: rel, children: children }
55
83
  node[:excluded] = true if is_excl
56
84
  node
57
85
  else
58
86
  node = { name: name, type: "file", path: rel }
59
- node[:size] = (File.size(full) rescue nil) unless is_excl
87
+ node[:size] = ((st&.symlink? ? File.size(full) : st&.size) rescue nil) unless is_excl
60
88
  node[:excluded] = true if is_excl
61
89
  node
62
90
  end
@@ -33,17 +33,27 @@ module Mbeditor
33
33
  private
34
34
 
35
35
  def local_diff
36
- out, status = GitService.run_git(repo_path, "diff", "HEAD")
36
+ out, status = git_diff("HEAD")
37
37
  status.success? ? cap_diff(out) : ""
38
38
  end
39
39
 
40
+ # --no-color/--no-ext-diff on every invocation: a host gitconfig with
41
+ # color.ui = always, or a diff.external driver, otherwise feeds escape
42
+ # codes or foreign output into the viewer's parser. max_bytes stops a
43
+ # pathological diff being fully materialised just for cap_diff to throw
44
+ # most of it away.
45
+ def git_diff(*revs)
46
+ GitService.run_git(repo_path, "diff", "--no-color", "--no-ext-diff", *revs,
47
+ max_bytes: MAX_DIFF_BYTES)
48
+ end
49
+
40
50
  def branch_diff
41
51
  branch = GitService.current_branch(repo_path)
42
52
  base_sha, ref = GitService.find_branch_base(repo_path, branch)
43
53
 
44
54
  if base_sha.present?
45
55
  @base_ref = ref
46
- out, status = GitService.run_git(repo_path, "diff", "#{base_sha}..HEAD")
56
+ out, status = git_diff("#{base_sha}..HEAD")
47
57
  return status.success? ? cap_diff(out) : ""
48
58
  end
49
59
 
@@ -56,7 +66,7 @@ module Mbeditor
56
66
  upstream = GitService.upstream_branch(repo_path)
57
67
  if GitService.base_branch?(branch) && upstream.present?
58
68
  @base_ref = upstream
59
- out, status = GitService.run_git(repo_path, "diff", "#{upstream}..HEAD")
69
+ out, status = git_diff("#{upstream}..HEAD")
60
70
  return status.success? ? cap_diff(out) : ""
61
71
  end
62
72
 
@@ -11,21 +11,33 @@ module Mbeditor
11
11
  @sha = sha.to_s
12
12
  end
13
13
 
14
+ EMPTY = { "title" => "", "author" => "", "date" => "", "files" => [] }.freeze
15
+ private_constant :EMPTY
16
+
17
+ # Two spawns per commit click, not three: `git show --name-status` prints
18
+ # the pretty header then the raw name-status body. The numstat still needs
19
+ # its own call — asking for --name-status and --numstat together makes git
20
+ # emit only the former. --no-renames keeps a rename as the D+A pair the
21
+ # two-field parse below expects, rather than a three-field R100 record.
14
22
  def call
15
- meta = fetch_meta
16
- { "sha" => sha, "title" => meta["title"], "author" => meta["author"], "date" => meta["date"], "files" => fetch_files }
23
+ out, status = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--name-status",
24
+ "--pretty=format:%s%x1f%an%x1f%aI", "--end-of-options", sha)
25
+ return EMPTY.merge("sha" => sha) unless status.success?
26
+
27
+ header, body = out.split("\n", 2)
28
+ fields = header.to_s.split("\x1f", 3)
29
+ { "sha" => sha, "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s,
30
+ "files" => parse_files(body.to_s) }
17
31
  end
18
32
 
19
33
  private
20
34
 
21
- def fetch_files
22
- name_out, name_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--name-status", sha)
23
- return [] unless name_st.success?
24
-
25
- num_out, num_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--numstat", sha)
35
+ def parse_files(body)
36
+ num_out, num_st = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--numstat",
37
+ "--pretty=format:", "--end-of-options", sha)
26
38
  numstat = num_st.success? ? GitService.parse_numstat(num_out) : {}
27
39
 
28
- name_out.lines.filter_map do |line|
40
+ body.lines.filter_map do |line|
29
41
  parts = line.strip.split("\t", 2)
30
42
  next if parts.length < 2
31
43
 
@@ -34,13 +46,5 @@ module Mbeditor
34
46
  { "status" => parts[0].strip, "path" => path, "added" => stats[:added], "removed" => stats[:removed] }
35
47
  end
36
48
  end
37
-
38
- def fetch_meta
39
- out, status = GitService.run_git(repo_path, "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha)
40
- return { "title" => "", "author" => "", "date" => "" } unless status.success?
41
-
42
- fields = out.strip.split("\x1f", 3)
43
- { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
44
- end
45
49
  end
46
50
  end
@@ -53,8 +53,12 @@ module Mbeditor
53
53
  end
54
54
 
55
55
  # Return the file content at a given git ref, or "" if it doesn't exist.
56
+ #
57
+ # --end-of-options (git >= 2.24, safely assumable for dev tooling) stops the
58
+ # rev being read as a command-line option, e.g. `--output=/tmp/x`, on top of
59
+ # the caller-side ref validation.
56
60
  def file_at_ref(ref, path)
57
- out, status = GitService.run_git(repo_path, "show", "#{ref}:#{path}")
61
+ out, status = GitService.run_git(repo_path, "show", "--end-of-options", "#{ref}:#{path}")
58
62
  return "" unless status.success?
59
63
 
60
64
  out
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Mbeditor
4
6
  module GitInfoService
5
7
  module_function
@@ -16,19 +18,26 @@ module Mbeditor
16
18
  cached = cached_git_info(repo_path)
17
19
  return cached if cached
18
20
 
19
- owner = nil
21
+ flight = nil
20
22
  GIT_INFO_MUTEX.synchronize do
21
23
  @git_info_flights ||= {}
22
- owner = @git_info_flights[repo_path]
23
- @git_info_flights[repo_path] = Thread.current unless owner
24
+ flight = @git_info_flights[repo_path]
25
+ @git_info_flights[repo_path] = Queue.new unless flight
24
26
  end
25
27
 
26
- if owner
28
+ if flight
27
29
  # Another thread is already running the fan-out; don't duplicate it.
28
30
  stale = stale_git_info(repo_path)
29
31
  return stale if stale
30
32
 
31
- owner.join(CACHE_TTL)
33
+ # The owner closes this queue when it is done, which releases every
34
+ # waiter at once. Waiting on the owner *thread* was the old mechanism,
35
+ # but a Puma pool thread never exits, so join always ran to CACHE_TTL.
36
+ begin
37
+ Timeout.timeout(CACHE_TTL) { flight.pop }
38
+ rescue Timeout::Error
39
+ nil
40
+ end
32
41
  return cached_git_info(repo_path) || stale_git_info(repo_path) ||
33
42
  { ok: false, error: "git info computation in progress" }
34
43
  end
@@ -36,7 +45,8 @@ module Mbeditor
36
45
  begin
37
46
  compute(repo_path)
38
47
  ensure
39
- GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
48
+ done = GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
49
+ done&.close
40
50
  end
41
51
  end
42
52
 
@@ -47,8 +57,10 @@ module Mbeditor
47
57
  end
48
58
 
49
59
  # Wave 1: all independent git reads run in parallel
50
- status_t = Thread.new { safe_git(repo_path, "status", "--porcelain") }
51
- numstat_t = Thread.new { safe_git(repo_path, "diff", "--numstat", "HEAD") }
60
+ # -z on both: it is the only form that leaves paths with spaces or
61
+ # non-ASCII bytes unquoted, and the two lists are paired by path below.
62
+ status_t = Thread.new { safe_git(repo_path, "status", "--porcelain", "-z") }
63
+ numstat_t = Thread.new { safe_git(repo_path, "diff", "--numstat", "-z", "HEAD") }
52
64
  upstream_t = Thread.new { safe_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
53
65
  base_t = Thread.new { GitService.find_branch_base(repo_path, branch) }
54
66
 
@@ -32,11 +32,15 @@ module Mbeditor
32
32
  end
33
33
 
34
34
  def call
35
- return untracked_result if untracked?
36
-
37
35
  output, status = GitService.run_git(
38
36
  repo_path, "diff", "--no-color", "--no-ext-diff", "-U0", "HEAD", "--", file_path
39
37
  )
38
+
39
+ # ls-files only distinguishes "no changes" from "never tracked", so it
40
+ # runs solely when the diff produced nothing. Asking first doubled the
41
+ # subprocess count on a path the editor polls every 10s.
42
+ return untracked_result if (!status.success? || output.empty?) && untracked?
43
+
40
44
  # A non-zero status here means the diff could not be produced at all (no
41
45
  # HEAD yet, path outside the repo). Report a clean file rather than
42
46
  # raising: line colouring is decoration, never a reason to fail a load.
@@ -10,17 +10,19 @@ module Mbeditor
10
10
  module_function
11
11
 
12
12
  # Safe pattern for git ref names (branch, remote/branch, tag).
13
- # Excludes @ to prevent reflog syntax like @{-1} or @{u}.
14
- SAFE_GIT_REF = %r{\A[\w./-]+\z}
13
+ # Excludes @ to prevent reflog syntax like @{-1} or @{u}, and a leading -
14
+ # so a ref can never be read as a command-line option by the git it is
15
+ # interpolated into.
16
+ SAFE_GIT_REF = %r{\A(?!-)[\w./-]+\z}
15
17
 
16
18
  # Run an arbitrary git command inside +repo_path+.
17
19
  # Returns [stdout, Process::Status]. stderr is discarded to prevent git
18
20
  # diagnostic messages from leaking into the Rails server log.
19
21
  # Honors config.git_timeout (seconds) when set.
20
- def run_git(repo_path, *args)
22
+ def run_git(repo_path, *args, max_bytes: nil)
21
23
  timeout_secs = Mbeditor.configuration.git_timeout&.to_i
22
24
  timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
23
- result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
25
+ result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout, max_bytes: max_bytes)
24
26
  [result[:stdout], result[:exit_status]]
25
27
  rescue ProcessRunner::TimeoutError
26
28
  raise Timeout::Error, "git timed out after #{timeout_secs}s"
@@ -70,12 +72,18 @@ module Mbeditor
70
72
  def find_branch_base(repo_path, current_branch, candidates: nil)
71
73
  candidates ||= Mbeditor.configuration.base_branch_candidates
72
74
 
75
+ # One listing instead of a rev-parse per candidate: with six defaults
76
+ # that was up to twelve sequential spawns on a path the git panel polls.
77
+ refs_out, refs_st = run_git(repo_path, "for-each-ref", "--format=%(refname:short)",
78
+ "refs/heads", "refs/remotes")
79
+ return [nil, nil] unless refs_st.success?
80
+
81
+ existing = refs_out.split("\n").map(&:strip)
82
+
73
83
  candidates.each do |ref|
74
84
  short = ref.delete_prefix("origin/")
75
85
  next if short == current_branch || ref == current_branch
76
-
77
- _o, st = run_git(repo_path, "rev-parse", "--verify", "--quiet", ref)
78
- next unless st.success?
86
+ next unless existing.include?(ref)
79
87
 
80
88
  base_out, base_st = run_git(repo_path, "merge-base", "HEAD", ref)
81
89
  next unless base_st.success?
@@ -101,17 +109,34 @@ module Mbeditor
101
109
  candidates.any? { |ref| ref == current_branch || ref.delete_prefix("origin/") == current_branch }
102
110
  end
103
111
 
104
- # Parse `git status --porcelain` output.
112
+ # Parse `git status --porcelain` output, in either the newline form or the
113
+ # NUL-delimited `-z` form.
114
+ #
115
+ # Only `-z` gives usable paths: without it git quotes anything containing a
116
+ # space or a non-ASCII byte (`"caf\303\251.rb"`) and writes a rename as
117
+ # `old -> new`, so the path came back unopenable and never matched numstat.
118
+ # In `-z` a rename/copy spans two records and the NEW name comes first.
119
+ #
105
120
  # Returns Array of { status: String, path: String }.
106
121
  def parse_porcelain_status(output)
107
- output.lines.filter_map do |line|
108
- next if line.length < 4
109
-
110
- path = line[3..].to_s.strip
122
+ nul = output.include?("\0")
123
+ records = nul ? output.split("\0") : output.lines.map(&:chomp)
124
+
125
+ result = []
126
+ i = 0
127
+ while i < records.length
128
+ record = records[i]
129
+ i += 1
130
+ next if record.length < 4
131
+
132
+ status = record[0..1].strip
133
+ i += 1 if nul && status.start_with?("R", "C") # skip the old name
134
+ path = nul ? record[3..].to_s : record[3..].to_s.strip
111
135
  next if path.blank?
112
136
 
113
- { status: line[0..1].strip, path: path }
137
+ result << { status: status, path: path }
114
138
  end
139
+ result
115
140
  end
116
141
 
117
142
  # Parse `git diff --name-status` output.
@@ -129,10 +154,15 @@ module Mbeditor
129
154
  end
130
155
  end
131
156
 
132
- # Parse `git diff --numstat` output.
157
+ # Parse `git diff --numstat` output, in either the newline form or the
158
+ # NUL-delimited `-z` form (which is what pairs with `status --porcelain -z`,
159
+ # since only it leaves non-ASCII paths unquoted).
133
160
  # Returns Hash of path => { added: Integer, removed: Integer }.
134
161
  def parse_numstat(output)
135
- (output || "").lines.each_with_object({}) do |line, map|
162
+ out = (output || "")
163
+ return parse_numstat_z(out) if out.include?("\0")
164
+
165
+ out.lines.each_with_object({}) do |line, map|
136
166
  parts = line.strip.split("\t", 3)
137
167
  next if parts.length < 3 || parts[0] == "-"
138
168
 
@@ -140,6 +170,26 @@ module Mbeditor
140
170
  end
141
171
  end
142
172
 
173
+ # In `-z` form a rename leaves the path field empty and follows the record
174
+ # with the old name then the new name, each NUL-terminated.
175
+ def parse_numstat_z(output)
176
+ records = output.split("\0")
177
+ map = {}
178
+ i = 0
179
+ while i < records.length
180
+ added, removed, path = records[i].split("\t", 3)
181
+ i += 1
182
+ if path.to_s.empty?
183
+ path = records[i + 1]
184
+ i += 2
185
+ end
186
+ next if path.nil? || added == "-"
187
+
188
+ map[path] = { added: added.to_i, removed: removed.to_i }
189
+ end
190
+ map
191
+ end
192
+
143
193
  # Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
144
194
  # Returns Array of hashes with string keys.
145
195
  def self.parse_git_log_with_parents(raw_output)
@@ -27,6 +27,8 @@ module Mbeditor
27
27
  @symbol = symbol.to_s
28
28
  @workspace_root = workspace_root.to_s.chomp("/")
29
29
  @parent = parent.to_s.empty? ? nil : parent.to_s
30
+ # Compiled once: top_level? runs on every parsed result line.
31
+ @window_assign = /\Awindow\.#{Regexp.escape(@symbol)}\s*=/
30
32
  end
31
33
 
32
34
  def call
@@ -126,7 +128,7 @@ module Mbeditor
126
128
  # top-level by grammar.
127
129
  def top_level?(indent, snippet)
128
130
  return true if indent.empty?
129
- return true if snippet.match?(/\Awindow\.#{Regexp.escape(@symbol)}\s*=/)
131
+ return true if snippet.match?(@window_assign)
130
132
  return true if snippet.start_with?("export ")
131
133
 
132
134
  false
@@ -22,6 +22,17 @@ module Mbeditor
22
22
 
23
23
  IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/
24
24
 
25
+ # Runtime globals no static scan can reach, for the same reason React and
26
+ # lodash can't be reached: a UMD bundle assigns its name through a closure
27
+ # parameter. react-rails ships `root["ReactRailsUJS"] = factory()` in the
28
+ # *gem's* lib/assets/javascripts/react_ujs.js — outside the workspace, so
29
+ # it is never scanned, and bracket-assigned to a parameter, so widening
30
+ # PATTERN to `IDENT["Str"] =` would not help and would declare thousands
31
+ # of junk names out of every minified bundle. Seeded like
32
+ # js_global_identifiers, so both consumers (the TS worker's ambient
33
+ # declarations and the babel scope lint's whitelist) get them.
34
+ KNOWN_RUNTIME_GLOBALS = %w[ReactRailsUJS].freeze
35
+
25
36
  # Minified bundles are the reason for both guards below.
26
37
  #
27
38
  # A minified file is one enormous line, and it usually opens with a
@@ -67,6 +78,13 @@ module Mbeditor
67
78
  symbols = {}
68
79
  truncated = false
69
80
 
81
+ # Seeded first so the cap can never drop them: they are the names the
82
+ # host app declared it needs, and appending them after the scan meant
83
+ # first(MAX_SYMBOLS) silently discarded every one on a big workspace.
84
+ (KNOWN_RUNTIME_GLOBALS + configured_identifiers).each do |name|
85
+ symbols[name] = { name: name, file: nil, line: nil, kind: "configured" }
86
+ end
87
+
70
88
  CodeSearchService.call(PATTERN, root).each do |raw|
71
89
  if symbols.length >= MAX_SYMBOLS
72
90
  truncated = true
@@ -90,10 +108,6 @@ module Mbeditor
90
108
  end
91
109
  end
92
110
 
93
- configured_identifiers.each do |name|
94
- symbols[name] ||= { name: name, file: nil, line: nil, kind: "configured" }
95
- end
96
-
97
111
  {
98
112
  ok: true,
99
113
  generatedAt: Time.now.to_i,