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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +161 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +210 -47
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +21 -0
- data/app/assets/javascripts/mbeditor/file_service.js +6 -0
- data/app/assets/javascripts/mbeditor/search_service.js +7 -3
- data/app/assets/javascripts/mbeditor/websocket_service.js +126 -0
- data/app/assets/stylesheets/mbeditor/editor.css +148 -9
- data/app/channels/mbeditor/editor_channel.rb +79 -0
- data/app/controllers/mbeditor/editors_controller.rb +42 -12
- data/app/views/layouts/mbeditor/application.html.erb +4 -0
- data/lib/mbeditor/cable_log_filter.rb +28 -0
- data/lib/mbeditor/engine.rb +10 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +4 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +5 -2
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
867
|
+
padding-right: 88px; /* 3 × 22px buttons + 4px clear + gaps */
|
|
852
868
|
}
|
|
853
869
|
|
|
854
|
-
/*
|
|
855
|
-
.search-input-
|
|
870
|
+
/* Adornment container — absolutely positioned inside the shell */
|
|
871
|
+
.search-input-adornments {
|
|
856
872
|
position: absolute;
|
|
857
873
|
right: 4px;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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-
|
|
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®ex=false&match_case=false&whole_word=false
|
|
316
323
|
def search
|
|
317
|
-
query
|
|
318
|
-
offset
|
|
319
|
-
limit
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|