mbeditor 0.2.2
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 +7 -0
- data/CHANGELOG.md +116 -0
- data/README.md +180 -0
- data/app/assets/javascripts/mbeditor/application.js +21 -0
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
- data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
- data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
- data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
- data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
- data/app/assets/javascripts/mbeditor/file_service.js +96 -0
- data/app/assets/javascripts/mbeditor/git_service.js +104 -0
- data/app/assets/javascripts/mbeditor/search_service.js +63 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
- data/app/assets/stylesheets/mbeditor/application.css +848 -0
- data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
- data/app/controllers/mbeditor/application_controller.rb +70 -0
- data/app/controllers/mbeditor/editors_controller.rb +996 -0
- data/app/controllers/mbeditor/git_controller.rb +234 -0
- data/app/services/mbeditor/git_blame_service.rb +98 -0
- data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
- data/app/services/mbeditor/git_diff_service.rb +74 -0
- data/app/services/mbeditor/git_file_history_service.rb +42 -0
- data/app/services/mbeditor/git_service.rb +95 -0
- data/app/services/mbeditor/redmine_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +168 -0
- data/app/services/mbeditor/test_runner_service.rb +286 -0
- data/app/views/layouts/mbeditor/application.html.erb +120 -0
- data/app/views/mbeditor/editors/index.html.erb +1 -0
- data/config/initializers/assets.rb +9 -0
- data/config/routes.rb +44 -0
- data/lib/mbeditor/configuration.rb +22 -0
- data/lib/mbeditor/engine.rb +37 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
- data/lib/mbeditor/version.rb +3 -0
- data/lib/mbeditor.rb +19 -0
- data/mbeditor.gemspec +31 -0
- data/public/mbeditor-icon.svg +4 -0
- data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
- data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
- data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
- data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
- data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
- data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
- data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
- data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
- data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
- data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/monaco-editor/vs/editor/editor.api.js +6 -0
- data/public/monaco-editor/vs/editor/editor.main.css +8 -0
- data/public/monaco-editor/vs/editor/editor.main.js +797 -0
- data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
- data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
- data/public/monaco-editor/vs/loader.js +10 -0
- data/public/monaco-editor/vs/nls.messages.de.js +20 -0
- data/public/monaco-editor/vs/nls.messages.es.js +20 -0
- data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
- data/public/monaco-editor/vs/nls.messages.it.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
- data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
- data/public/monaco_worker.js +5 -0
- data/public/sw.js +8 -0
- data/public/ts_worker.js +5 -0
- data/vendor/assets/javascripts/axios.min.js +5 -0
- data/vendor/assets/javascripts/emmet.js +5452 -0
- data/vendor/assets/javascripts/lodash.min.js +136 -0
- data/vendor/assets/javascripts/marked.min.js +6 -0
- data/vendor/assets/javascripts/minisearch.min.js +2044 -0
- data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
- data/vendor/assets/javascripts/monaco-vim.js +9867 -0
- data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
- data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
- data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
- data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
- data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
- data/vendor/assets/javascripts/prettier-standalone.js +37 -0
- data/vendor/assets/javascripts/react-dom.min.js +267 -0
- data/vendor/assets/javascripts/react.min.js +31 -0
- data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
- data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
- metadata +188 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "ripper"
|
|
5
|
+
|
|
6
|
+
module Mbeditor
|
|
7
|
+
# Searches .rb files in a workspace for definitions of a named Ruby method
|
|
8
|
+
# using Ripper's AST parser (no subprocesses).
|
|
9
|
+
#
|
|
10
|
+
# Returns an array of hashes, each describing one definition site:
|
|
11
|
+
# {
|
|
12
|
+
# file: String, # workspace-relative path
|
|
13
|
+
# line: Integer, # 1-based line number of the `def` keyword
|
|
14
|
+
# signature: String, # trimmed source text of the def line
|
|
15
|
+
# comments: String # leading # comment lines immediately above the def (may be empty)
|
|
16
|
+
# }
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# results = RubyDefinitionService.call(workspace_root, "my_method",
|
|
20
|
+
# excluded_dirnames: %w[tmp .git])
|
|
21
|
+
class RubyDefinitionService
|
|
22
|
+
MAX_RESULTS = 20
|
|
23
|
+
MAX_COMMENT_LOOKAHEAD = 15
|
|
24
|
+
|
|
25
|
+
def self.call(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
|
|
26
|
+
new(workspace_root, symbol,
|
|
27
|
+
excluded_dirnames: excluded_dirnames,
|
|
28
|
+
excluded_paths: excluded_paths).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
|
|
32
|
+
@workspace_root = workspace_root.to_s.chomp("/")
|
|
33
|
+
@symbol = symbol
|
|
34
|
+
@excluded_dirnames = Array(excluded_dirnames)
|
|
35
|
+
@excluded_paths = Array(excluded_paths)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
results = []
|
|
40
|
+
|
|
41
|
+
Find.find(@workspace_root) do |path|
|
|
42
|
+
# Prune excluded directories
|
|
43
|
+
if File.directory?(path)
|
|
44
|
+
dirname = File.basename(path)
|
|
45
|
+
rel_dir = relative_path(path)
|
|
46
|
+
if path != @workspace_root && excluded_dir?(dirname, rel_dir)
|
|
47
|
+
Find.prune
|
|
48
|
+
end
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
next unless path.end_with?(".rb")
|
|
53
|
+
|
|
54
|
+
rel = relative_path(path)
|
|
55
|
+
next if excluded_rel_path?(rel, File.basename(path))
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
59
|
+
lines = source.split("\n", -1)
|
|
60
|
+
hits = find_definitions(source, @symbol)
|
|
61
|
+
|
|
62
|
+
hits.each do |def_line|
|
|
63
|
+
results << {
|
|
64
|
+
file: rel,
|
|
65
|
+
line: def_line,
|
|
66
|
+
signature: (lines[def_line - 1] || "").strip,
|
|
67
|
+
comments: extract_comments(lines, def_line)
|
|
68
|
+
}
|
|
69
|
+
return results if results.length >= MAX_RESULTS
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
# Malformed file or unreadable; skip silently
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
results
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Parse `source` with Ripper and return sorted array of 1-based line numbers
|
|
82
|
+
# where a method named `symbol` is defined (handles both `def foo` and `def self.foo`).
|
|
83
|
+
def find_definitions(source, symbol)
|
|
84
|
+
sexp = Ripper.sexp(source)
|
|
85
|
+
return [] unless sexp
|
|
86
|
+
|
|
87
|
+
lines = []
|
|
88
|
+
walk(sexp, symbol, lines)
|
|
89
|
+
lines.sort
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Recursive sexp walker. Ripper represents:
|
|
93
|
+
# def foo(...) as [:def, [:@ident, "foo", [line, col]], ...]
|
|
94
|
+
# def self.foo as [:defs, receiver, [:@ident, "foo", [line, col]], ...]
|
|
95
|
+
def walk(node, symbol, lines)
|
|
96
|
+
return unless node.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
case node[0]
|
|
99
|
+
when :def
|
|
100
|
+
# node[1] is the method name node: [:@ident, "name", [line, col]]
|
|
101
|
+
name_node = node[1]
|
|
102
|
+
if name_node.is_a?(Array) && name_node[1].to_s == symbol
|
|
103
|
+
line = name_node[2]&.first
|
|
104
|
+
lines << line if line
|
|
105
|
+
end
|
|
106
|
+
# Still walk children in case of nested defs
|
|
107
|
+
node[1..].each { |child| walk(child, symbol, lines) }
|
|
108
|
+
|
|
109
|
+
when :defs
|
|
110
|
+
# node[3] is the method name node for `def self.foo`
|
|
111
|
+
name_node = node[3]
|
|
112
|
+
if name_node.is_a?(Array) && name_node[1].to_s == symbol
|
|
113
|
+
line = name_node[2]&.first
|
|
114
|
+
lines << line if line
|
|
115
|
+
end
|
|
116
|
+
node[1..].each { |child| walk(child, symbol, lines) }
|
|
117
|
+
|
|
118
|
+
else
|
|
119
|
+
node.each { |child| walk(child, symbol, lines) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Walk backwards from def_line collecting contiguous # comment lines.
|
|
124
|
+
# Stops on blank lines or non-comment lines.
|
|
125
|
+
def extract_comments(lines, def_line)
|
|
126
|
+
comment_lines = []
|
|
127
|
+
idx = def_line - 2 # 0-based index of the line immediately above the def
|
|
128
|
+
|
|
129
|
+
MAX_COMMENT_LOOKAHEAD.times do
|
|
130
|
+
break if idx < 0
|
|
131
|
+
|
|
132
|
+
line = lines[idx].to_s.strip
|
|
133
|
+
break if line.empty?
|
|
134
|
+
break unless line.start_with?("#")
|
|
135
|
+
|
|
136
|
+
comment_lines.unshift(line)
|
|
137
|
+
idx -= 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
comment_lines.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def relative_path(full_path)
|
|
144
|
+
full_path.to_s.delete_prefix(@workspace_root).delete_prefix("/")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def excluded_dir?(dirname, rel_dir)
|
|
148
|
+
@excluded_dirnames.include?(dirname) ||
|
|
149
|
+
@excluded_paths.any? do |pattern|
|
|
150
|
+
if pattern.include?("/")
|
|
151
|
+
rel_dir == pattern || rel_dir.start_with?("#{pattern}/")
|
|
152
|
+
else
|
|
153
|
+
dirname == pattern
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def excluded_rel_path?(rel, name)
|
|
159
|
+
@excluded_paths.any? do |pattern|
|
|
160
|
+
if pattern.include?("/")
|
|
161
|
+
rel == pattern || rel.start_with?("#{pattern}/")
|
|
162
|
+
else
|
|
163
|
+
name == pattern || rel.split("/").include?(pattern)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Mbeditor
|
|
8
|
+
# Runs a Ruby test file (Minitest or RSpec) and parses the output into a
|
|
9
|
+
# structured result suitable for the editor UI.
|
|
10
|
+
#
|
|
11
|
+
# Follows the same process-group kill pattern used by the lint endpoint to
|
|
12
|
+
# enforce a configurable timeout.
|
|
13
|
+
module TestRunnerService
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Run the test file at +test_path+ inside +repo_path+.
|
|
17
|
+
# Returns a Hash:
|
|
18
|
+
# {
|
|
19
|
+
# ok: true/false,
|
|
20
|
+
# summary: { total:, passed:, failed:, errored:, skipped:, duration: },
|
|
21
|
+
# tests: [{ name:, status:, line:, message: }],
|
|
22
|
+
# raw: String # full stdout+stderr for fallback display
|
|
23
|
+
# }
|
|
24
|
+
def run(repo_path, test_path, framework: nil, command: nil, timeout: 60)
|
|
25
|
+
framework = detect_framework(repo_path, test_path) if framework.nil?
|
|
26
|
+
return error_result("Could not detect test framework") unless framework
|
|
27
|
+
|
|
28
|
+
cmd = build_command(repo_path, test_path, framework, command)
|
|
29
|
+
raw, timed_out = execute_with_timeout(repo_path, cmd, timeout)
|
|
30
|
+
|
|
31
|
+
return error_result("Test run timed out after #{timeout} seconds") if timed_out
|
|
32
|
+
|
|
33
|
+
tests, summary = parse_output(raw, framework)
|
|
34
|
+
{
|
|
35
|
+
ok: true,
|
|
36
|
+
framework: framework.to_s,
|
|
37
|
+
summary: summary,
|
|
38
|
+
tests: tests,
|
|
39
|
+
raw: raw
|
|
40
|
+
}
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
error_result(e.message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Given a source file path, resolve it to its matching test/spec file.
|
|
46
|
+
# If the file is already a test/spec file, return it as-is.
|
|
47
|
+
def resolve_test_file(repo_path, relative_path)
|
|
48
|
+
return relative_path if test_file?(relative_path)
|
|
49
|
+
|
|
50
|
+
candidates = test_file_candidates(relative_path)
|
|
51
|
+
candidates.find { |c| File.exist?(File.join(repo_path, c)) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_file?(path)
|
|
55
|
+
path.match?(%r{(^|/)test/.*_test\.rb$}) ||
|
|
56
|
+
path.match?(%r{(^|/)spec/.*_spec\.rb$}) ||
|
|
57
|
+
path.end_with?("_test.rb") ||
|
|
58
|
+
path.end_with?("_spec.rb")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_file_candidates(relative_path)
|
|
62
|
+
return [] unless relative_path.end_with?(".rb")
|
|
63
|
+
|
|
64
|
+
basename = File.basename(relative_path, ".rb")
|
|
65
|
+
dir_parts = relative_path.split("/")
|
|
66
|
+
|
|
67
|
+
candidates = []
|
|
68
|
+
|
|
69
|
+
# app/models/user.rb -> test/models/user_test.rb
|
|
70
|
+
if dir_parts[0] == "app" && dir_parts.length > 1
|
|
71
|
+
sub_path = dir_parts[1..].join("/")
|
|
72
|
+
sub_dir = File.dirname(sub_path)
|
|
73
|
+
candidates << File.join("test", sub_dir, "#{basename}_test.rb")
|
|
74
|
+
candidates << File.join("spec", sub_dir, "#{basename}_spec.rb")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# lib/foo.rb -> test/lib/foo_test.rb or test/foo_test.rb
|
|
78
|
+
if dir_parts[0] == "lib"
|
|
79
|
+
sub_path = dir_parts[1..].join("/")
|
|
80
|
+
sub_dir = File.dirname(sub_path)
|
|
81
|
+
candidates << File.join("test", "lib", sub_dir, "#{basename}_test.rb")
|
|
82
|
+
candidates << File.join("test", sub_dir, "#{basename}_test.rb")
|
|
83
|
+
candidates << File.join("spec", "lib", sub_dir, "#{basename}_spec.rb")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fallback: test/<basename>_test.rb
|
|
87
|
+
candidates << File.join("test", "#{basename}_test.rb")
|
|
88
|
+
candidates << File.join("spec", "#{basename}_spec.rb")
|
|
89
|
+
|
|
90
|
+
candidates.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def detect_framework(repo_path, test_path)
|
|
94
|
+
return :rspec if test_path.end_with?("_spec.rb")
|
|
95
|
+
return :minitest if test_path.end_with?("_test.rb")
|
|
96
|
+
|
|
97
|
+
# Check project-level hints
|
|
98
|
+
return :rspec if File.exist?(File.join(repo_path, ".rspec"))
|
|
99
|
+
return :rspec if File.exist?(File.join(repo_path, "spec"))
|
|
100
|
+
|
|
101
|
+
:minitest if File.exist?(File.join(repo_path, "test"))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_command(repo_path, test_path, framework, custom_command)
|
|
105
|
+
full_path = File.join(repo_path, test_path)
|
|
106
|
+
|
|
107
|
+
if custom_command.present?
|
|
108
|
+
tokens = Shellwords.split(custom_command)
|
|
109
|
+
return tokens + [full_path]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
case framework.to_sym
|
|
113
|
+
when :rspec
|
|
114
|
+
bin = File.join(repo_path, "bin", "rspec")
|
|
115
|
+
cmd = File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"]
|
|
116
|
+
cmd + ["--format", "json", full_path]
|
|
117
|
+
when :minitest
|
|
118
|
+
bin = File.join(repo_path, "bin", "rails")
|
|
119
|
+
if File.exist?(bin)
|
|
120
|
+
[bin, "test", "--verbose", full_path]
|
|
121
|
+
else
|
|
122
|
+
["bundle", "exec", "ruby", "-Itest", full_path, "--verbose"]
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
["bundle", "exec", "ruby", "-Itest", full_path]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def execute_with_timeout(repo_path, cmd, timeout)
|
|
130
|
+
raw = +""
|
|
131
|
+
timed_out = false
|
|
132
|
+
|
|
133
|
+
Open3.popen3(*cmd, chdir: repo_path, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
134
|
+
stdin.close
|
|
135
|
+
|
|
136
|
+
timer = Thread.new do
|
|
137
|
+
sleep timeout
|
|
138
|
+
timed_out = true
|
|
139
|
+
Process.kill("-KILL", wait_thr.pid)
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
out = stdout.read
|
|
145
|
+
err = stderr.read
|
|
146
|
+
raw = out.to_s + err.to_s
|
|
147
|
+
wait_thr.value
|
|
148
|
+
timer.kill
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
[raw, timed_out]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_output(raw, framework)
|
|
155
|
+
case framework.to_sym
|
|
156
|
+
when :rspec
|
|
157
|
+
parse_rspec_output(raw)
|
|
158
|
+
when :minitest
|
|
159
|
+
parse_minitest_output(raw)
|
|
160
|
+
else
|
|
161
|
+
[[], empty_summary]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_rspec_output(raw)
|
|
166
|
+
# RSpec with --format json embeds JSON in the output
|
|
167
|
+
json_match = raw.match(/(\{.*"summary_line".*\})/m)
|
|
168
|
+
if json_match
|
|
169
|
+
data = JSON.parse(json_match[1])
|
|
170
|
+
summary = {
|
|
171
|
+
total: data.dig("summary", "example_count") || 0,
|
|
172
|
+
passed: (data.dig("summary", "example_count") || 0) - (data.dig("summary", "failure_count") || 0) - (data.dig("summary", "pending_count") || 0),
|
|
173
|
+
failed: data.dig("summary", "failure_count") || 0,
|
|
174
|
+
errored: 0,
|
|
175
|
+
skipped: data.dig("summary", "pending_count") || 0,
|
|
176
|
+
duration: data.dig("summary", "duration")&.round(3)
|
|
177
|
+
}
|
|
178
|
+
tests = (data["examples"] || []).map do |ex|
|
|
179
|
+
{
|
|
180
|
+
name: ex["full_description"] || ex["description"],
|
|
181
|
+
status: ex["status"] == "passed" ? "pass" : (ex["status"] == "pending" ? "skip" : "fail"),
|
|
182
|
+
line: ex.dig("line_number"),
|
|
183
|
+
message: ex.dig("exception", "message")
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
[tests, summary]
|
|
187
|
+
else
|
|
188
|
+
parse_minitest_output(raw) # fallback to text parsing
|
|
189
|
+
end
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
parse_minitest_output(raw)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_minitest_output(raw)
|
|
195
|
+
lines = raw.lines
|
|
196
|
+
|
|
197
|
+
# First pass: collect per-test results from verbose output.
|
|
198
|
+
# Verbose format (--verbose): "ClassName#test_name = N.NNN s = [./F/E/S]"
|
|
199
|
+
verbose_results = {}
|
|
200
|
+
lines.each do |line|
|
|
201
|
+
m = line.match(/\A([\w:]+#\w+)\s+=\s+[\d.]+\s+s\s+=\s+([.FES])\s*\z/)
|
|
202
|
+
next unless m
|
|
203
|
+
|
|
204
|
+
status = case m[2]
|
|
205
|
+
when "." then "pass"
|
|
206
|
+
when "F" then "fail"
|
|
207
|
+
when "E" then "error"
|
|
208
|
+
when "S" then "skip"
|
|
209
|
+
end
|
|
210
|
+
verbose_results[m[1]] = { name: m[1], status: status, line: nil, message: nil }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Second pass: parse failure/error blocks for messages and line numbers.
|
|
214
|
+
# Format: " 1) Failure:\nTestName#method [file:line]:\nmessage"
|
|
215
|
+
failure_entries = []
|
|
216
|
+
lines.each_with_index do |line, idx|
|
|
217
|
+
next unless line.match?(/^\s+\d+\)\s+(Failure|Error):/)
|
|
218
|
+
|
|
219
|
+
name_line = lines[idx + 1]
|
|
220
|
+
next unless name_line
|
|
221
|
+
|
|
222
|
+
name = name_line.strip.split(" [").first.chomp(":")
|
|
223
|
+
line_num = name_line[/:(\d+)\]/, 1]&.to_i
|
|
224
|
+
msg_lines = []
|
|
225
|
+
(idx + 2...lines.length).each do |j|
|
|
226
|
+
break if lines[j].strip.empty? || lines[j].match?(/^\s+\d+\)\s+/)
|
|
227
|
+
msg_lines << lines[j].strip
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
entry = {
|
|
231
|
+
name: name,
|
|
232
|
+
status: line.include?("Error") ? "error" : "fail",
|
|
233
|
+
line: line_num,
|
|
234
|
+
message: msg_lines.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if verbose_results.key?(name)
|
|
238
|
+
verbose_results[name][:line] = line_num
|
|
239
|
+
verbose_results[name][:message] = msg_lines.join("\n")
|
|
240
|
+
else
|
|
241
|
+
failure_entries << entry
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Build final tests list: verbose entries first (sorted by name for stability),
|
|
246
|
+
# then any failure entries not already covered by verbose output.
|
|
247
|
+
tests = verbose_results.values + failure_entries
|
|
248
|
+
|
|
249
|
+
summary = empty_summary
|
|
250
|
+
|
|
251
|
+
# Parse summary line: "X runs, Y assertions, Z failures, W errors, V skips"
|
|
252
|
+
# or "X tests, Y assertions, Z failures, W errors, V skips"
|
|
253
|
+
summary_line = lines.find { |l| l.match?(/\d+ (runs|tests), \d+ assertions/) }
|
|
254
|
+
if summary_line
|
|
255
|
+
nums = summary_line.scan(/\d+/).map(&:to_i)
|
|
256
|
+
summary[:total] = nums[0] || 0
|
|
257
|
+
summary[:failed] = nums[2] || 0
|
|
258
|
+
summary[:errored] = nums[3] || 0
|
|
259
|
+
summary[:skipped] = nums[4] || 0
|
|
260
|
+
summary[:passed] = summary[:total] - summary[:failed] - summary[:errored] - summary[:skipped]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Parse timing: "Finished in 0.123456s"
|
|
264
|
+
time_line = lines.find { |l| l.match?(/Finished in [\d.]+s/) }
|
|
265
|
+
if time_line
|
|
266
|
+
summary[:duration] = time_line[/([\d.]+)s/, 1]&.to_f&.round(3)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
[tests, summary]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def empty_summary
|
|
273
|
+
{ total: 0, passed: 0, failed: 0, errored: 0, skipped: 0, duration: nil }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def error_result(message)
|
|
277
|
+
{
|
|
278
|
+
ok: false,
|
|
279
|
+
error: message,
|
|
280
|
+
summary: empty_summary,
|
|
281
|
+
tests: [],
|
|
282
|
+
raw: ""
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="turbo-visit-control" content="reload" />
|
|
7
|
+
<meta name="theme-color" content="#1e1e2e" />
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
|
+
<% pwa_base = request.script_name.to_s.sub(%r{/$}, '') %>
|
|
11
|
+
<link rel="manifest" href="<%= "#{pwa_base}/manifest.webmanifest" %>" />
|
|
12
|
+
<script>
|
|
13
|
+
if ('serviceWorker' in navigator) {
|
|
14
|
+
navigator.serviceWorker.register('<%= json_escape("#{pwa_base}/sw.js") %>', { scope: '<%= json_escape("#{pwa_base}/") %>' });
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
<title>Mbeditor — <%= Rails.root.basename %></title>
|
|
18
|
+
|
|
19
|
+
<!-- ── Fonts and Styles ──────────────────────────────── -->
|
|
20
|
+
<%= stylesheet_link_tag "fontawesome.min", media: "all" %>
|
|
21
|
+
<%= stylesheet_link_tag "mbeditor/application", media: "all" %>
|
|
22
|
+
|
|
23
|
+
<!-- ── Vendor JS (order matters) ─────────────────────── -->
|
|
24
|
+
<script src="<%= asset_path('react.min.js') %>"></script>
|
|
25
|
+
<script src="<%= asset_path('react-dom.min.js') %>"></script>
|
|
26
|
+
<script src="<%= asset_path('axios.min.js') %>"></script>
|
|
27
|
+
<script src="<%= asset_path('lodash.min.js') %>"></script>
|
|
28
|
+
<script src="<%= asset_path('minisearch.min.js') %>"></script>
|
|
29
|
+
<script src="<%= asset_path('marked.min.js') %>"></script>
|
|
30
|
+
<!-- ── Monaco loader ─────────────────────────────────── -->
|
|
31
|
+
<% base_path = request.script_name.to_s.sub(%r{/$}, '') %>
|
|
32
|
+
<script>var require = { paths: { vs: '<%= json_escape("#{base_path}/monaco-editor/vs") %>', 'monaco-vim': '<%= json_escape(asset_path("monaco-vim.js").sub(/\.js$/, "")) %>', 'monaco-editor/esm/vs': '<%= json_escape("#{base_path}/monaco-editor/vs") %>' } };</script>
|
|
33
|
+
<script src="<%= "#{base_path}/monaco-editor/vs/loader.js" %>"></script>
|
|
34
|
+
<!-- ── Emmet + Extra themes (non-AMD, load before app) ── -->
|
|
35
|
+
<script src="<%= asset_path('emmet.js') %>"></script>
|
|
36
|
+
<script src="<%= asset_path('monaco-themes-bundle.js') %>"></script>
|
|
37
|
+
</head>
|
|
38
|
+
<body data-rails-root="<%= Rails.root.to_s %>" data-mbeditor-version="<%= Mbeditor::VERSION %>">
|
|
39
|
+
<script>
|
|
40
|
+
window.MBEDITOR_BASE_PATH = "<%= json_escape(base_path) %>";
|
|
41
|
+
|
|
42
|
+
// Monaco's restoreViewState cancels its own internal rendering promises and
|
|
43
|
+
// those cancellations surface as "Canceled: Canceled" unhandled rejections.
|
|
44
|
+
// This is a known Monaco quirk — the cancellation is intentional and benign.
|
|
45
|
+
// Suppress it here so it doesn't pollute the console.
|
|
46
|
+
window.addEventListener('unhandledrejection', function(event) {
|
|
47
|
+
if (event.reason && event.reason.name === 'Canceled' && event.reason.message === 'Canceled') {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div id="mbeditor-root">
|
|
54
|
+
<%= yield %>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
window.MonacoEnvironment = {
|
|
59
|
+
getWorkerUrl: function(workerId, label) {
|
|
60
|
+
var base = window.MBEDITOR_BASE_PATH || '';
|
|
61
|
+
if (label === 'typescript' || label === 'javascript') {
|
|
62
|
+
return base + '/ts_worker.js';
|
|
63
|
+
}
|
|
64
|
+
return base + '/monaco_worker.js';
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Load Prettier scripts sequentially with define temporarily hidden so their
|
|
69
|
+
// UMD wrapper sets window.prettier / window.prettierPlugins instead of
|
|
70
|
+
// registering as AMD modules via Monaco's loader. Then load Monaco and the
|
|
71
|
+
// app once all Prettier scripts are ready.
|
|
72
|
+
(function() {
|
|
73
|
+
var prettierScripts = [
|
|
74
|
+
'<%= asset_path("prettier-standalone.js") %>',
|
|
75
|
+
'<%= asset_path("prettier-plugin-babel.js") %>',
|
|
76
|
+
'<%= asset_path("prettier-plugin-estree.js") %>',
|
|
77
|
+
'<%= asset_path("prettier-plugin-html.js") %>',
|
|
78
|
+
'<%= asset_path("prettier-plugin-postcss.js") %>',
|
|
79
|
+
'<%= asset_path("prettier-plugin-markdown.js") %>'
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function loadSequential(srcs, done) {
|
|
83
|
+
if (!srcs.length) return done();
|
|
84
|
+
var s = document.createElement('script');
|
|
85
|
+
s.src = srcs[0];
|
|
86
|
+
s.onload = function() { loadSequential(srcs.slice(1), done); };
|
|
87
|
+
document.head.appendChild(s);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var _define = window.define;
|
|
91
|
+
window.define = undefined;
|
|
92
|
+
|
|
93
|
+
loadSequential(prettierScripts, function() {
|
|
94
|
+
window.define = _define;
|
|
95
|
+
|
|
96
|
+
// Wait for Monaco to load before initializing application scripts
|
|
97
|
+
require(['vs/editor/editor.main'], function() {
|
|
98
|
+
// Register custom themes from the vendored bundle
|
|
99
|
+
if (window.MBEDITOR_CUSTOM_THEMES && window.monaco) {
|
|
100
|
+
Object.keys(window.MBEDITOR_CUSTOM_THEMES).forEach(function(id) {
|
|
101
|
+
window.monaco.editor.defineTheme(id, window.MBEDITOR_CUSTOM_THEMES[id]);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
var appScript = document.createElement('script');
|
|
105
|
+
appScript.src = '<%= asset_path("mbeditor/application.js") %>';
|
|
106
|
+
appScript.onload = function() {
|
|
107
|
+
var root = document.getElementById('mbeditor-root');
|
|
108
|
+
if (window.MbeditorApp && window.ReactDOM) {
|
|
109
|
+
window.ReactDOM.render(React.createElement(window.MbeditorApp), root);
|
|
110
|
+
} else {
|
|
111
|
+
console.error("Failed to mount: MbeditorApp or ReactDOM is undefined.");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
document.body.appendChild(appScript);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
118
|
+
</script>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!-- Mounted automatically by layout script -->
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Be sure to restart your server when you modify this file.
|
|
2
|
+
|
|
3
|
+
# Version of your assets, change this if you want to expire all your assets.
|
|
4
|
+
if Rails.application.config.respond_to?(:assets)
|
|
5
|
+
Rails.application.config.assets.version = "1.0"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Add additional assets to the asset load path.
|
|
9
|
+
# Rails.application.config.assets.paths << Emoji.images_path
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Mbeditor::Engine.routes.draw do
|
|
4
|
+
root to: 'editors#index'
|
|
5
|
+
|
|
6
|
+
get 'ping', to: 'editors#ping'
|
|
7
|
+
get 'workspace', to: 'editors#workspace'
|
|
8
|
+
get 'files', to: 'editors#files'
|
|
9
|
+
get 'file', to: 'editors#show'
|
|
10
|
+
get 'raw', to: 'editors#raw'
|
|
11
|
+
post 'file', to: 'editors#save'
|
|
12
|
+
post 'create_file', to: 'editors#create_file'
|
|
13
|
+
post 'create_dir', to: 'editors#create_dir'
|
|
14
|
+
patch 'rename', to: 'editors#rename'
|
|
15
|
+
delete 'delete', to: 'editors#destroy_path'
|
|
16
|
+
get 'state', to: 'editors#state'
|
|
17
|
+
post 'state', to: 'editors#save_state'
|
|
18
|
+
get 'search', to: 'editors#search'
|
|
19
|
+
get 'definition', to: 'editors#definition'
|
|
20
|
+
get 'git_info', to: 'editors#git_info'
|
|
21
|
+
get 'git_status', to: 'editors#git_status'
|
|
22
|
+
get 'manifest.webmanifest', to: 'editors#pwa_manifest', format: false
|
|
23
|
+
get 'sw.js', to: 'editors#pwa_sw', format: false
|
|
24
|
+
get 'mbeditor-icon.svg', to: 'editors#pwa_icon', format: false
|
|
25
|
+
get 'monaco_worker.js', to: 'editors#monaco_worker', format: false
|
|
26
|
+
get 'ts_worker.js', to: 'editors#ts_worker', format: false
|
|
27
|
+
get 'monaco-editor/*asset_path', to: 'editors#monaco_asset', format: false
|
|
28
|
+
get 'min-maps/*asset_path', to: 'editors#monaco_asset', format: false
|
|
29
|
+
post 'lint', to: 'editors#lint'
|
|
30
|
+
post 'quick_fix', to: 'editors#quick_fix'
|
|
31
|
+
post 'format', to: 'editors#format_file'
|
|
32
|
+
post 'test', to: 'editors#run_test'
|
|
33
|
+
|
|
34
|
+
# ── Git & Code Review ──────────────────────────────────────────────────────
|
|
35
|
+
get 'git/diff', to: 'git#diff'
|
|
36
|
+
get 'git/blame', to: 'git#blame'
|
|
37
|
+
get 'git/file_history', to: 'git#file_history'
|
|
38
|
+
get 'git/commit_graph', to: 'git#commit_graph'
|
|
39
|
+
get 'git/commit_detail', to: 'git#commit_detail'
|
|
40
|
+
get 'git/combined_diff', to: 'git#combined_diff'
|
|
41
|
+
|
|
42
|
+
# Redmine integration (enabled via config.mbeditor.redmine_enabled)
|
|
43
|
+
get 'redmine/issue/:id', to: 'git#redmine_issue', as: :redmine_issue
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Mbeditor
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command,
|
|
4
|
+
:redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
|
|
5
|
+
:test_framework, :test_command, :test_timeout,
|
|
6
|
+
:authenticate_with
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@allowed_environments = [:development]
|
|
10
|
+
@workspace_root = nil
|
|
11
|
+
@excluded_paths = %w[.git tmp log node_modules .bundle coverage vendor/bundle]
|
|
12
|
+
@rubocop_command = "rubocop"
|
|
13
|
+
@redmine_enabled = false
|
|
14
|
+
@redmine_url = nil
|
|
15
|
+
@redmine_api_key = nil
|
|
16
|
+
@redmine_ticket_source = :commit # :commit (scan commit messages) or :branch (leading digits of branch name)
|
|
17
|
+
@test_framework = nil # :minitest or :rspec — auto-detected when nil
|
|
18
|
+
@test_command = nil # e.g. "bundle exec ruby -Itest" or "bundle exec rspec"
|
|
19
|
+
@test_timeout = 60 # seconds
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|