mbeditor 0.1.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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +127 -0
  3. data/app/assets/javascripts/mbeditor/application.js +19 -0
  4. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
  5. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
  6. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
  7. data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
  8. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +142 -0
  9. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +363 -0
  10. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +112 -0
  11. data/app/assets/javascripts/mbeditor/components/FileTree.js +304 -0
  12. data/app/assets/javascripts/mbeditor/components/GitPanel.js +416 -0
  13. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +2335 -0
  14. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +118 -0
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +123 -0
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +282 -0
  18. data/app/assets/javascripts/mbeditor/editor_store.js +53 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +77 -0
  20. data/app/assets/javascripts/mbeditor/git_service.js +104 -0
  21. data/app/assets/javascripts/mbeditor/search_service.js +53 -0
  22. data/app/assets/javascripts/mbeditor/tab_manager.js +461 -0
  23. data/app/assets/stylesheets/mbeditor/application.css +705 -0
  24. data/app/assets/stylesheets/mbeditor/editor.css +1264 -0
  25. data/app/controllers/mbeditor/application_controller.rb +10 -0
  26. data/app/controllers/mbeditor/editors_controller.rb +695 -0
  27. data/app/controllers/mbeditor/git_controller.rb +188 -0
  28. data/app/services/mbeditor/git_blame_service.rb +98 -0
  29. data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
  30. data/app/services/mbeditor/git_diff_service.rb +71 -0
  31. data/app/services/mbeditor/git_file_history_service.rb +42 -0
  32. data/app/services/mbeditor/git_service.rb +82 -0
  33. data/app/services/mbeditor/redmine_service.rb +86 -0
  34. data/app/views/layouts/mbeditor/application.html.erb +71 -0
  35. data/app/views/mbeditor/editors/index.html.erb +1 -0
  36. data/config/environments/development.rb +53 -0
  37. data/config/initializers/assets.rb +9 -0
  38. data/config/routes.rb +37 -0
  39. data/lib/mbeditor/configuration.rb +16 -0
  40. data/lib/mbeditor/engine.rb +28 -0
  41. data/lib/mbeditor/version.rb +3 -0
  42. data/lib/mbeditor.rb +19 -0
  43. data/mbeditor.gemspec +30 -0
  44. data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
  45. data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  46. data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
  47. data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
  48. data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
  49. data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
  50. data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
  51. data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
  52. data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
  53. data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
  54. data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
  55. data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
  56. data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
  57. data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
  58. data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  59. data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
  60. data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
  61. data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
  62. data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
  63. data/public/monaco-editor/vs/editor/editor.main.css +8 -0
  64. data/public/monaco-editor/vs/editor/editor.main.js +797 -0
  65. data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
  66. data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
  67. data/public/monaco-editor/vs/loader.js +10 -0
  68. data/public/monaco-editor/vs/nls.messages.de.js +20 -0
  69. data/public/monaco-editor/vs/nls.messages.es.js +20 -0
  70. data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
  71. data/public/monaco-editor/vs/nls.messages.it.js +18 -0
  72. data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
  73. data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
  74. data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
  75. data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
  76. data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
  77. data/public/monaco_worker.js +5 -0
  78. data/vendor/assets/javascripts/axios.min.js +2 -0
  79. data/vendor/assets/javascripts/lodash.min.js +140 -0
  80. data/vendor/assets/javascripts/marked.min.js +6 -0
  81. data/vendor/assets/javascripts/minisearch.min.js +2044 -0
  82. data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
  83. data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
  84. data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
  85. data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
  86. data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
  87. data/vendor/assets/javascripts/prettier-standalone.js +37 -0
  88. data/vendor/assets/javascripts/react-dom.min.js +267 -0
  89. data/vendor/assets/javascripts/react.min.js +31 -0
  90. data/vendor/assets/stylesheets/fontawesome.min.css +9 -0
  91. data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
  92. data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
  93. data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
  94. metadata +173 -0
@@ -0,0 +1,10 @@
1
+ module Mbeditor
2
+ class ApplicationController < ActionController::Base
3
+ private
4
+
5
+ def ensure_allowed_environment!
6
+ allowed = Array(Mbeditor.configuration.allowed_environments).map(&:to_sym)
7
+ render plain: 'Not found', status: :not_found unless allowed.include?(Rails.env.to_sym)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,695 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "tempfile"
7
+ require "tmpdir"
8
+
9
+ module Mbeditor
10
+ class EditorsController < ApplicationController
11
+ skip_before_action :verify_authenticity_token
12
+ before_action :ensure_allowed_environment!
13
+ before_action :verify_mbeditor_client, unless: -> { request.get? || request.head? }
14
+
15
+ ALLOWED_EXTENSIONS = %w[
16
+ rb js jsx ts tsx css scss sass html erb haml slim
17
+ json yaml yml md txt gemspec gemfile rakefile
18
+ gitignore env sh bash zsh conf config toml
19
+ ].freeze
20
+
21
+ IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
22
+ MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
23
+ RG_AVAILABLE = system("which rg > /dev/null 2>&1")
24
+ RUBOCOP_TIMEOUT_SECONDS = 15
25
+
26
+ # GET /mbeditor — renders the IDE shell
27
+ def index
28
+ render layout: "mbeditor/application"
29
+ end
30
+
31
+ # GET /mbeditor/ping — heartbeat for the frontend connectivity check
32
+ def ping
33
+ render json: { ok: true }
34
+ end
35
+
36
+ # GET /mbeditor/workspace — metadata about current workspace root
37
+ def workspace
38
+ render json: {
39
+ rootName: workspace_root.basename.to_s,
40
+ rootPath: workspace_root.to_s,
41
+ rubocopAvailable: rubocop_available?,
42
+ hamlLintAvailable: haml_lint_available?,
43
+ gitAvailable: git_available?,
44
+ blameAvailable: git_blame_available?
45
+ }
46
+ end
47
+
48
+ # GET /mbeditor/files — recursive file tree
49
+ def files
50
+ tree = build_tree(workspace_root.to_s)
51
+ render json: tree
52
+ end
53
+
54
+ # GET /mbeditor/state — load workspace state
55
+ def state
56
+ path = workspace_root.join("tmp", "mbeditor_workspace.json")
57
+ if File.exist?(path)
58
+ render json: JSON.parse(File.read(path))
59
+ else
60
+ render json: {}
61
+ end
62
+ rescue StandardError
63
+ render json: {}
64
+ end
65
+
66
+ # POST /mbeditor/state — save workspace state
67
+ def save_state
68
+ path = workspace_root.join("tmp", "mbeditor_workspace.json")
69
+ File.write(path, params[:state].to_json)
70
+ render json: { ok: true }
71
+ rescue StandardError => e
72
+ render json: { error: e.message }, status: :unprocessable_entity
73
+ end
74
+
75
+ # GET /mbeditor/file?path=...
76
+ def show
77
+ path = resolve_path(params[:path])
78
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
79
+
80
+ return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
81
+
82
+ size = File.size(path)
83
+ return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
84
+
85
+ if image_path?(path)
86
+ return render json: {
87
+ path: relative_path(path),
88
+ image: true,
89
+ size: size,
90
+ content: ""
91
+ }
92
+ end
93
+
94
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
95
+ render json: { path: relative_path(path), content: content }
96
+ rescue StandardError => e
97
+ render json: { error: e.message }, status: :unprocessable_entity
98
+ end
99
+
100
+ # GET /mbeditor/raw?path=... — send raw file directly (for images)
101
+ def raw
102
+ path = resolve_path(params[:path])
103
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
104
+ return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
105
+
106
+ size = File.size(path)
107
+ return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
108
+
109
+ send_file path, disposition: "inline"
110
+ end
111
+
112
+ # POST /mbeditor/file — save file
113
+ def save
114
+ path = resolve_path(params[:path])
115
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
116
+ return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
117
+
118
+ File.write(path, params[:code])
119
+ render json: { ok: true, path: relative_path(path) }
120
+ rescue StandardError => e
121
+ render json: { error: e.message }, status: :unprocessable_entity
122
+ end
123
+
124
+ # POST /mbeditor/create_file — create file and parent directories if needed
125
+ def create_file
126
+ path = resolve_path(params[:path])
127
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
128
+ return render json: { error: "Cannot create file in this path" }, status: :forbidden if path_blocked_for_operations?(path)
129
+ return render json: { error: "File already exists" }, status: :unprocessable_entity if File.exist?(path)
130
+
131
+ FileUtils.mkdir_p(File.dirname(path))
132
+ File.write(path, params[:code].to_s)
133
+
134
+ render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
135
+ rescue StandardError => e
136
+ render json: { error: e.message }, status: :unprocessable_entity
137
+ end
138
+
139
+ # POST /mbeditor/create_dir — create directory recursively
140
+ def create_dir
141
+ path = resolve_path(params[:path])
142
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
143
+ return render json: { error: "Cannot create directory in this path" }, status: :forbidden if path_blocked_for_operations?(path)
144
+ return render json: { error: "Path already exists" }, status: :unprocessable_entity if File.exist?(path)
145
+
146
+ FileUtils.mkdir_p(path)
147
+ render json: { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
148
+ rescue StandardError => e
149
+ render json: { error: e.message }, status: :unprocessable_entity
150
+ end
151
+
152
+ # PATCH /mbeditor/rename — rename file or directory
153
+ def rename
154
+ old_path = resolve_path(params[:path])
155
+ new_path = resolve_path(params[:new_path])
156
+ return render json: { error: "Forbidden" }, status: :forbidden unless old_path && new_path
157
+ return render json: { error: "Path not found" }, status: :not_found unless File.exist?(old_path)
158
+ return render json: { error: "Target path already exists" }, status: :unprocessable_entity if File.exist?(new_path)
159
+ return render json: { error: "Cannot rename this path" }, status: :forbidden if path_blocked_for_operations?(old_path) || path_blocked_for_operations?(new_path)
160
+
161
+ FileUtils.mkdir_p(File.dirname(new_path))
162
+ FileUtils.mv(old_path, new_path)
163
+
164
+ render json: {
165
+ ok: true,
166
+ oldPath: relative_path(old_path),
167
+ path: relative_path(new_path),
168
+ name: File.basename(new_path)
169
+ }
170
+ rescue StandardError => e
171
+ render json: { error: e.message }, status: :unprocessable_entity
172
+ end
173
+
174
+ # Backward compatibility for stale route/action caches.
175
+ def rename_path
176
+ rename
177
+ end
178
+
179
+ # DELETE /mbeditor/delete — remove file or directory
180
+ def destroy_path
181
+ path = resolve_path(params[:path])
182
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
183
+ return render json: { error: "Path not found" }, status: :not_found unless File.exist?(path)
184
+ return render json: { error: "Cannot delete this path" }, status: :forbidden if path_blocked_for_operations?(path)
185
+
186
+ if File.directory?(path)
187
+ FileUtils.rm_rf(path)
188
+ render json: { ok: true, type: "folder", path: relative_path(path) }
189
+ else
190
+ File.delete(path)
191
+ render json: { ok: true, type: "file", path: relative_path(path) }
192
+ end
193
+ rescue StandardError => e
194
+ render json: { error: e.message }, status: :unprocessable_entity
195
+ end
196
+
197
+ # Backward compatibility for stale route/action caches.
198
+ def delete_path
199
+ destroy_path
200
+ end
201
+
202
+ # GET /mbeditor/search?q=...
203
+ def search
204
+ query = params[:q].to_s.strip
205
+ return render json: [] if query.blank?
206
+
207
+ results = []
208
+ cmd = if RG_AVAILABLE
209
+ ["rg", "--json", "--max-count", "30", "--", query, workspace_root.to_s]
210
+ else
211
+ ["grep", "-rn", "-F", "-m", "30", query, workspace_root.to_s]
212
+ end
213
+
214
+ unless RG_AVAILABLE
215
+ excluded_dirnames.each do |dir_name|
216
+ cmd.insert(2, "--exclude-dir=#{dir_name}")
217
+ end
218
+ end
219
+
220
+ if RG_AVAILABLE
221
+ output, = Open3.capture2(*cmd)
222
+ output.lines.each do |line|
223
+ data = JSON.parse(line) rescue next
224
+ next unless data["type"] == "match"
225
+
226
+ match_data = data["data"]
227
+ results << {
228
+ file: relative_path(match_data.dig("path", "text").to_s),
229
+ line: match_data.dig("line_number"),
230
+ text: match_data.dig("lines", "text").to_s.strip
231
+ }
232
+ end
233
+ else
234
+ output, = Open3.capture2(*cmd)
235
+ output.lines.first(50).each do |line|
236
+ line.chomp!
237
+ next unless line =~ /\A(.+?):(\d+):(.*)\z/
238
+
239
+ file_path = Regexp.last_match(1)
240
+ next unless file_path.start_with?(workspace_root.to_s)
241
+
242
+ results << {
243
+ file: relative_path(file_path),
244
+ line: Regexp.last_match(2).to_i,
245
+ text: Regexp.last_match(3).strip
246
+ }
247
+ end
248
+ end
249
+
250
+ render json: results
251
+ rescue StandardError => e
252
+ render json: { error: e.message }, status: :unprocessable_entity
253
+ end
254
+
255
+ # GET /mbeditor/git_status
256
+ def git_status
257
+ output, status = Open3.capture2("git", "-C", workspace_root.to_s, "status", "--porcelain")
258
+ branch_output, = Open3.capture2("git", "-C", workspace_root.to_s, "branch", "--show-current")
259
+ files = output.lines.map do |line|
260
+ { status: line[0..1].strip, path: line[3..].strip }
261
+ end
262
+ render json: { ok: status.success?, files: files, branch: branch_output.strip }
263
+ rescue StandardError => e
264
+ render json: { error: e.message }, status: :unprocessable_entity
265
+ end
266
+
267
+ # GET /mbeditor/git_info
268
+ def git_info
269
+ repo = workspace_root.to_s
270
+ branch_output, branch_status = Open3.capture2("git", "-C", repo, "branch", "--show-current")
271
+ unless branch_status.success?
272
+ return render json: { ok: false, error: "Unable to determine current branch" }, status: :unprocessable_entity
273
+ end
274
+
275
+ branch = branch_output.strip
276
+ working_output, working_status = Open3.capture2("git", "-C", repo, "status", "--porcelain")
277
+ working_tree = working_status.success? ? parse_porcelain_status(working_output) : []
278
+
279
+ # Annotate each working-tree file with added/removed line counts
280
+ numstat_out, = Open3.capture2("git", "-C", repo, "diff", "--numstat", "HEAD")
281
+ numstat_map = parse_numstat(numstat_out)
282
+ working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
283
+
284
+ upstream_output, upstream_status = Open3.capture2("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
285
+ upstream_branch = upstream_status.success? ? upstream_output.strip : nil
286
+
287
+ ahead_count = 0
288
+ behind_count = 0
289
+ unpushed_files = []
290
+ unpushed_commits = []
291
+
292
+ if upstream_branch.present?
293
+ counts_output, counts_status = Open3.capture2("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
294
+ if counts_status.success?
295
+ ahead_str, behind_str = counts_output.strip.split("\t", 2)
296
+ ahead_count = ahead_str.to_i
297
+ behind_count = behind_str.to_i
298
+ end
299
+
300
+ unpushed_output, unpushed_status = Open3.capture2("git", "-C", repo, "diff", "--name-status", "#{upstream_branch}..HEAD")
301
+ if unpushed_status.success?
302
+ unpushed_files = parse_name_status(unpushed_output)
303
+ unp_numstat_out, = Open3.capture2("git", "-C", repo, "diff", "--numstat", "#{upstream_branch}..HEAD")
304
+ unp_numstat_map = parse_numstat(unp_numstat_out)
305
+ unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
306
+ end
307
+
308
+ unpushed_log_output, unpushed_log_status = Open3.capture2("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
309
+ unpushed_commits = parse_git_log(unpushed_log_output) if unpushed_log_status.success?
310
+ end
311
+
312
+ branch_log_output, branch_log_status = Open3.capture2("git", "-C", repo, "log", branch, "-n", "100", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
313
+ branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
314
+
315
+ render json: {
316
+ ok: true,
317
+ branch: branch,
318
+ upstreamBranch: upstream_branch,
319
+ ahead: ahead_count,
320
+ behind: behind_count,
321
+ workingTree: working_tree,
322
+ unpushedFiles: unpushed_files,
323
+ unpushedCommits: unpushed_commits,
324
+ branchCommits: branch_commits
325
+ }
326
+ rescue StandardError => e
327
+ render json: { ok: false, error: e.message }, status: :unprocessable_entity
328
+ end
329
+
330
+ # GET /mbeditor/monaco-editor/*asset_path — serve packaged Monaco files
331
+ def monaco_asset
332
+ # path_info is the path within the engine, e.g. "/monaco-editor/vs/loader.js"
333
+ relative = request.path_info.delete_prefix("/")
334
+ path = resolve_monaco_asset_path(relative)
335
+ return head :not_found unless path
336
+
337
+ send_file path, disposition: "inline", type: Mime::Type.lookup_by_extension(File.extname(path).delete_prefix(".")) || "application/octet-stream"
338
+ end
339
+
340
+ # GET /mbeditor/monaco_worker.js — serve packaged Monaco worker entrypoint
341
+ def monaco_worker
342
+ path = monaco_worker_file.to_s
343
+ return render plain: "Not found", status: :not_found unless File.file?(path)
344
+
345
+ send_file path, disposition: "inline", type: "application/javascript"
346
+ end
347
+
348
+ # POST /mbeditor/lint — run rubocop --stdin (or haml-lint for .haml files)
349
+ def lint
350
+ path = resolve_path(params[:path])
351
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
352
+
353
+ filename = File.basename(path)
354
+ code = params[:code] || File.read(path)
355
+
356
+ if filename.end_with?('.haml')
357
+ unless haml_lint_available?
358
+ return render json: { error: "haml-lint not available", markers: [] }, status: :unprocessable_entity
359
+ end
360
+
361
+ markers = run_haml_lint(code)
362
+ return render json: { markers: markers }
363
+ end
364
+
365
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "--stdin", filename, "--format", "json", "--no-color", "--force-exclusion"]
366
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
367
+ output = run_with_timeout(env, cmd, stdin_data: code)
368
+
369
+ idx = output.index("{")
370
+ result = idx ? JSON.parse(output[idx..]) : {}
371
+ result = {} unless result.is_a?(Hash)
372
+ offenses = result.dig("files", 0, "offenses") || []
373
+
374
+ markers = offenses.map do |offense|
375
+ {
376
+ severity: cop_severity(offense["severity"]),
377
+ message: "[#{offense['cop_name']}] #{offense['message']}",
378
+ startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
379
+ startCol: (offense.dig("location", "start_column") || offense.dig("location", "column") || 1) - 1,
380
+ endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
381
+ endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1
382
+ }
383
+ end
384
+
385
+ render json: { markers: markers, summary: result["summary"] }
386
+ rescue StandardError => e
387
+ render json: { error: e.message, markers: [] }, status: :unprocessable_entity
388
+ end
389
+
390
+ # POST /mbeditor/format — rubocop -A then return corrected content
391
+ def format_file
392
+ path = resolve_path(params[:path])
393
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
394
+
395
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", path]
396
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
397
+ _out, _err, status = Open3.capture3(env, *cmd)
398
+
399
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
400
+ render json: { ok: status.success? || status.exitstatus == 1, content: content }
401
+ rescue StandardError => e
402
+ render json: { error: e.message }, status: :unprocessable_entity
403
+ end
404
+
405
+ private
406
+
407
+ def verify_mbeditor_client
408
+ return if request.headers['X-Mbeditor-Client'] == '1'
409
+
410
+ render plain: 'Forbidden', status: :forbidden
411
+ end
412
+
413
+ def workspace_root
414
+ @workspace_root ||= begin
415
+ configured_root = Mbeditor.configuration.workspace_root
416
+ if configured_root.present?
417
+ Pathname.new(configured_root.to_s)
418
+ else
419
+ # Default to the git root of Rails.root so that paths returned by git
420
+ # commands (which are always relative to the git root) align with the
421
+ # file-tree paths used by the file service.
422
+ rails_root = Rails.root.to_s
423
+ out, status = Open3.capture2("git", "-C", rails_root, "rev-parse", "--show-toplevel")
424
+ Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
425
+ end
426
+ rescue StandardError
427
+ Rails.root
428
+ end
429
+ end
430
+
431
+ # Expand path and confirm it's inside workspace_root
432
+ def resolve_path(raw)
433
+ return nil if raw.blank?
434
+
435
+ root = workspace_root.to_s
436
+ full = File.expand_path(raw.to_s, root)
437
+ full.start_with?(root + "/") || full == root ? full : nil
438
+ end
439
+
440
+ def relative_path(full)
441
+ root = workspace_root.to_s
442
+ return "" if full == root
443
+
444
+ full.delete_prefix(root + "/")
445
+ end
446
+
447
+ def path_blocked_for_operations?(full_path)
448
+ rel = relative_path(full_path)
449
+ return true if rel.blank?
450
+
451
+ excluded_path?(rel, File.basename(full_path))
452
+ end
453
+
454
+ def build_tree(dir, max_depth: 10, depth: 0)
455
+ return [] if depth >= max_depth
456
+
457
+ entries = Dir.entries(dir).sort.reject { |entry| entry.start_with?(".") || entry == "." || entry == ".." }
458
+ entries.filter_map do |name|
459
+ full = File.join(dir, name)
460
+ rel = relative_path(full)
461
+
462
+ next if excluded_path?(rel, name)
463
+
464
+ if File.directory?(full)
465
+ { name: name, type: "folder", path: rel, children: build_tree(full, depth: depth + 1) }
466
+ else
467
+ { name: name, type: "file", path: rel }
468
+ end
469
+ end
470
+ rescue Errno::EACCES
471
+ []
472
+ end
473
+
474
+ def excluded_paths
475
+ Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
476
+ end
477
+
478
+ def excluded_dirnames
479
+ excluded_paths.filter { |path| !path.include?("/") }
480
+ end
481
+
482
+ def excluded_path?(relative_path, name)
483
+ excluded_paths.any? do |pattern|
484
+ if pattern.include?("/")
485
+ relative_path == pattern || relative_path.start_with?("#{pattern}/")
486
+ else
487
+ name == pattern || relative_path.split("/").include?(pattern)
488
+ end
489
+ end
490
+ end
491
+
492
+ def run_with_timeout(env, cmd, stdin_data:)
493
+ output = +""
494
+ timed_out = false
495
+
496
+ Open3.popen3(env, *cmd) do |stdin, stdout, _stderr, wait_thr|
497
+ stdin.write(stdin_data)
498
+ stdin.close
499
+
500
+ timer = Thread.new do
501
+ sleep RUBOCOP_TIMEOUT_SECONDS
502
+ timed_out = true
503
+ Process.kill('KILL', wait_thr.pid)
504
+ rescue Errno::ESRCH
505
+ nil
506
+ end
507
+
508
+ output = stdout.read
509
+ wait_thr.value
510
+ timer.kill
511
+ end
512
+
513
+ raise "RuboCop timed out after #{RUBOCOP_TIMEOUT_SECONDS} seconds" if timed_out
514
+
515
+ output
516
+ end
517
+
518
+ def cop_severity(severity)
519
+ case severity
520
+ when "error", "fatal" then "error"
521
+ when "warning" then "warning"
522
+ else "info"
523
+ end
524
+ end
525
+
526
+ def rubocop_command
527
+ command = Mbeditor.configuration.rubocop_command.to_s.strip
528
+ command = "rubocop" if command.empty?
529
+ tokens = Shellwords.split(command)
530
+
531
+ local_bin = workspace_root.join("bin", "rubocop")
532
+ return [local_bin.to_s] if tokens == ["rubocop"] && local_bin.exist?
533
+
534
+ tokens
535
+ rescue ArgumentError
536
+ ["rubocop"]
537
+ end
538
+
539
+ def rubocop_available?
540
+ _out, _err, status = Open3.capture3(*rubocop_command, "--version")
541
+ status.success?
542
+ rescue StandardError
543
+ false
544
+ end
545
+
546
+ def haml_lint_available?
547
+ _out, _err, status = Open3.capture3(*haml_lint_command, "--version")
548
+ status.success?
549
+ rescue StandardError
550
+ false
551
+ end
552
+
553
+ def git_available?
554
+ _out, status = Open3.capture2("git", "-C", workspace_root.to_s, "rev-parse", "--is-inside-work-tree")
555
+ status.success?
556
+ rescue StandardError
557
+ false
558
+ end
559
+
560
+ alias git_blame_available? git_available?
561
+
562
+ def haml_lint_command
563
+ workspace_bin = workspace_root.join("bin", "haml-lint")
564
+ return [workspace_bin.to_s] if workspace_bin.exist?
565
+
566
+ begin
567
+ [Gem.bin_path("haml_lint", "haml-lint")]
568
+ rescue Gem::Exception, Gem::GemNotFoundException
569
+ ["haml-lint"]
570
+ end
571
+ end
572
+
573
+ def run_haml_lint(code)
574
+ markers = []
575
+ Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
576
+ f.write(code)
577
+ f.flush
578
+ output, _err, _status = Open3.capture3(*haml_lint_command, "--reporter", "json", "--no-color", f.path)
579
+ idx = output.index("{")
580
+ result = idx ? JSON.parse(output[idx..]) : {}
581
+ result = {} unless result.is_a?(Hash)
582
+ offenses = result.dig("files", 0, "offenses") || []
583
+ markers = offenses.map do |offense|
584
+ {
585
+ severity: haml_lint_severity(offense["severity"]),
586
+ message: "[#{offense['linter_name']}] #{offense['text']}",
587
+ startLine: offense.dig("location", "line"),
588
+ startCol: (offense.dig("location", "column") || 1) - 1,
589
+ endLine: offense.dig("location", "line"),
590
+ endCol: offense.dig("location", "column") || 1
591
+ }
592
+ end
593
+ end
594
+ markers
595
+ end
596
+
597
+ def haml_lint_severity(severity)
598
+ case severity
599
+ when "error" then "error"
600
+ when "warning" then "warning"
601
+ else "info"
602
+ end
603
+ end
604
+
605
+ def parse_porcelain_status(output)
606
+ output.lines.map do |line|
607
+ { status: line[0..1].strip, path: line[3..].to_s.strip }
608
+ end
609
+ end
610
+
611
+ def parse_name_status(output)
612
+ output.lines.filter_map do |line|
613
+ parts = line.strip.split("\t")
614
+ next if parts.empty?
615
+
616
+ status = parts[0].to_s.strip
617
+ path = parts.last.to_s.strip
618
+ next if path.blank?
619
+
620
+ { status: status, path: path }
621
+ end
622
+ end
623
+
624
+ def parse_numstat(output)
625
+ (output || "").lines.each_with_object({}) do |line, map|
626
+ parts = line.strip.split("\t", 3)
627
+ next if parts.length < 3 || parts[0] == "-"
628
+
629
+ map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
630
+ end
631
+ end
632
+
633
+ def parse_git_log(output)
634
+ output.split("\x1e").filter_map do |entry|
635
+ fields = entry.strip.split("\x1f", 4)
636
+ next unless fields.length == 4
637
+
638
+ {
639
+ hash: fields[0],
640
+ title: fields[1],
641
+ author: fields[2],
642
+ date: fields[3]
643
+ }
644
+ end
645
+ end
646
+
647
+ def monaco_worker_file
648
+ engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
649
+ return engine_path if engine_path.file?
650
+
651
+ Rails.root.join("public", "monaco_worker.js")
652
+ end
653
+
654
+ def resolve_monaco_asset_path(asset_path)
655
+ return nil if asset_path.blank?
656
+
657
+ [
658
+ Mbeditor::Engine.root.join("public"),
659
+ Rails.root.join("public")
660
+ ].each do |public_root|
661
+ base = public_root.to_s
662
+ full = File.expand_path(asset_path.to_s, base)
663
+ next unless full.start_with?(base + '/')
664
+ return full if File.file?(full)
665
+ end
666
+
667
+ nil
668
+ end
669
+
670
+ def image_path?(path)
671
+ extension = File.extname(path).delete_prefix(".").downcase
672
+ IMAGE_EXTENSIONS.include?(extension)
673
+ end
674
+
675
+ def render_file_too_large(size)
676
+ render json: {
677
+ error: "File is too large to open (#{human_size(size)}). Limit is #{human_size(MAX_OPEN_FILE_SIZE_BYTES)}."
678
+ }, status: :payload_too_large
679
+ end
680
+
681
+ def human_size(bytes)
682
+ units = %w[B KB MB GB TB]
683
+ value = bytes.to_f
684
+ unit_index = 0
685
+
686
+ while value >= 1024.0 && unit_index < units.length - 1
687
+ value /= 1024.0
688
+ unit_index += 1
689
+ end
690
+
691
+ precision = unit_index.zero? ? 0 : 1
692
+ format("%.#{precision}f %s", value, units[unit_index])
693
+ end
694
+ end
695
+ end