mbeditor 0.10.1 → 0.12.0

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +481 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -2
@@ -117,35 +117,94 @@ module Mbeditor
117
117
  # installing e.g. rubocop or ripgrep while the server runs is picked up.
118
118
  NEGATIVE_PROBE_TTL = 60
119
119
 
120
- def self.rg
121
- probe_cached("rg") do
122
- _out, _err, status = Open3.capture3("rg", "--version")
123
- status.success?
120
+ # "Not found" is false for the boolean probes and nil for rg_command, which
121
+ # answers with a path instead. Both have to expire, or a tool installed
122
+ # after boot would never be picked up.
123
+ def self.negative_probe?(value)
124
+ value == false || value.nil?
125
+ end
126
+ private_class_method :negative_probe?
127
+
128
+ # Install prefixes checked when a bare "rg" isn't on the server's PATH.
129
+ # Homebrew (arm64 and intel), Linux distro packages, linuxbrew, cargo.
130
+ RG_FALLBACK_PATHS = [
131
+ "/opt/homebrew/bin/rg",
132
+ "/usr/local/bin/rg",
133
+ "/usr/bin/rg",
134
+ "/home/linuxbrew/.linuxbrew/bin/rg",
135
+ "~/.cargo/bin/rg"
136
+ ].freeze
137
+
138
+ # The ripgrep executable to run, or nil when there is none.
139
+ #
140
+ # Resolution order: config.ripgrep_command, then a bare "rg" on PATH, then
141
+ # the usual install prefixes. That last step is the point — see the note on
142
+ # config.ripgrep_command. Falling back to git grep costs 10-30x, and
143
+ # without this it happened silently whenever the server's PATH differed
144
+ # from the shell's.
145
+ def self.rg_command
146
+ probe_cached("rg_command") do
147
+ configured = Mbeditor.configuration.ripgrep_command.to_s.strip
148
+ candidates = configured.empty? ? ["rg", *RG_FALLBACK_PATHS] : [configured]
149
+
150
+ candidates.filter_map { |c| c.include?("/") ? File.expand_path(c) : c }
151
+ .find { |path| runnable_rg?(path) }
124
152
  end
125
153
  end
126
154
 
155
+ def self.runnable_rg?(path)
156
+ # A bare name is left to PATH resolution; an explicit path that isn't
157
+ # there is skipped without paying for a failed spawn.
158
+ return false if path.include?("/") && !File.executable?(path)
159
+
160
+ _out, _err, status = Open3.capture3(path, "--version")
161
+ status.success?
162
+ rescue StandardError
163
+ false
164
+ end
165
+ private_class_method :runnable_rg?
166
+
167
+ def self.rg
168
+ !rg_command.nil?
169
+ end
170
+
127
171
  def self.reset!
128
172
  MUTEX.synchronize { @cache = {} }
129
173
  nil
130
174
  end
131
175
 
176
+ # The probe itself runs OUTSIDE the lock. Every probe here spawns a
177
+ # subprocess, and a missing tool is re-probed once a minute forever
178
+ # (NEGATIVE_PROBE_TTL) — holding MUTEX across that spawn made one absent
179
+ # tool serialise every other availability check in the process, on a path
180
+ # that ruby-lsp and every search request go through.
181
+ #
182
+ # Racing threads may now run the same probe concurrently. That is fine:
183
+ # probes are read-only and idempotent, so the loser just overwrites an
184
+ # identical answer.
132
185
  def self.probe_cached(key)
133
- MUTEX.synchronize do
186
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+ cached = MUTEX.synchronize do
134
188
  @cache ||= {}
135
- entry = @cache[key]
136
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
-
138
- if entry.nil? || (entry[:value] == false && (now - entry[:ts]) > NEGATIVE_PROBE_TTL)
139
- value = begin
140
- yield
141
- rescue StandardError
142
- false
143
- end
144
- entry = @cache[key] = { value: value, ts: now }
145
- end
189
+ @cache[key]
190
+ end
146
191
 
147
- entry[:value]
192
+ if cached && !(negative_probe?(cached[:value]) && (now - cached[:ts]) > NEGATIVE_PROBE_TTL)
193
+ return cached[:value]
148
194
  end
195
+
196
+ value = begin
197
+ yield
198
+ rescue StandardError
199
+ false
200
+ end
201
+
202
+ MUTEX.synchronize do
203
+ @cache ||= {}
204
+ @cache[key] = { value: value, ts: Process.clock_gettime(Process::CLOCK_MONOTONIC) }
205
+ end
206
+
207
+ value
149
208
  end
150
209
  private_class_method :probe_cached
151
210
  end
@@ -8,6 +8,15 @@ module Mbeditor
8
8
  class CodeSearchService
9
9
  JS_GLOBS = %w[*.js *.jsx *.ts *.tsx *.js.jsx *.js.erb *.jsx.erb].freeze
10
10
 
11
+ # Minified bundles are skipped, matching JsProgramService and
12
+ # JsGlobalsService (which use the same [.-]min.ext convention as a Regexp).
13
+ # A bundle's globals are one-letter names inside a closure, so it can never
14
+ # hold the definition being looked for — but it is usually the largest file
15
+ # in the workspace and one enormous line, which is the worst case for the
16
+ # -E alternation these lookups run. Pure cost, no results.
17
+ MINIFIED_GLOBS = %w[*.min.js *.min.jsx *.min.ts *.min.tsx
18
+ *-min.js *-min.jsx *-min.ts *-min.tsx].freeze
19
+
11
20
  class << self
12
21
  def call(pattern, workspace_root, globs: JS_GLOBS)
13
22
  root = workspace_root.to_s
@@ -45,23 +54,33 @@ module Mbeditor
45
54
  end
46
55
 
47
56
  def run_rg(pattern, workspace_root, globs)
48
- args = ["rg", "--no-heading", "-n", "--color=never"]
57
+ args = [SearchReplaceService.rg_command, "--no-heading", "-n", "--color=never"]
49
58
  # Matches SearchReplaceService: both search paths honour the same
50
59
  # config so definition lookups and project search can't disagree about
51
60
  # which files exist.
52
61
  args << "--no-ignore" unless SearchReplaceService.respect_gitignore?
53
62
  args += ["-e", pattern]
54
63
  args += globs.flat_map { |g| ["-g", g] }
64
+ MINIFIED_GLOBS.each { |g| args << "--glob=!#{g}" }
55
65
  excluded_paths.each { |p| args << "--glob=!#{p}" }
56
66
  args << workspace_root
57
67
  run_command(args)
58
68
  end
59
69
 
70
+ # Exclusions must reach git as :(exclude) pathspecs, not just be filtered
71
+ # out of the results afterwards: without them git walks node_modules and
72
+ # every other excluded tree in full before we throw the matches away.
73
+ #
74
+ # No LC_ALL=C here. It is not free — the C locale sends git's -E engine
75
+ # down a slower path (measured 2.2x on this workspace), and the patterns
76
+ # built above are always -E.
60
77
  def run_git_grep(pattern, workspace_root, globs)
61
78
  gitignore_flag = SearchReplaceService.respect_gitignore? ? "--untracked" : "--no-index"
62
79
  args = ["git", "-C", workspace_root, "grep", "-I", "-n", "--no-color", gitignore_flag, "-E", "-e", pattern, "--"]
63
80
  args += globs
64
- lines = run_command(args, env: { "LC_ALL" => "C" })
81
+ args += MINIFIED_GLOBS.map { |g| ":(exclude)#{g}" }
82
+ args += excluded_paths.map { |p| ":(exclude)#{p}" }
83
+ lines = run_command(args)
65
84
  # git grep prints workspace-relative paths; callers expect absolute
66
85
  # (they re-relativize against workspace_root).
67
86
  lines.map { |l| "#{workspace_root}/#{l}" }
@@ -69,7 +88,8 @@ module Mbeditor
69
88
 
70
89
  def run_grep(pattern, workspace_root, globs)
71
90
  includes = globs.map { |g| "--include=#{g}" }
72
- args = ["grep", "-I", "-rn", "--color=never", "-E", pattern] + includes
91
+ excludes = MINIFIED_GLOBS.map { |g| "--exclude=#{g}" }
92
+ args = ["grep", "-I", "-rn", "--color=never", "-E", pattern] + includes + excludes
73
93
  excluded_paths.reject { |p| p.include?("/") }.select { |d| d.match?(/\A[\w.-]+\z/) }.each do |d|
74
94
  args << "--exclude-dir=#{d}"
75
95
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Thread-safe, in-memory cache of opaque Yjs bytes per active file. It never
5
+ # interprets the bytes (no Ruby CRDT): it only buffers the latest snapshot plus
6
+ # recent deltas so a late-joiner can sync instantly. Modeled on the existing
7
+ # TTL caches (GitInfoService, AvailabilityProbe): Mutex + monotonic clock.
8
+ module CollaborationDocStore
9
+ module_function
10
+
11
+ MUTEX = Mutex.new
12
+ private_constant :MUTEX
13
+
14
+ # Grace window (seconds): a room with no activity for longer than this is
15
+ # evicted by sweep!. Because every op refreshes last_activity, a room that
16
+ # just went empty survives this long, so a quick reopen recovers its buffer.
17
+ GRACE_TTL = 300
18
+
19
+ # Hard cap on cached rooms. Exceeding it evicts the least-recently-active
20
+ # room, so process memory stays bounded even if rooms are never swept.
21
+ ROOM_CAP = 200
22
+
23
+ # Idle GC rides on traffic: every write/read attempts a sweep, but the scan
24
+ # runs at most once per this interval so a busy room doesn't pay for it on
25
+ # every op. With no explicit scheduler, memory stays bounded over a long
26
+ # session as long as some activity continues. Kept well under GRACE_TTL so an
27
+ # idle room is reclaimed soon after its grace window elapses.
28
+ SWEEP_INTERVAL = 60
29
+
30
+ def record_update(path, bytes, now: monotonic)
31
+ MUTEX.synchronize do
32
+ room = touch(path, now)
33
+ room[:deltas] << bytes
34
+ end
35
+ nil
36
+ end
37
+
38
+ def replace_snapshot(path, bytes, now: monotonic)
39
+ MUTEX.synchronize do
40
+ room = touch(path, now)
41
+ room[:snapshot] = bytes
42
+ room[:deltas] = []
43
+ end
44
+ nil
45
+ end
46
+
47
+ def state_for(path, now: monotonic)
48
+ MUTEX.synchronize do
49
+ room = rooms[path]
50
+ if room
51
+ room[:last_activity] = now
52
+ result = { snapshot: room[:snapshot], deltas: room[:deltas].dup }
53
+ else
54
+ result = { snapshot: nil, deltas: [] }
55
+ end
56
+ maybe_sweep(now)
57
+ result
58
+ end
59
+ end
60
+
61
+ def sweep!(now: monotonic, grace: GRACE_TTL)
62
+ MUTEX.synchronize { evict_idle(now, grace) }
63
+ nil
64
+ end
65
+
66
+ def reset!
67
+ MUTEX.synchronize do
68
+ @rooms = {}
69
+ @last_sweep = nil
70
+ end
71
+ nil
72
+ end
73
+
74
+ def rooms
75
+ @rooms ||= {}
76
+ end
77
+ private_class_method :rooms
78
+
79
+ def touch(path, now)
80
+ new_room = !rooms.key?(path)
81
+ room = (rooms[path] ||= { snapshot: nil, deltas: [] })
82
+ room[:last_activity] = now
83
+ maybe_sweep(now)
84
+ evict_lru if new_room && rooms.size > ROOM_CAP
85
+ room
86
+ end
87
+ private_class_method :touch
88
+
89
+ # Opportunistic idle GC, throttled to one scan per SWEEP_INTERVAL. Callers hold
90
+ # MUTEX. The room that just touched its own last_activity has age 0, so a sweep
91
+ # never reclaims the room driving it.
92
+ def maybe_sweep(now)
93
+ return if @last_sweep && (now - @last_sweep) < SWEEP_INTERVAL
94
+
95
+ @last_sweep = now
96
+ evict_idle(now, GRACE_TTL)
97
+ end
98
+ private_class_method :maybe_sweep
99
+
100
+ def evict_idle(now, grace)
101
+ rooms.delete_if { |_path, room| (now - room[:last_activity]) > grace }
102
+ end
103
+ private_class_method :evict_idle
104
+
105
+ def evict_lru
106
+ oldest = rooms.min_by { |_path, room| room[:last_activity] }
107
+ rooms.delete(oldest.first) if oldest
108
+ end
109
+ private_class_method :evict_lru
110
+
111
+ def monotonic
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+ private_class_method :monotonic
115
+ end
116
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Mbeditor
7
+ # Writes files dragged into the explorer from outside the browser.
8
+ #
9
+ # Conflict handling is a two-pass protocol driven by the client. Pass one
10
+ # runs with on_conflict: :ask — every entry whose target is free is written,
11
+ # and the rest come back under :conflicts untouched. The client resolves
12
+ # them and re-sends just those entries with :overwrite or :rename. Because
13
+ # the existence check and the write happen inside the same call, the window
14
+ # is closed at the protocol level: the client never gets to check in one
15
+ # round trip and write in another. (File.exist? and File.open are still two
16
+ # syscalls, so this is not an atomic guarantee against other processes.)
17
+ #
18
+ # Callers must hand over target paths that have already cleared the
19
+ # controller's resolve_path / path_blocked_for_operations? guards; this
20
+ # service does no sandboxing of its own.
21
+ class FileImportService
22
+ MAX_FILE_SIZE_BYTES = FileOperationService::MAX_FILE_SIZE_BYTES
23
+ CONFLICT_MODES = %i[ask overwrite rename].freeze
24
+
25
+ def initialize(workspace_root)
26
+ @workspace_root = Pathname(workspace_root)
27
+ end
28
+
29
+ # entries: [{ target_path: <absolute String>, io: <#read, #rewind, #size> }]
30
+ # => { imported: [{path:, name:}], conflicts: [{path:}], errors: [{path:, error:}] }
31
+ def import(entries, on_conflict: :ask)
32
+ mode = on_conflict.to_s.to_sym
33
+ raise ArgumentError, "unknown on_conflict: #{on_conflict.inspect}" unless CONFLICT_MODES.include?(mode)
34
+
35
+ result = { imported: [], conflicts: [], errors: [] }
36
+ Array(entries).each { |entry| import_entry(entry, mode, result) }
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def import_entry(entry, mode, result)
43
+ target = entry[:target_path].to_s
44
+ io = entry[:io]
45
+
46
+ if io.size.to_i > MAX_FILE_SIZE_BYTES
47
+ return result[:errors] << {
48
+ path: relative_path(target),
49
+ error: "File is too large (limit is #{MAX_FILE_SIZE_BYTES / 1024 / 1024} MB)"
50
+ }
51
+ end
52
+
53
+ # A directory is never replaced, whatever the mode: overwriting one would
54
+ # mean deleting a subtree the user cannot see from the drop.
55
+ if File.directory?(target)
56
+ return result[:errors] << {
57
+ path: relative_path(target),
58
+ error: "A folder already exists at this path"
59
+ }
60
+ end
61
+
62
+ # Every bail-out has run by now, and that ordering is load-bearing:
63
+ # free_path picks its counter by probing the filesystem, so a batch
64
+ # renaming onto one name only works because each write lands before the
65
+ # next entry probes. Any new early return belongs above this line.
66
+ if File.exist?(target)
67
+ case mode
68
+ when :ask
69
+ return result[:conflicts] << { path: relative_path(target) }
70
+ when :rename
71
+ target = free_path(target)
72
+ end
73
+ end
74
+
75
+ write(target, io)
76
+ result[:imported] << { path: relative_path(target), name: File.basename(target) }
77
+ rescue SystemCallError, IOError => e
78
+ result[:errors] << { path: relative_path(entry[:target_path].to_s), error: e.message }
79
+ end
80
+
81
+ def write(target, io)
82
+ FileUtils.mkdir_p(File.dirname(target))
83
+ io.rewind
84
+ File.open(target, "wb") { |f| IO.copy_stream(io, f) }
85
+ end
86
+
87
+ # "logo.png" -> "logo 2.png" -> "logo 3.png" ...
88
+ def free_path(target)
89
+ dir = File.dirname(target)
90
+ ext = File.extname(target)
91
+ base = File.basename(target, ext)
92
+ counter = 2
93
+ counter += 1 while File.exist?(File.join(dir, "#{base} #{counter}#{ext}"))
94
+ File.join(dir, "#{base} #{counter}#{ext}")
95
+ end
96
+
97
+ def relative_path(path)
98
+ Pathname(path).relative_path_from(@workspace_root).to_s
99
+ rescue ArgumentError
100
+ path
101
+ end
102
+ end
103
+ end
@@ -22,6 +22,14 @@ module Mbeditor
22
22
  branch_diff
23
23
  end
24
24
 
25
+ # What the last #call actually compared against, for the UI to display.
26
+ # Set by #branch_diff; nil for :local scope.
27
+ attr_reader :base_ref
28
+
29
+ # Set when branch scope could not establish a base branch at all, so the
30
+ # caller can say why instead of rendering an empty diff as "No changes".
31
+ attr_reader :error
32
+
25
33
  private
26
34
 
27
35
  def local_diff
@@ -30,19 +38,42 @@ module Mbeditor
30
38
  end
31
39
 
32
40
  def branch_diff
33
- branch = GitService.current_branch(repo_path)
34
- base_sha, = GitService.find_branch_base(repo_path, branch)
41
+ branch = GitService.current_branch(repo_path)
42
+ base_sha, ref = GitService.find_branch_base(repo_path, branch)
35
43
 
36
44
  if base_sha.present?
45
+ @base_ref = ref
37
46
  out, status = GitService.run_git(repo_path, "diff", "#{base_sha}..HEAD")
38
47
  return status.success? ? cap_diff(out) : ""
39
48
  end
40
49
 
50
+ # No base branch resolved. Comparing against the upstream is only
51
+ # meaningful when this branch IS a base branch (develop vs origin/develop
52
+ # = "not yet pushed"). For a feature branch the upstream is
53
+ # origin/<same-branch>, so that diff compares the branch to itself and is
54
+ # reliably empty — which is how "Changes in Branch" came to show nothing
55
+ # on a branch that was many commits ahead of develop.
41
56
  upstream = GitService.upstream_branch(repo_path)
42
- return "" unless upstream.present?
57
+ if GitService.base_branch?(branch) && upstream.present?
58
+ @base_ref = upstream
59
+ out, status = GitService.run_git(repo_path, "diff", "#{upstream}..HEAD")
60
+ return status.success? ? cap_diff(out) : ""
61
+ end
43
62
 
44
- out, status = GitService.run_git(repo_path, "diff", "#{upstream}..HEAD")
45
- status.success? ? cap_diff(out) : ""
63
+ @error = base_unavailable_message(branch)
64
+ ""
65
+ end
66
+
67
+ def base_unavailable_message(branch)
68
+ if GitService.base_branch?(branch)
69
+ return "#{branch.inspect} is itself a base branch and has no upstream to compare against, " \
70
+ "so there is nothing to show here. Use \"Changes\" for uncommitted work."
71
+ end
72
+
73
+ candidates = Mbeditor.configuration.base_branch_candidates.join(", ")
74
+ "Could not determine a base branch to compare #{branch.inspect} against. " \
75
+ "None of these exist locally: #{candidates}. " \
76
+ "Fetch the base branch (git fetch origin) or set config.mbeditor.base_branch_candidates."
46
77
  end
47
78
 
48
79
  def cap_diff(out)
@@ -143,6 +143,12 @@ module Mbeditor
143
143
  unpushedCommits: unpushed_commits,
144
144
  branchCommits: branch_commits,
145
145
  branchBaseRef: base_ref,
146
+ # The merge-base sha the unpushedFiles list was actually computed
147
+ # against. The frontend needs this to diff an individual file against
148
+ # the same baseline — passing the ref name would compare the file to
149
+ # the tip of the base branch, and passing the upstream would compare
150
+ # the branch to itself.
151
+ branchBaseSha: diff_base,
146
152
  redmineTicketId: redmine_ticket_id
147
153
  }
148
154
  store_git_info(repo_path, payload)
@@ -55,13 +55,20 @@ module Mbeditor
55
55
  [parts[0].to_i, parts[1].to_i]
56
56
  end
57
57
 
58
- # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
59
- # or [nil, nil] if none can be determined. Candidates are tried in preference
60
- # order; skips the current branch and refs whose merge-base equals HEAD.
58
+ # Returns [merge_base_sha, ref_name] of the first candidate base branch that
59
+ # exists, or [nil, nil] when none does.
60
+ #
61
+ # Candidates are tried in preference order and the FIRST one that resolves
62
+ # wins, even when the merge-base turns out to be HEAD itself. That case
63
+ # means the branch is fully contained in its base — an empty "changes in
64
+ # branch" diff is then the truthful answer, and walking on to a
65
+ # lower-preference candidate would report a larger, wrong diff instead.
66
+ #
67
+ # A candidate naming the current branch is skipped: `develop` is not its own
68
+ # base. Callers handle that case by comparing against the upstream instead
69
+ # (see GitCombinedDiffService#branch_diff).
61
70
  def find_branch_base(repo_path, current_branch, candidates: nil)
62
71
  candidates ||= Mbeditor.configuration.base_branch_candidates
63
- head_sha_out, = run_git(repo_path, "rev-parse", "HEAD")
64
- head_sha = head_sha_out.strip
65
72
 
66
73
  candidates.each do |ref|
67
74
  short = ref.delete_prefix("origin/")
@@ -75,7 +82,6 @@ module Mbeditor
75
82
 
76
83
  sha = base_out.strip
77
84
  next unless sha.match?(/\A[0-9a-f]{40}\z/)
78
- next if sha == head_sha
79
85
 
80
86
  return [sha, ref]
81
87
  end
@@ -85,6 +91,16 @@ module Mbeditor
85
91
  [nil, nil]
86
92
  end
87
93
 
94
+ # True when the branch is itself one of the configured base branches, in
95
+ # which case "changes in branch" means "commits not yet pushed" and
96
+ # comparing against its own upstream is correct rather than degenerate.
97
+ def base_branch?(current_branch, candidates: nil)
98
+ return false if current_branch.to_s.empty? || current_branch == "HEAD"
99
+
100
+ candidates ||= Mbeditor.configuration.base_branch_candidates
101
+ candidates.any? { |ref| ref == current_branch || ref.delete_prefix("origin/") == current_branch }
102
+ end
103
+
88
104
  # Parse `git status --porcelain` output.
89
105
  # Returns Array of { status: String, path: String }.
90
106
  def parse_porcelain_status(output)
@@ -22,6 +22,24 @@ module Mbeditor
22
22
 
23
23
  IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/
24
24
 
25
+ # Minified bundles are the reason for both guards below.
26
+ #
27
+ # A minified file is one enormous line, and it usually opens with a
28
+ # multi-declarator `var a,b,c,d,…` running to thousands of names. Split on
29
+ # commas, that ONE line yields thousands of one-letter symbols — enough to
30
+ # exhaust MAX_SYMBOLS on its own, so the workspace's actual components are
31
+ # never reached and every reference to them shows "Cannot find name".
32
+ # Worse, declaring `a`/`n`/`t` as ambient `any` silences genuine
33
+ # diagnostics for those names everywhere.
34
+ #
35
+ # The name check catches the conventional cases; the line-length check
36
+ # catches bundles that don't say "min" in the filename. Neither is a
37
+ # judgement about vendored code in general — a normally-formatted
38
+ # vendor/assets library still contributes its globals, which is correct
39
+ # under Sprockets.
40
+ MINIFIED_NAME = /[.\-]min\.(js|jsx|ts|tsx)\z/i
41
+ MAX_LINE_LENGTH = 2_000
42
+
25
43
  MUTEX = Mutex.new
26
44
  private_constant :MUTEX
27
45
 
@@ -47,19 +65,27 @@ module Mbeditor
47
65
 
48
66
  def compute(root)
49
67
  symbols = {}
68
+ truncated = false
50
69
 
51
70
  CodeSearchService.call(PATTERN, root).each do |raw|
52
- break if symbols.length >= MAX_SYMBOLS
71
+ if symbols.length >= MAX_SYMBOLS
72
+ truncated = true
73
+ break
74
+ end
53
75
 
54
76
  m = raw.chomp.match(/\A(.+?):(\d+):(.*)\z/m)
55
77
  next unless m
56
78
 
57
79
  abs_path = m[1]
58
80
  next unless abs_path.start_with?(root)
81
+ next if abs_path.match?(MINIFIED_NAME)
82
+
83
+ snippet = m[3].strip
84
+ next if snippet.length > MAX_LINE_LENGTH
59
85
 
60
86
  rel = abs_path.delete_prefix(root).delete_prefix("/")
61
87
  line = m[2].to_i
62
- extract_identifiers(m[3].strip).each do |name, kind|
88
+ extract_identifiers(snippet).each do |name, kind|
63
89
  symbols[name] ||= { name: name, file: rel, line: line, kind: kind }
64
90
  end
65
91
  end
@@ -71,6 +97,9 @@ module Mbeditor
71
97
  {
72
98
  ok: true,
73
99
  generatedAt: Time.now.to_i,
100
+ # Surfaced so a workspace that outgrows the cap is diagnosable from
101
+ # the endpoint instead of silently missing globals.
102
+ truncated: truncated,
74
103
  symbols: symbols.values.first(MAX_SYMBOLS).sort_by { |s| s[:name] }
75
104
  }
76
105
  end