mbeditor 0.4.2 → 0.4.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d378fdda8fc933a3c3d1f5b1fc5eb1622ca4ebee91db435378dd069827f708e2
4
- data.tar.gz: 2c58b8db51c1df5e7f746af61550dc2ccf62ba33a515cb374c79931aefed1beb
3
+ metadata.gz: 64e27441c8b73cfd32fa121d310b4998592e379d3e60f064abe60591e54cfbcd
4
+ data.tar.gz: b7fb4b63d54f22fdfbcfebed83c5ff63d41d2c14bf753ba9a2bc41e964c47529
5
5
  SHA512:
6
- metadata.gz: 43753349cc14d328dbb45c4d6413cb0a56b9ac49979e7cd0bafe884de0889ede353decdd8552da38d872c278fa3c70162a1730df5827f45d0f629aa0d44a1f6f
7
- data.tar.gz: 3547eec20751a3c7279765e79429dda30398dfd6aa64c7b70d7e2f70cda6e8de6d9ae8c30a0c671a9750dcce1422d115d2b42f1a3d35b6679e81d795ba134c3e
6
+ metadata.gz: 2a9eb8bf88a3696639fcb28e59ac4593a2bf9b8b35b203a9cdc7d27ef2f4d5a7266062211813713d3f0af0b4ba14c9051090ebbe46732b5bee78f03b4d28723f
7
+ data.tar.gz: 8e17f8d267928862f9c26fd8395168b67fc6942a3612facfc6d815cfcffb9bbfeb370ee5ef4b4d8205fa1593e909fd88fdd63c44f7c9b804788e76e224fcfa65
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.4] - 2026-04-23
9
+
10
+ ### Fixed
11
+ - `CableLogFilter` now preserves Action Cable and Action Pack tagged-logger compatibility by supporting formatter-level `current_tags` access and no-op tag operations for untagged or nil formatters.
12
+ - Added regression coverage for formatter-tag compatibility paths to prevent `current_tags` runtime errors.
13
+ - System test Cuprite driver configuration now applies explicit startup timeout options through `driven_by` to avoid intermittent Ferrum browser bootstrap timeouts in CI.
14
+
15
+ ## [0.4.3] - 2026-04-22
16
+
17
+ ### Added
18
+ - Documented optional Action Cable host-app setup and fallback behavior for realtime editor updates.
19
+
20
+ ### Changed
21
+ - `bundle exec rake test` now includes `test/lib/**/*_test.rb` so release validation covers library-level regression tests.
22
+
23
+ ### Fixed
24
+ - Action Cable availability detection now respects whether `/cable` is actually mounted, and websocket handshake failures fall back to polling without noisy console errors.
25
+ - Circular loading indicators keep animating even when global reduced-motion styles are active in production.
26
+ - `CableLogFilter` now remains compatible with untagged logger stacks used outside full Action Cable setups.
27
+
8
28
  ## [0.4.2] - 2026-04-22
9
29
 
10
30
  ### Fixed
data/README.md CHANGED
@@ -121,9 +121,27 @@ The gem keeps host/tooling responsibilities in the host app:
121
121
  - `haml_lint` gem (optional, required for HAML lint — add to your app's Gemfile if needed)
122
122
  - `git` installed in environment (for Git panel data)
123
123
  - `minitest` or `rspec` in the host app's bundle (required for the test runner)
124
+ - `actioncable` framework/gem (optional, required only for realtime file-change push + websocket state saves)
124
125
 
125
126
  All lint and test tools are auto-detected at runtime. The engine gracefully disables features if the tools are not available. Neither `rubocop`, `haml_lint`, nor any test framework are runtime dependencies of the gem itself — they are discovered from the host app's environment.
126
127
 
128
+ ### Realtime via Action Cable (Optional)
129
+
130
+ Mbeditor works without Action Cable. If Action Cable is unavailable, unreachable, or returns transient errors, the editor automatically falls back to polling.
131
+
132
+ To enable realtime features in a host app:
133
+
134
+ 1. Ensure Action Cable is enabled in the host app (for apps that do not load it by default, add the framework/gem explicitly).
135
+ 2. Mount cable in host routes:
136
+
137
+ ```ruby
138
+ mount ActionCable.server => '/cable'
139
+ ```
140
+
141
+ 3. Make Action Cable JavaScript available to the page (for asset-pipeline apps, `actioncable.js` is typically sufficient).
142
+
143
+ If any of these are missing, mbeditor still runs in polling mode.
144
+
127
145
  ### Syntax Highlighting Support
128
146
  Monaco runtime assets are served from the engine route namespace (`/mbeditor/monaco-editor/*` and `/mbeditor/monaco_worker.js`).
129
147
  The gem includes syntax highlighting for common Rails and React development file types:
@@ -11,6 +11,10 @@ var WebSocketService = (function () {
11
11
  var _subscription = null;
12
12
  var _connected = false;
13
13
  var _filesChangedCallbacks = [];
14
+ var _serverSupportsWs = false;
15
+ var _reconnectTimer = null;
16
+ var _lastCableAttemptAt = 0;
17
+ var RECONNECT_INTERVAL_MS = 30000;
14
18
 
15
19
  // ---------------------------------------------------------------------------
16
20
  // Internal helpers
@@ -20,33 +24,78 @@ var WebSocketService = (function () {
20
24
  return typeof window.ActionCable !== 'undefined';
21
25
  }
22
26
 
27
+ function _cableUrl() {
28
+ return window.MBEDITOR_CABLE_URL || '/cable';
29
+ }
30
+
23
31
  function _getConsumer() {
24
32
  // Reuse an existing consumer the host app may have already created (App.cable
25
33
  // is the Rails default). Fall back to creating our own.
26
34
  if (typeof window.App !== 'undefined' && window.App.cable) {
27
35
  return window.App.cable;
28
36
  }
29
- var cableUrl = window.MBEDITOR_CABLE_URL || '/cable';
30
- return window.ActionCable.createConsumer(cableUrl);
37
+ return window.ActionCable.createConsumer(_cableUrl());
31
38
  }
32
39
 
33
- function _emitFilesChanged(data) {
34
- _filesChangedCallbacks.forEach(function (fn) {
35
- try { fn(data); } catch (e) { /* ignore */ }
40
+ function _cleanupConsumer() {
41
+ if (_subscription) {
42
+ try { _subscription.unsubscribe(); } catch (e) { /* ignore */ }
43
+ _subscription = null;
44
+ }
45
+ if (_consumer && typeof _consumer.disconnect === 'function') {
46
+ try { _consumer.disconnect(); } catch (e) { /* ignore */ }
47
+ }
48
+ _consumer = null;
49
+ _connected = false;
50
+ }
51
+
52
+ function _isCableRelatedRejection(reason) {
53
+ var url = _cableUrl();
54
+ if (!reason) return false;
55
+
56
+ // Common browser shape: ErrorEvent with target/currentTarget = WebSocket.
57
+ var target = reason.target || reason.currentTarget;
58
+ if (target && typeof target.url === 'string' && target.url.indexOf(url) !== -1) {
59
+ return true;
60
+ }
61
+
62
+ // Some environments stringify this as "[object Event]"; only suppress if
63
+ // it happened right after a cable connection attempt.
64
+ var ageMs = Date.now() - _lastCableAttemptAt;
65
+ var reasonText = String(reason);
66
+ if (reasonText.indexOf('[object Event]') !== -1 && ageMs >= 0 && ageMs < 3000) {
67
+ return true;
68
+ }
69
+
70
+ return false;
71
+ }
72
+
73
+ function _installUnhandledRejectionGuard() {
74
+ if (window.__mbeditorCableRejectionGuardInstalled) return;
75
+ window.__mbeditorCableRejectionGuardInstalled = true;
76
+ window.addEventListener('unhandledrejection', function (event) {
77
+ if (_isCableRelatedRejection(event.reason)) {
78
+ // Keep cable handshake failures internal; polling fallback remains active.
79
+ event.preventDefault();
80
+ }
36
81
  });
37
82
  }
38
83
 
39
- // ---------------------------------------------------------------------------
40
- // Public API
41
- // ---------------------------------------------------------------------------
84
+ function _scheduleReconnect() {
85
+ if (_reconnectTimer || !_serverSupportsWs) return;
86
+ _reconnectTimer = setTimeout(function () {
87
+ _reconnectTimer = null;
88
+ _attemptConnect();
89
+ }, RECONNECT_INTERVAL_MS);
90
+ }
42
91
 
43
- // Call once after the workspace response is received.
44
- // serverSupportsWs: boolean from workspace.actionCableEnabled
45
- function connect(serverSupportsWs) {
46
- if (!serverSupportsWs || !_isActionCableAvailable()) {
47
- return; // polling remains the only refresh mechanism
92
+ function _attemptConnect() {
93
+ if (!_serverSupportsWs || !_isActionCableAvailable() || _connected || _subscription) {
94
+ return;
48
95
  }
49
96
 
97
+ _lastCableAttemptAt = Date.now();
98
+
50
99
  try {
51
100
  _consumer = _getConsumer();
52
101
  _subscription = _consumer.subscriptions.create(
@@ -56,15 +105,12 @@ var WebSocketService = (function () {
56
105
  _connected = true;
57
106
  },
58
107
  disconnected: function () {
59
- _connected = false;
108
+ _cleanupConsumer();
109
+ _scheduleReconnect();
60
110
  },
61
111
  rejected: function () {
62
- _connected = false;
63
- // Channel was rejected — unsubscribe so we stop trying.
64
- if (_subscription) {
65
- _subscription.unsubscribe();
66
- _subscription = null;
67
- }
112
+ _cleanupConsumer();
113
+ _scheduleReconnect();
68
114
  },
69
115
  received: function (data) {
70
116
  if (data && data.type === 'files_changed') {
@@ -74,18 +120,41 @@ var WebSocketService = (function () {
74
120
  }
75
121
  );
76
122
  } catch (e) {
77
- // Any setup error means we silently stay in polling-only mode.
78
- _connected = false;
79
- _subscription = null;
123
+ // Any setup error means we silently stay in polling-only mode for now.
124
+ _cleanupConsumer();
125
+ _scheduleReconnect();
126
+ }
127
+ }
128
+
129
+ function _emitFilesChanged(data) {
130
+ _filesChangedCallbacks.forEach(function (fn) {
131
+ try { fn(data); } catch (e) { /* ignore */ }
132
+ });
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Public API
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // Call once after the workspace response is received.
140
+ // serverSupportsWs: boolean from workspace.actionCableEnabled
141
+ function connect(serverSupportsWs) {
142
+ _serverSupportsWs = !!serverSupportsWs;
143
+ if (!_serverSupportsWs || !_isActionCableAvailable()) {
144
+ return; // polling remains the only refresh mechanism
80
145
  }
146
+ _installUnhandledRejectionGuard();
147
+ _attemptConnect();
148
+ _scheduleReconnect();
81
149
  }
82
150
 
83
151
  function disconnect() {
84
- if (_subscription) {
85
- _subscription.unsubscribe();
86
- _subscription = null;
152
+ _serverSupportsWs = false;
153
+ if (_reconnectTimer) {
154
+ clearTimeout(_reconnectTimer);
155
+ _reconnectTimer = null;
87
156
  }
88
- _connected = false;
157
+ _cleanupConsumer();
89
158
  }
90
159
 
91
160
  // Returns true only when the WebSocket is currently live.
@@ -841,4 +841,23 @@
841
841
  @keyframes mbeditor-spin {
842
842
  to { transform: rotate(360deg); }
843
843
  }
844
+
845
+ @media (prefers-reduced-motion: reduce) {
846
+ .mbeditor-spinner,
847
+ .editor-loading-spinner,
848
+ .search-loading-spinner {
849
+ animation-duration: 0.75s !important;
850
+ animation-delay: 0s !important;
851
+ animation-iteration-count: infinite !important;
852
+ }
853
+
854
+ .editor-loading-spinner {
855
+ animation-duration: 0.65s !important;
856
+ }
857
+
858
+ .search-loading-spinner {
859
+ animation-duration: 0.7s !important;
860
+ }
861
+ }
862
+
844
863
  .cdiff-ctx { color: var(--ide-text-muted); }
@@ -4,12 +4,14 @@ require "fileutils"
4
4
  require "pathname"
5
5
 
6
6
  module Mbeditor
7
- class EditorChannel < ActionCable::Channel::Base
7
+ CableBaseClass = defined?(ActionCable::Channel::Base) ? ActionCable::Channel::Base : Object
8
+
9
+ class EditorChannel < CableBaseClass
8
10
  STATE_MAX_BYTES = 1 * 1024 * 1024
9
11
  SAFE_BRANCH_NAME = /\A[a-zA-Z0-9._\-\/]+\z/
10
12
 
11
13
  def subscribed
12
- stream_from "mbeditor_editor"
14
+ stream_from "mbeditor_editor" if respond_to?(:stream_from)
13
15
  end
14
16
 
15
17
  def unsubscribed
@@ -39,7 +39,7 @@ module Mbeditor
39
39
  blameAvailable: git_blame_available?,
40
40
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
41
41
  testAvailable: test_available?,
42
- actionCableEnabled: defined?(ActionCable::Channel::Base) ? true : false
42
+ actionCableEnabled: action_cable_enabled?
43
43
  }
44
44
  end
45
45
 
@@ -691,6 +691,23 @@ module Mbeditor
691
691
  str.match?(SAFE_BRANCH_NAME) ? str : nil
692
692
  end
693
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
+
694
711
  def allow_missing_file?
695
712
  %w[1 true yes on].include?(params[:allow_missing].to_s.downcase)
696
713
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'delegate'
4
+ require 'logger'
5
+
3
6
  module Mbeditor
4
7
  # Wraps the ActionCable logger and suppresses all log lines that mention
5
8
  # Mbeditor channels so the development console stays readable.
@@ -7,6 +10,50 @@ module Mbeditor
7
10
  class CableLogFilter < SimpleDelegator
8
11
  SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor/
9
12
 
13
+ # Provides no-op tagged logging APIs for plain Ruby formatters.
14
+ class UntaggedFormatter < SimpleDelegator
15
+ def tagged(*_tags)
16
+ return yield self if block_given?
17
+
18
+ self
19
+ end
20
+
21
+ def current_tags
22
+ []
23
+ end
24
+
25
+ def push_tags(*tags)
26
+ tags
27
+ end
28
+
29
+ def pop_tags(_count = 1)
30
+ []
31
+ end
32
+
33
+ def clear_tags!
34
+ nil
35
+ end
36
+ end
37
+
38
+ def formatter
39
+ underlying_formatter = resolve_formatter
40
+
41
+ return underlying_formatter if underlying_formatter.respond_to?(:current_tags)
42
+
43
+ if defined?(@untagged_formatter_source) && @untagged_formatter_source.equal?(underlying_formatter)
44
+ return @untagged_formatter
45
+ end
46
+
47
+ @untagged_formatter_source = underlying_formatter
48
+ @untagged_formatter = UntaggedFormatter.new(underlying_formatter)
49
+ end
50
+
51
+ def resolve_formatter
52
+ return Logger::Formatter.new unless __getobj__.respond_to?(:formatter)
53
+
54
+ __getobj__.formatter || Logger::Formatter.new
55
+ end
56
+
10
57
  %w[debug info warn error fatal unknown].each do |level|
11
58
  define_method(level) do |message = nil, &block|
12
59
  msg = message.nil? && block ? block.call : message.to_s
@@ -19,10 +66,44 @@ module Mbeditor
19
66
  # Tagged-logging compat — the block body still passes through the filter.
20
67
  def tagged(*tags, &block)
21
68
  if __getobj__.respond_to?(:tagged)
22
- __getobj__.tagged(*tags) { block.call }
23
- else
69
+ tagged_logger = __getobj__.tagged(*tags, &block)
70
+ block ? tagged_logger : self.class.new(tagged_logger)
71
+ elsif block
24
72
  block.call
73
+ else
74
+ self
25
75
  end
26
76
  end
77
+
78
+ # Rails/ActiveSupport logger compatibility. Some logger stacks call these
79
+ # methods even when the underlying logger is not TaggedLogging.
80
+ def current_tags
81
+ return __getobj__.current_tags if __getobj__.respond_to?(:current_tags)
82
+
83
+ []
84
+ end
85
+
86
+ def push_tags(*tags)
87
+ return __getobj__.push_tags(*tags) if __getobj__.respond_to?(:push_tags)
88
+
89
+ tags
90
+ end
91
+
92
+ def pop_tags(count = 1)
93
+ return __getobj__.pop_tags(count) if __getobj__.respond_to?(:pop_tags)
94
+
95
+ []
96
+ end
97
+
98
+ def clear_tags!
99
+ return __getobj__.clear_tags! if __getobj__.respond_to?(:clear_tags!)
100
+
101
+ nil
102
+ end
103
+
104
+ def flush
105
+ clear_tags!
106
+ __getobj__.flush if __getobj__.respond_to?(:flush)
107
+ end
27
108
  end
28
109
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.4"
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.4.2
4
+ version: 0.4.4
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-22 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails