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
|
@@ -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
|
|
21
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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(
|
|
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 = [
|
|
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
|
-
#
|
|
268
|
-
#
|
|
269
|
-
|
|
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,
|
|
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
|
-
:
|
|
15
|
+
:exception_capture,
|
|
16
|
+
:search_respect_gitignore, :ripgrep_command
|
|
16
17
|
|
|
17
18
|
def initialize
|
|
18
19
|
@allowed_environments = [:development]
|
|
@@ -31,10 +32,33 @@ 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
|
-
|
|
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 the client-generated name
|
|
38
62
|
@js_global_identifiers = [] # extra ambient JS globals for the editor (runtime-only names invisible to static scan, e.g. %w[Routes I18n])
|
|
39
63
|
# Load the workspace's own JS source into Monaco's TypeScript program, so
|
|
40
64
|
# cross-file references get real inferred types instead of ambient `any`.
|
|
@@ -50,6 +74,12 @@ module Mbeditor
|
|
|
50
74
|
@ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
|
|
51
75
|
@ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
|
|
52
76
|
@ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
|
|
77
|
+
# Record exceptions raised by the host app's controllers so they show up
|
|
78
|
+
# in the editor's Problems panel instead of only in the log. Development
|
|
79
|
+
# only. Exception messages can contain interpolated request params — the
|
|
80
|
+
# same exposure the log panel already has, since it tails the dev log.
|
|
81
|
+
# Set to false to record nothing.
|
|
82
|
+
@exception_capture = :auto
|
|
53
83
|
@mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
|
|
54
84
|
@resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
|
|
55
85
|
end
|
data/lib/mbeditor/engine.rb
CHANGED
|
@@ -77,6 +77,8 @@ module Mbeditor
|
|
|
77
77
|
|
|
78
78
|
cfg = Mbeditor.configuration
|
|
79
79
|
|
|
80
|
+
subscribe_to_exceptions if Rails.env.development? && cfg.exception_capture != false
|
|
81
|
+
|
|
80
82
|
if cfg.workspace_root.present? && !File.directory?(cfg.workspace_root.to_s)
|
|
81
83
|
raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
|
|
82
84
|
end
|
|
@@ -110,6 +112,7 @@ module Mbeditor
|
|
|
110
112
|
react-dom.min.js
|
|
111
113
|
axios.min.js
|
|
112
114
|
lodash.min.js
|
|
115
|
+
yjs-collab.js
|
|
113
116
|
minisearch.min.js
|
|
114
117
|
marked.min.js
|
|
115
118
|
prettier-standalone.js
|
|
@@ -125,5 +128,36 @@ module Mbeditor
|
|
|
125
128
|
fa-solid-900.woff2
|
|
126
129
|
]
|
|
127
130
|
end
|
|
131
|
+
|
|
132
|
+
# Records controller exceptions into ExceptionLog and pushes them to any
|
|
133
|
+
# open editor over the existing cable channel.
|
|
134
|
+
#
|
|
135
|
+
# process_action.action_controller rather than a Rack middleware:
|
|
136
|
+
# ActionDispatch::DebugExceptions rescues and renders the error, so nothing
|
|
137
|
+
# outside it ever sees the raise. Rails.error.subscribe would be a cleaner
|
|
138
|
+
# API but only carries unhandled request errors reliably on newer Rails,
|
|
139
|
+
# and this gem supports 7.1.
|
|
140
|
+
def self.subscribe_to_exceptions
|
|
141
|
+
return if @exception_subscriber
|
|
142
|
+
|
|
143
|
+
@exception_subscriber = ActiveSupport::Notifications.subscribe(
|
|
144
|
+
"process_action.action_controller"
|
|
145
|
+
) do |*args|
|
|
146
|
+
payload = ActiveSupport::Notifications::Event.new(*args).payload
|
|
147
|
+
exception = payload[:exception_object]
|
|
148
|
+
next unless exception
|
|
149
|
+
|
|
150
|
+
entry = Mbeditor::ExceptionLog.record(
|
|
151
|
+
exception, payload,
|
|
152
|
+
workspace_root: Mbeditor::WorkspaceRootResolver.call
|
|
153
|
+
)
|
|
154
|
+
next unless entry && defined?(ActionCable.server)
|
|
155
|
+
|
|
156
|
+
ActionCable.server.broadcast("mbeditor_editor", entry)
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
# A monitoring hook must never be able to break the request it observes.
|
|
159
|
+
Rails.logger.debug("[mbeditor] exception capture failed: #{e.class}: #{e.message}")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
128
162
|
end
|
|
129
163
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# A bounded, in-memory ring of exceptions raised by the host app, so a failed
|
|
5
|
+
# request shows up in the editor instead of only in the log.
|
|
6
|
+
#
|
|
7
|
+
# Lives in lib/ (required from lib/mbeditor.rb, NOT autoloaded) for the same
|
|
8
|
+
# reason as RubyLspClient: a Zeitwerk reload in the host's dev environment
|
|
9
|
+
# must not wipe the buffer you are trying to read.
|
|
10
|
+
#
|
|
11
|
+
# Fed by an ActiveSupport::Notifications subscriber wired up in the engine.
|
|
12
|
+
# A Rack middleware cannot be used here: ActionDispatch::DebugExceptions
|
|
13
|
+
# rescues and renders the error, so nothing outside it ever sees the raise.
|
|
14
|
+
module ExceptionLog
|
|
15
|
+
MAX_ENTRIES = 50
|
|
16
|
+
|
|
17
|
+
# Backtraces are mostly framework and gem frames, which this editor cannot
|
|
18
|
+
# open and which push the app's own frames off the end.
|
|
19
|
+
MAX_FRAMES = 10
|
|
20
|
+
|
|
21
|
+
MUTEX = Mutex.new
|
|
22
|
+
private_constant :MUTEX
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Returns the recorded entry, or nil when there was nothing to record.
|
|
26
|
+
def record(exception, payload = {}, workspace_root: nil)
|
|
27
|
+
return nil if exception.nil?
|
|
28
|
+
|
|
29
|
+
entry = build(exception, payload, workspace_root)
|
|
30
|
+
MUTEX.synchronize do
|
|
31
|
+
@entries ||= []
|
|
32
|
+
@entries << entry
|
|
33
|
+
@entries.shift while @entries.length > MAX_ENTRIES
|
|
34
|
+
end
|
|
35
|
+
entry
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Newest first — the one you just triggered is the one you want.
|
|
39
|
+
def entries
|
|
40
|
+
MUTEX.synchronize { (@entries || []).reverse }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear!
|
|
44
|
+
MUTEX.synchronize { @entries = [] }
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build(exception, payload, workspace_root)
|
|
51
|
+
@seq = (@seq || 0) + 1
|
|
52
|
+
{
|
|
53
|
+
type: "exception",
|
|
54
|
+
id: @seq,
|
|
55
|
+
at: Time.now.utc.iso8601,
|
|
56
|
+
klass: exception.class.name,
|
|
57
|
+
message: exception.message.to_s[0, 2000],
|
|
58
|
+
controller: payload[:controller],
|
|
59
|
+
action: payload[:action],
|
|
60
|
+
path: payload[:path],
|
|
61
|
+
frames: workspace_frames(exception, workspace_root)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Only frames inside the workspace, with the root stripped. Absolute host
|
|
66
|
+
# paths are both a leak and useless to an editor that addresses files
|
|
67
|
+
# workspace-relative.
|
|
68
|
+
def workspace_frames(exception, workspace_root)
|
|
69
|
+
root = workspace_root.to_s
|
|
70
|
+
return [] if root.empty?
|
|
71
|
+
|
|
72
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
73
|
+
Array(exception.backtrace).filter_map do |frame|
|
|
74
|
+
next unless frame.start_with?(prefix)
|
|
75
|
+
|
|
76
|
+
file, line = frame.delete_prefix(prefix).split(":", 3)
|
|
77
|
+
next if file.nil? || file.empty?
|
|
78
|
+
|
|
79
|
+
{ file: file, line: line.to_i }
|
|
80
|
+
end.first(MAX_FRAMES)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Mbeditor
|
|
|
17
17
|
post 'file', to: 'editors#save'
|
|
18
18
|
post 'create_file', to: 'editors#create_file'
|
|
19
19
|
post 'create_dir', to: 'editors#create_dir'
|
|
20
|
+
post 'import', to: 'editors#import'
|
|
20
21
|
patch 'rename', to: 'editors#rename'
|
|
21
22
|
delete 'delete', to: 'editors#destroy_path'
|
|
22
23
|
get 'state', to: 'editors#state'
|
|
@@ -34,6 +35,10 @@ module Mbeditor
|
|
|
34
35
|
get 'js_globals', to: 'editors#js_globals'
|
|
35
36
|
get 'js_program', to: 'editors#js_program'
|
|
36
37
|
post 'ruby_lsp', to: 'editors#ruby_lsp'
|
|
38
|
+
post 'ruby_rename', to: 'editors#ruby_rename'
|
|
39
|
+
get 'model_graph', to: 'editors#model_graph'
|
|
40
|
+
get 'exceptions', to: 'editors#exceptions'
|
|
41
|
+
delete 'exceptions', to: 'editors#clear_exceptions'
|
|
37
42
|
get 'module_members', to: 'editors#module_members'
|
|
38
43
|
get 'file_includes', to: 'editors#file_includes'
|
|
39
44
|
get 'client_config', to: 'editors#client_config'
|