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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -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 +392 -62
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +20 -4
- data/app/assets/javascripts/mbeditor/components/TabBar.js +3 -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 +225 -10
- data/app/channels/mbeditor/editor_channel.rb +79 -0
- data/app/controllers/mbeditor/editors_controller.rb +48 -13
- 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
|
@@ -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
|
-
//
|
|
121
|
-
// JS sort is stable in modern engines
|
|
122
|
-
//
|
|
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
|
-
|
|
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:
|
|
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%;
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
867
|
+
padding-right: 88px; /* 3 × 22px buttons + 4px clear + gaps */
|
|
840
868
|
}
|
|
841
869
|
|
|
842
|
-
/*
|
|
843
|
-
.search-input-
|
|
870
|
+
/* Adornment container — absolutely positioned inside the shell */
|
|
871
|
+
.search-input-adornments {
|
|
844
872
|
position: absolute;
|
|
845
873
|
right: 4px;
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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-
|
|
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
|