mbeditor 0.13.1 → 0.14.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 +91 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
- data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
- data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
- data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +38 -39
- data/app/assets/javascripts/mbeditor/git_service.js +15 -1
- data/app/assets/javascripts/mbeditor/history_service.js +5 -13
- data/app/assets/javascripts/mbeditor/search_service.js +9 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
- data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
- data/app/assets/stylesheets/mbeditor/application.css +6 -1
- data/app/assets/stylesheets/mbeditor/editor.css +642 -248
- data/app/assets/stylesheets/mbeditor/glass.css +163 -0
- data/app/assets/stylesheets/mbeditor/themes.css +90 -30
- data/app/channels/mbeditor/collaboration_channel.rb +17 -4
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +117 -439
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
- data/app/services/mbeditor/editor_state_service.rb +14 -51
- data/app/services/mbeditor/file_history_service.rb +222 -0
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
- data/app/services/mbeditor/lint_service.rb +137 -0
- data/app/services/mbeditor/locked_json_file.rb +67 -0
- data/app/services/mbeditor/process_runner.rb +32 -0
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/search_replace_service.rb +8 -0
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -1
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -0
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- metadata +12 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Translates ruby-lsp results for the definition/hover/completion/raw-passthrough
|
|
5
|
+
# kinds into the shapes their existing consumers expect. Diagnostics have
|
|
6
|
+
# their own translator (LspDiagnosticsTranslator) with a different interface
|
|
7
|
+
# — no workspace_root, a different result shape — and are not handled here;
|
|
8
|
+
# see CLAUDE.md's "Ruby intelligence: ruby-lsp" section for why the two
|
|
9
|
+
# conventions live side by side.
|
|
10
|
+
#
|
|
11
|
+
# +workspace_root+ is a Pathname, the same one EditorsController#workspace_root
|
|
12
|
+
# resolves. This is the trust boundary every one of these methods sits on:
|
|
13
|
+
# an in-workspace file:// URI becomes a workspace-relative path, anything
|
|
14
|
+
# else is dropped — a reference in a gem is not something this editor can
|
|
15
|
+
# open, and a path outside the root is not something it should hand to the
|
|
16
|
+
# browser at all.
|
|
17
|
+
module RubyLspResultTranslator
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Recursion bound for #raw: the payload comes from a subprocess, and a
|
|
21
|
+
# cyclic or pathologically nested one must not take the request thread
|
|
22
|
+
# down with it.
|
|
23
|
+
MAX_LSP_DEPTH = 32
|
|
24
|
+
|
|
25
|
+
URI_KEYS = %w[uri targetUri].freeze
|
|
26
|
+
|
|
27
|
+
# URI::DEFAULT_PARSER became the RFC3986 parser in Ruby 4.0, where #unescape
|
|
28
|
+
# is deprecated; URI::RFC2396_PARSER only exists from Ruby 3.4. The gem
|
|
29
|
+
# supports >= 3.0, so take whichever this Ruby has.
|
|
30
|
+
URI_UNESCAPER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
|
|
31
|
+
|
|
32
|
+
LSP_COMPLETION_KINDS = {
|
|
33
|
+
2 => "Method", 3 => "Function", 4 => "Constructor", 5 => "Field",
|
|
34
|
+
6 => "Variable", 7 => "Class", 8 => "Interface", 9 => "Module",
|
|
35
|
+
10 => "Property", 14 => "Keyword", 15 => "Snippet", 21 => "Constant"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
|
|
39
|
+
# `[user.rb](file:///abs/path/user.rb#L3,1-9,4)`. Monaco renders those as
|
|
40
|
+
# links but clicking one does nothing, since nothing can open a file:// URI
|
|
41
|
+
# here. Point in-workspace links at the `mbeditor.openDefinition` Monaco
|
|
42
|
+
# command (registered in editor_plugins.js) and demote gem/stdlib links —
|
|
43
|
+
# which the editor cannot open at all — to plain code spans.
|
|
44
|
+
LSP_HOVER_FILE_LINK = %r{\[([^\]\n]+)\]\(file://([^)\s#]+)(?:\#L(\d+),\d+(?:-\d+,\d+)?)?\)}
|
|
45
|
+
|
|
46
|
+
def definition(result, workspace_root:)
|
|
47
|
+
{ results: translate_lsp_locations(result, workspace_root) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def hover(result, workspace_root:)
|
|
51
|
+
{ markdown: translate_lsp_hover(result, workspace_root) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def completion(result)
|
|
55
|
+
{ suggestions: translate_lsp_completions(result) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The one trust boundary for every raw-passthrough method (references,
|
|
59
|
+
# documentHighlight, documentSymbol, foldingRange, formatting,
|
|
60
|
+
# signatureHelp, selectionRange, prepareRename). Walks the LSP response and
|
|
61
|
+
# rewrites each file:// URI to a workspace-relative path, dropping any
|
|
62
|
+
# object that points outside the workspace.
|
|
63
|
+
def raw(result, workspace_root:)
|
|
64
|
+
{ result: sanitize_lsp_uris(result, workspace_root) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sanitize_lsp_uris(node, workspace_root, depth = 0)
|
|
68
|
+
return nil if depth > MAX_LSP_DEPTH
|
|
69
|
+
|
|
70
|
+
case node
|
|
71
|
+
when Array
|
|
72
|
+
node.filter_map { |child| sanitize_lsp_uris(child, workspace_root, depth + 1) }
|
|
73
|
+
when Hash
|
|
74
|
+
sanitized = {}
|
|
75
|
+
node.each do |key, value|
|
|
76
|
+
if URI_KEYS.include?(key) && value.is_a?(String)
|
|
77
|
+
rel = workspace_relative_uri(value, workspace_root)
|
|
78
|
+
# A URI we can't place inside the workspace disqualifies its object.
|
|
79
|
+
return nil unless rel
|
|
80
|
+
|
|
81
|
+
sanitized[key] = rel
|
|
82
|
+
else
|
|
83
|
+
child = sanitize_lsp_uris(value, workspace_root, depth + 1)
|
|
84
|
+
sanitized[key] = child unless child.nil? && !value.nil?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
sanitized
|
|
88
|
+
when String
|
|
89
|
+
sanitize_lsp_markdown(node, workspace_root)
|
|
90
|
+
else
|
|
91
|
+
node
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# URIs also turn up *inside* strings: ruby-lsp's signatureHelp and hover
|
|
96
|
+
# documentation embed a "Definitions" line of file:// markdown links. Those
|
|
97
|
+
# would leak absolute host paths and render as links that go nowhere, so
|
|
98
|
+
# they get the same treatment hover already gives them.
|
|
99
|
+
def sanitize_lsp_markdown(text, workspace_root)
|
|
100
|
+
return text unless text.include?("file://")
|
|
101
|
+
|
|
102
|
+
rewritten = rewrite_lsp_hover_links(text, workspace_root)
|
|
103
|
+
return rewritten unless rewritten.include?("file://")
|
|
104
|
+
|
|
105
|
+
# Backstop for any file:// URI that wasn't in markdown-link form. An
|
|
106
|
+
# absolute host path must never reach the browser, linkable or not.
|
|
107
|
+
rewritten.gsub(%r{file://\S*}) do |raw|
|
|
108
|
+
workspace_relative_uri(raw.sub(/[)\]\s].*\z/m, ""), workspace_root) || "(external)"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def workspace_relative_uri(uri, workspace_root)
|
|
113
|
+
return nil unless uri.start_with?("file://")
|
|
114
|
+
|
|
115
|
+
# ruby-lsp percent-escapes its URIs; workspace_root is a raw path. A
|
|
116
|
+
# checkout with a space (or any other escaped character) in its path
|
|
117
|
+
# matched nothing here, so every result was silently dropped.
|
|
118
|
+
path = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
119
|
+
prefix = "#{workspace_root}/"
|
|
120
|
+
return nil unless path.start_with?(prefix)
|
|
121
|
+
|
|
122
|
+
path.delete_prefix(prefix)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def translate_lsp_locations(result, workspace_root)
|
|
126
|
+
items = result.is_a?(Array) ? result : [result].compact
|
|
127
|
+
root = workspace_root.to_s
|
|
128
|
+
items.filter_map do |loc|
|
|
129
|
+
next unless loc.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
uri = loc["uri"] || loc["targetUri"]
|
|
132
|
+
range = loc["range"] || loc["targetSelectionRange"] || loc["targetRange"]
|
|
133
|
+
next unless uri.to_s.start_with?("file://")
|
|
134
|
+
|
|
135
|
+
fpath = URI_UNESCAPER.unescape(uri.delete_prefix("file://"))
|
|
136
|
+
# Drop gem/stdlib locations the editor can't open; an empty list makes
|
|
137
|
+
# the frontend fall back to the legacy services (ri covers stdlib).
|
|
138
|
+
next unless fpath.start_with?("#{root}/")
|
|
139
|
+
|
|
140
|
+
start_line = (range&.dig("start", "line") || 0) + 1
|
|
141
|
+
{
|
|
142
|
+
file: fpath.delete_prefix("#{root}/"),
|
|
143
|
+
line: start_line,
|
|
144
|
+
# Columns and the end of the range are additive: existing consumers
|
|
145
|
+
# only read :file and :line, but peek-definition needs a real range
|
|
146
|
+
# to highlight rather than the start of the line.
|
|
147
|
+
col: (range&.dig("start", "character") || 0) + 1,
|
|
148
|
+
endLine: (range&.dig("end", "line") || range&.dig("start", "line") || 0) + 1,
|
|
149
|
+
endCol: (range&.dig("end", "character") || range&.dig("start", "character") || 0) + 1
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def translate_lsp_hover(result, workspace_root)
|
|
155
|
+
contents = result.is_a?(Hash) ? result["contents"] : nil
|
|
156
|
+
return nil if contents.nil?
|
|
157
|
+
|
|
158
|
+
markdown =
|
|
159
|
+
case contents
|
|
160
|
+
when Hash then contents["value"].to_s
|
|
161
|
+
when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
|
|
162
|
+
else contents.to_s
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
neutralize_comment_headings(rewrite_lsp_hover_links(markdown, workspace_root))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ruby-lsp renders a doc comment by stripping exactly one leading "# " from
|
|
169
|
+
# each line, then hands the result to the editor as markdown. A `##`-opened
|
|
170
|
+
# doc block — a very common Ruby convention — therefore arrives as
|
|
171
|
+
# "# Title" and renders as an <h1> filling the hover.
|
|
172
|
+
#
|
|
173
|
+
# Ruby comments are not markdown, so escape a `#` that opens a line and let
|
|
174
|
+
# it render as the text it is. Fenced code blocks are left alone: `#` inside
|
|
175
|
+
# them is Ruby source, not a heading, and needs no escaping.
|
|
176
|
+
#
|
|
177
|
+
# This deliberately diverges from other ruby-lsp clients, which show the
|
|
178
|
+
# heading.
|
|
179
|
+
def neutralize_comment_headings(markdown)
|
|
180
|
+
in_fence = false
|
|
181
|
+
markdown.lines.map do |line|
|
|
182
|
+
in_fence = !in_fence if line.start_with?("```")
|
|
183
|
+
next line if in_fence || line.start_with?("```")
|
|
184
|
+
|
|
185
|
+
line.sub(/\A(\s*)(#+)(?=\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
|
|
186
|
+
end.join
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def rewrite_lsp_hover_links(markdown, workspace_root)
|
|
190
|
+
prefix = "#{workspace_root}/"
|
|
191
|
+
markdown.gsub(LSP_HOVER_FILE_LINK) do
|
|
192
|
+
label, raw_path, line = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3).to_i
|
|
193
|
+
# Percent-decode by hand: URI's unescape helpers are deprecated on new
|
|
194
|
+
# Rubies and their replacements are missing on the old ones we support.
|
|
195
|
+
# (Must come after reading the other captures — gsub resets last_match.)
|
|
196
|
+
path = raw_path.gsub(/%\h\h/) { |esc| esc[1..].hex.chr }.force_encoding(Encoding::UTF_8)
|
|
197
|
+
|
|
198
|
+
if path.start_with?(prefix)
|
|
199
|
+
args = [path.delete_prefix(prefix), line.positive? ? line : 1]
|
|
200
|
+
"[#{label}](command:mbeditor.openDefinition?#{ERB::Util.url_encode(args.to_json)})"
|
|
201
|
+
else
|
|
202
|
+
"`#{label}`"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def translate_lsp_completions(result)
|
|
208
|
+
items = result.is_a?(Hash) ? Array(result["items"]) : Array(result)
|
|
209
|
+
items.first(100).filter_map do |item|
|
|
210
|
+
next unless item.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
label: item["label"].to_s,
|
|
214
|
+
kind: lsp_completion_kind(item["kind"]),
|
|
215
|
+
insertText: (item.dig("textEdit", "newText") || item["insertText"] || item["label"]).to_s,
|
|
216
|
+
detail: item["detail"].to_s,
|
|
217
|
+
isSnippet: item["insertTextFormat"] == 2
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def lsp_completion_kind(kind)
|
|
223
|
+
LSP_COMPLETION_KINDS[kind] || "Text"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -229,6 +229,7 @@ module Mbeditor
|
|
|
229
229
|
end
|
|
230
230
|
|
|
231
231
|
def scan(workspace_root, query, use_regex:, match_case:, whole_word:, excluded_paths:, paths:, max:, supersede: false)
|
|
232
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
233
|
root = workspace_root.to_s
|
|
233
234
|
tier = pick_tier(root)
|
|
234
235
|
env, args = build_command(tier, root, query, use_regex: use_regex, match_case: match_case,
|
|
@@ -290,6 +291,13 @@ module Mbeditor
|
|
|
290
291
|
complete: !timed_out && !superseded && results.length < max,
|
|
291
292
|
superseded: superseded
|
|
292
293
|
}
|
|
294
|
+
ensure
|
|
295
|
+
# One record per real search — cache hits never reach here. `backend` is
|
|
296
|
+
# the tier that actually ran, `hits` the number of matched lines. Never
|
|
297
|
+
# the query and never a path.
|
|
298
|
+
AuditLog.record(:search,
|
|
299
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
300
|
+
backend: tier, hits: results&.length || 0)
|
|
293
301
|
end
|
|
294
302
|
|
|
295
303
|
def pick_tier(root)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Mbeditor
|
|
7
|
+
# A bounded, in-memory audit/telemetry log the developer can download and
|
|
8
|
+
# hand to an AI to analyse, plus the batches the browser ring pushes up.
|
|
9
|
+
#
|
|
10
|
+
# Lives in lib/ (required from lib/mbeditor.rb, NOT autoloaded) for the same
|
|
11
|
+
# reason as ExceptionLog: a Zeitwerk reload must not wipe the buffer you are
|
|
12
|
+
# about to download.
|
|
13
|
+
#
|
|
14
|
+
# The privacy guarantee is structural, not a scrubber. A field value is kept
|
|
15
|
+
# only when it is Numeric, true, false or a Symbol. A Symbol is authored in
|
|
16
|
+
# code and can never be user data; a String can, so Strings are rejected
|
|
17
|
+
# outright. The client mirrors this rule by dropping any argument to rec()
|
|
18
|
+
# whose typeof is not 'number'. Neither channel can carry a path, a file
|
|
19
|
+
# name, a URL or a line of source.
|
|
20
|
+
module AuditLog
|
|
21
|
+
MAX_ENTRIES = 5000
|
|
22
|
+
|
|
23
|
+
# One client batch. The browser ring holds 512, so this only bounds a
|
|
24
|
+
# forged or replayed POST.
|
|
25
|
+
MAX_INGEST = 2048
|
|
26
|
+
|
|
27
|
+
# Largest body the ingest endpoint will parse. 512 five-number entries plus
|
|
28
|
+
# the legend is well under this.
|
|
29
|
+
MAX_POST_BYTES = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
ENTRY_LENGTH = 5
|
|
32
|
+
|
|
33
|
+
# Refuse to read back a file larger than the two rings could ever produce.
|
|
34
|
+
MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
# Write-behind: at most one file write every PERSIST_INTERVAL seconds.
|
|
37
|
+
PERSIST_INTERVAL = 5
|
|
38
|
+
|
|
39
|
+
MUTEX = Mutex.new
|
|
40
|
+
private_constant :MUTEX
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def enabled?
|
|
44
|
+
Mbeditor.configuration.audit_log != false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the recorded entry, or nil when nothing was recorded.
|
|
48
|
+
def record(event, **fields)
|
|
49
|
+
return nil unless enabled?
|
|
50
|
+
return nil unless event.is_a?(Symbol)
|
|
51
|
+
|
|
52
|
+
entry = { at: Time.now.utc.iso8601, event: event }
|
|
53
|
+
fields.each { |key, value| entry[key] = value if safe?(value) }
|
|
54
|
+
|
|
55
|
+
# try_lock, not synchronize, and no file write. ProcessRunner is the
|
|
56
|
+
# choke point every subprocess goes through, including AvailabilityProbe's
|
|
57
|
+
# concurrent probes, which are deliberately run outside that service's own
|
|
58
|
+
# mutex so they do not serialise (see CLAUDE.md). Blocking them on a
|
|
59
|
+
# telemetry lock would put that back. A dropped sample under contention
|
|
60
|
+
# is the right trade: contention is exactly when blocking would hurt, and
|
|
61
|
+
# an uncontended record — the normal case — never drops.
|
|
62
|
+
return nil unless MUTEX.try_lock
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
load_unlocked
|
|
66
|
+
bound(@entries << entry)
|
|
67
|
+
@dirty = true
|
|
68
|
+
ensure
|
|
69
|
+
MUTEX.unlock
|
|
70
|
+
end
|
|
71
|
+
entry
|
|
72
|
+
# Every caller is an `ensure` block wrapping real work. A raise here would
|
|
73
|
+
# replace the exception that block is unwinding, so a telemetry failure
|
|
74
|
+
# would masquerade as the bug it was recording.
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Rails.logger.debug("[mbeditor] audit record failed: #{e.class}: #{e.message}")
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A batch from the browser ring. Returns the number of entries kept.
|
|
81
|
+
#
|
|
82
|
+
# Deliberately takes no legend. The legend is a constant of the browser
|
|
83
|
+
# bundle, so shipping it up here would be the one channel in this module
|
|
84
|
+
# that accepts an arbitrary String, and the guarantee above would be a
|
|
85
|
+
# claim rather than a property. The download handler merges it in client
|
|
86
|
+
# side instead, where it never crosses the wire at all.
|
|
87
|
+
def ingest(events)
|
|
88
|
+
return nil unless enabled?
|
|
89
|
+
return nil unless events.is_a?(Array)
|
|
90
|
+
|
|
91
|
+
kept = events.first(MAX_INGEST).select { |entry| client_entry?(entry) }
|
|
92
|
+
|
|
93
|
+
MUTEX.synchronize do
|
|
94
|
+
load_unlocked
|
|
95
|
+
bound(@client_events.concat(kept))
|
|
96
|
+
@dirty = true
|
|
97
|
+
end
|
|
98
|
+
persist!
|
|
99
|
+
kept.length
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Rails.logger.debug("[mbeditor] audit ingest failed: #{e.class}: #{e.message}")
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Persisting is driven from ingest and payload, never from record: the
|
|
106
|
+
# client flush is already batched and the download is a deliberate act,
|
|
107
|
+
# whereas record runs inside every request and subprocess.
|
|
108
|
+
#
|
|
109
|
+
# The download payload. Persists unconditionally first, so the file on
|
|
110
|
+
# disk always matches what the developer just downloaded.
|
|
111
|
+
def payload
|
|
112
|
+
persist!(force: true)
|
|
113
|
+
MUTEX.synchronize do
|
|
114
|
+
load_unlocked
|
|
115
|
+
{
|
|
116
|
+
schema: 1,
|
|
117
|
+
generatedAt: Time.now.utc.iso8601,
|
|
118
|
+
client: { events: @client_events.dup },
|
|
119
|
+
server: { events: @entries.dup }
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def clear!
|
|
125
|
+
MUTEX.synchronize do
|
|
126
|
+
@loaded = true
|
|
127
|
+
@entries = []
|
|
128
|
+
@client_events = []
|
|
129
|
+
@dirty = false
|
|
130
|
+
@persisted_at = nil
|
|
131
|
+
end
|
|
132
|
+
path = file_path
|
|
133
|
+
File.delete(path) if path && File.exist?(path)
|
|
134
|
+
nil
|
|
135
|
+
rescue SystemCallError, IOError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def safe?(value)
|
|
142
|
+
value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Numbers only, fixed width. A string, a nested object or a short array
|
|
146
|
+
# is dropped rather than repaired.
|
|
147
|
+
def client_entry?(entry)
|
|
148
|
+
entry.is_a?(Array) && entry.length == ENTRY_LENGTH &&
|
|
149
|
+
entry.all? { |value| value.is_a?(Numeric) && value.finite? }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bound(ring)
|
|
153
|
+
ring.shift while ring.length > MAX_ENTRIES
|
|
154
|
+
ring
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Serialises under the lock and writes outside it: the file runs to a
|
|
158
|
+
# megabyte and a request should never wait on that IO.
|
|
159
|
+
def persist!(force: false)
|
|
160
|
+
json = MUTEX.synchronize do
|
|
161
|
+
load_unlocked
|
|
162
|
+
next nil unless force || (@dirty && Time.now.to_f - (@persisted_at || 0) >= PERSIST_INTERVAL)
|
|
163
|
+
|
|
164
|
+
@dirty = false
|
|
165
|
+
@persisted_at = Time.now.to_f
|
|
166
|
+
JSON.generate("schema" => 1, "client" => @client_events, "server" => @entries)
|
|
167
|
+
end
|
|
168
|
+
return if json.nil?
|
|
169
|
+
|
|
170
|
+
path = file_path
|
|
171
|
+
File.write(path, json) if path
|
|
172
|
+
nil
|
|
173
|
+
rescue SystemCallError, IOError => e
|
|
174
|
+
Rails.logger.debug("[mbeditor] audit log persist failed: #{e.class}: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Loaded once, lazily. Surviving a page reload is the point of persisting,
|
|
178
|
+
# and the server process may have restarted in between.
|
|
179
|
+
def load_unlocked
|
|
180
|
+
return if @loaded
|
|
181
|
+
|
|
182
|
+
@loaded = true
|
|
183
|
+
@entries = []
|
|
184
|
+
@client_events = []
|
|
185
|
+
|
|
186
|
+
path = file_path
|
|
187
|
+
return unless path && File.exist?(path) && File.size(path) <= MAX_FILE_BYTES
|
|
188
|
+
|
|
189
|
+
data = JSON.parse(File.read(path))
|
|
190
|
+
return unless data.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
@entries = bound(Array(data["server"]).grep(Hash).map { |raw| raw.transform_keys(&:to_sym) })
|
|
193
|
+
@client_events = bound(Array(data["client"]).select { |raw| client_entry?(raw) })
|
|
194
|
+
rescue JSON::ParserError, SystemCallError, IOError => e
|
|
195
|
+
Rails.logger.debug("[mbeditor] audit log load failed: #{e.class}: #{e.message}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def file_path
|
|
199
|
+
Rails.root && Rails.root.join("tmp", "mbeditor_audit.json").to_s
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -12,7 +12,7 @@ module Mbeditor
|
|
|
12
12
|
:js_program, :js_program_exclude,
|
|
13
13
|
:js_syntax_check, :babel_standalone_path, :js_scope_lint,
|
|
14
14
|
:ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
|
|
15
|
-
:exception_capture, :model_graph_max_models,
|
|
15
|
+
:exception_capture, :audit_log, :model_graph_max_models,
|
|
16
16
|
:search_respect_gitignore, :ripgrep_command
|
|
17
17
|
|
|
18
18
|
def initialize
|
|
@@ -98,6 +98,11 @@ module Mbeditor
|
|
|
98
98
|
# same exposure the log panel already has, since it tails the dev log.
|
|
99
99
|
# Set to false to record nothing.
|
|
100
100
|
@exception_capture = :auto
|
|
101
|
+
# Record an audit/telemetry log the developer can download and hand to an
|
|
102
|
+
# AI to analyse. It carries numbers only — no code, file names, URLs or
|
|
103
|
+
# host paths — because both the browser ring and AuditLog.record refuse
|
|
104
|
+
# to store a String at all. Set to false to record nothing.
|
|
105
|
+
@audit_log = true
|
|
101
106
|
# Models drawn in the model graph before it reports itself truncated.
|
|
102
107
|
# nil uses ModelGraphService::DEFAULT_MAX_MODELS.
|
|
103
108
|
@model_graph_max_models = nil
|
|
@@ -46,34 +46,40 @@ module Mbeditor
|
|
|
46
46
|
|
|
47
47
|
# Announced on every bypassed response so the editor can warn about a
|
|
48
48
|
# migration created mid-session, not only one that existed at page load.
|
|
49
|
+
#
|
|
50
|
+
# It is always present, "1" or "0" — an omitted header is NOT the same as
|
|
51
|
+
# "no longer pending". These responses are conditional-GET cacheable, and
|
|
52
|
+
# a 304 only *updates* the stored headers of the cached 200: a header the
|
|
53
|
+
# 304 leaves out stays in the cache verbatim. Omitting it when the check
|
|
54
|
+
# passed therefore replayed a stale "pending" to the browser for as long
|
|
55
|
+
# as the cache entry lived — which is exactly what made the warning stick
|
|
56
|
+
# after `rails db:migrate`, and flicker as cached and fresh responses
|
|
57
|
+
# disagreed. A value that is always sent is overwritten on every
|
|
58
|
+
# revalidation. The message itself does not travel; the editor's warning
|
|
59
|
+
# supplies the instructions.
|
|
49
60
|
PENDING_HEADER = "X-Mbeditor-Pending-Migration"
|
|
50
61
|
|
|
51
62
|
def call(env)
|
|
52
63
|
return @checked.call(env) unless editor_traffic?(env)
|
|
53
64
|
|
|
54
65
|
begin
|
|
55
|
-
|
|
66
|
+
status, headers, body = @checked.call(env)
|
|
56
67
|
# The check passed, so anything recorded earlier is stale.
|
|
57
68
|
Mbeditor::PendingMigrations.clear
|
|
58
|
-
|
|
69
|
+
headers[PENDING_HEADER] = "0"
|
|
70
|
+
[status, headers, body]
|
|
59
71
|
rescue StandardError => e
|
|
60
72
|
raise unless pending_migration_error?(e)
|
|
61
73
|
|
|
62
74
|
Mbeditor::PendingMigrations.note(e.message)
|
|
63
75
|
status, headers, body = @app.call(env)
|
|
64
|
-
headers[PENDING_HEADER] =
|
|
76
|
+
headers[PENDING_HEADER] = "1"
|
|
65
77
|
[status, headers, body]
|
|
66
78
|
end
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
private
|
|
70
82
|
|
|
71
|
-
# A header cannot carry the newlines the error message has, and it does
|
|
72
|
-
# not need the whole thing — the banner supplies the instructions.
|
|
73
|
-
def header_safe(message)
|
|
74
|
-
message.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
83
|
def pending_migration_error?(error)
|
|
78
84
|
defined?(ActiveRecord::PendingMigrationError) &&
|
|
79
85
|
error.is_a?(ActiveRecord::PendingMigrationError)
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Mbeditor
|
|
|
14
14
|
get 'files', to: 'editors#files'
|
|
15
15
|
get 'file', to: 'editors#show'
|
|
16
16
|
get 'raw', to: 'editors#raw'
|
|
17
|
+
get 'archive', to: 'editors#archive'
|
|
17
18
|
post 'file', to: 'editors#save'
|
|
18
19
|
post 'create_file', to: 'editors#create_file'
|
|
19
20
|
post 'create_dir', to: 'editors#create_dir'
|
|
@@ -39,6 +40,9 @@ module Mbeditor
|
|
|
39
40
|
get 'model_graph', to: 'editors#model_graph'
|
|
40
41
|
get 'exceptions', to: 'editors#exceptions'
|
|
41
42
|
delete 'exceptions', to: 'editors#clear_exceptions'
|
|
43
|
+
get 'audit_log', to: 'editors#audit_log'
|
|
44
|
+
post 'audit_log', to: 'editors#ingest_audit_log'
|
|
45
|
+
delete 'audit_log', to: 'editors#clear_audit_log'
|
|
42
46
|
get 'module_members', to: 'editors#module_members'
|
|
43
47
|
get 'file_includes', to: 'editors#file_includes'
|
|
44
48
|
get 'client_config', to: 'editors#client_config'
|