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