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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. 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)
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-theme="<%= @initial_theme || "vs-dark" %>"<%= " data-glass=\"1\"".html_safe if @initial_glass %>>
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -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
- result = @checked.call(env)
66
+ status, headers, body = @checked.call(env)
56
67
  # The check passed, so anything recorded earlier is stale.
57
68
  Mbeditor::PendingMigrations.clear
58
- result
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] = header_safe(e.message)
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)
@@ -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'