mbeditor 0.4.0 → 0.4.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.
@@ -31,6 +31,24 @@
31
31
  'or': true, not: true, require: true, include: true, extend: true
32
32
  };
33
33
 
34
+ // Ruby core / Kernel built-in methods that should never trigger a
35
+ // definition lookup — ctrl+click or F12 on these is a no-op.
36
+ var RUBY_CORE_METHODS = {
37
+ puts: true, print: true, p: true, pp: true, warn: true, printf: true,
38
+ fail: true, require_relative: true, prepend: true,
39
+ attr_accessor: true, attr_reader: true, attr_writer: true,
40
+ lambda: true, proc: true, 'loop': true, sleep: true,
41
+ exit: true, abort: true, rand: true, srand: true, gets: true,
42
+ sprintf: true, format: true, open: true,
43
+ Integer: true, Float: true, String: true, Array: true, Hash: true,
44
+ Rational: true, Complex: true,
45
+ readline: true, readlines: true,
46
+ system: true, exec: true, fork: true, spawn: true,
47
+ freeze: true, frozen: true, dup: true, clone: true, object_id: true,
48
+ respond_to: true, send: true, public_send: true, method: true,
49
+ tap: true, itself: true
50
+ };
51
+
34
52
  var globalsRegistered = false;
35
53
 
36
54
  function leadingWhitespace(line) {
@@ -287,6 +305,7 @@
287
305
  var wordInfo = model.getWordAtPosition(position);
288
306
  if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
289
307
  if (RUBY_KEYWORDS[wordInfo.word]) return;
308
+ if (RUBY_CORE_METHODS[wordInfo.word]) return;
290
309
  if (typeof FileService === 'undefined' || !FileService.getDefinition) return;
291
310
 
292
311
  event.event.preventDefault();
@@ -315,6 +334,7 @@
315
334
  var wordInfo = model.getWordAtPosition(pos);
316
335
  if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
317
336
  if (RUBY_KEYWORDS[wordInfo.word]) return;
337
+ if (RUBY_CORE_METHODS[wordInfo.word]) return;
318
338
  if (typeof FileService === 'undefined' || !FileService.getDefinition) return;
319
339
 
320
340
  FileService.getDefinition(wordInfo.word, 'ruby').then(function(data) {
@@ -707,6 +727,7 @@
707
727
  var word = wordInfo.word;
708
728
  if (!word || word.length < 2) return null;
709
729
  if (RUBY_KEYWORDS[word]) return null;
730
+ if (RUBY_CORE_METHODS[word]) return null;
710
731
  if (typeof FileService === 'undefined' || !FileService.getDefinition) return null;
711
732
 
712
733
  var currentFile = model._mbeditorPath || null;
@@ -93,6 +93,9 @@ var FileService = (function () {
93
93
  }
94
94
 
95
95
  function saveState(state) {
96
+ if (WebSocketService.isConnected() && WebSocketService.perform('save_state', { state: state })) {
97
+ return Promise.resolve({ ok: true });
98
+ }
96
99
  return axios.post(window.mbeditorBasePath() + '/state', { state: state }).then(function(res) { return res.data; });
97
100
  }
98
101
 
@@ -101,6 +104,9 @@ var FileService = (function () {
101
104
  }
102
105
 
103
106
  function saveBranchState(branch, state) {
107
+ if (WebSocketService.isConnected() && WebSocketService.perform('save_branch_state', { branch: branch, state: state })) {
108
+ return Promise.resolve({ ok: true });
109
+ }
104
110
  return axios.post(window.mbeditorBasePath() + '/branch_state', { branch: branch, state: state }).then(function(res) { return res.data; });
105
111
  }
106
112
 
@@ -43,7 +43,7 @@ var SearchService = (function () {
43
43
  // Returns merged results; MiniSearch scored entries come first.
44
44
  function searchFiles(query) {
45
45
  if (!query) return [];
46
- var msResults = _miniSearch.search(query, { prefix: true, fuzzy: 0.2 });
46
+ var msResults = _miniSearch.search(query, { prefix: true, fuzzy: false, combineWith: 'AND' });
47
47
  // Substring fallback — catch anything MiniSearch missed
48
48
  var q = query.toLowerCase();
49
49
  var msIds = new Set(msResults.map(function(r) { return r.id; }));
@@ -59,13 +59,17 @@ var SearchService = (function () {
59
59
  // Fetch one page of project-wide full-text search results.
60
60
  // offset=0 replaces the EditorStore results list; offset>0 appends.
61
61
  // The server includes total_count only on offset=0 (fast rg --count pass).
62
- function projectSearch(query, offset, limit) {
62
+ // options.regex=true enables regex mode on the server.
63
+ function projectSearch(query, offset, limit, options) {
63
64
  if (!query) return Promise.resolve({ results: [], hasMore: false, totalCount: 0 });
64
65
  var off = (typeof offset === 'number') ? offset : 0;
65
66
  var lim = (typeof limit === 'number') ? limit : SEARCH_PAGE_SIZE;
67
+ var useRegex = !!(options && options.regex);
68
+ var matchCase = !!(options && options.matchCase);
69
+ var wholeWord = !!(options && options.wholeWord);
66
70
 
67
71
  return axios.get(window.mbeditorBasePath() + '/search', {
68
- params: { q: query, offset: off, limit: lim }
72
+ params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' }
69
73
  }).then(function(res) {
70
74
  var data = res.data;
71
75
  var results = Array.isArray(data) ? data : (data && data.results || []);
@@ -0,0 +1,126 @@
1
+ // WebSocketService — wraps ActionCable when available.
2
+ // Falls back gracefully when ActionCable is absent or the WebSocket connection
3
+ // cannot be established (e.g. the host app does not mount /cable).
4
+ //
5
+ // The ping heartbeat is intentionally NOT routed through this service.
6
+ // Polling keeps working as the authoritative "is the server reachable?" check
7
+ // because WebSocket connections can survive DNS/network changes that would
8
+ // otherwise prevent reconnection.
9
+ var WebSocketService = (function () {
10
+ var _consumer = null;
11
+ var _subscription = null;
12
+ var _connected = false;
13
+ var _filesChangedCallbacks = [];
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Internal helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function _isActionCableAvailable() {
20
+ return typeof window.ActionCable !== 'undefined';
21
+ }
22
+
23
+ function _getConsumer() {
24
+ // Reuse an existing consumer the host app may have already created (App.cable
25
+ // is the Rails default). Fall back to creating our own.
26
+ if (typeof window.App !== 'undefined' && window.App.cable) {
27
+ return window.App.cable;
28
+ }
29
+ var cableUrl = window.MBEDITOR_CABLE_URL || '/cable';
30
+ return window.ActionCable.createConsumer(cableUrl);
31
+ }
32
+
33
+ function _emitFilesChanged(data) {
34
+ _filesChangedCallbacks.forEach(function (fn) {
35
+ try { fn(data); } catch (e) { /* ignore */ }
36
+ });
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
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
48
+ }
49
+
50
+ try {
51
+ _consumer = _getConsumer();
52
+ _subscription = _consumer.subscriptions.create(
53
+ { channel: 'Mbeditor::EditorChannel' },
54
+ {
55
+ connected: function () {
56
+ _connected = true;
57
+ },
58
+ disconnected: function () {
59
+ _connected = false;
60
+ },
61
+ rejected: function () {
62
+ _connected = false;
63
+ // Channel was rejected — unsubscribe so we stop trying.
64
+ if (_subscription) {
65
+ _subscription.unsubscribe();
66
+ _subscription = null;
67
+ }
68
+ },
69
+ received: function (data) {
70
+ if (data && data.type === 'files_changed') {
71
+ _emitFilesChanged(data);
72
+ }
73
+ }
74
+ }
75
+ );
76
+ } catch (e) {
77
+ // Any setup error means we silently stay in polling-only mode.
78
+ _connected = false;
79
+ _subscription = null;
80
+ }
81
+ }
82
+
83
+ function disconnect() {
84
+ if (_subscription) {
85
+ _subscription.unsubscribe();
86
+ _subscription = null;
87
+ }
88
+ _connected = false;
89
+ }
90
+
91
+ // Returns true only when the WebSocket is currently live.
92
+ function isConnected() {
93
+ return _connected;
94
+ }
95
+
96
+ // Register a callback to be invoked when the server broadcasts files_changed.
97
+ function onFilesChanged(fn) {
98
+ _filesChangedCallbacks.push(fn);
99
+ }
100
+
101
+ // Remove a previously registered callback.
102
+ function offFilesChanged(fn) {
103
+ _filesChangedCallbacks = _filesChangedCallbacks.filter(function (f) { return f !== fn; });
104
+ }
105
+
106
+ // Send a server-side channel action (e.g. 'save_state').
107
+ // Returns true if the message was dispatched, false if not connected.
108
+ function perform(action, data) {
109
+ if (!_subscription || !_connected) return false;
110
+ try {
111
+ _subscription.perform(action, data);
112
+ return true;
113
+ } catch (e) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ return {
119
+ connect: connect,
120
+ disconnect: disconnect,
121
+ isConnected: isConnected,
122
+ perform: perform,
123
+ onFilesChanged: onFilesChanged,
124
+ offFilesChanged: offFilesChanged
125
+ };
126
+ })();
@@ -1,6 +1,13 @@
1
1
  /* ── Mini Browser Editor Global Styles ──────────────────── */
2
2
  *, *::before, *::after { box-sizing: border-box; }
3
3
 
4
+ /* Suppress all focus outlines / coloured glows inside the IDE shell */
5
+ #mbeditor-root *:focus,
6
+ #mbeditor-root *:focus-visible {
7
+ outline: none !important;
8
+ box-shadow: none !important;
9
+ }
10
+
4
11
  html, body, #mbeditor-root {
5
12
  margin: 0; padding: 0;
6
13
  width: 100%; height: 100%;
@@ -822,6 +829,14 @@ html, body, #mbeditor-root {
822
829
  padding-left: 8px;
823
830
  }
824
831
 
832
+ .statusbar-eol-btn {
833
+ font-size: 10px;
834
+ font-weight: 600;
835
+ letter-spacing: 0.04em;
836
+ opacity: 0.85;
837
+ padding: 0 6px;
838
+ }
839
+
825
840
  /* ── Search Panel ─────────────────────────────────────────── */
826
841
  .search-panel {
827
842
  padding: 8px;
@@ -840,28 +855,64 @@ html, body, #mbeditor-root {
840
855
  }
841
856
 
842
857
  .search-input-shell {
843
- flex: 1;
858
+ flex-shrink: 0;
844
859
  position: relative;
845
860
  display: flex;
846
861
  align-items: center;
847
862
  min-width: 0;
848
863
  }
849
864
 
865
+ /* The input grows to fill; right padding makes room for adornments */
850
866
  .search-input-shell .search-input {
851
- padding-right: 24px;
867
+ padding-right: 88px; /* 3 × 22px buttons + 4px clear + gaps */
852
868
  }
853
869
 
854
- /* search-clear-btn sits absolutely inside the input shell */
855
- .search-input-shell .search-clear-btn {
870
+ /* Adornment container absolutely positioned inside the shell */
871
+ .search-input-adornments {
856
872
  position: absolute;
857
873
  right: 4px;
858
- width: 16px;
859
- height: 16px;
860
- font-size: 10px;
861
- color: var(--ide-text-muted);
874
+ top: 50%;
875
+ transform: translateY(-50%);
876
+ display: flex;
877
+ align-items: center;
878
+ gap: 1px;
879
+ }
880
+
881
+ /* Each small icon button inside the input */
882
+ .search-adornment-btn {
883
+ width: 20px;
884
+ height: 20px;
885
+ padding: 0;
886
+ border: 1px solid transparent;
887
+ border-radius: 3px;
862
888
  background: transparent;
889
+ color: var(--ide-text-muted);
890
+ font-size: 13px;
891
+ display: inline-flex;
892
+ align-items: center;
893
+ justify-content: center;
894
+ cursor: pointer;
895
+ flex-shrink: 0;
896
+ transition: color 0.1s, background 0.1s, border-color 0.1s;
897
+ }
898
+
899
+ .search-adornment-btn:hover {
900
+ color: var(--ide-text);
901
+ background: var(--ide-hover-bg);
902
+ }
903
+
904
+ .search-adornment-btn.active {
905
+ color: var(--ide-accent-fg);
906
+ background: rgba(88, 166, 255, 0.15);
907
+ border-color: var(--ide-accent-fg);
863
908
  }
864
909
 
910
+ .search-adornment-clear {
911
+ font-size: 10px;
912
+ }
913
+
914
+ /* ── old .search-options-row / .search-option-btn removed ── */
915
+
865
916
  /* Use .search-panel ancestor for higher specificity than button:not(.pico-btn) */
866
917
  .search-panel .search-btn {
867
918
  flex-shrink: 0;
@@ -903,11 +954,50 @@ html, body, #mbeditor-root {
903
954
  box-sizing: border-box;
904
955
  }
905
956
  .search-input::placeholder { color: var(--ide-text-muted); }
906
- .search-input:focus { border-color: var(--ide-accent-fg); }
957
+ .search-input:focus { border-color: var(--ide-border-input); }
958
+
959
+ /* ── Results area wrapper (positions the loading overlay) ── */
960
+ .search-results-area {
961
+ position: relative;
962
+ flex: 1;
963
+ min-height: 0;
964
+ display: flex;
965
+ flex-direction: column;
966
+ }
907
967
 
908
968
  .search-results {
909
969
  flex: 1;
910
970
  overflow-y: auto;
971
+ transition: filter 0.15s;
972
+ }
973
+
974
+ .search-results.search-results-blurred {
975
+ filter: blur(2px);
976
+ pointer-events: none;
977
+ user-select: none;
978
+ }
979
+
980
+ /* Centered circular loading overlay */
981
+ .search-loading-overlay {
982
+ position: absolute;
983
+ inset: 0;
984
+ display: flex;
985
+ align-items: center;
986
+ justify-content: center;
987
+ pointer-events: none;
988
+ }
989
+
990
+ .search-loading-spinner {
991
+ width: 28px;
992
+ height: 28px;
993
+ border: 3px solid var(--ide-border-input);
994
+ border-top-color: var(--ide-accent-fg);
995
+ border-radius: 50%;
996
+ animation: search-spin 0.7s linear infinite;
997
+ }
998
+
999
+ @keyframes search-spin {
1000
+ to { transform: rotate(360deg); }
911
1001
  }
912
1002
 
913
1003
  .search-result-item {
@@ -1443,6 +1533,55 @@ html, body, #mbeditor-root {
1443
1533
  min-width: 0;
1444
1534
  }
1445
1535
 
1536
+ .ide-methods-dropdown {
1537
+ background: #252526;
1538
+ border: 1px solid #454545;
1539
+ border-radius: 4px;
1540
+ box-shadow: 0 4px 14px rgba(0,0,0,0.55);
1541
+ min-width: 200px;
1542
+ max-width: 340px;
1543
+ max-height: 320px;
1544
+ overflow-y: auto;
1545
+ padding: 4px 0;
1546
+ }
1547
+
1548
+ .ide-methods-dropdown-item {
1549
+ display: flex;
1550
+ align-items: center;
1551
+ gap: 8px;
1552
+ padding: 5px 12px;
1553
+ cursor: pointer;
1554
+ color: #ccc;
1555
+ font-size: 12px;
1556
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
1557
+ white-space: nowrap;
1558
+ overflow: hidden;
1559
+ text-overflow: ellipsis;
1560
+ }
1561
+
1562
+ .ide-methods-dropdown-item:hover {
1563
+ background: #094771;
1564
+ color: #fff;
1565
+ }
1566
+
1567
+ .ide-methods-dropdown-line {
1568
+ color: #858585;
1569
+ font-size: 11px;
1570
+ min-width: 28px;
1571
+ text-align: right;
1572
+ flex-shrink: 0;
1573
+ user-select: none;
1574
+ }
1575
+
1576
+ .ide-methods-dropdown-empty {
1577
+ padding: 8px 12px;
1578
+ color: #858585;
1579
+ font-size: 12px;
1580
+ font-style: italic;
1581
+ }
1582
+
1583
+
1584
+
1446
1585
  .project-action-btn {
1447
1586
  width: 24px;
1448
1587
  height: 24px;
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Mbeditor
7
+ class EditorChannel < ActionCable::Channel::Base
8
+ STATE_MAX_BYTES = 1 * 1024 * 1024
9
+ SAFE_BRANCH_NAME = /\A[a-zA-Z0-9._\-\/]+\z/
10
+
11
+ def subscribed
12
+ stream_from "mbeditor_editor"
13
+ end
14
+
15
+ def unsubscribed
16
+ # no-op
17
+ end
18
+
19
+ # Called via WebSocketService.perform('save_state', { state: ... })
20
+ def save_state(data)
21
+ Rails.logger.silence do
22
+ payload = (data["state"] || data).to_json
23
+ return if payload.bytesize > STATE_MAX_BYTES
24
+
25
+ root = workspace_root
26
+ path = root.join("tmp", "mbeditor_workspace.json")
27
+ FileUtils.mkdir_p(root.join("tmp"))
28
+ File.open(path, File::RDWR | File::CREAT) do |f|
29
+ f.flock(File::LOCK_EX)
30
+ f.truncate(0)
31
+ f.rewind
32
+ f.write(payload)
33
+ end
34
+ end
35
+ rescue StandardError
36
+ # Never let a state-save failure crash the WebSocket connection
37
+ end
38
+
39
+ # Called via WebSocketService.perform('save_branch_state', { branch: ..., state: ... })
40
+ def save_branch_state(data)
41
+ Rails.logger.silence do
42
+ branch = data["branch"].to_s.strip
43
+ return unless branch.match?(SAFE_BRANCH_NAME)
44
+
45
+ state_data = data["state"]
46
+ payload_json = state_data.to_json
47
+ return if payload_json.bytesize > STATE_MAX_BYTES
48
+
49
+ root = workspace_root
50
+ path = root.join("tmp", "mbeditor_branch_states.json")
51
+ FileUtils.mkdir_p(root.join("tmp"))
52
+ File.open(path, File::RDWR | File::CREAT) do |f|
53
+ f.flock(File::LOCK_EX)
54
+ existing = f.size > 0 ? JSON.parse(f.read) : {}
55
+ existing[branch] = state_data
56
+ f.truncate(0)
57
+ f.rewind
58
+ f.write(existing.to_json)
59
+ end
60
+ end
61
+ rescue StandardError
62
+ # Never let a state-save failure crash the WebSocket connection
63
+ end
64
+
65
+ private
66
+
67
+ def workspace_root
68
+ configured = Mbeditor.configuration.workspace_root
69
+ return Pathname.new(configured.to_s) if configured.present?
70
+
71
+ # Fall back to git root, same logic as ApplicationController
72
+ rails_root = Rails.root.to_s
73
+ out, _err, status = Open3.capture3("git", "-C", rails_root, "rev-parse", "--show-toplevel")
74
+ Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
75
+ rescue StandardError
76
+ Rails.root
77
+ end
78
+ end
79
+ 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: defined?(ActionCable::Channel::Base) ? true : false
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,20 +319,23 @@ 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
341
  if count_thread
@@ -666,6 +676,14 @@ module Mbeditor
666
676
 
667
677
  private
668
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
+
669
687
  def sanitize_branch_name(branch)
670
688
  return nil if branch.blank?
671
689
  str = branch.to_s.strip
@@ -701,11 +719,14 @@ module Mbeditor
701
719
  # Stream search results using popen so we can stop reading early once we
702
720
  # have collected `limit` matches (avoids buffering the entire rg/grep output
703
721
  # in memory when searching large codebases for common tokens).
704
- def stream_search_results(query, limit)
722
+ def stream_search_results(query, limit, use_regex: false, match_case: false, whole_word: false)
705
723
  results = []
706
724
 
707
725
  if RG_AVAILABLE
708
726
  args = ["rg", "--json", "--no-ignore"]
727
+ args << "-F" unless use_regex
728
+ args << "--ignore-case" unless match_case
729
+ args << "--word-regexp" if whole_word
709
730
  excluded_paths.each { |p| args << "--glob=!#{p}" }
710
731
  args += ["--", query, workspace_root.to_s]
711
732
 
@@ -730,7 +751,10 @@ module Mbeditor
730
751
  end
731
752
  end
732
753
  else
733
- args = ["grep", "-rn", "-F"]
754
+ base_flags = use_regex ? "-E" : "-F"
755
+ args = ["grep", "-rn", base_flags]
756
+ args << "-i" unless match_case
757
+ args << "-w" if whole_word
734
758
  excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
735
759
  args += [query, workspace_root.to_s]
736
760
 
@@ -761,17 +785,23 @@ module Mbeditor
761
785
 
762
786
  # Count total matching lines across the workspace using rg --count (or grep -c).
763
787
  # Fast: rg just counts without extracting context. Runs in a background thread.
764
- def count_search_results(query)
788
+ def count_search_results(query, use_regex: false, match_case: false, whole_word: false)
765
789
  total = 0
766
790
  if RG_AVAILABLE
767
791
  args = ["rg", "--count", "--no-ignore"]
792
+ args << "-F" unless use_regex
793
+ args << "--ignore-case" unless match_case
794
+ args << "--word-regexp" if whole_word
768
795
  excluded_paths.each { |p| args << "--glob=!#{p}" }
769
796
  args += ["--", query, workspace_root.to_s]
770
797
  IO.popen(args, err: File::NULL) do |io|
771
798
  io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
772
799
  end
773
800
  else
774
- args = ["grep", "-rc", "-F"]
801
+ base_flags = use_regex ? "-E" : "-F"
802
+ args = ["grep", "-rc", base_flags]
803
+ args << "-i" unless match_case
804
+ args << "-w" if whole_word
775
805
  excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
776
806
  args += [query, workspace_root.to_s]
777
807
  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>