mbeditor 0.4.4 → 0.5.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 +23 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +35 -16
- data/app/assets/javascripts/mbeditor/components/FileTree.js +23 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +324 -48
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +173 -0
- data/app/assets/javascripts/mbeditor/file_service.js +84 -1
- data/app/assets/javascripts/mbeditor/git_service.js +7 -3
- data/app/assets/javascripts/mbeditor/search_service.js +91 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +55 -2
- data/app/assets/stylesheets/mbeditor/editor.css +29 -0
- data/app/controllers/mbeditor/editors_controller.rb +295 -41
- data/app/services/mbeditor/ruby_definition_service.rb +163 -21
- data/app/services/mbeditor/unused_methods_service.rb +139 -0
- data/app/views/layouts/mbeditor/application.html.erb +86 -56
- data/config/routes.rb +4 -0
- data/lib/mbeditor/cable_log_filter.rb +6 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +3 -2
|
@@ -19,13 +19,26 @@ module Mbeditor
|
|
|
19
19
|
# Usage:
|
|
20
20
|
# results = RubyDefinitionService.call(workspace_root, "my_method",
|
|
21
21
|
# excluded_dirnames: %w[tmp .git])
|
|
22
|
+
#
|
|
23
|
+
# Additional class methods for IntelliSense features:
|
|
24
|
+
# RubyDefinitionService.defs_in_file(abs_path)
|
|
25
|
+
# → { "method_name" => [{ line: Integer, signature: String }, ...] }
|
|
26
|
+
# RubyDefinitionService.module_defined_in(workspace_root, "ModuleName", ...)
|
|
27
|
+
# → abs_path String or nil
|
|
28
|
+
# RubyDefinitionService.includes_in_file(abs_path)
|
|
29
|
+
# → ["ModuleName", ...] (include/extend/prepend calls)
|
|
22
30
|
class RubyDefinitionService
|
|
23
31
|
MAX_RESULTS = 20
|
|
24
32
|
MAX_COMMENT_LOOKAHEAD = 15
|
|
25
33
|
MAX_FILES_SCANNED = 10_000
|
|
26
34
|
|
|
27
35
|
# In-process file-index cache.
|
|
28
|
-
# Structure: { absolute_path => {
|
|
36
|
+
# Structure: { absolute_path => {
|
|
37
|
+
# mtime: Float, lines: [String],
|
|
38
|
+
# all_defs: { method_name => [line, ...] },
|
|
39
|
+
# module_names: [String], # module/class names defined in the file
|
|
40
|
+
# include_calls: [String] # module names passed to include/extend/prepend
|
|
41
|
+
# } }
|
|
29
42
|
# Protected by a Mutex; entries are invalidated lazily via mtime comparison.
|
|
30
43
|
# When +cache_path+ is set (done automatically by the engine initializer), the
|
|
31
44
|
# cache is persisted to disk as JSON so it survives process restarts.
|
|
@@ -59,9 +72,11 @@ module Mbeditor
|
|
|
59
72
|
raw = JSON.parse(File.read(path))
|
|
60
73
|
raw.each do |abs_path, entry|
|
|
61
74
|
@file_cache[abs_path] = {
|
|
62
|
-
mtime:
|
|
63
|
-
lines:
|
|
64
|
-
all_defs:
|
|
75
|
+
mtime: entry["mtime"].to_f,
|
|
76
|
+
lines: entry["lines"],
|
|
77
|
+
all_defs: entry["all_defs"],
|
|
78
|
+
module_names: entry["module_names"], # nil for old-format entries → triggers re-parse
|
|
79
|
+
include_calls: entry["include_calls"] # nil for old-format entries → triggers re-parse
|
|
65
80
|
}
|
|
66
81
|
end
|
|
67
82
|
rescue StandardError
|
|
@@ -90,6 +105,59 @@ module Mbeditor
|
|
|
90
105
|
rescue StandardError
|
|
91
106
|
nil
|
|
92
107
|
end
|
|
108
|
+
|
|
109
|
+
# Returns all method defs in a single file from the cache (no workspace walk).
|
|
110
|
+
# The file must already be cached; returns {} if not found.
|
|
111
|
+
# Result: { "method_name" => [{ line: Integer, signature: String }, ...] }
|
|
112
|
+
def defs_in_file(file_path)
|
|
113
|
+
load_disk_cache_once
|
|
114
|
+
entry = @mutex.synchronize { @file_cache[file_path.to_s] }
|
|
115
|
+
return {} unless entry
|
|
116
|
+
|
|
117
|
+
entry[:all_defs].transform_values do |lines_arr|
|
|
118
|
+
lines_arr.map { |line| { line: line, signature: (entry[:lines][line - 1] || "").strip } }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Searches the cache (and triggers a workspace scan if needed) to find
|
|
123
|
+
# which file in +workspace_root+ defines the given module or class name.
|
|
124
|
+
# Returns the absolute file path string or nil.
|
|
125
|
+
def module_defined_in(workspace_root, module_name, excluded_dirnames: [], excluded_paths: [])
|
|
126
|
+
load_disk_cache_once
|
|
127
|
+
result = @mutex.synchronize do
|
|
128
|
+
@file_cache.find { |_path, entry| entry[:module_names]&.include?(module_name) }
|
|
129
|
+
end
|
|
130
|
+
return result[0] if result
|
|
131
|
+
|
|
132
|
+
# Cache miss: scan workspace to populate cache entries with module_names.
|
|
133
|
+
new(workspace_root, nil,
|
|
134
|
+
excluded_dirnames: excluded_dirnames,
|
|
135
|
+
excluded_paths: excluded_paths).scan_workspace
|
|
136
|
+
|
|
137
|
+
result = @mutex.synchronize do
|
|
138
|
+
@file_cache.find { |_path, entry| entry[:module_names]&.include?(module_name) }
|
|
139
|
+
end
|
|
140
|
+
result ? result[0] : nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns the list of module/class names passed to include/extend/prepend
|
|
144
|
+
# in the given file, from the cache. The file must already be cached;
|
|
145
|
+
# returns [] if not found.
|
|
146
|
+
def includes_in_file(file_path)
|
|
147
|
+
load_disk_cache_once
|
|
148
|
+
entry = @mutex.synchronize { @file_cache[file_path.to_s] }
|
|
149
|
+
return [] unless entry
|
|
150
|
+
|
|
151
|
+
entry[:include_calls] || []
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Convenience wrapper: scan the whole workspace to warm the cache.
|
|
155
|
+
# Fast on subsequent calls (only re-parses files whose mtime changed).
|
|
156
|
+
def scan(workspace_root, excluded_dirnames: [], excluded_paths: [])
|
|
157
|
+
new(workspace_root, nil,
|
|
158
|
+
excluded_dirnames: excluded_dirnames,
|
|
159
|
+
excluded_paths: excluded_paths).scan_workspace
|
|
160
|
+
end
|
|
93
161
|
end
|
|
94
162
|
|
|
95
163
|
def initialize(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
|
|
@@ -99,6 +167,42 @@ module Mbeditor
|
|
|
99
167
|
@excluded_paths = Array(excluded_paths)
|
|
100
168
|
end
|
|
101
169
|
|
|
170
|
+
# Walks the entire workspace and populates the per-file cache (including the
|
|
171
|
+
# new module_names and include_calls fields) without filtering by symbol.
|
|
172
|
+
# Used by +module_defined_in+ to ensure the cache is warm.
|
|
173
|
+
def scan_workspace
|
|
174
|
+
self.class.load_disk_cache_once
|
|
175
|
+
@new_entries = false
|
|
176
|
+
files_scanned = 0
|
|
177
|
+
evict_deleted_cache_entries
|
|
178
|
+
|
|
179
|
+
Find.find(@workspace_root) do |path|
|
|
180
|
+
if File.directory?(path)
|
|
181
|
+
dirname = File.basename(path)
|
|
182
|
+
rel_dir = relative_path(path)
|
|
183
|
+
Find.prune if path != @workspace_root && excluded_dir?(dirname, rel_dir)
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
next unless path.end_with?(".rb")
|
|
187
|
+
|
|
188
|
+
rel = relative_path(path)
|
|
189
|
+
next if excluded_rel_path?(rel, File.basename(path))
|
|
190
|
+
|
|
191
|
+
files_scanned += 1
|
|
192
|
+
if files_scanned > MAX_FILES_SCANNED
|
|
193
|
+
Rails.logger.warn("[mbeditor] RubyDefinitionService: workspace exceeds #{MAX_FILES_SCANNED} .rb files; stopping scan early")
|
|
194
|
+
break
|
|
195
|
+
end
|
|
196
|
+
begin
|
|
197
|
+
cache_entry_for(path)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
self.class.persist_cache if @new_entries
|
|
204
|
+
end
|
|
205
|
+
|
|
102
206
|
def call
|
|
103
207
|
self.class.load_disk_cache_once
|
|
104
208
|
|
|
@@ -134,7 +238,7 @@ module Mbeditor
|
|
|
134
238
|
cached = cache_entry_for(path)
|
|
135
239
|
next unless cached
|
|
136
240
|
|
|
137
|
-
hit_lines = cached[:all_defs]
|
|
241
|
+
hit_lines = @symbol ? cached[:all_defs].fetch(@symbol, nil) : nil
|
|
138
242
|
next unless hit_lines && hit_lines.any?
|
|
139
243
|
|
|
140
244
|
hit_lines.each do |def_line|
|
|
@@ -173,13 +277,14 @@ module Mbeditor
|
|
|
173
277
|
def cache_entry_for(path)
|
|
174
278
|
mtime = File.mtime(path).to_f
|
|
175
279
|
cached = self.class.mutex.synchronize { self.class.file_cache[path] }
|
|
176
|
-
return cached if cached && cached[:mtime] == mtime
|
|
177
|
-
|
|
178
|
-
source
|
|
179
|
-
lines
|
|
180
|
-
sexp
|
|
181
|
-
all_defs = sexp ? collect_all_defs(sexp) : {}
|
|
182
|
-
entry
|
|
280
|
+
return cached if cached && cached[:mtime] == mtime && !cached[:module_names].nil?
|
|
281
|
+
|
|
282
|
+
source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
283
|
+
lines = source.split("\n", -1)
|
|
284
|
+
sexp = Ripper.sexp(source)
|
|
285
|
+
all_defs, module_names, include_calls = sexp ? collect_all_defs(sexp) : [{}, [], []]
|
|
286
|
+
entry = { mtime: mtime, lines: lines, all_defs: all_defs,
|
|
287
|
+
module_names: module_names, include_calls: include_calls }
|
|
183
288
|
self.class.mutex.synchronize { self.class.file_cache[path] = entry }
|
|
184
289
|
@new_entries = true
|
|
185
290
|
entry
|
|
@@ -187,15 +292,18 @@ module Mbeditor
|
|
|
187
292
|
nil
|
|
188
293
|
end
|
|
189
294
|
|
|
190
|
-
# Walk the Ripper sexp once and collect
|
|
191
|
-
#
|
|
295
|
+
# Walk the Ripper sexp once and collect method definitions, module/class names,
|
|
296
|
+
# and include/extend/prepend calls.
|
|
297
|
+
# Returns [defs_hash, module_names_array, include_calls_array].
|
|
192
298
|
def collect_all_defs(sexp)
|
|
193
|
-
defs
|
|
194
|
-
|
|
195
|
-
|
|
299
|
+
defs = Hash.new { |h, k| h[k] = [] }
|
|
300
|
+
module_names = []
|
|
301
|
+
include_calls = []
|
|
302
|
+
walk_all(sexp, defs, module_names, include_calls)
|
|
303
|
+
[defs, module_names, include_calls]
|
|
196
304
|
end
|
|
197
305
|
|
|
198
|
-
def walk_all(node, defs)
|
|
306
|
+
def walk_all(node, defs, module_names, include_calls)
|
|
199
307
|
return unless node.is_a?(Array)
|
|
200
308
|
|
|
201
309
|
case node[0]
|
|
@@ -205,7 +313,7 @@ module Mbeditor
|
|
|
205
313
|
line = name_node[2]&.first
|
|
206
314
|
defs[name_node[1]] << line if line
|
|
207
315
|
end
|
|
208
|
-
node[1..].each { |child| walk_all(child, defs) }
|
|
316
|
+
node[1..].each { |child| walk_all(child, defs, module_names, include_calls) }
|
|
209
317
|
|
|
210
318
|
when :defs
|
|
211
319
|
name_node = node[3]
|
|
@@ -213,10 +321,44 @@ module Mbeditor
|
|
|
213
321
|
line = name_node[2]&.first
|
|
214
322
|
defs[name_node[1]] << line if line
|
|
215
323
|
end
|
|
216
|
-
node[1..].each { |child| walk_all(child, defs) }
|
|
324
|
+
node[1..].each { |child| walk_all(child, defs, module_names, include_calls) }
|
|
325
|
+
|
|
326
|
+
when :module, :class
|
|
327
|
+
# Collect the module/class constant name.
|
|
328
|
+
# sexp: [:module, [:const_ref, [:@const, "Name", [line, col]]], body]
|
|
329
|
+
# or: [:class, [:const_ref, [:@const, "Name", [line, col]]], superclass, body]
|
|
330
|
+
const_ref = node[1]
|
|
331
|
+
if const_ref.is_a?(Array)
|
|
332
|
+
inner = const_ref[0] == :const_ref ? const_ref[1] : const_ref
|
|
333
|
+
if inner.is_a?(Array) && inner[0] == :@const && inner[1].is_a?(String)
|
|
334
|
+
module_names << inner[1]
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
node[1..].each { |child| walk_all(child, defs, module_names, include_calls) }
|
|
338
|
+
|
|
339
|
+
when :command
|
|
340
|
+
# Collect include/extend/prepend calls.
|
|
341
|
+
# sexp: [:command, [:@ident, "include", [line, col]],
|
|
342
|
+
# [:args_add_block, [[:var_ref, [:@const, "Name", ...]], ...], false]]
|
|
343
|
+
ident_node = node[1]
|
|
344
|
+
if ident_node.is_a?(Array) && ident_node[0] == :@ident &&
|
|
345
|
+
%w[include extend prepend].include?(ident_node[1])
|
|
346
|
+
args_node = node[2]
|
|
347
|
+
if args_node.is_a?(Array) && args_node[0] == :args_add_block
|
|
348
|
+
Array(args_node[1]).each do |arg|
|
|
349
|
+
if arg.is_a?(Array) && arg[0] == :var_ref
|
|
350
|
+
const_node = arg[1]
|
|
351
|
+
if const_node.is_a?(Array) && const_node[0] == :@const && const_node[1].is_a?(String)
|
|
352
|
+
include_calls << const_node[1]
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
node[1..].each { |child| walk_all(child, defs, module_names, include_calls) }
|
|
217
359
|
|
|
218
360
|
else
|
|
219
|
-
node.each { |child| walk_all(child, defs) }
|
|
361
|
+
node.each { |child| walk_all(child, defs, module_names, include_calls) }
|
|
220
362
|
end
|
|
221
363
|
end
|
|
222
364
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Mbeditor
|
|
7
|
+
# Finds method definitions in a file that have no call-sites anywhere in the
|
|
8
|
+
# workspace. Uses a single ripgrep (or grep) subprocess with an alternation
|
|
9
|
+
# pattern so cost is O(1) subprocesses regardless of how many methods the
|
|
10
|
+
# file contains.
|
|
11
|
+
#
|
|
12
|
+
# Results are cached per-file; entries are invalidated when the file's mtime
|
|
13
|
+
# changes OR when the entry is older than CACHE_TTL_SECONDS (handles edits
|
|
14
|
+
# to other files that may add or remove call-sites).
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# UnusedMethodsService.call(workspace_root, abs_path,
|
|
18
|
+
# excluded_dirnames: [], excluded_paths: [])
|
|
19
|
+
# → [{ name: "my_method", line: 42 }, ...]
|
|
20
|
+
class UnusedMethodsService
|
|
21
|
+
CACHE_TTL_SECONDS = 30
|
|
22
|
+
RG_TIMEOUT = 10
|
|
23
|
+
GREP_TIMEOUT = 30
|
|
24
|
+
|
|
25
|
+
@cache = {}
|
|
26
|
+
@cache_mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def call(workspace_root, file_path, excluded_dirnames: [], excluded_paths: [])
|
|
30
|
+
file_path = file_path.to_s
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
mtime = File.mtime(file_path).to_f
|
|
34
|
+
rescue StandardError
|
|
35
|
+
return []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Return cached result if file mtime matches and the entry is fresh.
|
|
39
|
+
cached = @cache_mutex.synchronize { @cache[file_path] }
|
|
40
|
+
if cached && cached[:mtime] == mtime &&
|
|
41
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - cached[:ts]) < CACHE_TTL_SECONDS
|
|
42
|
+
return cached[:result]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Ensure the file is indexed (populates module_names / include_calls too).
|
|
46
|
+
defs = RubyDefinitionService.defs_in_file(file_path)
|
|
47
|
+
if defs.empty?
|
|
48
|
+
# File not yet in cache; trigger a parse of just this one file by
|
|
49
|
+
# calling the definition service with a dummy symbol.
|
|
50
|
+
RubyDefinitionService.call(workspace_root, "__mbeditor_warmup__",
|
|
51
|
+
excluded_dirnames: excluded_dirnames,
|
|
52
|
+
excluded_paths: excluded_paths)
|
|
53
|
+
defs = RubyDefinitionService.defs_in_file(file_path)
|
|
54
|
+
end
|
|
55
|
+
return [] if defs.empty?
|
|
56
|
+
|
|
57
|
+
method_names = defs.keys
|
|
58
|
+
counts = count_occurrences(method_names, workspace_root.to_s,
|
|
59
|
+
excluded_dirnames: excluded_dirnames,
|
|
60
|
+
excluded_paths: excluded_paths)
|
|
61
|
+
|
|
62
|
+
# A method with ≤1 total occurrence has only its own `def` line; no call-sites.
|
|
63
|
+
unused = method_names.select { |n| counts.fetch(n, 0) <= 1 }
|
|
64
|
+
result = unused.filter_map do |name|
|
|
65
|
+
entries = defs[name]
|
|
66
|
+
next unless entries&.any?
|
|
67
|
+
{ name: name, line: entries.first[:line] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
@cache_mutex.synchronize do
|
|
72
|
+
@cache[file_path] = { mtime: mtime, ts: ts, result: result }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result
|
|
76
|
+
rescue StandardError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Exposed for tests.
|
|
81
|
+
def clear_cache!
|
|
82
|
+
@cache_mutex.synchronize { @cache.clear }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
RG_AVAILABLE = system("which rg > /dev/null 2>&1")
|
|
88
|
+
|
|
89
|
+
def count_occurrences(method_names, workspace_root, excluded_dirnames:, excluded_paths:)
|
|
90
|
+
# Build one alternation pattern matching any of the method names as
|
|
91
|
+
# whole identifiers. We escape each name and join with |.
|
|
92
|
+
escaped = method_names.map { |n| Regexp.escape(n) }
|
|
93
|
+
pattern = "\\b(#{escaped.join('|')})\\b"
|
|
94
|
+
|
|
95
|
+
if RG_AVAILABLE
|
|
96
|
+
run_ripgrep(pattern, method_names, workspace_root, excluded_paths)
|
|
97
|
+
else
|
|
98
|
+
run_grep(pattern, method_names, workspace_root, excluded_dirnames)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ripgrep: --only-matching outputs one match per line (just the matched text).
|
|
103
|
+
def run_ripgrep(pattern, method_names, workspace_root, excluded_paths)
|
|
104
|
+
args = %w[rg --only-matching --no-filename --no-ignore --glob **/*.rb]
|
|
105
|
+
excluded_paths.each { |p| args += ["--glob", "!#{p}"] }
|
|
106
|
+
args += ["-e", pattern, workspace_root]
|
|
107
|
+
|
|
108
|
+
counts = Hash.new(0)
|
|
109
|
+
name_set = method_names.to_set
|
|
110
|
+
Timeout.timeout(RG_TIMEOUT) do
|
|
111
|
+
IO.popen(args, err: File::NULL) do |io|
|
|
112
|
+
io.each_line { |line| name = line.chomp; counts[name] += 1 if name_set.include?(name) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
counts
|
|
116
|
+
rescue StandardError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# grep -oh: -o outputs only the matching part, -h suppresses filenames.
|
|
121
|
+
def run_grep(pattern, method_names, workspace_root, excluded_dirnames)
|
|
122
|
+
args = ["grep", "-roh", "-E", pattern, "--include=*.rb"]
|
|
123
|
+
excluded_dirnames.each { |d| args += ["--exclude-dir=#{d}"] }
|
|
124
|
+
args << workspace_root
|
|
125
|
+
|
|
126
|
+
counts = Hash.new(0)
|
|
127
|
+
name_set = method_names.to_set
|
|
128
|
+
Timeout.timeout(GREP_TIMEOUT) do
|
|
129
|
+
IO.popen(args, err: File::NULL) do |io|
|
|
130
|
+
io.each_line { |line| name = line.chomp; counts[name] += 1 if name_set.include?(name) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
counts
|
|
134
|
+
rescue StandardError
|
|
135
|
+
{}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -69,11 +69,16 @@
|
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
// UMD
|
|
74
|
-
//
|
|
75
|
-
// app once all Prettier scripts are ready.
|
|
72
|
+
// Hide window.define before deferred vendor scripts (React, ReactDOM, etc.) execute
|
|
73
|
+
// so their UMD wrappers set window globals instead of registering as AMD modules.
|
|
74
|
+
// Prettier is loaded lazily on first Format action via window.loadPrettierPlugins().
|
|
76
75
|
(function() {
|
|
76
|
+
var _define = window.define;
|
|
77
|
+
window.define = undefined;
|
|
78
|
+
|
|
79
|
+
// Expose lazy Prettier loader — called on first Format action.
|
|
80
|
+
// Temporarily hides window.define so Prettier's UMD wrapper sets
|
|
81
|
+
// window.prettier / window.prettierPlugins instead of using AMD.
|
|
77
82
|
var prettierScripts = [
|
|
78
83
|
'<%= asset_path("prettier-standalone.js") %>',
|
|
79
84
|
'<%= asset_path("prettier-plugin-babel.js") %>',
|
|
@@ -83,64 +88,89 @@
|
|
|
83
88
|
'<%= asset_path("prettier-plugin-markdown.js") %>'
|
|
84
89
|
];
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
ReactDOM: window.ReactDOM
|
|
100
|
-
};
|
|
101
|
-
// Restore whatever the host app had before our deferred scripts ran.
|
|
102
|
-
window.React = window._mbeditorHostReact;
|
|
103
|
-
window.ReactDOM = window._mbeditorHostReactDOM;
|
|
104
|
-
|
|
105
|
-
window.define = _define;
|
|
106
|
-
|
|
107
|
-
// Wait for Monaco to load before initializing application scripts
|
|
108
|
-
require(['vs/editor/editor.main'], function() {
|
|
109
|
-
// Register custom themes from the vendored bundle
|
|
110
|
-
if (window.MBEDITOR_CUSTOM_THEMES && window.monaco) {
|
|
111
|
-
Object.keys(window.MBEDITOR_CUSTOM_THEMES).forEach(function(id) {
|
|
112
|
-
window.monaco.editor.defineTheme(id, window.MBEDITOR_CUSTOM_THEMES[id]);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
var appScript = document.createElement('script');
|
|
116
|
-
appScript.src = '<%= asset_path("mbeditor/application.js") %>';
|
|
117
|
-
appScript.onload = function() {
|
|
118
|
-
var root = document.getElementById('mbeditor-root');
|
|
119
|
-
var _R = window.MbeditorRuntime.React;
|
|
120
|
-
var _RD = window.MbeditorRuntime.ReactDOM;
|
|
121
|
-
if (window.MbeditorApp && _R && _RD) {
|
|
122
|
-
_RD.render(_R.createElement(window.MbeditorApp), root);
|
|
123
|
-
} else {
|
|
124
|
-
console.error("Failed to mount: MbeditorApp or MbeditorRuntime is undefined.");
|
|
91
|
+
window.loadPrettierPlugins = function() {
|
|
92
|
+
if (window._prettierLoadPromise) return window._prettierLoadPromise;
|
|
93
|
+
window._prettierLoadPromise = new Promise(function(resolve, reject) {
|
|
94
|
+
var savedDefine = window.define;
|
|
95
|
+
window.define = undefined;
|
|
96
|
+
var pending = prettierScripts.length;
|
|
97
|
+
prettierScripts.forEach(function(src) {
|
|
98
|
+
var s = document.createElement('script');
|
|
99
|
+
s.src = src;
|
|
100
|
+
s.onload = function() {
|
|
101
|
+
if (--pending === 0) {
|
|
102
|
+
window.define = savedDefine;
|
|
103
|
+
resolve();
|
|
125
104
|
}
|
|
126
105
|
};
|
|
127
|
-
|
|
106
|
+
s.onerror = function() {
|
|
107
|
+
window.define = savedDefine;
|
|
108
|
+
reject(new Error('Failed to load Prettier: ' + src));
|
|
109
|
+
};
|
|
110
|
+
document.head.appendChild(s);
|
|
128
111
|
});
|
|
129
|
-
}
|
|
112
|
+
});
|
|
113
|
+
return window._prettierLoadPromise;
|
|
114
|
+
};
|
|
130
115
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
// Deferred vendor scripts (React, ReactDOM, etc.) run before DOMContentLoaded.
|
|
117
|
+
// Restoring window.define before they execute would cause them to register
|
|
118
|
+
// as AMD modules instead of setting window globals, so wait for DOMContentLoaded first.
|
|
119
|
+
function proceed() {
|
|
120
|
+
// Store mbeditor's React/ReactDOM under a private namespace so the
|
|
121
|
+
// app bundle can use them without touching window.React / window.ReactDOM.
|
|
122
|
+
window.MbeditorRuntime = {
|
|
123
|
+
React: window.React,
|
|
124
|
+
ReactDOM: window.ReactDOM
|
|
125
|
+
};
|
|
126
|
+
// Restore whatever the host app had before our deferred scripts ran.
|
|
127
|
+
window.React = window._mbeditorHostReact;
|
|
128
|
+
window.ReactDOM = window._mbeditorHostReactDOM;
|
|
129
|
+
|
|
130
|
+
window.define = _define;
|
|
131
|
+
|
|
132
|
+
// Expose a promise that resolves when Monaco finishes loading.
|
|
133
|
+
// EditorPanel awaits this before calling monaco.editor.create().
|
|
134
|
+
var _monacoResolve;
|
|
135
|
+
window.__monacoReady = new Promise(function(resolve) { _monacoResolve = resolve; });
|
|
136
|
+
|
|
137
|
+
// Load application.js immediately — React mounts before Monaco is ready.
|
|
138
|
+
// EditorPanel will show a skeleton until window.__monacoReady resolves.
|
|
139
|
+
var appScript = document.createElement('script');
|
|
140
|
+
appScript.src = '<%= asset_path("mbeditor/application.js") %>';
|
|
141
|
+
appScript.onload = function() {
|
|
142
|
+
var root = document.getElementById('mbeditor-root');
|
|
143
|
+
var _R = window.MbeditorRuntime.React;
|
|
144
|
+
var _RD = window.MbeditorRuntime.ReactDOM;
|
|
145
|
+
if (window.MbeditorApp && _R && _RD) {
|
|
146
|
+
_RD.render(_R.createElement(window.MbeditorApp), root);
|
|
147
|
+
} else {
|
|
148
|
+
console.error("Failed to mount: MbeditorApp or MbeditorRuntime is undefined.");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
appScript.onerror = function() {
|
|
152
|
+
console.error('Mbeditor: failed to load application.js');
|
|
153
|
+
document.getElementById('mbeditor-root').innerHTML = '<div style="padding:2rem;font-family:sans-serif;color:#c00">Editor failed to load. Please refresh the page.</div>';
|
|
154
|
+
};
|
|
155
|
+
document.body.appendChild(appScript);
|
|
156
|
+
|
|
157
|
+
// Load Monaco in parallel — resolves the promise when done.
|
|
158
|
+
require(['vs/editor/editor.main'], function() {
|
|
159
|
+
// Register custom themes from the vendored bundle
|
|
160
|
+
if (window.MBEDITOR_CUSTOM_THEMES && window.monaco) {
|
|
161
|
+
Object.keys(window.MBEDITOR_CUSTOM_THEMES).forEach(function(id) {
|
|
162
|
+
window.monaco.editor.defineTheme(id, window.MBEDITOR_CUSTOM_THEMES[id]);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
_monacoResolve();
|
|
166
|
+
});
|
|
136
167
|
}
|
|
137
168
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
169
|
+
if (window._mbeditorDOMReady) {
|
|
170
|
+
proceed();
|
|
171
|
+
} else {
|
|
172
|
+
document.addEventListener('DOMContentLoaded', proceed, { once: true });
|
|
173
|
+
}
|
|
144
174
|
})();
|
|
145
175
|
</script>
|
|
146
176
|
</body>
|
data/config/routes.rb
CHANGED
|
@@ -19,7 +19,11 @@ Mbeditor::Engine.routes.draw do
|
|
|
19
19
|
post 'branch_state', to: 'editors#save_branch_state'
|
|
20
20
|
post 'prune_branch_states', to: 'editors#prune_branch_states'
|
|
21
21
|
get 'search', to: 'editors#search'
|
|
22
|
+
post 'replace_in_files', to: 'editors#replace_in_files'
|
|
22
23
|
get 'definition', to: 'editors#definition'
|
|
24
|
+
get 'module_members', to: 'editors#module_members'
|
|
25
|
+
get 'file_includes', to: 'editors#file_includes'
|
|
26
|
+
get 'unused_methods', to: 'editors#unused_methods'
|
|
23
27
|
get 'git_info', to: 'editors#git_info'
|
|
24
28
|
get 'git_status', to: 'editors#git_status'
|
|
25
29
|
get 'manifest.webmanifest', to: 'editors#pwa_manifest', format: false
|
|
@@ -9,6 +9,7 @@ module Mbeditor
|
|
|
9
9
|
# Non-Mbeditor ActionCable messages pass through unchanged.
|
|
10
10
|
class CableLogFilter < SimpleDelegator
|
|
11
11
|
SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor/
|
|
12
|
+
CABLE_WEBSOCKET_REQUEST_PATTERN = /(?:Started|Finished) "\/cable(?:\/[^\"]*)?" \[WebSocket\]/
|
|
12
13
|
|
|
13
14
|
# Provides no-op tagged logging APIs for plain Ruby formatters.
|
|
14
15
|
class UntaggedFormatter < SimpleDelegator
|
|
@@ -57,12 +58,16 @@ module Mbeditor
|
|
|
57
58
|
%w[debug info warn error fatal unknown].each do |level|
|
|
58
59
|
define_method(level) do |message = nil, &block|
|
|
59
60
|
msg = message.nil? && block ? block.call : message.to_s
|
|
60
|
-
return if
|
|
61
|
+
return if suppress_message?(msg)
|
|
61
62
|
|
|
62
63
|
super(message, &block)
|
|
63
64
|
end
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
def suppress_message?(message)
|
|
68
|
+
message.match?(SUPPRESS_PATTERN) || message.match?(CABLE_WEBSOCKET_REQUEST_PATTERN)
|
|
69
|
+
end
|
|
70
|
+
|
|
66
71
|
# Tagged-logging compat — the block body still passes through the filter.
|
|
67
72
|
def tagged(*tags, &block)
|
|
68
73
|
if __getobj__.respond_to?(:tagged)
|
data/lib/mbeditor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- app/services/mbeditor/ri_definition_service.rb
|
|
96
96
|
- app/services/mbeditor/ruby_definition_service.rb
|
|
97
97
|
- app/services/mbeditor/test_runner_service.rb
|
|
98
|
+
- app/services/mbeditor/unused_methods_service.rb
|
|
98
99
|
- app/views/layouts/mbeditor/application.html.erb
|
|
99
100
|
- app/views/mbeditor/editors/index.html.erb
|
|
100
101
|
- config/initializers/assets.rb
|