mbeditor 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +116 -0
- data/README.md +180 -0
- data/app/assets/javascripts/mbeditor/application.js +21 -0
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
- data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
- data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
- data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
- data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
- data/app/assets/javascripts/mbeditor/file_service.js +96 -0
- data/app/assets/javascripts/mbeditor/git_service.js +104 -0
- data/app/assets/javascripts/mbeditor/search_service.js +63 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
- data/app/assets/stylesheets/mbeditor/application.css +848 -0
- data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
- data/app/controllers/mbeditor/application_controller.rb +70 -0
- data/app/controllers/mbeditor/editors_controller.rb +996 -0
- data/app/controllers/mbeditor/git_controller.rb +234 -0
- data/app/services/mbeditor/git_blame_service.rb +98 -0
- data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
- data/app/services/mbeditor/git_diff_service.rb +74 -0
- data/app/services/mbeditor/git_file_history_service.rb +42 -0
- data/app/services/mbeditor/git_service.rb +95 -0
- data/app/services/mbeditor/redmine_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +168 -0
- data/app/services/mbeditor/test_runner_service.rb +286 -0
- data/app/views/layouts/mbeditor/application.html.erb +120 -0
- data/app/views/mbeditor/editors/index.html.erb +1 -0
- data/config/initializers/assets.rb +9 -0
- data/config/routes.rb +44 -0
- data/lib/mbeditor/configuration.rb +22 -0
- data/lib/mbeditor/engine.rb +37 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
- data/lib/mbeditor/version.rb +3 -0
- data/lib/mbeditor.rb +19 -0
- data/mbeditor.gemspec +31 -0
- data/public/mbeditor-icon.svg +4 -0
- data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
- data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
- data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
- data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
- data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
- data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
- data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
- data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
- data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
- data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/monaco-editor/vs/editor/editor.api.js +6 -0
- data/public/monaco-editor/vs/editor/editor.main.css +8 -0
- data/public/monaco-editor/vs/editor/editor.main.js +797 -0
- data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
- data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
- data/public/monaco-editor/vs/loader.js +10 -0
- data/public/monaco-editor/vs/nls.messages.de.js +20 -0
- data/public/monaco-editor/vs/nls.messages.es.js +20 -0
- data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
- data/public/monaco-editor/vs/nls.messages.it.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
- data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
- data/public/monaco_worker.js +5 -0
- data/public/sw.js +8 -0
- data/public/ts_worker.js +5 -0
- data/vendor/assets/javascripts/axios.min.js +5 -0
- data/vendor/assets/javascripts/emmet.js +5452 -0
- data/vendor/assets/javascripts/lodash.min.js +136 -0
- data/vendor/assets/javascripts/marked.min.js +6 -0
- data/vendor/assets/javascripts/minisearch.min.js +2044 -0
- data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
- data/vendor/assets/javascripts/monaco-vim.js +9867 -0
- data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
- data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
- data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
- data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
- data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
- data/vendor/assets/javascripts/prettier-standalone.js +37 -0
- data/vendor/assets/javascripts/react-dom.min.js +267 -0
- data/vendor/assets/javascripts/react.min.js +31 -0
- data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
- data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
- metadata +188 -0
|
@@ -0,0 +1,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
|