mbeditor 0.3.9 → 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.
@@ -94,6 +94,17 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
94
94
  return 50;
95
95
  }
96
96
 
97
+ // Match relevance within a priority tier: exact basename > prefix > substring > other.
98
+ function getMatchRelevance(result, q) {
99
+ if (!q) return 3;
100
+ var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
101
+ var lq = q.toLowerCase();
102
+ if (name === lq) return 0;
103
+ if (name.slice(0, lq.length) === lq) return 1;
104
+ if (name.indexOf(lq) >= 0) return 2;
105
+ return 3;
106
+ }
107
+
97
108
  var getQuickOpenIcon = function getQuickOpenIcon(path, name, type) {
98
109
  if (type === 'dir') {
99
110
  return React.createElement('i', { className: 'fas fa-folder quick-open-result-icon quick-open-folder-icon', 'aria-hidden': 'true' });
@@ -117,11 +128,16 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
117
128
  var res = SearchService.searchFiles(query);
118
129
  // Filter by type: always include files; include dirs only when showFolders is on
119
130
  var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
120
- // Stable priority sort: controller > model > helper > concern > view > job > other > noise.
121
- // JS sort is stable in modern engines, so relative order within the same tier
122
- // (i.e. MiniSearch relevance score) is preserved.
131
+ // Sort: files always beat dirs; within the same tier exact basename > prefix > substring > other.
132
+ // JS sort is stable in modern engines so MiniSearch relevance score order is the tiebreaker
133
+ // when match relevance is equal.
123
134
  filtered.sort(function(a, b) {
124
- return getFilePriority(a.path) - getFilePriority(b.path);
135
+ var aRelevance = getMatchRelevance(a, query);
136
+ var bRelevance = getMatchRelevance(b, query);
137
+ if (aRelevance !== bRelevance) return aRelevance - bRelevance;
138
+ var aPriority = getFilePriority(a.path) + (a.type === 'dir' ? 100 : 0);
139
+ var bPriority = getFilePriority(b.path) + (b.type === 'dir' ? 100 : 0);
140
+ return aPriority - bPriority;
125
141
  });
126
142
  setResults(filtered.slice(0, 200));
127
143
  setSelectedIndex(0);
@@ -18,6 +18,7 @@ var TabBar = function TabBar(_ref) {
18
18
  var onHardenTab = _ref.onHardenTab;
19
19
  var onShowHistory = _ref.onShowHistory;
20
20
  var onRevealInExplorer = _ref.onRevealInExplorer;
21
+ var tabDisplayMode = _ref.tabDisplayMode || 'scroll';
21
22
 
22
23
  var containerRef = useRef(null);
23
24
 
@@ -87,8 +88,8 @@ var TabBar = function TabBar(_ref) {
87
88
  null,
88
89
  React.createElement(
89
90
  'div',
90
- { className: 'tab-bar', ref: containerRef, onWheel: function (e) {
91
- if (containerRef.current) {
91
+ { className: 'tab-bar tab-bar-' + tabDisplayMode, ref: containerRef, onWheel: function (e) {
92
+ if (tabDisplayMode !== 'wrap' && containerRef.current) {
92
93
  containerRef.current.scrollLeft += e.deltaY;
93
94
  }
94
95
  } },
@@ -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%;
@@ -327,6 +334,18 @@ html, body, #mbeditor-root {
327
334
  .tab-bar::-webkit-scrollbar { height: 3px; }
328
335
  .tab-bar::-webkit-scrollbar-thumb { background: var(--ide-hover-bg); }
329
336
 
337
+ /* Wrap (multi-row) tab layout */
338
+ .tab-bar.tab-bar-wrap {
339
+ flex-wrap: wrap;
340
+ overflow-x: hidden;
341
+ overflow-y: visible;
342
+ height: auto;
343
+ }
344
+ .tab-bar.tab-bar-wrap .tab-item {
345
+ flex-shrink: 1;
346
+ flex-grow: 0;
347
+ }
348
+
330
349
  .tab-item {
331
350
  display: flex;
332
351
  align-items: center;
@@ -792,7 +811,7 @@ html, body, #mbeditor-root {
792
811
 
793
812
  .statusbar-msg {
794
813
  margin-left: auto;
795
- color: color-mix(in srgb, var(--ide-accent-text) 80%, transparent);
814
+ color: var(--ide-accent-text);
796
815
  font-size: 11px;
797
816
  overflow: hidden;
798
817
  text-overflow: ellipsis;
@@ -810,6 +829,14 @@ html, body, #mbeditor-root {
810
829
  padding-left: 8px;
811
830
  }
812
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
+
813
840
  /* ── Search Panel ─────────────────────────────────────────── */
814
841
  .search-panel {
815
842
  padding: 8px;
@@ -828,28 +855,64 @@ html, body, #mbeditor-root {
828
855
  }
829
856
 
830
857
  .search-input-shell {
831
- flex: 1;
858
+ flex-shrink: 0;
832
859
  position: relative;
833
860
  display: flex;
834
861
  align-items: center;
835
862
  min-width: 0;
836
863
  }
837
864
 
865
+ /* The input grows to fill; right padding makes room for adornments */
838
866
  .search-input-shell .search-input {
839
- padding-right: 24px;
867
+ padding-right: 88px; /* 3 × 22px buttons + 4px clear + gaps */
840
868
  }
841
869
 
842
- /* search-clear-btn sits absolutely inside the input shell */
843
- .search-input-shell .search-clear-btn {
870
+ /* Adornment container absolutely positioned inside the shell */
871
+ .search-input-adornments {
844
872
  position: absolute;
845
873
  right: 4px;
846
- width: 16px;
847
- height: 16px;
848
- font-size: 10px;
849
- 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;
850
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);
851
908
  }
852
909
 
910
+ .search-adornment-clear {
911
+ font-size: 10px;
912
+ }
913
+
914
+ /* ── old .search-options-row / .search-option-btn removed ── */
915
+
853
916
  /* Use .search-panel ancestor for higher specificity than button:not(.pico-btn) */
854
917
  .search-panel .search-btn {
855
918
  flex-shrink: 0;
@@ -891,11 +954,50 @@ html, body, #mbeditor-root {
891
954
  box-sizing: border-box;
892
955
  }
893
956
  .search-input::placeholder { color: var(--ide-text-muted); }
894
- .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
+ }
895
967
 
896
968
  .search-results {
897
969
  flex: 1;
898
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); }
899
1001
  }
900
1002
 
901
1003
  .search-result-item {
@@ -1431,6 +1533,55 @@ html, body, #mbeditor-root {
1431
1533
  min-width: 0;
1432
1534
  }
1433
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
+
1434
1585
  .project-action-btn {
1435
1586
  width: 24px;
1436
1587
  height: 24px;
@@ -1683,6 +1834,70 @@ html, body, #mbeditor-root {
1683
1834
  to { opacity: 1; transform: translateY(0); }
1684
1835
  }
1685
1836
 
1837
+ /* ── Draft restore dialog ───────────────────────────────────── */
1838
+ .ide-draft-restore-overlay {
1839
+ position: fixed;
1840
+ inset: 0;
1841
+ background: rgba(0,0,0,0.55);
1842
+ z-index: 10001;
1843
+ display: flex;
1844
+ align-items: center;
1845
+ justify-content: center;
1846
+ }
1847
+ .ide-draft-restore-dialog {
1848
+ background: var(--ide-panel-bg-alt);
1849
+ border: 1px solid var(--ide-border-input);
1850
+ border-radius: 8px;
1851
+ padding: 20px 24px;
1852
+ min-width: 320px;
1853
+ max-width: 480px;
1854
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
1855
+ color: var(--ide-text);
1856
+ font-size: 13px;
1857
+ }
1858
+ .ide-draft-restore-title {
1859
+ font-size: 14px;
1860
+ font-weight: 600;
1861
+ margin-bottom: 10px;
1862
+ color: var(--ide-warning, #e5c07b);
1863
+ }
1864
+ .ide-draft-restore-body { margin-bottom: 8px; color: var(--ide-text-muted); }
1865
+ .ide-draft-restore-list {
1866
+ list-style: none;
1867
+ padding: 0;
1868
+ margin: 0 0 16px 0;
1869
+ max-height: 160px;
1870
+ overflow-y: auto;
1871
+ }
1872
+ .ide-draft-restore-list li {
1873
+ padding: 3px 0;
1874
+ font-family: monospace;
1875
+ font-size: 12px;
1876
+ color: var(--ide-accent-fg, #9cdcfe);
1877
+ }
1878
+ .ide-draft-restore-actions {
1879
+ display: flex;
1880
+ gap: 8px;
1881
+ justify-content: flex-end;
1882
+ }
1883
+ .ide-draft-restore-btn {
1884
+ padding: 5px 14px;
1885
+ border-radius: 4px;
1886
+ border: 1px solid var(--ide-border-input);
1887
+ background: var(--ide-input-bg);
1888
+ color: var(--ide-text);
1889
+ font-size: 12px;
1890
+ cursor: pointer;
1891
+ transition: background 0.1s;
1892
+ }
1893
+ .ide-draft-restore-btn:hover { background: var(--ide-hover-bg); }
1894
+ .ide-draft-restore-btn-primary {
1895
+ background: var(--ide-accent);
1896
+ color: var(--ide-accent-text);
1897
+ border-color: var(--ide-accent);
1898
+ }
1899
+ .ide-draft-restore-btn-primary:hover { opacity: 0.9; }
1900
+
1686
1901
  /* ── Settings panel ─────────────────────────────────────────── */
1687
1902
  .ide-settings-panel { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
1688
1903
  .ide-settings-body { padding: 8px 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 10px; align-items: start; }
@@ -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