mbeditor 0.10.1 → 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 +191 -0
- data/README.md +226 -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 +948 -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 +661 -140
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +68 -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 +481 -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/js_globals_service.rb +31 -2
- data/app/services/mbeditor/js_program_service.rb +173 -0
- 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 +43 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +6 -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 +16 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Enumerates the workspace's own JavaScript source and hands it to the editor
|
|
5
|
+
# so Monaco's TypeScript worker can build a real program from it.
|
|
6
|
+
#
|
|
7
|
+
# This is the file-based replacement for declaring every discovered name as
|
|
8
|
+
# ambient `any` (JsGlobalsService). Under Sprockets a JS file with no
|
|
9
|
+
# import/export is a *script*, so its top-level declarations land in the
|
|
10
|
+
# global scope — which is exactly TypeScript's own model for script files.
|
|
11
|
+
# Giving the worker the sources instead of a name list yields real inferred
|
|
12
|
+
# types, member completions, and argument-count checking, and it still
|
|
13
|
+
# reports "Cannot find name" for genuinely unknown identifiers.
|
|
14
|
+
#
|
|
15
|
+
# What it deliberately does NOT solve: UMD-wrapped libraries. React, lodash
|
|
16
|
+
# and axios all assign their global inside a closure
|
|
17
|
+
# (`factory(global.React = {})`), which TypeScript cannot follow statically —
|
|
18
|
+
# loading their source produces no global at all. Those stay on hand-written
|
|
19
|
+
# declarations (the React mini-UMD stub) or on JsGlobalsService's ambient
|
|
20
|
+
# names, which is why that service is still here.
|
|
21
|
+
#
|
|
22
|
+
# No truncation: a workspace that exceeds any limit reports what it skipped
|
|
23
|
+
# and why, rather than silently returning a partial program.
|
|
24
|
+
class JsProgramService
|
|
25
|
+
SOURCE_EXT = /\.(js|jsx|ts|tsx)\z/i
|
|
26
|
+
|
|
27
|
+
# Minified bundles cost parse time and yield nothing useful — their globals
|
|
28
|
+
# are one-letter names inside a closure. Matched by convention, then by
|
|
29
|
+
# shape for bundles whose filename doesn't say so.
|
|
30
|
+
MINIFIED_NAME = /[.\-]min\.(js|jsx|ts|tsx)\z/i
|
|
31
|
+
MAX_LINE_LENGTH = 2_000
|
|
32
|
+
|
|
33
|
+
# A single source file this large is a bundle or generated output, not
|
|
34
|
+
# something a person edits.
|
|
35
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
36
|
+
|
|
37
|
+
CACHE_TTL = 10 # seconds
|
|
38
|
+
|
|
39
|
+
MUTEX = Mutex.new
|
|
40
|
+
private_constant :MUTEX
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def call(workspace_root)
|
|
44
|
+
root = File.expand_path(workspace_root.to_s)
|
|
45
|
+
now = monotonic
|
|
46
|
+
MUTEX.synchronize do
|
|
47
|
+
entry = (@cache ||= {})[root]
|
|
48
|
+
return entry[:data] if entry && (now - entry[:ts]) < CACHE_TTL
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
data = compute(root)
|
|
52
|
+
MUTEX.synchronize { (@cache ||= {})[root] = { ts: monotonic, data: data } }
|
|
53
|
+
data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Content for a single workspace-relative path, for incremental refresh
|
|
57
|
+
# after a file changes. Returns nil when the path isn't program material.
|
|
58
|
+
def file(workspace_root, relative_path)
|
|
59
|
+
root = File.expand_path(workspace_root.to_s)
|
|
60
|
+
rel = relative_path.to_s.delete_prefix("/")
|
|
61
|
+
return nil unless rel.match?(SOURCE_EXT)
|
|
62
|
+
return nil if rel.match?(MINIFIED_NAME)
|
|
63
|
+
return nil if matcher(root).excluded?(rel)
|
|
64
|
+
|
|
65
|
+
abs = File.expand_path(File.join(root, rel))
|
|
66
|
+
return nil unless abs == File.join(root, rel) # no traversal out of the workspace
|
|
67
|
+
return nil unless File.file?(abs) && !File.symlink?(abs)
|
|
68
|
+
|
|
69
|
+
content = read_source(abs)
|
|
70
|
+
content && { path: rel, content: content }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def invalidate(workspace_root)
|
|
74
|
+
MUTEX.synchronize { (@cache ||= {}).delete(File.expand_path(workspace_root.to_s)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compute(root)
|
|
80
|
+
return disabled_result unless Mbeditor.configuration.js_program
|
|
81
|
+
|
|
82
|
+
files = []
|
|
83
|
+
skipped = []
|
|
84
|
+
total = 0
|
|
85
|
+
|
|
86
|
+
each_candidate(root) do |rel, abs|
|
|
87
|
+
if File.size(abs) > MAX_FILE_BYTES
|
|
88
|
+
skipped << { path: rel, reason: "larger than #{MAX_FILE_BYTES} bytes" }
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
content = read_source(abs)
|
|
93
|
+
if content.nil?
|
|
94
|
+
skipped << { path: rel, reason: "minified or unreadable" }
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
total += content.bytesize
|
|
99
|
+
files << { path: rel, content: content }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
ok: true,
|
|
104
|
+
enabled: true,
|
|
105
|
+
generatedAt: Time.now.to_i,
|
|
106
|
+
fileCount: files.length,
|
|
107
|
+
totalBytes: total,
|
|
108
|
+
skipped: skipped,
|
|
109
|
+
files: files.sort_by { |f| f[:path] }
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def disabled_result
|
|
114
|
+
{ ok: true, enabled: false, generatedAt: Time.now.to_i,
|
|
115
|
+
fileCount: 0, totalBytes: 0, skipped: [], files: [] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Walks the workspace, pruning excluded directories before descending so
|
|
119
|
+
# a node_modules tree is never entered. Symlinks are skipped outright:
|
|
120
|
+
# this walk is an enumeration, not a resolve_path lookup, and following
|
|
121
|
+
# them could both escape the workspace and loop.
|
|
122
|
+
def each_candidate(root)
|
|
123
|
+
m = matcher(root)
|
|
124
|
+
stack = [root]
|
|
125
|
+
while (dir = stack.pop)
|
|
126
|
+
children(dir).each do |name|
|
|
127
|
+
abs = File.join(dir, name)
|
|
128
|
+
rel = abs.delete_prefix(root).delete_prefix("/")
|
|
129
|
+
next if m.excluded?(rel)
|
|
130
|
+
next if File.symlink?(abs)
|
|
131
|
+
|
|
132
|
+
if File.directory?(abs)
|
|
133
|
+
stack.push(abs)
|
|
134
|
+
elsif name.match?(SOURCE_EXT) && !name.match?(MINIFIED_NAME)
|
|
135
|
+
yield rel, abs
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def children(dir)
|
|
142
|
+
Dir.children(dir)
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def matcher(root)
|
|
148
|
+
ExclusionMatcher.new(exclusion_patterns, root: root)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def exclusion_patterns
|
|
152
|
+
Array(Mbeditor.configuration.excluded_paths).map(&:to_s) +
|
|
153
|
+
Array(Mbeditor.configuration.js_program_exclude).map(&:to_s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns nil for anything that isn't usable program source: unreadable,
|
|
157
|
+
# invalid encoding, or minified-by-shape (one very long line).
|
|
158
|
+
def read_source(abs)
|
|
159
|
+
content = File.read(abs, encoding: Encoding::UTF_8)
|
|
160
|
+
return nil unless content.valid_encoding?
|
|
161
|
+
return nil if content.each_line.any? { |line| line.chomp.length > MAX_LINE_LENGTH }
|
|
162
|
+
|
|
163
|
+
content
|
|
164
|
+
rescue SystemCallError, IOError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monotonic
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -11,24 +11,57 @@ module Mbeditor
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
# LSP DiagnosticSeverity -> the severity strings cop_severity also emits.
|
|
14
|
-
|
|
14
|
+
# Mirrors ruby-lsp's own RUBOCOP_TO_LSP_SEVERITY in reverse: rubocop's
|
|
15
|
+
# `info` becomes HINT(4) and convention/refactor become INFORMATION(3), so
|
|
16
|
+
# the two must map back to distinct editor severities.
|
|
17
|
+
SEVERITIES = { 1 => "error", 2 => "warning", 3 => "info", 4 => "hint" }.freeze
|
|
15
18
|
|
|
16
19
|
# ruby-lsp appends this to non-correctable RuboCop messages; it's noise in
|
|
17
20
|
# a Monaco hover.
|
|
18
21
|
UNCORRECTABLE_SUFFIX = "This offense is not auto-correctable."
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
# Cops whose offense means "this code does nothing" rather than "this code
|
|
24
|
+
# is wrong". Monaco fades these out (MarkerTag.Unnecessary) instead of
|
|
25
|
+
# squiggling them, which is the whole point of greying out dead code.
|
|
26
|
+
#
|
|
27
|
+
# ruby-lsp emits no DiagnosticTag of its own, so the cop name is the only
|
|
28
|
+
# signal available — and it's the same signal on the plain-rubocop /lint
|
|
29
|
+
# path, which is why this predicate is public and shared by both.
|
|
30
|
+
#
|
|
31
|
+
# ponytail: name heuristic, not an enumeration. Upgrade path is an explicit
|
|
32
|
+
# cop list if a badly-named cop ever gets faded by mistake.
|
|
33
|
+
UNNECESSARY_COP = /Unused|Useless|Redundant|Unreachable|Deprecat/.freeze
|
|
34
|
+
|
|
35
|
+
# Prism reports the same dead code as Lint/UselessAssignment and
|
|
36
|
+
# Lint/UnreachableCode, but as a parser warning with no cop name — so both
|
|
37
|
+
# markers land on the same range. Fading only the RuboCop one leaves the
|
|
38
|
+
# Prism squiggle drawn on top and nothing looks greyed out at all.
|
|
39
|
+
UNNECESSARY_MESSAGE = /\Aassigned but unused variable|\Astatement not reached|\Aunused literal/.freeze
|
|
40
|
+
|
|
41
|
+
# Most actions a diagnostic can carry. RuboCop sends two (autocorrect and
|
|
42
|
+
# disable-for-this-line); the cap is a bound on a payload we don't control.
|
|
43
|
+
MAX_CODE_ACTIONS = 5
|
|
44
|
+
|
|
45
|
+
# Refuse to ship an absurd replacement into the browser. RuboCop's
|
|
46
|
+
# replacements are a few characters; anything near this is a bug or an
|
|
47
|
+
# attack, not a quick fix.
|
|
48
|
+
MAX_EDIT_BYTES = 64 * 1024
|
|
49
|
+
|
|
50
|
+
# +uri+ is the request's own document URI. Code actions are only accepted
|
|
51
|
+
# when every edit they carry targets exactly that document — see
|
|
52
|
+
# sanitize_code_actions.
|
|
53
|
+
def call(result, uri = nil)
|
|
21
54
|
items = case result
|
|
22
55
|
when Hash then Array(result["items"])
|
|
23
56
|
when Array then result
|
|
24
57
|
else []
|
|
25
58
|
end
|
|
26
59
|
|
|
27
|
-
markers = items.filter_map { |diag| translate_one(diag) }
|
|
60
|
+
markers = items.filter_map { |diag| translate_one(diag, uri) }
|
|
28
61
|
{ markers: markers, summary: { "offense_count" => markers.length } }
|
|
29
62
|
end
|
|
30
63
|
|
|
31
|
-
def translate_one(diag)
|
|
64
|
+
def translate_one(diag, uri = nil)
|
|
32
65
|
return nil unless diag.is_a?(Hash)
|
|
33
66
|
|
|
34
67
|
range = diag["range"] || {}
|
|
@@ -41,6 +74,7 @@ module Mbeditor
|
|
|
41
74
|
|
|
42
75
|
cop_name = extract_code(diag["code"])
|
|
43
76
|
message = clean_message(diag["message"])
|
|
77
|
+
fixes = sanitize_code_actions(diag, uri)
|
|
44
78
|
|
|
45
79
|
{
|
|
46
80
|
severity: SEVERITIES.fetch(diag["severity"], "info"),
|
|
@@ -53,10 +87,70 @@ module Mbeditor
|
|
|
53
87
|
source: rubocop_source?(diag["source"]) ? "rubocop" : diag["source"].to_s.downcase,
|
|
54
88
|
message: cop_name.empty? ? message : "[#{cop_name}] #{message}",
|
|
55
89
|
startLine: start_line, startCol: start_col,
|
|
56
|
-
endLine: end_line, endCol: end_col
|
|
90
|
+
endLine: end_line, endCol: end_col,
|
|
91
|
+
unnecessary: unnecessary?(cop_name, message),
|
|
92
|
+
# RuboCop >= 1.64 ships a docs URL per cop; Monaco renders it as a link
|
|
93
|
+
# on the marker's code. Absent for non-RuboCop diagnostics.
|
|
94
|
+
# NB: the LSP wire key is camelCase.
|
|
95
|
+
codeHref: diag.dig("codeDescription", "href")
|
|
96
|
+
}.tap { |marker| marker[:fixes] = fixes if fixes.any? }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ruby-lsp embeds the complete edits for each RuboCop fix in the diagnostic
|
|
100
|
+
# itself. Its textDocument/codeAction handler does nothing but echo these
|
|
101
|
+
# back (see Requests::CodeActions#perform), so lifting them here lets the
|
|
102
|
+
# editor apply a fix with no further request at all -- no codeAction call
|
|
103
|
+
# and no `rubocop -A` subprocess.
|
|
104
|
+
#
|
|
105
|
+
# This is a trust boundary: the payload arrives from a subprocess and is
|
|
106
|
+
# applied to the user's buffer. An action is taken only if every edit in it
|
|
107
|
+
# targets the very document we asked about.
|
|
108
|
+
def sanitize_code_actions(diag, uri)
|
|
109
|
+
return [] if uri.nil?
|
|
110
|
+
|
|
111
|
+
Array(diag.dig("data", "code_actions")).first(MAX_CODE_ACTIONS).filter_map do |action|
|
|
112
|
+
next unless action.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
changes = Array(action.dig("edit", "documentChanges"))
|
|
115
|
+
next if changes.empty?
|
|
116
|
+
# Any edit aimed at another file disqualifies the whole action. RuboCop
|
|
117
|
+
# fixes are always single-file, so this rejects nothing legitimate.
|
|
118
|
+
next unless changes.all? { |c| c.is_a?(Hash) && c.dig("textDocument", "uri") == uri }
|
|
119
|
+
|
|
120
|
+
edits = changes.flat_map { |c| Array(c["edits"]) }.filter_map { |edit| sanitize_edit(edit) }
|
|
121
|
+
next if edits.empty?
|
|
122
|
+
|
|
123
|
+
{ title: action["title"].to_s, edits: edits }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# One LSP TextEdit -> the 1-based shape the rest of the marker uses.
|
|
128
|
+
def sanitize_edit(edit)
|
|
129
|
+
return nil unless edit.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
text = edit["newText"].to_s
|
|
132
|
+
return nil if text.bytesize > MAX_EDIT_BYTES
|
|
133
|
+
|
|
134
|
+
range = edit["range"]
|
|
135
|
+
return nil unless range.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
startLine: (range.dig("start", "line") || 0) + 1,
|
|
139
|
+
startCol: (range.dig("start", "character") || 0) + 1,
|
|
140
|
+
endLine: (range.dig("end", "line") || range.dig("start", "line") || 0) + 1,
|
|
141
|
+
endCol: (range.dig("end", "character") || range.dig("start", "character") || 0) + 1,
|
|
142
|
+
text: text
|
|
57
143
|
}
|
|
58
144
|
end
|
|
59
145
|
|
|
146
|
+
def unnecessary?(cop_name, message = nil)
|
|
147
|
+
return true if UNNECESSARY_COP.match?(cop_name.to_s)
|
|
148
|
+
|
|
149
|
+
# Only consult the message when there is no cop to go on, so a RuboCop
|
|
150
|
+
# offense is never faded because of a phrase inside its description.
|
|
151
|
+
cop_name.to_s.empty? && UNNECESSARY_MESSAGE.match?(message.to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
60
154
|
def rubocop_source?(source)
|
|
61
155
|
source.to_s.match?(/rubocop/i)
|
|
62
156
|
end
|
|
@@ -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
|