mbeditor 0.3.9 → 0.4.3

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.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Mbeditor
7
+ CableBaseClass = defined?(ActionCable::Channel::Base) ? ActionCable::Channel::Base : Object
8
+
9
+ class EditorChannel < CableBaseClass
10
+ STATE_MAX_BYTES = 1 * 1024 * 1024
11
+ SAFE_BRANCH_NAME = /\A[a-zA-Z0-9._\-\/]+\z/
12
+
13
+ def subscribed
14
+ stream_from "mbeditor_editor" if respond_to?(:stream_from)
15
+ end
16
+
17
+ def unsubscribed
18
+ # no-op
19
+ end
20
+
21
+ # Called via WebSocketService.perform('save_state', { state: ... })
22
+ def save_state(data)
23
+ Rails.logger.silence do
24
+ payload = (data["state"] || data).to_json
25
+ return if payload.bytesize > STATE_MAX_BYTES
26
+
27
+ root = workspace_root
28
+ path = root.join("tmp", "mbeditor_workspace.json")
29
+ FileUtils.mkdir_p(root.join("tmp"))
30
+ File.open(path, File::RDWR | File::CREAT) do |f|
31
+ f.flock(File::LOCK_EX)
32
+ f.truncate(0)
33
+ f.rewind
34
+ f.write(payload)
35
+ end
36
+ end
37
+ rescue StandardError
38
+ # Never let a state-save failure crash the WebSocket connection
39
+ end
40
+
41
+ # Called via WebSocketService.perform('save_branch_state', { branch: ..., state: ... })
42
+ def save_branch_state(data)
43
+ Rails.logger.silence do
44
+ branch = data["branch"].to_s.strip
45
+ return unless branch.match?(SAFE_BRANCH_NAME)
46
+
47
+ state_data = data["state"]
48
+ payload_json = state_data.to_json
49
+ return if payload_json.bytesize > STATE_MAX_BYTES
50
+
51
+ root = workspace_root
52
+ path = root.join("tmp", "mbeditor_branch_states.json")
53
+ FileUtils.mkdir_p(root.join("tmp"))
54
+ File.open(path, File::RDWR | File::CREAT) do |f|
55
+ f.flock(File::LOCK_EX)
56
+ existing = f.size > 0 ? JSON.parse(f.read) : {}
57
+ existing[branch] = state_data
58
+ f.truncate(0)
59
+ f.rewind
60
+ f.write(existing.to_json)
61
+ end
62
+ end
63
+ rescue StandardError
64
+ # Never let a state-save failure crash the WebSocket connection
65
+ end
66
+
67
+ private
68
+
69
+ def workspace_root
70
+ configured = Mbeditor.configuration.workspace_root
71
+ return Pathname.new(configured.to_s) if configured.present?
72
+
73
+ # Fall back to git root, same logic as ApplicationController
74
+ rails_root = Rails.root.to_s
75
+ out, _err, status = Open3.capture3("git", "-C", rails_root, "rev-parse", "--show-toplevel")
76
+ Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
77
+ rescue StandardError
78
+ Rails.root
79
+ end
80
+ end
81
+ end
@@ -38,7 +38,8 @@ module Mbeditor
38
38
  gitAvailable: git_available?,
39
39
  blameAvailable: git_blame_available?,
40
40
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
41
- testAvailable: test_available?
41
+ testAvailable: test_available?,
42
+ actionCableEnabled: action_cable_enabled?
42
43
  }
43
44
  end
44
45
 
@@ -206,6 +207,7 @@ module Mbeditor
206
207
  return render_file_too_large(content.bytesize) if content.bytesize > MAX_OPEN_FILE_SIZE_BYTES
207
208
 
208
209
  File.write(path, content)
210
+ broadcast_files_changed
209
211
  render json: { ok: true, path: relative_path(path) }
210
212
  rescue StandardError => e
211
213
  render json: { error: e.message }, status: :unprocessable_content
@@ -223,6 +225,7 @@ module Mbeditor
223
225
 
224
226
  FileUtils.mkdir_p(File.dirname(path))
225
227
  File.write(path, content)
228
+ broadcast_files_changed
226
229
 
227
230
  render json: { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
228
231
  rescue StandardError => e
@@ -237,6 +240,7 @@ module Mbeditor
237
240
  return render json: { error: "Path already exists" }, status: :unprocessable_content if File.exist?(path)
238
241
 
239
242
  FileUtils.mkdir_p(path)
243
+ broadcast_files_changed
240
244
  render json: { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
241
245
  rescue StandardError => e
242
246
  render json: { error: e.message }, status: :unprocessable_content
@@ -253,6 +257,7 @@ module Mbeditor
253
257
 
254
258
  FileUtils.mkdir_p(File.dirname(new_path))
255
259
  FileUtils.mv(old_path, new_path)
260
+ broadcast_files_changed
256
261
 
257
262
  render json: {
258
263
  ok: true,
@@ -274,9 +279,11 @@ module Mbeditor
274
279
 
275
280
  if File.directory?(path)
276
281
  FileUtils.rm_rf(path)
282
+ broadcast_files_changed
277
283
  render json: { ok: true, type: "folder", path: relative_path(path) }
278
284
  else
279
285
  File.delete(path)
286
+ broadcast_files_changed
280
287
  render json: { ok: true, type: "file", path: relative_path(path) }
281
288
  end
282
289
  rescue StandardError => e
@@ -312,23 +319,31 @@ module Mbeditor
312
319
  render json: { error: e.message }, status: :unprocessable_content
313
320
  end
314
321
 
315
- # GET /mbeditor/search?q=...&offset=0&limit=50
322
+ # GET /mbeditor/search?q=...&offset=0&limit=50&regex=false&match_case=false&whole_word=false
316
323
  def search
317
- query = params[:q].to_s.strip
318
- offset = [params[:offset].to_i, 0].max
319
- limit = [[params[:limit].to_i > 0 ? params[:limit].to_i : 50, 200].min, 1].max
320
- needed = offset + limit + 1 # collect one extra to detect has_more
324
+ query = params[:q].to_s.strip
325
+ offset = [params[:offset].to_i, 0].max
326
+ limit = [[params[:limit].to_i > 0 ? params[:limit].to_i : 50, 200].min, 1].max
327
+ use_regex = params[:regex] == 'true'
328
+ match_case = params[:match_case] == 'true'
329
+ whole_word = params[:whole_word] == 'true'
330
+ needed = offset + limit + 1 # collect one extra to detect has_more
321
331
 
322
332
  return render json: [] if query.blank?
323
333
  return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
324
334
 
325
335
  # On first page, count total matches in parallel with fetching results.
326
- count_thread = offset == 0 ? Thread.new { count_search_results(query) } : nil
336
+ count_thread = offset == 0 ? Thread.new { count_search_results(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word) } : nil
327
337
 
328
- results = stream_search_results(query, needed)
338
+ results = stream_search_results(query, needed, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
329
339
  has_more = results.length > offset + limit
330
340
  response = { results: results[offset, limit] || [], has_more: has_more }
331
- response[:total_count] = count_thread.value if count_thread
341
+ if count_thread
342
+ # Give the count thread up to 100 ms; omit total_count when it hasn't finished yet
343
+ # so the first page is never blocked by the counting subprocess.
344
+ count_thread.join(0.1)
345
+ response[:total_count] = count_thread.value unless count_thread.alive?
346
+ end
332
347
 
333
348
  render json: response
334
349
  rescue StandardError => e
@@ -661,6 +676,14 @@ module Mbeditor
661
676
 
662
677
  private
663
678
 
679
+ def broadcast_files_changed
680
+ return unless defined?(ActionCable.server)
681
+
682
+ ActionCable.server.broadcast("mbeditor_editor", { type: "files_changed" })
683
+ rescue StandardError
684
+ # Never let a broadcast failure affect the HTTP response
685
+ end
686
+
664
687
  def sanitize_branch_name(branch)
665
688
  return nil if branch.blank?
666
689
  str = branch.to_s.strip
@@ -668,6 +691,23 @@ module Mbeditor
668
691
  str.match?(SAFE_BRANCH_NAME) ? str : nil
669
692
  end
670
693
 
694
+ def action_cable_enabled?
695
+ return false unless defined?(ActionCable::Channel::Base)
696
+
697
+ mount_path = begin
698
+ ActionCable.server.config.mount_path
699
+ rescue StandardError
700
+ nil
701
+ end
702
+ mount_path = '/cable' if mount_path.blank?
703
+
704
+ Rails.application.routes.routes.any? do |route|
705
+ route.path.spec.to_s.start_with?(mount_path)
706
+ end
707
+ rescue StandardError
708
+ false
709
+ end
710
+
671
711
  def allow_missing_file?
672
712
  %w[1 true yes on].include?(params[:allow_missing].to_s.downcase)
673
713
  end
@@ -696,11 +736,14 @@ module Mbeditor
696
736
  # Stream search results using popen so we can stop reading early once we
697
737
  # have collected `limit` matches (avoids buffering the entire rg/grep output
698
738
  # in memory when searching large codebases for common tokens).
699
- def stream_search_results(query, limit)
739
+ def stream_search_results(query, limit, use_regex: false, match_case: false, whole_word: false)
700
740
  results = []
701
741
 
702
742
  if RG_AVAILABLE
703
743
  args = ["rg", "--json", "--no-ignore"]
744
+ args << "-F" unless use_regex
745
+ args << "--ignore-case" unless match_case
746
+ args << "--word-regexp" if whole_word
704
747
  excluded_paths.each { |p| args << "--glob=!#{p}" }
705
748
  args += ["--", query, workspace_root.to_s]
706
749
 
@@ -725,7 +768,10 @@ module Mbeditor
725
768
  end
726
769
  end
727
770
  else
728
- args = ["grep", "-rn", "-F"]
771
+ base_flags = use_regex ? "-E" : "-F"
772
+ args = ["grep", "-rn", base_flags]
773
+ args << "-i" unless match_case
774
+ args << "-w" if whole_word
729
775
  excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
730
776
  args += [query, workspace_root.to_s]
731
777
 
@@ -756,17 +802,23 @@ module Mbeditor
756
802
 
757
803
  # Count total matching lines across the workspace using rg --count (or grep -c).
758
804
  # Fast: rg just counts without extracting context. Runs in a background thread.
759
- def count_search_results(query)
805
+ def count_search_results(query, use_regex: false, match_case: false, whole_word: false)
760
806
  total = 0
761
807
  if RG_AVAILABLE
762
808
  args = ["rg", "--count", "--no-ignore"]
809
+ args << "-F" unless use_regex
810
+ args << "--ignore-case" unless match_case
811
+ args << "--word-regexp" if whole_word
763
812
  excluded_paths.each { |p| args << "--glob=!#{p}" }
764
813
  args += ["--", query, workspace_root.to_s]
765
814
  IO.popen(args, err: File::NULL) do |io|
766
815
  io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
767
816
  end
768
817
  else
769
- args = ["grep", "-rc", "-F"]
818
+ base_flags = use_regex ? "-E" : "-F"
819
+ args = ["grep", "-rc", base_flags]
820
+ args << "-i" unless match_case
821
+ args << "-w" if whole_word
770
822
  excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
771
823
  args += [query, workspace_root.to_s]
772
824
  IO.popen(args, err: File::NULL) do |io|
@@ -20,6 +20,10 @@
20
20
  <%= stylesheet_link_tag "fontawesome.min", media: "all", preload_links_header: false %>
21
21
  <%= stylesheet_link_tag "mbeditor/application", media: "all", preload_links_header: false %>
22
22
 
23
+ <!-- ── ActionCable (deferred — sets window.ActionCable for WebSocket service) ── -->
24
+ <% if defined?(ActionCable::Channel::Base) %>
25
+ <script defer src="<%= asset_path('actioncable.js') %>"></script>
26
+ <% end %>
23
27
  <!-- ── Vendor JS (deferred — only needed inside Monaco callback) ── -->
24
28
  <script defer src="<%= asset_path('react.min.js') %>"></script>
25
29
  <script defer src="<%= asset_path('react-dom.min.js') %>"></script>
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Wraps the ActionCable logger and suppresses all log lines that mention
5
+ # Mbeditor channels so the development console stays readable.
6
+ # Non-Mbeditor ActionCable messages pass through unchanged.
7
+ class CableLogFilter < SimpleDelegator
8
+ SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor/
9
+
10
+ %w[debug info warn error fatal unknown].each do |level|
11
+ define_method(level) do |message = nil, &block|
12
+ msg = message.nil? && block ? block.call : message.to_s
13
+ return if msg.match?(SUPPRESS_PATTERN)
14
+
15
+ super(message, &block)
16
+ end
17
+ end
18
+
19
+ # Tagged-logging compat — the block body still passes through the filter.
20
+ def tagged(*tags, &block)
21
+ if __getobj__.respond_to?(:tagged)
22
+ __getobj__.tagged(*tags, &block)
23
+ elsif block
24
+ block.call
25
+ else
26
+ self
27
+ end
28
+ end
29
+
30
+ # Rails/ActiveSupport logger compatibility. Some logger stacks call these
31
+ # methods even when the underlying logger is not TaggedLogging.
32
+ def current_tags
33
+ return __getobj__.current_tags if __getobj__.respond_to?(:current_tags)
34
+
35
+ []
36
+ end
37
+
38
+ def push_tags(*tags)
39
+ return __getobj__.push_tags(*tags) if __getobj__.respond_to?(:push_tags)
40
+
41
+ tags
42
+ end
43
+
44
+ def pop_tags(count = 1)
45
+ return __getobj__.pop_tags(count) if __getobj__.respond_to?(:pop_tags)
46
+
47
+ []
48
+ end
49
+
50
+ def clear_tags!
51
+ return __getobj__.clear_tags! if __getobj__.respond_to?(:clear_tags!)
52
+
53
+ nil
54
+ end
55
+ end
56
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mbeditor/rack/silence_ping_request"
4
4
  require "mbeditor/rack/handle_pending_migrations"
5
+ require "mbeditor/cable_log_filter"
5
6
 
6
7
  module Mbeditor
7
8
  class Engine < ::Rails::Engine
@@ -24,6 +25,15 @@ module Mbeditor
24
25
  end
25
26
 
26
27
  config.after_initialize do
28
+ # Silence ActionCable framework logs for Mbeditor channels (subscription
29
+ # confirmations, streaming notices, action invocations, disconnect messages).
30
+ # We wrap the existing ActionCable logger in a filter proxy rather than
31
+ # replacing it, so the host app's non-Mbeditor channel logs are unaffected.
32
+ if defined?(ActionCable)
33
+ original_logger = ActionCable.server.config.logger || Rails.logger
34
+ ActionCable.server.config.logger = Mbeditor::CableLogFilter.new(original_logger)
35
+ end
36
+
27
37
  Mbeditor::RubyDefinitionService.cache_path =
28
38
  Rails.root.join("tmp", "mbeditor_ruby_defs.json").to_s
29
39
 
@@ -17,7 +17,7 @@ module Mbeditor
17
17
  path = normalized_request_path(env)
18
18
  if root_request?(env, path)
19
19
  @app.call(env)
20
- elsif mbeditor_request?(path) || editor_asset_request?(env, path)
20
+ elsif mbeditor_request?(path) || cable_request?(path) || editor_asset_request?(env, path)
21
21
  Rails.logger.silence { @app.call(env) }
22
22
  else
23
23
  @app.call(env)
@@ -30,6 +30,9 @@ module Mbeditor
30
30
  path.start_with?("/mbeditor/")
31
31
  end
32
32
 
33
+ def cable_request?(path)
34
+ path == "/cable" || path.start_with?("/cable/")
35
+ end
33
36
  # Silence asset pipeline requests that belong to the editor:
34
37
  # - /assets/mbeditor/... is always an editor asset (CSS/JS bundle)
35
38
  # - other /assets/... requests are silenced only when the browser is
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.3.9"
4
+ VERSION = "0.4.3"
5
5
  end
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.3.9
4
+ version: 0.4.3
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-04-21 00:00:00.000000000 Z
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -78,9 +78,11 @@ files:
78
78
  - app/assets/javascripts/mbeditor/git_service.js
79
79
  - app/assets/javascripts/mbeditor/search_service.js
80
80
  - app/assets/javascripts/mbeditor/tab_manager.js
81
+ - app/assets/javascripts/mbeditor/websocket_service.js
81
82
  - app/assets/stylesheets/mbeditor/application.css
82
83
  - app/assets/stylesheets/mbeditor/editor.css
83
84
  - app/assets/stylesheets/mbeditor/themes.css
85
+ - app/channels/mbeditor/editor_channel.rb
84
86
  - app/controllers/mbeditor/application_controller.rb
85
87
  - app/controllers/mbeditor/editors_controller.rb
86
88
  - app/controllers/mbeditor/git_controller.rb
@@ -98,6 +100,7 @@ files:
98
100
  - config/initializers/assets.rb
99
101
  - config/routes.rb
100
102
  - lib/mbeditor.rb
103
+ - lib/mbeditor/cable_log_filter.rb
101
104
  - lib/mbeditor/configuration.rb
102
105
  - lib/mbeditor/engine.rb
103
106
  - lib/mbeditor/rack/handle_pending_migrations.rb