mbeditor 0.1.5 → 0.1.6

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.

Potentially problematic release.


This version of mbeditor might be problematic. Click here for more details.

@@ -36,7 +36,8 @@ module Mbeditor
36
36
  rubocopAvailable: rubocop_available?,
37
37
  hamlLintAvailable: haml_lint_available?,
38
38
  gitAvailable: git_available?,
39
- blameAvailable: git_blame_available?
39
+ blameAvailable: git_blame_available?,
40
+ redmineEnabled: Mbeditor.configuration.redmine_enabled == true
40
41
  }
41
42
  end
42
43
 
@@ -54,8 +55,10 @@ module Mbeditor
54
55
  else
55
56
  render json: {}
56
57
  end
57
- rescue StandardError
58
+ rescue Errno::ENOENT
58
59
  render json: {}
60
+ rescue StandardError => e
61
+ render json: { error: e.message }, status: :unprocessable_entity
59
62
  end
60
63
 
61
64
  # POST /mbeditor/state — save workspace state
@@ -110,7 +113,10 @@ module Mbeditor
110
113
  return render json: { error: "Forbidden" }, status: :forbidden unless path
111
114
  return render json: { error: "Cannot write to this path" }, status: :forbidden if path_blocked_for_operations?(path)
112
115
 
113
- File.write(path, params[:code])
116
+ content = params[:code].to_s
117
+ return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
118
+
119
+ File.write(path, content)
114
120
  render json: { ok: true, path: relative_path(path) }
115
121
  rescue StandardError => e
116
122
  render json: { error: e.message }, status: :unprocessable_entity
@@ -123,8 +129,11 @@ module Mbeditor
123
129
  return render json: { error: "Cannot create file in this path" }, status: :forbidden if path_blocked_for_operations?(path)
124
130
  return render json: { error: "File already exists" }, status: :unprocessable_entity if File.exist?(path)
125
131
 
132
+ content = params[:code].to_s
133
+ return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
134
+
126
135
  FileUtils.mkdir_p(File.dirname(path))
127
- File.write(path, params[:code].to_s)
136
+ File.write(path, content)
128
137
 
129
138
  render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
130
139
  rescue StandardError => e
@@ -188,6 +197,7 @@ module Mbeditor
188
197
  def search
189
198
  query = params[:q].to_s.strip
190
199
  return render json: [] if query.blank?
200
+ return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
191
201
 
192
202
  results = []
193
203
  cmd = if RG_AVAILABLE
@@ -205,6 +215,8 @@ module Mbeditor
205
215
  if RG_AVAILABLE
206
216
  output, = Open3.capture2(*cmd)
207
217
  output.lines.each do |line|
218
+ break if results.length > 30
219
+
208
220
  data = JSON.parse(line) rescue next
209
221
  next unless data["type"] == "match"
210
222
 
@@ -217,7 +229,9 @@ module Mbeditor
217
229
  end
218
230
  else
219
231
  output, = Open3.capture2(*cmd)
220
- output.lines.first(50).each do |line|
232
+ output.lines.each do |line|
233
+ break if results.length > 30
234
+
221
235
  line.chomp!
222
236
  next unless line =~ /\A(.+?):(\d+):(.*)\z/
223
237
 
@@ -232,7 +246,8 @@ module Mbeditor
232
246
  end
233
247
  end
234
248
 
235
- render json: results
249
+ capped = results.length > 30
250
+ render json: { results: results.first(30), capped: capped }
236
251
  rescue StandardError => e
237
252
  render json: { error: e.message }, status: :unprocessable_entity
238
253
  end
@@ -266,6 +281,7 @@ module Mbeditor
266
281
 
267
282
  upstream_output, upstream_status = Open3.capture2("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
268
283
  upstream_branch = upstream_status.success? ? upstream_output.strip : nil
284
+ upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
269
285
 
270
286
  ahead_count = 0
271
287
  behind_count = 0
@@ -357,9 +373,11 @@ module Mbeditor
357
373
  markers = offenses.map do |offense|
358
374
  {
359
375
  severity: cop_severity(offense["severity"]),
376
+ copName: offense["cop_name"],
377
+ correctable: offense["correctable"] == true,
360
378
  message: "[#{offense['cop_name']}] #{offense['message']}",
361
379
  startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
362
- startCol: (offense.dig("location", "start_column") || offense.dig("location", "column") || 1) - 1,
380
+ startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
363
381
  endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
364
382
  endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1
365
383
  }
@@ -370,6 +388,54 @@ module Mbeditor
370
388
  render json: { error: e.message, markers: [] }, status: :unprocessable_entity
371
389
  end
372
390
 
391
+ # POST /mbeditor/quick_fix — autocorrect the buffer with rubocop -A and return the diff as a text edit
392
+ #
393
+ # Runs a full `rubocop -A` pass on the in-memory buffer content (not the file
394
+ # on disk). Using a full pass (rather than --only <cop>) means coupled cops
395
+ # like Layout/EmptyLineAfterMagicComment are also applied in the same round,
396
+ # so the result is always a clean, lint-passing state. The minimal line diff
397
+ # returned to Monaco keeps the edit tight.
398
+ #
399
+ # Params:
400
+ # path - workspace-relative file path (used to derive the filename for rubocop)
401
+ # code - current file content as a string
402
+ # cop_name - the cop the user clicked on (used only for the action label; not passed to rubocop)
403
+ #
404
+ # Returns:
405
+ # { fix: { startLine, startCol, endLine, endCol, replacement } }
406
+ # or { fix: null } when rubocop produced no change
407
+ def quick_fix
408
+ path = resolve_path(params[:path])
409
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
410
+
411
+ cop_name = params[:cop_name].to_s.strip
412
+ return render json: { error: "cop_name required" }, status: :unprocessable_entity if cop_name.empty?
413
+ return render json: { error: "Invalid cop name" }, status: :unprocessable_entity unless cop_name.match?(/\A[\w\/]+\z/)
414
+
415
+ code = params[:code].to_s
416
+ ext = File.extname(File.basename(path))
417
+
418
+ Tempfile.create(["mbeditor_fix", ext]) do |tmp|
419
+ tmp.write(code)
420
+ tmp.flush
421
+
422
+ cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmp.path]
423
+ env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
424
+ _out, _err, status = Open3.capture3(env, *cmd)
425
+
426
+ # exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
427
+ unless status.success? || status.exitstatus == 1
428
+ return render json: { fix: nil }
429
+ end
430
+
431
+ corrected = File.read(tmp.path, encoding: "UTF-8", invalid: :replace, undef: :replace)
432
+ fix = compute_text_edit(code, corrected)
433
+ render json: { fix: fix }
434
+ end
435
+ rescue StandardError => e
436
+ render json: { error: e.message }, status: :unprocessable_entity
437
+ end
438
+
373
439
  # POST /mbeditor/format — rubocop -A then return corrected content
374
440
  def format_file
375
441
  path = resolve_path(params[:path])
@@ -442,14 +508,14 @@ module Mbeditor
442
508
  output = +""
443
509
  timed_out = false
444
510
 
445
- Open3.popen3(env, *cmd) do |stdin, stdout, _stderr, wait_thr|
511
+ Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
446
512
  stdin.write(stdin_data)
447
513
  stdin.close
448
514
 
449
515
  timer = Thread.new do
450
516
  sleep RUBOCOP_TIMEOUT_SECONDS
451
517
  timed_out = true
452
- Process.kill('KILL', wait_thr.pid)
518
+ Process.kill('-KILL', wait_thr.pid)
453
519
  rescue Errno::ESRCH
454
520
  nil
455
521
  end
@@ -472,6 +538,50 @@ module Mbeditor
472
538
  end
473
539
  end
474
540
 
541
+ # Given the original source string and the autocorrected source string, find
542
+ # the smallest single edit that transforms original into corrected. Returns a
543
+ # hash suitable for Monaco's SingleEditOperation, or nil when there is no diff.
544
+ #
545
+ # The strategy is line-level: find the first and last line that differ, then
546
+ # slice out that region from both versions and return it as one replacement.
547
+ def compute_text_edit(original, corrected)
548
+ return nil if original == corrected
549
+
550
+ orig_lines = original.split("\n", -1)
551
+ corr_lines = corrected.split("\n", -1)
552
+
553
+ max_len = [orig_lines.length, corr_lines.length].max
554
+
555
+ first_diff = (0...max_len).find { |i| orig_lines[i] != corr_lines[i] }
556
+ return nil if first_diff.nil?
557
+
558
+ # Walk from the end to find the last differing line (mirror-image of above)
559
+ last_diff_orig = orig_lines.length - 1
560
+ last_diff_corr = corr_lines.length - 1
561
+ # Use strict > so we never walk past first_diff (which would make last_diff_orig negative
562
+ # and cause Ruby's negative-index wraparound to silently return the wrong element).
563
+ while last_diff_orig > first_diff && last_diff_corr > first_diff &&
564
+ orig_lines[last_diff_orig] == corr_lines[last_diff_corr]
565
+ last_diff_orig -= 1
566
+ last_diff_corr -= 1
567
+ end
568
+
569
+ # Monaco ranges are 1-based; endColumn one past the last char covers the full line content.
570
+ start_line = first_diff + 1
571
+ end_line = last_diff_orig + 1
572
+ end_col = (orig_lines[last_diff_orig] || "").length + 1 # 1-based: one past last char
573
+
574
+ replacement = corr_lines[first_diff..last_diff_corr].join("\n")
575
+
576
+ {
577
+ startLine: start_line,
578
+ startCol: 1,
579
+ endLine: end_line,
580
+ endCol: end_col,
581
+ replacement: replacement
582
+ }
583
+ end
584
+
475
585
  def rubocop_command
476
586
  command = Mbeditor.configuration.rubocop_command.to_s.strip
477
587
  command = "rubocop" if command.empty?
@@ -648,7 +758,7 @@ module Mbeditor
648
758
  def render_file_too_large(size)
649
759
  render json: {
650
760
  error: "File is too large to open (#{human_size(size)}). Limit is #{human_size(MAX_OPEN_FILE_SIZE_BYTES)}."
651
- }, status: :payload_too_large
761
+ }, status: :content_too_large
652
762
  end
653
763
 
654
764
  def human_size(bytes)
@@ -20,11 +20,18 @@ module Mbeditor
20
20
  file = require_file_param
21
21
  return unless file
22
22
 
23
+ base = params[:base].presence
24
+ head = params[:head].presence
25
+ valid_sha = /\A[0-9a-fA-F]{1,40}\z/
26
+ if [base, head].any? { |s| s && !s.match?(valid_sha) }
27
+ return render json: { error: 'Invalid sha' }, status: :bad_request
28
+ end
29
+
23
30
  result = GitDiffService.new(
24
31
  repo_path: workspace_root,
25
- file_path: file,
26
- base_sha: params[:base].presence,
27
- head_sha: params[:head].presence
32
+ file_path: file,
33
+ base_sha: base,
34
+ head_sha: head
28
35
  ).call
29
36
 
30
37
  render json: result
@@ -128,6 +135,7 @@ module Mbeditor
128
135
  "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
129
136
  )
130
137
  upstream = upstream_status.success? ? upstream_out.strip : nil
138
+ upstream = nil unless upstream&.match?(%r{\A[\w./-]+\z})
131
139
  if upstream.present?
132
140
  out, status = Open3.capture2("git", "-C", workspace_root.to_s, "diff", "#{upstream}..HEAD")
133
141
  out = status.success? ? out : ""
@@ -144,9 +152,11 @@ module Mbeditor
144
152
  # GET /mbeditor/redmine/issue/:id
145
153
  def redmine_issue
146
154
  unless Mbeditor.configuration.redmine_enabled
147
- return render json: { error: "Redmine integration is disabled." }, status: :service_unavailable
155
+ return render json: { error: 'Redmine integration is disabled.' }, status: :service_unavailable
148
156
  end
149
157
 
158
+ return render json: { error: 'Invalid issue id' }, status: :bad_request unless params[:id].to_s.match?(/\A\d+\z/)
159
+
150
160
  result = RedmineService.new(issue_id: params[:id]).call
151
161
  render json: result
152
162
  rescue RedmineDisabledError => e
@@ -9,6 +9,11 @@ module Mbeditor
9
9
  module GitService
10
10
  module_function
11
11
 
12
+ # Safe pattern for git ref names (branch, remote/branch, tag).
13
+ # Rejects refs containing whitespace, NUL, shell metacharacters, or
14
+ # git reflog syntax (e.g. "@{" sequences beyond the trailing "@{u}").
15
+ SAFE_GIT_REF = %r{\A[\w./-]+\z}
16
+
12
17
  # Run an arbitrary git command inside +repo_path+.
13
18
  # Returns [stdout, Process::Status].
14
19
  def run_git(repo_path, *args)
@@ -23,14 +28,19 @@ module Mbeditor
23
28
  end
24
29
 
25
30
  # Upstream tracking branch for the current branch, e.g. "origin/main".
31
+ # Returns nil if the branch name contains characters outside SAFE_GIT_REF.
26
32
  def upstream_branch(repo_path)
27
33
  out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
28
- status.success? ? out.strip : nil
34
+ return nil unless status.success?
35
+
36
+ ref = out.strip
37
+ ref.match?(SAFE_GIT_REF) ? ref : nil
29
38
  end
30
39
 
31
40
  # Returns [ahead_count, behind_count] relative to upstream, or [0,0].
32
41
  def ahead_behind(repo_path, upstream)
33
42
  return [0, 0] if upstream.blank?
43
+ return [0, 0] unless upstream.match?(SAFE_GIT_REF)
34
44
 
35
45
  out, status = run_git(repo_path, "rev-list", "--left-right", "--count", "HEAD...#{upstream}")
36
46
  return [0, 0] unless status.success?
@@ -48,7 +48,11 @@
48
48
  <script>
49
49
  window.MonacoEnvironment = {
50
50
  getWorkerUrl: function(workerId, label) {
51
- return (window.MBEDITOR_BASE_PATH || '') + '/monaco_worker.js';
51
+ var base = window.MBEDITOR_BASE_PATH || '';
52
+ if (label === 'typescript' || label === 'javascript') {
53
+ return base + '/ts_worker.js';
54
+ }
55
+ return base + '/monaco_worker.js';
52
56
  }
53
57
  };
54
58
 
data/config/routes.rb CHANGED
@@ -22,6 +22,7 @@ Mbeditor::Engine.routes.draw do
22
22
  get 'monaco-editor/*asset_path', to: 'editors#monaco_asset', format: false
23
23
  get 'min-maps/*asset_path', to: 'editors#monaco_asset', format: false
24
24
  post 'lint', to: 'editors#lint'
25
+ post 'quick_fix', to: 'editors#quick_fix'
25
26
  post 'format', to: 'editors#format_file'
26
27
 
27
28
  # ── Git & Code Review ──────────────────────────────────────────────────────
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/logger_silence"
4
+ require "uri"
4
5
 
5
6
  module Mbeditor
6
7
  module Rack
7
- # Silence periodic editor heartbeats so development logs stay readable.
8
+ # Silence editor traffic so development logs stay readable, while leaving
9
+ # the initial GET to the engine root visible as a signal that a developer
10
+ # opened Mbeditor.
8
11
  class SilencePingRequest
9
12
  def initialize(app)
10
13
  @app = app
11
14
  end
12
15
 
13
16
  def call(env)
14
- if ping_request?(env)
17
+ if root_request?(env)
18
+ @app.call(env)
19
+ elsif mbeditor_request?(env)
15
20
  Rails.logger.silence { @app.call(env) }
16
21
  else
17
22
  @app.call(env)
@@ -20,10 +25,19 @@ module Mbeditor
20
25
 
21
26
  private
22
27
 
23
- def ping_request?(env)
24
- env["REQUEST_METHOD"] == "GET" &&
25
- env["HTTP_X_MBEDITOR_CLIENT"] == "1" &&
26
- env["PATH_INFO"].to_s.end_with?("/ping")
28
+ def mbeditor_request?(env)
29
+ normalized_request_path(env).start_with?("/mbeditor/")
30
+ end
31
+
32
+ def root_request?(env)
33
+ env["REQUEST_METHOD"] == "GET" && normalized_request_path(env) == "/mbeditor"
34
+ end
35
+
36
+ def normalized_request_path(env)
37
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
38
+ path = env["PATH_INFO"].to_s if path.empty?
39
+ path = "/" if path.empty?
40
+ path.chomp("/")
27
41
  end
28
42
  end
29
43
  end
@@ -1,3 +1,3 @@
1
1
  module Mbeditor
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
@@ -0,0 +1,5 @@
1
+ const workerPath = self.location.pathname;
2
+ const basePath = workerPath.replace(/\/ts_worker\.js$/, "");
3
+
4
+ self.MonacoEnvironment = { baseUrl: `${basePath}/monaco-editor/` };
5
+ importScripts(`${basePath}/monaco-editor/vs/base/worker/workerMain.js`);
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-24 00:00:00.000000000 Z
11
+ date: 2026-03-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -132,6 +132,7 @@ files:
132
132
  - public/monaco-editor/vs/nls.messages.zh-cn.js
133
133
  - public/monaco-editor/vs/nls.messages.zh-tw.js
134
134
  - public/monaco_worker.js
135
+ - public/ts_worker.js
135
136
  - vendor/assets/javascripts/axios.min.js
136
137
  - vendor/assets/javascripts/lodash.min.js
137
138
  - vendor/assets/javascripts/marked.min.js