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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +180 -0
  4. data/app/assets/javascripts/mbeditor/application.js +21 -0
  5. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
  7. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
  8. data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
  9. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
  10. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
  11. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
  12. data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
  13. data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
  14. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
  16. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
  17. data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
  18. data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
  19. data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
  20. data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
  21. data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
  22. data/app/assets/javascripts/mbeditor/file_service.js +96 -0
  23. data/app/assets/javascripts/mbeditor/git_service.js +104 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +63 -0
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
  26. data/app/assets/stylesheets/mbeditor/application.css +848 -0
  27. data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
  28. data/app/controllers/mbeditor/application_controller.rb +70 -0
  29. data/app/controllers/mbeditor/editors_controller.rb +996 -0
  30. data/app/controllers/mbeditor/git_controller.rb +234 -0
  31. data/app/services/mbeditor/git_blame_service.rb +98 -0
  32. data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
  33. data/app/services/mbeditor/git_diff_service.rb +74 -0
  34. data/app/services/mbeditor/git_file_history_service.rb +42 -0
  35. data/app/services/mbeditor/git_service.rb +95 -0
  36. data/app/services/mbeditor/redmine_service.rb +86 -0
  37. data/app/services/mbeditor/ruby_definition_service.rb +168 -0
  38. data/app/services/mbeditor/test_runner_service.rb +286 -0
  39. data/app/views/layouts/mbeditor/application.html.erb +120 -0
  40. data/app/views/mbeditor/editors/index.html.erb +1 -0
  41. data/config/initializers/assets.rb +9 -0
  42. data/config/routes.rb +44 -0
  43. data/lib/mbeditor/configuration.rb +22 -0
  44. data/lib/mbeditor/engine.rb +37 -0
  45. data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
  46. data/lib/mbeditor/version.rb +3 -0
  47. data/lib/mbeditor.rb +19 -0
  48. data/mbeditor.gemspec +31 -0
  49. data/public/mbeditor-icon.svg +4 -0
  50. data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
  51. data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
  53. data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
  54. data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
  55. data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
  56. data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
  57. data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
  58. data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
  59. data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
  60. data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
  61. data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
  62. data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
  63. data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
  64. data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  65. data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
  66. data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
  67. data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
  68. data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
  69. data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
  70. data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
  71. data/public/monaco-editor/vs/editor/editor.api.js +6 -0
  72. data/public/monaco-editor/vs/editor/editor.main.css +8 -0
  73. data/public/monaco-editor/vs/editor/editor.main.js +797 -0
  74. data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
  75. data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
  76. data/public/monaco-editor/vs/loader.js +10 -0
  77. data/public/monaco-editor/vs/nls.messages.de.js +20 -0
  78. data/public/monaco-editor/vs/nls.messages.es.js +20 -0
  79. data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
  80. data/public/monaco-editor/vs/nls.messages.it.js +18 -0
  81. data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
  82. data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
  83. data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
  84. data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
  85. data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
  86. data/public/monaco_worker.js +5 -0
  87. data/public/sw.js +8 -0
  88. data/public/ts_worker.js +5 -0
  89. data/vendor/assets/javascripts/axios.min.js +5 -0
  90. data/vendor/assets/javascripts/emmet.js +5452 -0
  91. data/vendor/assets/javascripts/lodash.min.js +136 -0
  92. data/vendor/assets/javascripts/marked.min.js +6 -0
  93. data/vendor/assets/javascripts/minisearch.min.js +2044 -0
  94. data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
  95. data/vendor/assets/javascripts/monaco-vim.js +9867 -0
  96. data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
  97. data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
  98. data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
  99. data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
  100. data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
  101. data/vendor/assets/javascripts/prettier-standalone.js +37 -0
  102. data/vendor/assets/javascripts/react-dom.min.js +267 -0
  103. data/vendor/assets/javascripts/react.min.js +31 -0
  104. data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
  105. data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
  106. data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
  107. data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
  108. 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