mbeditor 0.3.8 → 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 +35 -0
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/application_iife_head.js +7 -0
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +213 -11
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +14 -4
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +673 -160
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +41 -1
- 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/editor_store.js +10 -2
- data/app/assets/javascripts/mbeditor/file_service.js +29 -23
- data/app/assets/javascripts/mbeditor/git_service.js +7 -11
- data/app/assets/javascripts/mbeditor/search_service.js +51 -14
- data/app/assets/javascripts/mbeditor/tab_manager.js +3 -3
- data/app/assets/javascripts/mbeditor/websocket_service.js +126 -0
- data/app/assets/stylesheets/mbeditor/editor.css +237 -15
- data/app/channels/mbeditor/editor_channel.rb +79 -0
- data/app/controllers/mbeditor/editors_controller.rb +177 -136
- data/app/controllers/mbeditor/git_controller.rb +5 -40
- data/app/services/mbeditor/git_blame_service.rb +6 -0
- data/app/services/mbeditor/git_commit_graph_service.rb +2 -0
- data/app/services/mbeditor/git_service.rb +97 -28
- data/app/services/mbeditor/redmine_service.rb +7 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -2
- data/app/views/layouts/mbeditor/application.html.erb +4 -0
- data/lib/mbeditor/cable_log_filter.rb +28 -0
- data/lib/mbeditor/configuration.rb +7 -1
- data/lib/mbeditor/engine.rb +37 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +4 -1
- data/lib/mbeditor/version.rb +3 -1
- data/lib/mbeditor.rb +2 -0
- metadata +5 -2
|
@@ -76,6 +76,35 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
|
|
|
76
76
|
if (inputRef.current) inputRef.current.focus();
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
// Priority tier for a file path: lower = shown first.
|
|
80
|
+
// Order: controller > model > helper > concern > view > job > other > noise
|
|
81
|
+
function getFilePriority(path) {
|
|
82
|
+
var p = (path || '').toLowerCase();
|
|
83
|
+
if (p.indexOf('/controllers/') >= 0) return 1;
|
|
84
|
+
if (p.indexOf('/models/') >= 0) return 2;
|
|
85
|
+
if (p.indexOf('/helpers/') >= 0) return 3;
|
|
86
|
+
if (p.indexOf('/concerns/') >= 0) return 4;
|
|
87
|
+
if (p.indexOf('/views/') >= 0) return 5;
|
|
88
|
+
if (p.indexOf('/jobs/') >= 0) return 6;
|
|
89
|
+
// Deprioritise: migrations, schema, compiled assets, vendor, lock files
|
|
90
|
+
if (p.indexOf('/migrate/') >= 0 || p.indexOf('schema.rb') >= 0) return 90;
|
|
91
|
+
if (p.indexOf('/public/') >= 0 || p.indexOf('/vendor/') >= 0) return 91;
|
|
92
|
+
if (p.slice(-7) === '.min.js' || p.slice(-8) === '.min.css' ||
|
|
93
|
+
p.slice(-4) === '.map' || p.slice(-5) === '.lock') return 92;
|
|
94
|
+
return 50;
|
|
95
|
+
}
|
|
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
|
+
|
|
79
108
|
var getQuickOpenIcon = function getQuickOpenIcon(path, name, type) {
|
|
80
109
|
if (type === 'dir') {
|
|
81
110
|
return React.createElement('i', { className: 'fas fa-folder quick-open-result-icon quick-open-folder-icon', 'aria-hidden': 'true' });
|
|
@@ -99,7 +128,18 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
|
|
|
99
128
|
var res = SearchService.searchFiles(query);
|
|
100
129
|
// Filter by type: always include files; include dirs only when showFolders is on
|
|
101
130
|
var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
|
|
102
|
-
|
|
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.
|
|
134
|
+
filtered.sort(function(a, b) {
|
|
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;
|
|
141
|
+
});
|
|
142
|
+
setResults(filtered.slice(0, 200));
|
|
103
143
|
setSelectedIndex(0);
|
|
104
144
|
}, [query, showFolders]);
|
|
105
145
|
|
|
@@ -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;
|
|
@@ -20,6 +20,7 @@ var EditorStore = (function () {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
var _listeners = [];
|
|
23
|
+
var _statusTimer = null;
|
|
23
24
|
|
|
24
25
|
function getState() { return _state; }
|
|
25
26
|
|
|
@@ -42,6 +43,9 @@ var EditorStore = (function () {
|
|
|
42
43
|
// Subscribe to changes in a specific subset of state keys.
|
|
43
44
|
// The listener is only called when at least one of the watched keys changes
|
|
44
45
|
// by reference (===), preventing unnecessary re-renders for unrelated updates.
|
|
46
|
+
// IMPORTANT: all state updates MUST produce a new object reference for any
|
|
47
|
+
// nested value (use Object.assign / spread — never mutate in place), otherwise
|
|
48
|
+
// subscribeToSlice will not detect the change.
|
|
45
49
|
function subscribeToSlice(keys, fn) {
|
|
46
50
|
var prev = {};
|
|
47
51
|
keys.forEach(function(k) { prev[k] = _state[k]; });
|
|
@@ -57,9 +61,13 @@ var EditorStore = (function () {
|
|
|
57
61
|
function setStatus(text, kind) {
|
|
58
62
|
kind = kind || "info";
|
|
59
63
|
setState({ statusMessage: { text: text, kind: kind } });
|
|
60
|
-
|
|
64
|
+
if (_statusTimer !== null) {
|
|
65
|
+
clearTimeout(_statusTimer);
|
|
66
|
+
_statusTimer = null;
|
|
67
|
+
}
|
|
61
68
|
if (kind !== "error") {
|
|
62
|
-
setTimeout(function () {
|
|
69
|
+
_statusTimer = setTimeout(function () {
|
|
70
|
+
_statusTimer = null;
|
|
63
71
|
if (_state.statusMessage.text === text) {
|
|
64
72
|
setState({ statusMessage: { text: "", kind: "info" } });
|
|
65
73
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// The server uses this header to silence editor logs and guard non-GET requests.
|
|
3
3
|
axios.defaults.headers.common['X-Mbeditor-Client'] = '1';
|
|
4
4
|
|
|
5
|
+
// Cap all API calls at 30 s so a hung Rails server never blocks the UI forever.
|
|
6
|
+
// The ping endpoint overrides this with a tighter 4 s timeout per-request.
|
|
7
|
+
axios.defaults.timeout = 30000;
|
|
8
|
+
|
|
5
9
|
// Surface pending-migration errors as a dismissible banner instead of silently failing.
|
|
6
10
|
axios.interceptors.response.use(null, function(error) {
|
|
7
11
|
if (error.response && error.response.data && error.response.data.pending_migration_error) {
|
|
@@ -28,16 +32,12 @@ axios.interceptors.response.use(null, function(error) {
|
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
var FileService = (function () {
|
|
31
|
-
function basePath() {
|
|
32
|
-
return (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
35
|
function getWorkspace() {
|
|
36
|
-
return axios.get(
|
|
36
|
+
return axios.get(window.mbeditorBasePath() + '/workspace').then(function(res) { return res.data; });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function getTree() {
|
|
40
|
-
return axios.get(
|
|
40
|
+
return axios.get(window.mbeditorBasePath() + '/files').then(function(res) { return res.data; });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function getFile(path, options) {
|
|
@@ -45,72 +45,78 @@ var FileService = (function () {
|
|
|
45
45
|
if (options && options.allowMissing) {
|
|
46
46
|
params.allow_missing = '1';
|
|
47
47
|
}
|
|
48
|
-
return axios.get(
|
|
48
|
+
return axios.get(window.mbeditorBasePath() + '/file', { params: params }).then(function(res) { return res.data; });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function saveFile(path, code) {
|
|
52
|
-
return axios.post(
|
|
52
|
+
return axios.post(window.mbeditorBasePath() + '/file', { path: path, code: code }).then(function(res) { return res.data; });
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function createFile(path, code) {
|
|
56
|
-
return axios.post(
|
|
56
|
+
return axios.post(window.mbeditorBasePath() + '/create_file', { path: path, code: code || '' }).then(function(res) { return res.data; });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function createDir(path) {
|
|
60
|
-
return axios.post(
|
|
60
|
+
return axios.post(window.mbeditorBasePath() + '/create_dir', { path: path }).then(function(res) { return res.data; });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function renamePath(path, newPath) {
|
|
64
|
-
return axios.patch(
|
|
64
|
+
return axios.patch(window.mbeditorBasePath() + '/rename', { path: path, new_path: newPath }).then(function(res) { return res.data; });
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function deletePath(path) {
|
|
68
|
-
return axios.delete(
|
|
68
|
+
return axios.delete(window.mbeditorBasePath() + '/delete', { data: { path: path } }).then(function(res) { return res.data; });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function lintFile(path, code) {
|
|
72
|
-
return axios.post(
|
|
72
|
+
return axios.post(window.mbeditorBasePath() + '/lint', { path: path, code: code }).then(function(res) { return res.data; });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function quickFixOffense(path, code, copName) {
|
|
76
|
-
return axios.post(
|
|
76
|
+
return axios.post(window.mbeditorBasePath() + '/quick_fix', { path: path, code: code, cop_name: copName }).then(function(res) { return res.data; });
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function formatFile(path, code) {
|
|
80
|
-
return axios.post(
|
|
80
|
+
return axios.post(window.mbeditorBasePath() + '/format', { path: path, code: code }).then(function(res) { return res.data; });
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function runTests(path) {
|
|
84
|
-
return axios.post(
|
|
84
|
+
return axios.post(window.mbeditorBasePath() + '/test', { path: path }).then(function(res) { return res.data; });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function ping() {
|
|
88
|
-
return axios.get(
|
|
88
|
+
return axios.get(window.mbeditorBasePath() + '/ping', { timeout: 4000 }).then(function(res) { return res.data; });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function getState() {
|
|
92
|
-
return axios.get(
|
|
92
|
+
return axios.get(window.mbeditorBasePath() + '/state').then(function(res) { return res.data; });
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function saveState(state) {
|
|
96
|
-
|
|
96
|
+
if (WebSocketService.isConnected() && WebSocketService.perform('save_state', { state: state })) {
|
|
97
|
+
return Promise.resolve({ ok: true });
|
|
98
|
+
}
|
|
99
|
+
return axios.post(window.mbeditorBasePath() + '/state', { state: state }).then(function(res) { return res.data; });
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
function getBranchState(branch) {
|
|
100
|
-
return axios.get(
|
|
103
|
+
return axios.get(window.mbeditorBasePath() + '/branch_state', { params: { branch: branch } }).then(function(res) { return res.data; });
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
function saveBranchState(branch, state) {
|
|
104
|
-
|
|
107
|
+
if (WebSocketService.isConnected() && WebSocketService.perform('save_branch_state', { branch: branch, state: state })) {
|
|
108
|
+
return Promise.resolve({ ok: true });
|
|
109
|
+
}
|
|
110
|
+
return axios.post(window.mbeditorBasePath() + '/branch_state', { branch: branch, state: state }).then(function(res) { return res.data; });
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
function pruneBranchStates() {
|
|
108
|
-
return axios.post(
|
|
114
|
+
return axios.post(window.mbeditorBasePath() + '/prune_branch_states').then(function(res) { return res.data; });
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function getDefinition(symbol, language, extraOptions) {
|
|
112
118
|
var config = Object.assign({ params: { symbol: symbol, language: language }, timeout: 5000 }, extraOptions || {});
|
|
113
|
-
return axios.get(
|
|
119
|
+
return axios.get(window.mbeditorBasePath() + '/definition', config).then(function(res) { return res.data; });
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
return {
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
var GitService = (function () {
|
|
2
|
-
function basePath() {
|
|
3
|
-
return (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
|
|
4
|
-
}
|
|
5
|
-
|
|
6
2
|
function applyGitInfo(data) {
|
|
7
3
|
var files = data.workingTree || data.files || [];
|
|
8
4
|
EditorStore.setState({
|
|
@@ -14,7 +10,7 @@ var GitService = (function () {
|
|
|
14
10
|
}
|
|
15
11
|
|
|
16
12
|
function fetchInfo() {
|
|
17
|
-
return axios.get(
|
|
13
|
+
return axios.get(window.mbeditorBasePath() + '/git_info')
|
|
18
14
|
.then(function(res) {
|
|
19
15
|
if (res.data && res.data.ok) {
|
|
20
16
|
applyGitInfo(res.data);
|
|
@@ -33,7 +29,7 @@ var GitService = (function () {
|
|
|
33
29
|
return fetchInfo().then(function(data) {
|
|
34
30
|
if (data && data.ok) return data;
|
|
35
31
|
|
|
36
|
-
return axios.get(
|
|
32
|
+
return axios.get(window.mbeditorBasePath() + '/git_status')
|
|
37
33
|
.then(function(res) {
|
|
38
34
|
if (res.data.ok) {
|
|
39
35
|
EditorStore.setState({
|
|
@@ -63,31 +59,31 @@ var GitService = (function () {
|
|
|
63
59
|
if (baseSha) query += '&base=' + encodeURIComponent(baseSha);
|
|
64
60
|
if (headSha) query += '&head=' + encodeURIComponent(headSha);
|
|
65
61
|
|
|
66
|
-
return axios.get(
|
|
62
|
+
return axios.get(window.mbeditorBasePath() + '/git/diff' + query).then(function(res) {
|
|
67
63
|
return res.data;
|
|
68
64
|
});
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
function fetchBlame(path) {
|
|
72
|
-
return axios.get(
|
|
68
|
+
return axios.get(window.mbeditorBasePath() + '/git/blame?file=' + encodeURIComponent(path)).then(function(res) {
|
|
73
69
|
return res.data;
|
|
74
70
|
});
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
function fetchFileHistory(path) {
|
|
78
|
-
return axios.get(
|
|
74
|
+
return axios.get(window.mbeditorBasePath() + '/git/file_history?file=' + encodeURIComponent(path)).then(function(res) {
|
|
79
75
|
return res.data;
|
|
80
76
|
});
|
|
81
77
|
}
|
|
82
78
|
|
|
83
79
|
function fetchCommitGraph() {
|
|
84
|
-
return axios.get(
|
|
80
|
+
return axios.get(window.mbeditorBasePath() + '/git/commit_graph').then(function(res) {
|
|
85
81
|
return res.data;
|
|
86
82
|
});
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
function fetchCommitDetail(sha) {
|
|
90
|
-
return axios.get(
|
|
86
|
+
return axios.get(window.mbeditorBasePath() + '/git/commit_detail?sha=' + encodeURIComponent(sha)).then(function(res) {
|
|
91
87
|
return res.data;
|
|
92
88
|
});
|
|
93
89
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
var SearchService = (function () {
|
|
2
2
|
var SEARCH_PAGE_SIZE = 50;
|
|
3
3
|
|
|
4
|
-
function basePath() {
|
|
5
|
-
return (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
|
|
6
|
-
}
|
|
7
|
-
|
|
8
4
|
var _miniSearch = new MiniSearch({
|
|
9
5
|
fields: ['path', 'name'], // indexed fields
|
|
10
6
|
storeFields: ['path', 'name', 'type'] // returned fields (type: 'file'|'dir')
|
|
11
7
|
});
|
|
12
8
|
|
|
9
|
+
// Flat doc list kept in sync with _miniSearch so we can do substring lookups.
|
|
10
|
+
var _allDocs = [];
|
|
11
|
+
|
|
13
12
|
function buildIndex(treeData) {
|
|
14
13
|
// Capture the tree data immediately so a subsequent refresh doesn't
|
|
15
14
|
// clobber us before the idle callback fires.
|
|
@@ -32,31 +31,50 @@ var SearchService = (function () {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
traverse(snapshot);
|
|
34
|
+
_allDocs = docs.slice();
|
|
35
35
|
_miniSearch.removeAll();
|
|
36
36
|
_miniSearch.addAll(docs);
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Search files (and optionally folders) in the local MiniSearch index.
|
|
41
|
-
//
|
|
41
|
+
// Also performs a case-insensitive substring scan so that partial-word
|
|
42
|
+
// queries like "project" reliably find "projects_controller.rb".
|
|
43
|
+
// Returns merged results; MiniSearch scored entries come first.
|
|
42
44
|
function searchFiles(query) {
|
|
43
45
|
if (!query) return [];
|
|
44
|
-
|
|
46
|
+
var msResults = _miniSearch.search(query, { prefix: true, fuzzy: false, combineWith: 'AND' });
|
|
47
|
+
// Substring fallback — catch anything MiniSearch missed
|
|
48
|
+
var q = query.toLowerCase();
|
|
49
|
+
var msIds = new Set(msResults.map(function(r) { return r.id; }));
|
|
50
|
+
var subResults = _allDocs.filter(function(doc) {
|
|
51
|
+
if (msIds.has(doc.id)) return false;
|
|
52
|
+
return doc.path.toLowerCase().indexOf(q) >= 0 || doc.name.toLowerCase().indexOf(q) >= 0;
|
|
53
|
+
}).map(function(doc) {
|
|
54
|
+
return { id: doc.id, path: doc.path, name: doc.name, type: doc.type, score: 0 };
|
|
55
|
+
});
|
|
56
|
+
return msResults.concat(subResults);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
// Fetch one page of project-wide full-text search results.
|
|
48
60
|
// offset=0 replaces the EditorStore results list; offset>0 appends.
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
// The server includes total_count only on offset=0 (fast rg --count pass).
|
|
62
|
+
// options.regex=true enables regex mode on the server.
|
|
63
|
+
function projectSearch(query, offset, limit, options) {
|
|
64
|
+
if (!query) return Promise.resolve({ results: [], hasMore: false, totalCount: 0 });
|
|
51
65
|
var off = (typeof offset === 'number') ? offset : 0;
|
|
52
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);
|
|
53
70
|
|
|
54
|
-
return axios.get(
|
|
55
|
-
params: { q: query, offset: off, limit: lim }
|
|
71
|
+
return axios.get(window.mbeditorBasePath() + '/search', {
|
|
72
|
+
params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' }
|
|
56
73
|
}).then(function(res) {
|
|
57
74
|
var data = res.data;
|
|
58
|
-
var results
|
|
59
|
-
var hasMore
|
|
75
|
+
var results = Array.isArray(data) ? data : (data && data.results || []);
|
|
76
|
+
var hasMore = !Array.isArray(data) && !!(data && data.has_more);
|
|
77
|
+
var totalCount = (data && data.total_count != null) ? data.total_count : null;
|
|
60
78
|
|
|
61
79
|
if (off === 0) {
|
|
62
80
|
EditorStore.setState({ searchResults: results, searchHasMore: hasMore });
|
|
@@ -64,18 +82,37 @@ var SearchService = (function () {
|
|
|
64
82
|
var prev = EditorStore.getState().searchResults || [];
|
|
65
83
|
EditorStore.setState({ searchResults: prev.concat(results), searchHasMore: hasMore });
|
|
66
84
|
}
|
|
67
|
-
return { results: results, hasMore: hasMore };
|
|
85
|
+
return { results: results, hasMore: hasMore, totalCount: totalCount };
|
|
68
86
|
})
|
|
69
87
|
.catch(function(err) {
|
|
70
88
|
EditorStore.setStatus("Search failed: " + err.message, "error");
|
|
71
|
-
return { results: [], hasMore: false };
|
|
89
|
+
return { results: [], hasMore: false, totalCount: null };
|
|
72
90
|
});
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
// Fetch a specific page by index without touching EditorStore.
|
|
94
|
+
// Used by the random-access virtual scroll loader.
|
|
95
|
+
function fetchPage(query, pageIndex) {
|
|
96
|
+
if (!query) return Promise.resolve({ results: [], hasMore: false });
|
|
97
|
+
var offset = pageIndex * SEARCH_PAGE_SIZE;
|
|
98
|
+
return axios.get(basePath() + '/search', {
|
|
99
|
+
params: { q: query, offset: offset, limit: SEARCH_PAGE_SIZE }
|
|
100
|
+
}).then(function(res) {
|
|
101
|
+
var data = res.data;
|
|
102
|
+
var results = Array.isArray(data) ? data : (data && data.results || []);
|
|
103
|
+
var hasMore = !Array.isArray(data) && !!(data && data.has_more);
|
|
104
|
+
return { results: results, hasMore: hasMore };
|
|
105
|
+
}).catch(function(err) {
|
|
106
|
+
EditorStore.setStatus("Search failed: " + err.message, "error");
|
|
107
|
+
return { results: [], hasMore: false };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
return {
|
|
76
112
|
buildIndex: buildIndex,
|
|
77
113
|
searchFiles: searchFiles,
|
|
78
114
|
projectSearch: projectSearch,
|
|
115
|
+
fetchPage: fetchPage,
|
|
79
116
|
PAGE_SIZE: SEARCH_PAGE_SIZE
|
|
80
117
|
};
|
|
81
118
|
})();
|
|
@@ -257,11 +257,11 @@ var TabManager = (function () {
|
|
|
257
257
|
});
|
|
258
258
|
EditorStore.setState({ panes: newPanes, focusedPaneId: paneId, activeTabId: tabId });
|
|
259
259
|
|
|
260
|
-
|
|
261
|
-
axios.get(basePath + '/git/combined_diff', { params: { scope: scope || 'local' } })
|
|
260
|
+
axios.get(window.mbeditorBasePath() + '/git/combined_diff', { params: { scope: scope || 'local' } })
|
|
262
261
|
.then(function(res) {
|
|
262
|
+
var data = res.data;
|
|
263
263
|
_updateTab(paneId, tabId, {
|
|
264
|
-
combinedDiffText: typeof
|
|
264
|
+
combinedDiffText: typeof data === 'string' ? data : (data && data.diff) || '',
|
|
265
265
|
combinedDiffLoaded: true
|
|
266
266
|
});
|
|
267
267
|
})
|
|
@@ -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
|
+
})();
|