mbeditor 0.11.0 → 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 +131 -0
- data/README.md +153 -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 +911 -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 +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -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 +465 -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/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 +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -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 +15 -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)
|
|
@@ -11,24 +11,57 @@ module Mbeditor
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
# LSP DiagnosticSeverity -> the severity strings cop_severity also emits.
|
|
14
|
-
|
|
14
|
+
# Mirrors ruby-lsp's own RUBOCOP_TO_LSP_SEVERITY in reverse: rubocop's
|
|
15
|
+
# `info` becomes HINT(4) and convention/refactor become INFORMATION(3), so
|
|
16
|
+
# the two must map back to distinct editor severities.
|
|
17
|
+
SEVERITIES = { 1 => "error", 2 => "warning", 3 => "info", 4 => "hint" }.freeze
|
|
15
18
|
|
|
16
19
|
# ruby-lsp appends this to non-correctable RuboCop messages; it's noise in
|
|
17
20
|
# a Monaco hover.
|
|
18
21
|
UNCORRECTABLE_SUFFIX = "This offense is not auto-correctable."
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
# Cops whose offense means "this code does nothing" rather than "this code
|
|
24
|
+
# is wrong". Monaco fades these out (MarkerTag.Unnecessary) instead of
|
|
25
|
+
# squiggling them, which is the whole point of greying out dead code.
|
|
26
|
+
#
|
|
27
|
+
# ruby-lsp emits no DiagnosticTag of its own, so the cop name is the only
|
|
28
|
+
# signal available — and it's the same signal on the plain-rubocop /lint
|
|
29
|
+
# path, which is why this predicate is public and shared by both.
|
|
30
|
+
#
|
|
31
|
+
# ponytail: name heuristic, not an enumeration. Upgrade path is an explicit
|
|
32
|
+
# cop list if a badly-named cop ever gets faded by mistake.
|
|
33
|
+
UNNECESSARY_COP = /Unused|Useless|Redundant|Unreachable|Deprecat/.freeze
|
|
34
|
+
|
|
35
|
+
# Prism reports the same dead code as Lint/UselessAssignment and
|
|
36
|
+
# Lint/UnreachableCode, but as a parser warning with no cop name — so both
|
|
37
|
+
# markers land on the same range. Fading only the RuboCop one leaves the
|
|
38
|
+
# Prism squiggle drawn on top and nothing looks greyed out at all.
|
|
39
|
+
UNNECESSARY_MESSAGE = /\Aassigned but unused variable|\Astatement not reached|\Aunused literal/.freeze
|
|
40
|
+
|
|
41
|
+
# Most actions a diagnostic can carry. RuboCop sends two (autocorrect and
|
|
42
|
+
# disable-for-this-line); the cap is a bound on a payload we don't control.
|
|
43
|
+
MAX_CODE_ACTIONS = 5
|
|
44
|
+
|
|
45
|
+
# Refuse to ship an absurd replacement into the browser. RuboCop's
|
|
46
|
+
# replacements are a few characters; anything near this is a bug or an
|
|
47
|
+
# attack, not a quick fix.
|
|
48
|
+
MAX_EDIT_BYTES = 64 * 1024
|
|
49
|
+
|
|
50
|
+
# +uri+ is the request's own document URI. Code actions are only accepted
|
|
51
|
+
# when every edit they carry targets exactly that document — see
|
|
52
|
+
# sanitize_code_actions.
|
|
53
|
+
def call(result, uri = nil)
|
|
21
54
|
items = case result
|
|
22
55
|
when Hash then Array(result["items"])
|
|
23
56
|
when Array then result
|
|
24
57
|
else []
|
|
25
58
|
end
|
|
26
59
|
|
|
27
|
-
markers = items.filter_map { |diag| translate_one(diag) }
|
|
60
|
+
markers = items.filter_map { |diag| translate_one(diag, uri) }
|
|
28
61
|
{ markers: markers, summary: { "offense_count" => markers.length } }
|
|
29
62
|
end
|
|
30
63
|
|
|
31
|
-
def translate_one(diag)
|
|
64
|
+
def translate_one(diag, uri = nil)
|
|
32
65
|
return nil unless diag.is_a?(Hash)
|
|
33
66
|
|
|
34
67
|
range = diag["range"] || {}
|
|
@@ -41,6 +74,7 @@ module Mbeditor
|
|
|
41
74
|
|
|
42
75
|
cop_name = extract_code(diag["code"])
|
|
43
76
|
message = clean_message(diag["message"])
|
|
77
|
+
fixes = sanitize_code_actions(diag, uri)
|
|
44
78
|
|
|
45
79
|
{
|
|
46
80
|
severity: SEVERITIES.fetch(diag["severity"], "info"),
|
|
@@ -53,10 +87,70 @@ module Mbeditor
|
|
|
53
87
|
source: rubocop_source?(diag["source"]) ? "rubocop" : diag["source"].to_s.downcase,
|
|
54
88
|
message: cop_name.empty? ? message : "[#{cop_name}] #{message}",
|
|
55
89
|
startLine: start_line, startCol: start_col,
|
|
56
|
-
endLine: end_line, endCol: end_col
|
|
90
|
+
endLine: end_line, endCol: end_col,
|
|
91
|
+
unnecessary: unnecessary?(cop_name, message),
|
|
92
|
+
# RuboCop >= 1.64 ships a docs URL per cop; Monaco renders it as a link
|
|
93
|
+
# on the marker's code. Absent for non-RuboCop diagnostics.
|
|
94
|
+
# NB: the LSP wire key is camelCase.
|
|
95
|
+
codeHref: diag.dig("codeDescription", "href")
|
|
96
|
+
}.tap { |marker| marker[:fixes] = fixes if fixes.any? }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ruby-lsp embeds the complete edits for each RuboCop fix in the diagnostic
|
|
100
|
+
# itself. Its textDocument/codeAction handler does nothing but echo these
|
|
101
|
+
# back (see Requests::CodeActions#perform), so lifting them here lets the
|
|
102
|
+
# editor apply a fix with no further request at all -- no codeAction call
|
|
103
|
+
# and no `rubocop -A` subprocess.
|
|
104
|
+
#
|
|
105
|
+
# This is a trust boundary: the payload arrives from a subprocess and is
|
|
106
|
+
# applied to the user's buffer. An action is taken only if every edit in it
|
|
107
|
+
# targets the very document we asked about.
|
|
108
|
+
def sanitize_code_actions(diag, uri)
|
|
109
|
+
return [] if uri.nil?
|
|
110
|
+
|
|
111
|
+
Array(diag.dig("data", "code_actions")).first(MAX_CODE_ACTIONS).filter_map do |action|
|
|
112
|
+
next unless action.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
changes = Array(action.dig("edit", "documentChanges"))
|
|
115
|
+
next if changes.empty?
|
|
116
|
+
# Any edit aimed at another file disqualifies the whole action. RuboCop
|
|
117
|
+
# fixes are always single-file, so this rejects nothing legitimate.
|
|
118
|
+
next unless changes.all? { |c| c.is_a?(Hash) && c.dig("textDocument", "uri") == uri }
|
|
119
|
+
|
|
120
|
+
edits = changes.flat_map { |c| Array(c["edits"]) }.filter_map { |edit| sanitize_edit(edit) }
|
|
121
|
+
next if edits.empty?
|
|
122
|
+
|
|
123
|
+
{ title: action["title"].to_s, edits: edits }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# One LSP TextEdit -> the 1-based shape the rest of the marker uses.
|
|
128
|
+
def sanitize_edit(edit)
|
|
129
|
+
return nil unless edit.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
text = edit["newText"].to_s
|
|
132
|
+
return nil if text.bytesize > MAX_EDIT_BYTES
|
|
133
|
+
|
|
134
|
+
range = edit["range"]
|
|
135
|
+
return nil unless range.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
startLine: (range.dig("start", "line") || 0) + 1,
|
|
139
|
+
startCol: (range.dig("start", "character") || 0) + 1,
|
|
140
|
+
endLine: (range.dig("end", "line") || range.dig("start", "line") || 0) + 1,
|
|
141
|
+
endCol: (range.dig("end", "character") || range.dig("start", "character") || 0) + 1,
|
|
142
|
+
text: text
|
|
57
143
|
}
|
|
58
144
|
end
|
|
59
145
|
|
|
146
|
+
def unnecessary?(cop_name, message = nil)
|
|
147
|
+
return true if UNNECESSARY_COP.match?(cop_name.to_s)
|
|
148
|
+
|
|
149
|
+
# Only consult the message when there is no cop to go on, so a RuboCop
|
|
150
|
+
# offense is never faded because of a phrase inside its description.
|
|
151
|
+
cop_name.to_s.empty? && UNNECESSARY_MESSAGE.match?(message.to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
60
154
|
def rubocop_source?(source)
|
|
61
155
|
source.to_s.match?(/rubocop/i)
|
|
62
156
|
end
|