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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +191 -0
- data/README.md +226 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +68 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +481 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/js_globals_service.rb +31 -2
- data/app/services/mbeditor/js_program_service.rb +173 -0
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +43 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +6 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- 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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
186
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
cached = MUTEX.synchronize do
|
|
134
188
|
@cache ||= {}
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
59
|
-
# or [nil, nil]
|
|
60
|
-
#
|
|
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
|
-
|
|
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(
|
|
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
|