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,996 @@
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
+ IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
16
+ MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
17
+ RG_AVAILABLE = system("which rg > /dev/null 2>&1")
18
+ RUBOCOP_TIMEOUT_SECONDS = 15
19
+
20
+ # GET /mbeditor — renders the IDE shell
21
+ def index
22
+ render layout: "mbeditor/application"
23
+ end
24
+
25
+ # GET /mbeditor/ping — heartbeat for the frontend connectivity check
26
+ # Silence the log line so development consoles are not spammed.
27
+ def ping
28
+ Rails.logger.silence { render json: { ok: true } }
29
+ end
30
+
31
+ # GET /mbeditor/workspace — metadata about current workspace root
32
+ def workspace
33
+ render json: {
34
+ rootName: workspace_root.basename.to_s,
35
+ rootPath: workspace_root.to_s,
36
+ rubocopAvailable: rubocop_available?,
37
+ rubocopConfigPath: rubocop_config_path,
38
+ hamlLintAvailable: haml_lint_available?,
39
+ gitAvailable: git_available?,
40
+ blameAvailable: git_blame_available?,
41
+ redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
42
+ testAvailable: test_available?
43
+ }
44
+ end
45
+
46
+ # GET /mbeditor/files — recursive file tree
47
+ def files
48
+ tree = build_tree(workspace_root.to_s)
49
+ render json: tree
50
+ end
51
+
52
+ # GET /mbeditor/state — load workspace state
53
+ def state
54
+ path = workspace_root.join("tmp", "mbeditor_workspace.json")
55
+ if File.exist?(path)
56
+ render json: JSON.parse(File.read(path))
57
+ else
58
+ render json: {}
59
+ end
60
+ rescue Errno::ENOENT
61
+ render json: {}
62
+ rescue StandardError => e
63
+ render json: { error: e.message }, status: :unprocessable_content
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_content
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
+ unless File.file?(path)
81
+ return render json: missing_file_payload(params[:path]) if allow_missing_file?
82
+
83
+ return render json: { error: "Not found" }, status: :not_found
84
+ end
85
+
86
+ size = File.size(path)
87
+ return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
88
+
89
+ if image_path?(path)
90
+ return render json: {
91
+ path: relative_path(path),
92
+ image: true,
93
+ size: size,
94
+ content: ""
95
+ }
96
+ end
97
+
98
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
99
+ render json: { path: relative_path(path), content: content }
100
+ rescue StandardError => e
101
+ render json: { error: e.message }, status: :unprocessable_content
102
+ end
103
+
104
+ # GET /mbeditor/raw?path=... — send raw file directly (for images)
105
+ def raw
106
+ path = resolve_path(params[:path])
107
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
108
+ return render json: { error: "Not found" }, status: :not_found unless File.file?(path)
109
+
110
+ size = File.size(path)
111
+ return render_file_too_large(size) if size > MAX_OPEN_FILE_SIZE_BYTES
112
+
113
+ send_file path, disposition: "inline"
114
+ end
115
+
116
+ # POST /mbeditor/file — save file
117
+ def save
118
+ path = resolve_path(params[:path])
119
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
120
+ return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
121
+
122
+ content = params[:code].to_s
123
+ return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
124
+
125
+ File.write(path, content)
126
+ render json: { ok: true, path: relative_path(path) }
127
+ rescue StandardError => e
128
+ render json: { error: e.message }, status: :unprocessable_content
129
+ end
130
+
131
+ # POST /mbeditor/create_file — create file and parent directories if needed
132
+ def create_file
133
+ path = resolve_path(params[:path])
134
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
135
+ return render json: { error: "Cannot create file in this path" }, status: :forbidden if path_blocked_for_operations?(path)
136
+ return render json: { error: "File already exists" }, status: :unprocessable_content if File.exist?(path)
137
+
138
+ content = params[:code].to_s
139
+ return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
140
+
141
+ FileUtils.mkdir_p(File.dirname(path))
142
+ File.write(path, content)
143
+
144
+ render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
145
+ rescue StandardError => e
146
+ render json: { error: e.message }, status: :unprocessable_content
147
+ end
148
+
149
+ # POST /mbeditor/create_dir — create directory recursively
150
+ def create_dir
151
+ path = resolve_path(params[:path])
152
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
153
+ return render json: { error: "Cannot create directory in this path" }, status: :forbidden if path_blocked_for_operations?(path)
154
+ return render json: { error: "Path already exists" }, status: :unprocessable_content if File.exist?(path)
155
+
156
+ FileUtils.mkdir_p(path)
157
+ render json: { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
158
+ rescue StandardError => e
159
+ render json: { error: e.message }, status: :unprocessable_content
160
+ end
161
+
162
+ # PATCH /mbeditor/rename — rename file or directory
163
+ def rename
164
+ old_path = resolve_path(params[:path])
165
+ new_path = resolve_path(params[:new_path])
166
+ return render json: { error: "Forbidden" }, status: :forbidden unless old_path && new_path
167
+ return render json: { error: "Path not found" }, status: :not_found unless File.exist?(old_path)
168
+ return render json: { error: "Target path already exists" }, status: :unprocessable_content if File.exist?(new_path)
169
+ return render json: { error: "Cannot rename this path" }, status: :forbidden if path_blocked_for_operations?(old_path) || path_blocked_for_operations?(new_path)
170
+
171
+ FileUtils.mkdir_p(File.dirname(new_path))
172
+ FileUtils.mv(old_path, new_path)
173
+
174
+ render json: {
175
+ ok: true,
176
+ oldPath: relative_path(old_path),
177
+ path: relative_path(new_path),
178
+ name: File.basename(new_path)
179
+ }
180
+ rescue StandardError => e
181
+ render json: { error: e.message }, status: :unprocessable_content
182
+ end
183
+
184
+ # DELETE /mbeditor/delete — remove file or directory
185
+ def destroy_path
186
+ path = resolve_path(params[:path])
187
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
188
+ return render json: { error: "Path not found" }, status: :not_found unless File.exist?(path)
189
+ return render json: { error: "Cannot delete this path" }, status: :forbidden if path_blocked_for_operations?(path)
190
+
191
+ if File.directory?(path)
192
+ FileUtils.rm_rf(path)
193
+ render json: { ok: true, type: "folder", path: relative_path(path) }
194
+ else
195
+ File.delete(path)
196
+ render json: { ok: true, type: "file", path: relative_path(path) }
197
+ end
198
+ rescue StandardError => e
199
+ render json: { error: e.message }, status: :unprocessable_content
200
+ end
201
+
202
+ # GET /mbeditor/definition?symbol=...&language=...
203
+ # Looks up method definitions in workspace source files using Ripper (Ruby
204
+ # AST parser). Returns up to 20 matches with signature and preceding comments.
205
+ def definition
206
+ symbol = params[:symbol].to_s.strip
207
+ language = params[:language].to_s.strip
208
+
209
+ return render json: { results: [] } if symbol.blank?
210
+ return render json: { error: "Invalid symbol" }, status: :bad_request unless symbol.match?(/\A[a-zA-Z0-9_]{1,60}\z/)
211
+
212
+ results = case language
213
+ when "ruby"
214
+ RubyDefinitionService.call(
215
+ workspace_root,
216
+ symbol,
217
+ excluded_dirnames: excluded_dirnames,
218
+ excluded_paths: excluded_paths
219
+ )
220
+ else
221
+ []
222
+ end
223
+
224
+ render json: { results: results }
225
+ rescue StandardError => e
226
+ render json: { error: e.message }, status: :unprocessable_content
227
+ end
228
+
229
+ # GET /mbeditor/search?q=...
230
+ def search
231
+ query = params[:q].to_s.strip
232
+ return render json: [] if query.blank?
233
+ return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
234
+
235
+ results = []
236
+ cmd = if RG_AVAILABLE
237
+ args = ["rg", "--json", "--max-count", "30", "--no-ignore"]
238
+ excluded_paths.each { |p| args << "--glob=!#{p}" }
239
+ args + ["--", query, workspace_root.to_s]
240
+ else
241
+ args = ["grep", "-rn", "-F", "-m", "30"]
242
+ excluded_dirnames.each do |dirname|
243
+ args << "--exclude-dir=#{dirname}"
244
+ end
245
+ args + [query, workspace_root.to_s]
246
+ end
247
+
248
+ if RG_AVAILABLE
249
+ output, = Open3.capture3(*cmd)
250
+ output.lines.each do |line|
251
+ break if results.length > 30
252
+
253
+ data = JSON.parse(line) rescue next
254
+ next unless data["type"] == "match"
255
+
256
+ match_data = data["data"]
257
+ results << {
258
+ file: relative_path(match_data.dig("path", "text").to_s),
259
+ line: match_data.dig("line_number"),
260
+ text: match_data.dig("lines", "text").to_s.strip
261
+ }
262
+ end
263
+ else
264
+ output, = Open3.capture3(*cmd)
265
+ output.lines.each do |line|
266
+ break if results.length > 30
267
+
268
+ line.chomp!
269
+ next unless line =~ /\A(.+?):(\d+):(.*)\z/
270
+
271
+ file_path = Regexp.last_match(1)
272
+ next unless file_path.start_with?(workspace_root.to_s)
273
+
274
+ relative_file_path = relative_path(file_path)
275
+ next if excluded_path?(relative_file_path, File.basename(file_path))
276
+
277
+ results << {
278
+ file: relative_file_path,
279
+ line: Regexp.last_match(2).to_i,
280
+ text: Regexp.last_match(3).strip
281
+ }
282
+ end
283
+ end
284
+
285
+ capped = results.length > 30
286
+ render json: { results: results.first(30), capped: capped }
287
+ rescue StandardError => e
288
+ render json: { error: e.message }, status: :unprocessable_content
289
+ end
290
+
291
+ # GET /mbeditor/git_status
292
+ def git_status
293
+ output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
294
+ branch = GitService.current_branch(workspace_root.to_s) || ""
295
+ files = output.lines.map do |line|
296
+ { status: line[0..1].strip, path: line[3..].strip }
297
+ end
298
+ render json: { ok: status.success?, files: files, branch: branch }
299
+ rescue StandardError => e
300
+ render json: { error: e.message }, status: :unprocessable_content
301
+ end
302
+
303
+ # GET /mbeditor/git_info
304
+ def git_info
305
+ repo = workspace_root.to_s
306
+ branch = GitService.current_branch(repo)
307
+ unless branch
308
+ return render json: { ok: false, error: "Unable to determine current branch" }, status: :unprocessable_content
309
+ end
310
+ working_output, _err, working_status = Open3.capture3("git", "-C", repo, "status", "--porcelain")
311
+ working_tree = working_status.success? ? parse_porcelain_status(working_output) : []
312
+
313
+ # Annotate each working-tree file with added/removed line counts
314
+ numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
315
+ numstat_map = parse_numstat(numstat_out)
316
+ working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
317
+
318
+ upstream_output, _err, upstream_status = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
319
+ upstream_branch = upstream_status.success? ? upstream_output.strip : nil
320
+ upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
321
+
322
+ ahead_count = 0
323
+ behind_count = 0
324
+ unpushed_files = []
325
+ unpushed_commits = []
326
+
327
+ # Determine the branch's fork point relative to a base branch (develop/main/master).
328
+ # This ensures History and Changes only show work unique to this branch.
329
+ base_sha, base_ref = find_branch_base(repo, branch)
330
+
331
+ if upstream_branch.present?
332
+ counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
333
+ if counts_status.success?
334
+ ahead_str, behind_str = counts_output.strip.split("\t", 2)
335
+ ahead_count = ahead_str.to_i
336
+ behind_count = behind_str.to_i
337
+ end
338
+
339
+ unpushed_log_output, _err, unpushed_log_status = Open3.capture3("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
340
+ unpushed_commits = parse_git_log(unpushed_log_output) if unpushed_log_status.success?
341
+ end
342
+
343
+ # "Changes in Branch" — use the merge-base against the base branch when available
344
+ # so that files changed in develop (and merged into this branch) are excluded.
345
+ diff_base = base_sha || upstream_branch
346
+ if diff_base.present?
347
+ unpushed_output, _err, unpushed_status = Open3.capture3("git", "-C", repo, "diff", "--name-status", "#{diff_base}..HEAD")
348
+ if unpushed_status.success?
349
+ unpushed_files = parse_name_status(unpushed_output)
350
+ unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD")
351
+ unp_numstat_map = parse_numstat(unp_numstat_out)
352
+ unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
353
+ end
354
+ end
355
+
356
+ branch_log_output, _err, branch_log_status = if base_sha
357
+ Open3.capture3("git", "-C", repo, "log", "--first-parent", "#{base_sha}..HEAD",
358
+ "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
359
+ else
360
+ Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
361
+ "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
362
+ end
363
+ branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
364
+
365
+ redmine_ticket_id = nil
366
+ if Mbeditor.configuration.redmine_enabled
367
+ if Mbeditor.configuration.redmine_ticket_source == :branch
368
+ m = branch.match(/\A(\d+)/)
369
+ redmine_ticket_id = m[1] if m
370
+ else
371
+ branch_commits.each do |commit|
372
+ m = commit[:title]&.match(/#(\d+)/)
373
+ if m
374
+ redmine_ticket_id = m[1]
375
+ break
376
+ end
377
+ end
378
+ end
379
+ end
380
+
381
+ render json: {
382
+ ok: true,
383
+ branch: branch,
384
+ upstreamBranch: upstream_branch,
385
+ ahead: ahead_count,
386
+ behind: behind_count,
387
+ workingTree: working_tree,
388
+ unpushedFiles: unpushed_files,
389
+ unpushedCommits: unpushed_commits,
390
+ branchCommits: branch_commits,
391
+ branchBaseRef: base_ref,
392
+ redmineTicketId: redmine_ticket_id
393
+ }
394
+ rescue StandardError => e
395
+ render json: { ok: false, error: e.message }, status: :unprocessable_content
396
+ end
397
+
398
+ # GET /mbeditor/monaco-editor/*asset_path — serve packaged Monaco files
399
+ def monaco_asset
400
+ # path_info is the path within the engine, e.g. "/monaco-editor/vs/loader.js"
401
+ relative = request.path_info.delete_prefix("/")
402
+ path = resolve_monaco_asset_path(relative)
403
+ return head :not_found unless path
404
+
405
+ send_file path, disposition: "inline", type: Mime::Type.lookup_by_extension(File.extname(path).delete_prefix(".")) || "application/octet-stream"
406
+ end
407
+
408
+ # GET /mbeditor/monaco_worker.js — serve packaged Monaco worker entrypoint
409
+ def monaco_worker
410
+ path = monaco_worker_file.to_s
411
+ return render plain: "Not found", status: :not_found unless File.file?(path)
412
+
413
+ send_file path, disposition: "inline", type: "application/javascript"
414
+ end
415
+
416
+ # GET /mbeditor/manifest.webmanifest — PWA manifest
417
+ def pwa_manifest
418
+ base = request.script_name.to_s.sub(%r{/$}, "")
419
+ manifest = {
420
+ name: "Mbeditor — #{Rails.root.basename}",
421
+ short_name: "Mbeditor",
422
+ description: "Mini Browser Editor",
423
+ start_url: "#{base}/",
424
+ scope: "#{base}/",
425
+ display: "standalone",
426
+ background_color: "#1e1e2e",
427
+ theme_color: "#1e1e2e",
428
+ icons: [
429
+ { src: "#{base}/mbeditor-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }
430
+ ]
431
+ }
432
+ render plain: JSON.generate(manifest), content_type: "application/manifest+json"
433
+ end
434
+
435
+ # GET /mbeditor/sw.js — minimal PWA service worker
436
+ def pwa_sw
437
+ path = Mbeditor::Engine.root.join("public", "sw.js").to_s
438
+ return render plain: "Not found", status: :not_found unless File.file?(path)
439
+
440
+ send_file path, disposition: "inline", type: "application/javascript"
441
+ end
442
+
443
+ # GET /mbeditor/mbeditor-icon.svg — PWA icon
444
+ def pwa_icon
445
+ path = Mbeditor::Engine.root.join("public", "mbeditor-icon.svg").to_s
446
+ return render plain: "Not found", status: :not_found unless File.file?(path)
447
+
448
+ send_file path, disposition: "inline", type: "image/svg+xml"
449
+ end
450
+
451
+ # GET /mbeditor/ts_worker.js — serve TypeScript/JavaScript Monaco worker
452
+ def ts_worker
453
+ path = [
454
+ Mbeditor::Engine.root.join("public", "ts_worker.js"),
455
+ Rails.root.join("public", "ts_worker.js")
456
+ ].find { |p| p.file? }.to_s
457
+ return render plain: "Not found", status: :not_found unless File.file?(path)
458
+
459
+ send_file path, disposition: "inline", type: "application/javascript"
460
+ end
461
+
462
+ # POST /mbeditor/lint — run rubocop --stdin (or haml-lint for .haml files)
463
+ def lint
464
+ path = resolve_path(params[:path])
465
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
466
+
467
+ filename = File.basename(path)
468
+ code = params[:code] || File.read(path)
469
+
470
+ if filename.end_with?('.haml')
471
+ unless haml_lint_available?
472
+ return render json: { error: "haml-lint not available", markers: [] }, status: :unprocessable_content
473
+ end
474
+
475
+ markers = run_haml_lint(code)
476
+ return render json: { markers: markers }
477
+ end
478
+
479
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "--stdin", filename, "--format", "json", "--no-color", "--force-exclusion"]
480
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
481
+ output = run_with_timeout(env, cmd, stdin_data: code)
482
+
483
+ idx = output.index("{")
484
+ result = idx ? JSON.parse(output[idx..]) : {}
485
+ result = {} unless result.is_a?(Hash)
486
+ offenses = result.dig("files", 0, "offenses") || []
487
+
488
+ markers = offenses.map do |offense|
489
+ {
490
+ severity: cop_severity(offense["severity"]),
491
+ copName: offense["cop_name"],
492
+ correctable: offense["correctable"] == true,
493
+ message: "[#{offense['cop_name']}] #{offense['message']}",
494
+ startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
495
+ startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
496
+ endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
497
+ endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1
498
+ }
499
+ end
500
+
501
+ render json: { markers: markers, summary: result["summary"] }
502
+ rescue StandardError => e
503
+ render json: { error: e.message, markers: [] }, status: :unprocessable_content
504
+ end
505
+
506
+ # POST /mbeditor/quick_fix — autocorrect the buffer with rubocop -A and return the diff as a text edit
507
+ #
508
+ # Runs a full `rubocop -A` pass on the in-memory buffer content (not the file
509
+ # on disk). Using a full pass (rather than --only <cop>) means coupled cops
510
+ # like Layout/EmptyLineAfterMagicComment are also applied in the same round,
511
+ # so the result is always a clean, lint-passing state. The minimal line diff
512
+ # returned to Monaco keeps the edit tight.
513
+ #
514
+ # Params:
515
+ # path - workspace-relative file path (used to derive the filename for rubocop)
516
+ # code - current file content as a string
517
+ # cop_name - the cop the user clicked on (used only for the action label; not passed to rubocop)
518
+ #
519
+ # Returns:
520
+ # { fix: { startLine, startCol, endLine, endCol, replacement } }
521
+ # or { fix: null } when rubocop produced no change
522
+ def quick_fix
523
+ path = resolve_path(params[:path])
524
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
525
+
526
+ cop_name = params[:cop_name].to_s.strip
527
+ return render json: { error: "cop_name required" }, status: :unprocessable_content if cop_name.empty?
528
+ return render json: { error: "Invalid cop name" }, status: :unprocessable_content unless cop_name.match?(/\A[\w\/]+\z/)
529
+
530
+ code = params[:code].to_s
531
+ ext = File.extname(File.basename(path))
532
+
533
+ # Use a workspace-local tempfile so RuboCop's config discovery walks up
534
+ # from the source file's directory and finds the host app's .rubocop.yml.
535
+ tmpfile = File.join(File.dirname(path), ".mbeditor_fix_#{SecureRandom.hex(8)}#{ext}")
536
+ begin
537
+ File.write(tmpfile, code)
538
+
539
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
540
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
541
+ _out, _err, status = Open3.capture3(env, *cmd)
542
+
543
+ # exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
544
+ unless status.success? || status.exitstatus == 1
545
+ return render json: { fix: nil }
546
+ end
547
+
548
+ corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
549
+ fix = compute_text_edit(code, corrected)
550
+ render json: { fix: fix }
551
+ ensure
552
+ File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
553
+ end
554
+ rescue StandardError => e
555
+ render json: { error: e.message }, status: :unprocessable_content
556
+ end
557
+
558
+ # POST /mbeditor/test — run tests for the given file
559
+ def run_test
560
+ path = resolve_path(params[:path])
561
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
562
+
563
+ relative = relative_path(path)
564
+ test_file = TestRunnerService.resolve_test_file(workspace_root.to_s, relative)
565
+ return render json: { error: "No matching test file found for #{relative}" }, status: :not_found unless test_file
566
+
567
+ full_test = File.join(workspace_root.to_s, test_file)
568
+ return render json: { error: "Test file does not exist: #{test_file}" }, status: :not_found unless File.file?(full_test)
569
+
570
+ config = Mbeditor.configuration
571
+ result = TestRunnerService.run(
572
+ workspace_root.to_s,
573
+ test_file,
574
+ framework: config.test_framework&.to_sym,
575
+ command: config.test_command,
576
+ timeout: config.test_timeout || 60
577
+ )
578
+
579
+ render json: result.merge(testFile: test_file)
580
+ rescue StandardError => e
581
+ render json: { error: e.message, ok: false }, status: :unprocessable_content
582
+ end
583
+
584
+ # POST /mbeditor/format — rubocop -A on buffer content; returns corrected content WITHOUT saving to disk
585
+ #
586
+ # Accepts the current buffer content as `code` and formats it using a
587
+ # workspace-local tempfile so that RuboCop's config discovery walks up from
588
+ # the source file's own directory (finds the host app's .rubocop.yml).
589
+ # Does NOT write the result back to the original file — the frontend marks
590
+ # the tab dirty and lets the user decide when to save.
591
+ def format_file
592
+ path = resolve_path(params[:path])
593
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
594
+
595
+ code = params[:code].to_s
596
+ return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
597
+
598
+ ext = File.extname(File.basename(path))
599
+ tmpfile = File.join(File.dirname(path), ".mbeditor_fmt_#{SecureRandom.hex(8)}#{ext}")
600
+ begin
601
+ File.write(tmpfile, code)
602
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
603
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
604
+ _out, _err, status = Open3.capture3(env, *cmd)
605
+ unless status.success? || status.exitstatus == 1
606
+ return render json: { ok: false, content: code }
607
+ end
608
+
609
+ corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
610
+ render json: { ok: true, content: corrected }
611
+ ensure
612
+ File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
613
+ end
614
+ rescue StandardError => e
615
+ render json: { error: e.message }, status: :unprocessable_content
616
+ end
617
+
618
+ private
619
+
620
+ def allow_missing_file?
621
+ %w[1 true yes on].include?(params[:allow_missing].to_s.downcase)
622
+ end
623
+
624
+ def missing_file_payload(raw_path)
625
+ {
626
+ path: raw_path.to_s.sub(%r{\A/+}, ""),
627
+ content: "",
628
+ missing: true
629
+ }
630
+ end
631
+
632
+ def verify_mbeditor_client
633
+ return if request.headers['X-Mbeditor-Client'] == '1'
634
+
635
+ render plain: 'Forbidden', status: :forbidden
636
+ end
637
+
638
+ def path_blocked_for_operations?(full_path)
639
+ rel = relative_path(full_path)
640
+ return true if rel.blank?
641
+
642
+ excluded_path?(rel, File.basename(full_path))
643
+ end
644
+
645
+ def build_tree(dir, max_depth: 10, depth: 0)
646
+ return [] if depth >= max_depth
647
+
648
+ entries = Dir.entries(dir).sort.reject { |entry| entry == "." || entry == ".." }
649
+ entries.filter_map do |name|
650
+ full = File.join(dir, name)
651
+ rel = relative_path(full)
652
+
653
+ next if excluded_path?(rel, name)
654
+
655
+ if File.directory?(full)
656
+ { name: name, type: "folder", path: rel, children: build_tree(full, depth: depth + 1) }
657
+ else
658
+ { name: name, type: "file", path: rel }
659
+ end
660
+ end
661
+ rescue Errno::EACCES
662
+ []
663
+ end
664
+
665
+ def excluded_paths
666
+ Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:blank?)
667
+ end
668
+
669
+ def excluded_dirnames
670
+ excluded_paths.filter { |path| !path.include?("/") }
671
+ end
672
+
673
+ def excluded_path?(relative_path, name)
674
+ excluded_paths.any? do |pattern|
675
+ if pattern.include?("/")
676
+ relative_path == pattern || relative_path.start_with?("#{pattern}/")
677
+ else
678
+ name == pattern || relative_path.split("/").include?(pattern)
679
+ end
680
+ end
681
+ end
682
+
683
+ def run_with_timeout(env, cmd, stdin_data:)
684
+ output = +""
685
+ timed_out = false
686
+
687
+ Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
688
+ stdin.write(stdin_data)
689
+ stdin.close
690
+
691
+ timer = Thread.new do
692
+ sleep RUBOCOP_TIMEOUT_SECONDS
693
+ timed_out = true
694
+ Process.kill('-KILL', wait_thr.pid)
695
+ rescue Errno::ESRCH
696
+ nil
697
+ end
698
+
699
+ output = stdout.read
700
+ wait_thr.value
701
+ timer.kill
702
+ end
703
+
704
+ raise "RuboCop timed out after #{RUBOCOP_TIMEOUT_SECONDS} seconds" if timed_out
705
+
706
+ output
707
+ end
708
+
709
+ def cop_severity(severity)
710
+ case severity
711
+ when "error", "fatal" then "error"
712
+ when "warning" then "warning"
713
+ else "info"
714
+ end
715
+ end
716
+
717
+ # Given the original source string and the autocorrected source string, find
718
+ # the smallest single edit that transforms original into corrected. Returns a
719
+ # hash suitable for Monaco's SingleEditOperation, or nil when there is no diff.
720
+ #
721
+ # The strategy is line-level: find the first and last line that differ, then
722
+ # slice out that region from both versions and return it as one replacement.
723
+ def compute_text_edit(original, corrected)
724
+ return nil if original == corrected
725
+
726
+ orig_lines = original.split("\n", -1)
727
+ corr_lines = corrected.split("\n", -1)
728
+
729
+ max_len = [orig_lines.length, corr_lines.length].max
730
+
731
+ first_diff = (0...max_len).find { |i| orig_lines[i] != corr_lines[i] }
732
+ return nil if first_diff.nil?
733
+
734
+ # Walk from the end to find the last differing line (mirror-image of above)
735
+ last_diff_orig = orig_lines.length - 1
736
+ last_diff_corr = corr_lines.length - 1
737
+ # Use strict > so we never walk past first_diff (which would make last_diff_orig negative
738
+ # and cause Ruby's negative-index wraparound to silently return the wrong element).
739
+ while last_diff_orig > first_diff && last_diff_corr > first_diff &&
740
+ orig_lines[last_diff_orig] == corr_lines[last_diff_corr]
741
+ last_diff_orig -= 1
742
+ last_diff_corr -= 1
743
+ end
744
+
745
+ # Monaco ranges are 1-based; endColumn one past the last char covers the full line content.
746
+ start_line = first_diff + 1
747
+ end_line = last_diff_orig + 1
748
+ end_col = (orig_lines[last_diff_orig] || "").length + 1 # 1-based: one past last char
749
+
750
+ replacement = corr_lines[first_diff..last_diff_corr].join("\n")
751
+
752
+ {
753
+ startLine: start_line,
754
+ startCol: 1,
755
+ endLine: end_line,
756
+ endCol: end_col,
757
+ replacement: replacement
758
+ }
759
+ end
760
+
761
+ def rubocop_command
762
+ command = Mbeditor.configuration.rubocop_command.to_s.strip
763
+ command = "rubocop" if command.empty?
764
+ tokens = Shellwords.split(command)
765
+
766
+ local_bin = workspace_root.join("bin", "rubocop")
767
+ return [local_bin.to_s] if tokens == ["rubocop"] && local_bin.exist?
768
+
769
+ tokens
770
+ rescue ArgumentError
771
+ ["rubocop"]
772
+ end
773
+
774
+ def rubocop_config_path
775
+ candidate = workspace_root.join(".rubocop.yml")
776
+ candidate.exist? ? ".rubocop.yml" : nil
777
+ end
778
+
779
+ def rubocop_available?
780
+ # Key on the configured rubocop command so the cache is invalidated if the
781
+ # command changes (e.g., between test cases or after reconfiguration).
782
+ key = Mbeditor.configuration.rubocop_command.to_s
783
+ cache = self.class.instance_variable_get(:@rubocop_available_cache) ||
784
+ self.class.instance_variable_set(:@rubocop_available_cache, {})
785
+ return cache[key] if cache.key?(key)
786
+ cache[key] = begin
787
+ _out, _err, status = Open3.capture3(*rubocop_command, "--version")
788
+ status.success?
789
+ rescue StandardError
790
+ false
791
+ end
792
+ end
793
+
794
+ def haml_lint_available?
795
+ # Key on the resolved command array so workspace-local bin/haml-lint is
796
+ # respected without re-running the probe on every request.
797
+ cmd = haml_lint_command
798
+ key = cmd.join(" ")
799
+ cache = self.class.instance_variable_get(:@haml_lint_available_cache) ||
800
+ self.class.instance_variable_set(:@haml_lint_available_cache, {})
801
+ return cache[key] if cache.key?(key)
802
+ cache[key] = begin
803
+ _out, _err, status = Open3.capture3(*cmd, "--version")
804
+ status.success?
805
+ rescue StandardError
806
+ false
807
+ end
808
+ end
809
+
810
+ def git_available?
811
+ # Key on workspace path so different directories get their own probe result.
812
+ key = workspace_root.to_s
813
+ cache = self.class.instance_variable_get(:@git_available_cache) ||
814
+ self.class.instance_variable_set(:@git_available_cache, {})
815
+ return cache[key] if cache.key?(key)
816
+ cache[key] = begin
817
+ _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
818
+ status.success?
819
+ rescue StandardError
820
+ false
821
+ end
822
+ end
823
+
824
+ alias git_blame_available? git_available?
825
+
826
+ def test_available?
827
+ root = workspace_root.to_s
828
+ File.directory?(File.join(root, "test")) || File.directory?(File.join(root, "spec"))
829
+ end
830
+
831
+ def haml_lint_command
832
+ workspace_bin = workspace_root.join("bin", "haml-lint")
833
+ return [workspace_bin.to_s] if workspace_bin.exist?
834
+
835
+ begin
836
+ [Gem.bin_path("haml_lint", "haml-lint")]
837
+ rescue Gem::Exception, Gem::GemNotFoundException
838
+ ["haml-lint"]
839
+ end
840
+ end
841
+
842
+ def run_haml_lint(code)
843
+ markers = []
844
+ Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
845
+ f.write(code)
846
+ f.flush
847
+ output, _err, _status = Open3.capture3(*haml_lint_command, "--reporter", "json", "--no-color", f.path)
848
+ idx = output.index("{")
849
+ result = idx ? JSON.parse(output[idx..]) : {}
850
+ result = {} unless result.is_a?(Hash)
851
+ offenses = result.dig("files", 0, "offenses") || []
852
+ markers = offenses.map do |offense|
853
+ {
854
+ severity: haml_lint_severity(offense["severity"]),
855
+ message: "[#{offense['linter_name']}] #{offense['message']}",
856
+ startLine: offense.dig("location", "line"),
857
+ startCol: (offense.dig("location", "column") || 1) - 1,
858
+ endLine: offense.dig("location", "line"),
859
+ endCol: offense.dig("location", "column") || 1
860
+ }
861
+ end
862
+ end
863
+ markers
864
+ end
865
+
866
+ def haml_lint_severity(severity)
867
+ case severity
868
+ when "error" then "error"
869
+ when "warning" then "warning"
870
+ else "info"
871
+ end
872
+ end
873
+
874
+ def parse_porcelain_status(output)
875
+ output.lines.map do |line|
876
+ { status: line[0..1].strip, path: line[3..].to_s.strip }
877
+ end
878
+ end
879
+
880
+ def parse_name_status(output)
881
+ output.lines.filter_map do |line|
882
+ parts = line.strip.split("\t")
883
+ next if parts.empty?
884
+
885
+ status = parts[0].to_s.strip
886
+ path = parts.last.to_s.strip
887
+ next if path.blank?
888
+
889
+ { status: status, path: path }
890
+ end
891
+ end
892
+
893
+ def parse_numstat(output)
894
+ (output || "").lines.each_with_object({}) do |line, map|
895
+ parts = line.strip.split("\t", 3)
896
+ next if parts.length < 3 || parts[0] == "-"
897
+
898
+ map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
899
+ end
900
+ end
901
+
902
+ def parse_git_log(output)
903
+ output.split("\x1e").filter_map do |entry|
904
+ fields = entry.strip.split("\x1f", 4)
905
+ next unless fields.length == 4
906
+
907
+ {
908
+ hash: fields[0],
909
+ title: fields[1],
910
+ author: fields[2],
911
+ date: fields[3]
912
+ }
913
+ end
914
+ end
915
+
916
+ def monaco_worker_file
917
+ engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
918
+ return engine_path if engine_path.file?
919
+
920
+ Rails.root.join("public", "monaco_worker.js")
921
+ end
922
+
923
+ # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
924
+ # or [nil, nil] if none can be determined. Candidates are tried in preference order:
925
+ # origin/develop → origin/main → origin/master → develop → main → master.
926
+ # Skips refs that ARE the current branch and refs where the merge-base equals HEAD
927
+ # (meaning the current branch is behind or at the same point as that ref).
928
+ def find_branch_base(repo, current_branch)
929
+ candidates = %w[origin/develop origin/main origin/master develop main master]
930
+ head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
931
+ head_sha = head_sha_out.strip
932
+
933
+ candidates.each do |ref|
934
+ short = ref.delete_prefix("origin/")
935
+ next if short == current_branch || ref == current_branch
936
+
937
+ _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
938
+ next unless st.success?
939
+
940
+ base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
941
+ next unless base_st.success?
942
+
943
+ sha = base_out.strip
944
+ next unless sha.match?(/\A[0-9a-f]{40}\z/)
945
+ next if sha == head_sha # branch is at/behind this ref — no unique commits
946
+
947
+ return [sha, ref]
948
+ end
949
+
950
+ [nil, nil]
951
+ rescue StandardError
952
+ [nil, nil]
953
+ end
954
+
955
+ def resolve_monaco_asset_path(asset_path)
956
+ return nil if asset_path.blank?
957
+
958
+ [
959
+ Mbeditor::Engine.root.join("public"),
960
+ Rails.root.join("public")
961
+ ].each do |public_root|
962
+ base = public_root.to_s
963
+ full = File.expand_path(asset_path.to_s, base)
964
+ next unless full.start_with?(base + '/')
965
+ return full if File.file?(full)
966
+ end
967
+
968
+ nil
969
+ end
970
+
971
+ def image_path?(path)
972
+ extension = File.extname(path).delete_prefix(".").downcase
973
+ IMAGE_EXTENSIONS.include?(extension)
974
+ end
975
+
976
+ def render_file_too_large(size)
977
+ render json: {
978
+ error: "File is too large to open (#{human_size(size)}). Limit is #{human_size(MAX_OPEN_FILE_SIZE_BYTES)}."
979
+ }, status: :content_too_large
980
+ end
981
+
982
+ def human_size(bytes)
983
+ units = %w[B KB MB GB TB]
984
+ value = bytes.to_f
985
+ unit_index = 0
986
+
987
+ while value >= 1024.0 && unit_index < units.length - 1
988
+ value /= 1024.0
989
+ unit_index += 1
990
+ end
991
+
992
+ precision = unit_index.zero? ? 0 : 1
993
+ format("%.#{precision}f %s", value, units[unit_index])
994
+ end
995
+ end
996
+ end