mbeditor 0.11.0 → 0.12.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -0
  3. data/README.md +190 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +916 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +365 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +492 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +37 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -11,24 +11,57 @@ module Mbeditor
11
11
  module_function
12
12
 
13
13
  # LSP DiagnosticSeverity -> the severity strings cop_severity also emits.
14
- SEVERITIES = { 1 => "error", 2 => "warning", 3 => "info", 4 => "info" }.freeze
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
- def call(result)
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
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Builds a graph of the host app's ActiveRecord models and the associations
5
+ # between them, for the Models sidebar tab.
6
+ #
7
+ # Reads the associations by *reflection* rather than by parsing model files.
8
+ # mbeditor already runs inside the host app, so `reflect_on_all_associations`
9
+ # is right there — and it resolves `class_name:`, `through:`, polymorphic and
10
+ # inverse sides correctly, which regex or AST parsing of `has_many` lines
11
+ # silently gets wrong. (ruby-lsp cannot help here: its index models constants
12
+ # and methods, not associations.)
13
+ #
14
+ # Deliberately never touches the database connection. Reflections are pure
15
+ # metadata, so this works on an app whose database isn't running or migrated;
16
+ # column counts are only filled in when a connection happens to be live.
17
+ class ModelGraphService
18
+ # Rails' own models — ActiveStorage, ActionText, SolidQueue, schema
19
+ # migrations — are not what you opened this tab to look at.
20
+ IGNORED_NAMESPACES = %w[
21
+ ActiveRecord ActiveStorage ActionText ActionMailbox
22
+ SolidQueue SolidCache SolidCable
23
+ ].freeze
24
+
25
+ MAX_MODELS = 300
26
+
27
+ # Only the first few columns travel: the diagram box shows that many and the
28
+ # full list is a click away in the schema modal, which fetches its own data
29
+ # from /model_schema.
30
+ MAX_COLUMNS_SENT = 8
31
+
32
+ class << self
33
+ def call(workspace_root)
34
+ root = workspace_root.to_s
35
+ fingerprint = fingerprint_for(root)
36
+
37
+ cached = MUTEX.synchronize { @cache }
38
+ return cached[:payload] if cached && cached[:fingerprint] == fingerprint && cached[:root] == root
39
+
40
+ payload = build(root)
41
+ payload[:fingerprint] = fingerprint
42
+ MUTEX.synchronize { @cache = { root: root, fingerprint: fingerprint, payload: payload } }
43
+ write_artifacts(root, payload)
44
+ payload
45
+ end
46
+
47
+ # Only needed for an explicit refresh: ordinary saves are picked up by
48
+ # the fingerprint below, so there is no file-change hook to keep in sync.
49
+ def invalidate(_root = nil)
50
+ MUTEX.synchronize { @cache = nil }
51
+ nil
52
+ end
53
+
54
+ # A cheap stand-in for "have the models changed": the newest mtime and the
55
+ # file count across the model and migration directories. Avoids
56
+ # eager-loading the app just to decide whether the cache is stale.
57
+ def fingerprint_for(root)
58
+ dirs = [File.join(root, "app", "models"), File.join(root, "db", "migrate")]
59
+ newest = 0
60
+ count = 0
61
+ dirs.each do |dir|
62
+ next unless File.directory?(dir)
63
+
64
+ Dir.glob(File.join(dir, "**", "*.rb")).each do |f|
65
+ count += 1
66
+ mtime = File.mtime(f).to_i
67
+ newest = mtime if mtime > newest
68
+ end
69
+ end
70
+ "#{newest}:#{count}"
71
+ rescue StandardError
72
+ "unknown"
73
+ end
74
+
75
+ private
76
+
77
+ MUTEX = Mutex.new
78
+ private_constant :MUTEX
79
+
80
+ def build(root)
81
+ unless defined?(::ActiveRecord::Base)
82
+ return unavailable("This app does not use ActiveRecord, so there are no models to graph.")
83
+ end
84
+
85
+ eager_load!
86
+ classes = model_classes
87
+ return unavailable("No ActiveRecord models found in this application.") if classes.empty?
88
+
89
+ models = classes.first(MAX_MODELS).map { |klass| describe_model(klass, root) }
90
+ known = models.map { |m| m[:name] }.to_set
91
+
92
+ {
93
+ ok: true,
94
+ models: models,
95
+ # Edges to classes outside the graph (a gem's model, or a typo in
96
+ # class_name:) would render as arrows into nowhere.
97
+ edges: models.flat_map { |m| m.delete(:edges) }.select { |e| known.include?(e[:to]) }.uniq,
98
+ truncated: classes.length > MAX_MODELS,
99
+ generatedAt: Time.now.utc.iso8601
100
+ }
101
+ rescue StandardError => e
102
+ unavailable("Could not read the model graph: #{e.class}: #{e.message}")
103
+ end
104
+
105
+ def unavailable(message)
106
+ { ok: false, error: message, models: [], edges: [], generatedAt: Time.now.utc.iso8601 }
107
+ end
108
+
109
+ # In development the host app is lazily loaded, so most models are not
110
+ # constants yet. This is the one genuinely expensive step.
111
+ def eager_load!
112
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
113
+ rescue StandardError => e
114
+ # A model that raises on load shouldn't cost you the whole diagram —
115
+ # whatever did load is still worth drawing.
116
+ Rails.logger.warn("[mbeditor] eager_load for the model graph failed: #{e.class}: #{e.message}") if defined?(Rails)
117
+ end
118
+
119
+ def model_classes
120
+ ::ActiveRecord::Base.descendants.reject do |klass|
121
+ klass.name.nil? ||
122
+ klass.abstract_class? ||
123
+ IGNORED_NAMESPACES.any? { |ns| klass.name.start_with?("#{ns}::") }
124
+ end.sort_by(&:name)
125
+ rescue StandardError
126
+ []
127
+ end
128
+
129
+ def describe_model(klass, root)
130
+ columns = column_list(klass)
131
+ {
132
+ name: klass.name,
133
+ table: safe(-> { klass.table_name }),
134
+ columns: columns.first(MAX_COLUMNS_SENT),
135
+ columnCount: columns.length,
136
+ superclass: klass.superclass&.name,
137
+ file: model_file(klass, root),
138
+ edges: associations_for(klass)
139
+ }
140
+ end
141
+
142
+ # Needs a live database connection, so it degrades to an empty list rather
143
+ # than failing: the graph is still worth drawing against a database that
144
+ # isn't running or migrated.
145
+ def column_list(klass)
146
+ klass.columns.map { |c| { name: c.name, type: c.type.to_s } }
147
+ rescue StandardError
148
+ []
149
+ end
150
+
151
+ # Where the class was defined, made workspace-relative so the editor can
152
+ # open it. Models outside the workspace (a gem, or an engine) get no file
153
+ # and simply aren't clickable.
154
+ def model_file(klass, root)
155
+ location = Object.const_source_location(klass.name)
156
+ path = location && location.first
157
+ return nil unless path
158
+
159
+ prefix = root.end_with?("/") ? root : "#{root}/"
160
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : nil
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def associations_for(klass)
166
+ klass.reflect_on_all_associations.filter_map do |reflection|
167
+ # A polymorphic belongs_to has no single target class — there is no
168
+ # edge to draw.
169
+ next if safe(-> { reflection.polymorphic? })
170
+
171
+ target = safe(-> { reflection.class_name })
172
+ next if target.nil?
173
+
174
+ {
175
+ from: klass.name,
176
+ to: target,
177
+ macro: reflection.macro.to_s,
178
+ name: reflection.name.to_s,
179
+ through: safe(-> { reflection.options[:through]&.to_s })
180
+ }
181
+ end
182
+ rescue StandardError
183
+ []
184
+ end
185
+
186
+ # Reflection can raise for half-configured associations; one bad
187
+ # association must not take out the whole graph.
188
+ def safe(callable)
189
+ callable.call
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ # The user asked for a file in tmp. The JSON is what the editor reads back
195
+ # if it wants to; the .mmd is a Mermaid ER diagram that GitHub, VS Code
196
+ # and mermaid.live all render, for when you want a prettier picture than
197
+ # the sidebar draws.
198
+ def write_artifacts(root, payload)
199
+ dir = File.join(root, "tmp")
200
+ return unless File.directory?(dir)
201
+
202
+ File.write(File.join(dir, "mbeditor_model_graph.json"), JSON.pretty_generate(payload))
203
+ File.write(File.join(dir, "mbeditor_model_graph.mmd"), to_mermaid(payload))
204
+ rescue StandardError => e
205
+ Rails.logger.debug("[mbeditor] could not write model graph artifacts: #{e.message}") if defined?(Rails)
206
+ end
207
+
208
+ MERMAID_CARDINALITY = {
209
+ "belongs_to" => "}o--||",
210
+ "has_one" => "||--o|",
211
+ "has_many" => "||--o{",
212
+ "has_and_belongs_to_many" => "}o--o{"
213
+ }.freeze
214
+
215
+ def to_mermaid(payload)
216
+ lines = ["erDiagram"]
217
+ payload[:models].each { |m| lines << " #{mermaid_id(m[:name])} {" << " }" }
218
+ payload[:edges].each do |e|
219
+ arrow = MERMAID_CARDINALITY.fetch(e[:macro], "||--||")
220
+ label = e[:through] ? "#{e[:name]} (through #{e[:through]})" : e[:name]
221
+ lines << %( #{mermaid_id(e[:from])} #{arrow} #{mermaid_id(e[:to])} : "#{label}")
222
+ end
223
+ "#{lines.join("\n")}\n"
224
+ end
225
+
226
+ # Mermaid entity names can't contain ::
227
+ def mermaid_id(name)
228
+ name.to_s.gsub("::", "_")
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Who is currently connected for collaborative editing. Thread-safe and
5
+ # in-memory, modelled on CollaborationDocStore (Mutex + monotonic clock).
6
+ #
7
+ # This exists because the clients cannot maintain an accurate roster between
8
+ # themselves. The original protocol relayed one "here"/"leave" event per
9
+ # participant and every client merged those into its own roster, which meant a
10
+ # single dropped or missed message — a peer's browser killed outright, a relay
11
+ # failure, or simply reloading your own page at the moment someone else closed
12
+ # theirs — left a participant in your roster permanently, with no expiry to
13
+ # reclaim it. That is not cosmetic: the roster gates collaboration, so one ghost
14
+ # kept persistent undo disabled and external on-disk changes suppressed for the
15
+ # rest of the session, for a peer who was not there.
16
+ #
17
+ # The server already knows exactly who is subscribed — Action Cable calls
18
+ # `unsubscribed` on a clean close and on its own connection timeout — so it is
19
+ # the only party that can answer this correctly. Every broadcast now carries the
20
+ # complete roster and clients replace theirs wholesale, so a dropped message
21
+ # costs one stale interval rather than a permanent phantom. No TTL sweep: an
22
+ # entry outlives its connection only if the process dies, which takes the
23
+ # registry with it.
24
+ module PresenceRegistry
25
+ module_function
26
+
27
+ MUTEX = Mutex.new
28
+ private_constant :MUTEX
29
+
30
+ # Fields a participant publishes about itself. Anything else in the payload is
31
+ # dropped — this is relayed to every other client, so the shape is a boundary.
32
+ # `seq` is the sender's own heartbeat counter, echoed back so they can tell
33
+ # which broadcast their message caused — every participant's heartbeat now
34
+ # rebroadcasts everyone, so "my entry came back" alone does not mean "my
35
+ # message round-tripped", and timing against it inflates the measurement.
36
+ # `seed` is a stable number derived from the sender's persisted identity. It
37
+ # exists so a colour clash resolves to the same winner on both sides and stays
38
+ # resolved across a reload; it is deliberately a hash rather than the id itself.
39
+ RELAYED_FIELDS = %w[name colour current_file rtt seq seed].freeze
40
+
41
+ def record(client_id, attrs, now: monotonic)
42
+ id = client_id.to_s
43
+ return if id.empty?
44
+
45
+ entry = attrs.slice(*RELAYED_FIELDS)
46
+ MUTEX.synchronize do
47
+ participants[id] = entry.merge("client_id" => id, "last_seen" => now)
48
+ end
49
+ nil
50
+ end
51
+
52
+ def remove(client_id)
53
+ id = client_id.to_s
54
+ MUTEX.synchronize { participants.delete(id) }
55
+ nil
56
+ end
57
+
58
+ # The full roster, with each entry's idle time resolved against a monotonic
59
+ # clock here rather than compared across machines — no clock skew to correct.
60
+ def roster(now: monotonic)
61
+ MUTEX.synchronize do
62
+ participants.transform_values do |entry|
63
+ entry.except("last_seen").merge("idle" => (now - entry["last_seen"]).round)
64
+ end
65
+ end
66
+ end
67
+
68
+ def reset!
69
+ MUTEX.synchronize { @participants = {} }
70
+ nil
71
+ end
72
+
73
+ def participants
74
+ @participants ||= {}
75
+ end
76
+ private_class_method :participants
77
+
78
+ def monotonic
79
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ end
81
+ private_class_method :monotonic
82
+ end
83
+ end
@@ -17,8 +17,18 @@ module Mbeditor
17
17
  MAX_DESC_LINES = 3
18
18
 
19
19
  # When a method is defined on many classes, prefer these over the
20
- # first-alphabetical class (e.g. prefer BasicObject#new over Addrinfo#new).
21
- PREFERRED_CLASSES = %w[BasicObject Object Kernel Module Class].freeze
20
+ # first-alphabetical class, which is almost never the one meant:
21
+ # `new` would resolve to Addrinfo and `each` to ARGF.
22
+ #
23
+ # Ordered most-general first. We don't know the receiver's type, so the
24
+ # honest answer for a bare `each` is Enumerable's, not the first class
25
+ # ri happens to list.
26
+ PREFERRED_CLASSES = %w[
27
+ BasicObject Object Kernel Module Class
28
+ Enumerable Comparable
29
+ Array Hash String Symbol Integer Float Numeric Range Enumerator
30
+ Struct Set Time File IO Exception Proc Method NilClass
31
+ ].freeze
22
32
 
23
33
  @cache = {}
24
34
  @mutex = Mutex.new
@@ -55,6 +65,12 @@ module Mbeditor
55
65
 
56
66
  private
57
67
 
68
+ # "map()" or "each()" — a name and empty parens, telling you nothing about
69
+ # arguments, block or return value.
70
+ def bare_signature?(signature)
71
+ signature.to_s.strip.match?(/\A[\w.:]+\(\)\z/)
72
+ end
73
+
58
74
  def run_ri
59
75
  out = nil
60
76
  Open3.popen3("ri", "--no-pager", "--format=rdoc", @symbol) do |stdin, stdout, _stderr, wait_thr|
@@ -83,8 +99,23 @@ module Mbeditor
83
99
  blocks = extract_blocks(lines)
84
100
  return [] if blocks.empty?
85
101
 
86
- best = blocks.find { |b| PREFERRED_CLASSES.include?(b[:impl]) } || blocks.first
87
- return [] if best[:sigs].empty?
102
+ # Rank by position in PREFERRED_CLASSES, not by ri's own ordering, so the
103
+ # most general implementation wins rather than whichever preferred class
104
+ # ri happened to print first.
105
+ #
106
+ # Choose only among recognised classes when there are any — otherwise a
107
+ # rich signature on some obscure class (Enumerator::Lazy#map) would beat
108
+ # the terse one on the class actually meant.
109
+ preferred = blocks.select { |b| PREFERRED_CLASSES.include?(b[:impl]) }
110
+ candidates = preferred.any? ? preferred : blocks
111
+
112
+ # Within that set, skip entries ri prints as a bare "map()" — they
113
+ # document nothing — before falling back to class order.
114
+ best = candidates.min_by do |b|
115
+ [bare_signature?(b[:sigs].first) ? 1 : 0,
116
+ PREFERRED_CLASSES.index(b[:impl]) || PREFERRED_CLASSES.length]
117
+ end
118
+ return [] if best.nil? || best[:sigs].empty?
88
119
 
89
120
  desc = best[:desc]
90
121
  .first(MAX_DESC_LINES)
@@ -127,8 +158,11 @@ module Mbeditor
127
158
  sep_indices = lines.each_index.select { |i| lines[i].match?(/\A-{5,}\z/) }
128
159
  return nil if sep_indices.length < 2
129
160
 
161
+ # ri pads signatures into columns for its terminal output, e.g.
162
+ # "each(sep=$/) {|line| block } -> ARGF". Those runs of
163
+ # spaces are meaningless here and render as a gaping hole in the hover.
130
164
  sigs = lines[(sep_indices[0] + 1)..(sep_indices[1] - 1)]
131
- .map(&:strip)
165
+ .map { |l| l.strip.squeeze(" ") }
132
166
  .reject(&:empty?)
133
167
  return nil if sigs.empty?
134
168
 
@@ -39,6 +39,19 @@ module Mbeditor
39
39
  AvailabilityProbe.rg
40
40
  end
41
41
 
42
+ # The resolved ripgrep executable, shared with CodeSearchService so both
43
+ # search paths run the same binary.
44
+ def rg_command
45
+ AvailabilityProbe.rg_command || "rg"
46
+ end
47
+
48
+ # Which backend a search on this workspace will actually use. Reported by
49
+ # GET /workspace: the difference between the tiers is 10-30x, so "why is
50
+ # search slow" needs to be answerable without reading the source.
51
+ def backend(workspace_root)
52
+ pick_tier(workspace_root.to_s)
53
+ end
54
+
42
55
  # Returns up to +limit+ result rows. When +paths+ is given the scan is
43
56
  # restricted to those files (used by the live result-refresh) and the
44
57
  # result cache is bypassed.
@@ -242,7 +255,7 @@ module Mbeditor
242
255
 
243
256
  case tier
244
257
  when :rg
245
- args = ["rg", "--json"]
258
+ args = [rg_command, "--json"]
246
259
  args << "--no-ignore" unless respect_gitignore?
247
260
  args << "-F" unless use_regex
248
261
  args << "--ignore-case" unless match_case
@@ -264,9 +277,16 @@ module Mbeditor
264
277
  args << "-w" if whole_word
265
278
  args += ["-e", query, "--"]
266
279
  args += paths.map { |p| relative_path(File.expand_path(p, root), root) } if paths
267
- # C locale restores fast byte-wise case folding for -i (non-ASCII
268
- # characters simply don't case-fold, which is acceptable here).
269
- [{ "LC_ALL" => "C" }, args]
280
+ # Exclusions have to reach git as pathspecs, not just be dropped from
281
+ # the results by the matcher below: otherwise git walks node_modules
282
+ # and every other excluded tree in full before we discard the matches.
283
+ # A pathspec list of nothing but :(exclude) entries means
284
+ # "everything except these", which is exactly what the unscoped
285
+ # search wants.
286
+ args += exclusions.map { |p| ":(exclude)#{p}" }
287
+ # No LC_ALL=C: measured neutral for the -F -i default and 2.2x slower
288
+ # for -E, and the UTF-8 locale case-folds non-ASCII correctly.
289
+ [{}, args]
270
290
  else
271
291
  base_flags = use_regex ? "-E" : "-F"
272
292
  args = ["grep", "-I", "-Hn", base_flags]
@@ -29,6 +29,8 @@
29
29
  <script defer src="<%= asset_path('react-dom.min.js') %>"></script>
30
30
  <script defer src="<%= asset_path('axios.min.js') %>"></script>
31
31
  <script defer src="<%= asset_path('lodash.min.js') %>"></script>
32
+ <!-- Yjs + y-monaco + y-protocols (awareness) — maintainer-prebuilt bundle for collaborative editing; exposes window.Y / window.MonacoBinding / window.awarenessProtocol -->
33
+ <script defer src="<%= asset_path('yjs-collab.js') %>"></script>
32
34
  <script defer src="<%= asset_path('minisearch.min.js') %>"></script>
33
35
  <script defer src="<%= asset_path('marked.min.js') %>"></script>
34
36
  <!-- ── Monaco (ESM bundle, ADR 0002): stylesheet here, JS loaded in body ── -->
@@ -5,14 +5,15 @@ module Mbeditor
5
5
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
6
6
  :redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
7
7
  :test_framework, :test_command, :test_timeout,
8
- :authenticate_with, :authentication_cache_ttl,
8
+ :authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
9
9
  :lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
10
10
  :ruby_def_include_dirs, :related_files_custom_paths,
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
12
12
  :js_program, :js_program_exclude,
13
13
  :js_syntax_check, :babel_standalone_path,
14
14
  :ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
15
- :search_respect_gitignore
15
+ :exception_capture,
16
+ :search_respect_gitignore, :ripgrep_command
16
17
 
17
18
  def initialize
18
19
  @allowed_environments = [:development]
@@ -31,10 +32,37 @@ module Mbeditor
31
32
  @base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
32
33
  @git_timeout = 10 # seconds; nil disables (no timeout on git subprocesses)
33
34
  @search_timeout = 15 # seconds; wall-clock bound on every search subprocess, nil disables
34
- @search_respect_gitignore = false # true skips .gitignore'd files in project search and definition lookups
35
+ # Skip .gitignore'd files in project search and definition lookups.
36
+ #
37
+ # Defaults to true, matching VS Code and ripgrep. The false path has to
38
+ # ask git for --no-index, which walks every ignored tree the app has —
39
+ # node_modules, public/packs, app/assets/builds, caches — and on the
40
+ # git-grep tier (no ripgrep installed) that measured 29x slower on this
41
+ # repo alone: 3714 files walked instead of 253. It also fills results
42
+ # with matches inside minified bundles.
43
+ #
44
+ # Set to false to search ignored files too; expect it to be slow on a
45
+ # machine without ripgrep.
46
+ @search_respect_gitignore = true
47
+ # Path to the ripgrep binary. nil auto-resolves: PATH first, then the
48
+ # usual install prefixes.
49
+ #
50
+ # This matters more than it looks. ripgrep is 10-30x faster than the
51
+ # git-grep fallback, and the probe used to run a bare "rg" — so it found
52
+ # ripgrep only if the *server process* had it on PATH. A Rails server
53
+ # started from launchd, systemd, foreman or an IDE typically inherits a
54
+ # stripped PATH without /opt/homebrew/bin or /usr/local/bin, so ripgrep
55
+ # could be installed and still invisible, silently dropping every search
56
+ # to the slow tier. GET /workspace reports the tier actually in use.
57
+ @ripgrep_command = nil
35
58
  @ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
36
59
  @related_files_custom_paths = []
37
60
  @authentication_cache_ttl = 0
61
+ @user_name_callback = nil # proc resolved in controller context (instance_exec) → collaboration display name; nil falls through to current_user, then to the client-generated name
62
+ # Attributes tried on current_user, in order, when no user_name_callback
63
+ # is set. First non-blank one wins. Name your own column here rather than
64
+ # writing a callback for the common case.
65
+ @user_name_methods = %w[name full_name display_name username login email]
38
66
  @js_global_identifiers = [] # extra ambient JS globals for the editor (runtime-only names invisible to static scan, e.g. %w[Routes I18n])
39
67
  # Load the workspace's own JS source into Monaco's TypeScript program, so
40
68
  # cross-file references get real inferred types instead of ambient `any`.
@@ -50,6 +78,12 @@ module Mbeditor
50
78
  @ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
51
79
  @ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
52
80
  @ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
81
+ # Record exceptions raised by the host app's controllers so they show up
82
+ # in the editor's Problems panel instead of only in the log. Development
83
+ # only. Exception messages can contain interpolated request params — the
84
+ # same exposure the log panel already has, since it tails the dev log.
85
+ # Set to false to record nothing.
86
+ @exception_capture = :auto
53
87
  @mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
54
88
  @resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
55
89
  end