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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/app/assets/javascripts/mbeditor/application.js +1 -0
  4. data/app/assets/javascripts/mbeditor/application_iife_head.js +7 -0
  5. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +1 -1
  6. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +213 -11
  7. data/app/assets/javascripts/mbeditor/components/GitPanel.js +14 -4
  8. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +673 -160
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +41 -1
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +3 -2
  11. data/app/assets/javascripts/mbeditor/editor_plugins.js +21 -0
  12. data/app/assets/javascripts/mbeditor/editor_store.js +10 -2
  13. data/app/assets/javascripts/mbeditor/file_service.js +29 -23
  14. data/app/assets/javascripts/mbeditor/git_service.js +7 -11
  15. data/app/assets/javascripts/mbeditor/search_service.js +51 -14
  16. data/app/assets/javascripts/mbeditor/tab_manager.js +3 -3
  17. data/app/assets/javascripts/mbeditor/websocket_service.js +126 -0
  18. data/app/assets/stylesheets/mbeditor/editor.css +237 -15
  19. data/app/channels/mbeditor/editor_channel.rb +79 -0
  20. data/app/controllers/mbeditor/editors_controller.rb +177 -136
  21. data/app/controllers/mbeditor/git_controller.rb +5 -40
  22. data/app/services/mbeditor/git_blame_service.rb +6 -0
  23. data/app/services/mbeditor/git_commit_graph_service.rb +2 -0
  24. data/app/services/mbeditor/git_service.rb +97 -28
  25. data/app/services/mbeditor/redmine_service.rb +7 -0
  26. data/app/services/mbeditor/ruby_definition_service.rb +23 -2
  27. data/app/views/layouts/mbeditor/application.html.erb +4 -0
  28. data/lib/mbeditor/cable_log_filter.rb +28 -0
  29. data/lib/mbeditor/configuration.rb +7 -1
  30. data/lib/mbeditor/engine.rb +37 -0
  31. data/lib/mbeditor/rack/silence_ping_request.rb +4 -1
  32. data/lib/mbeditor/version.rb +3 -1
  33. data/lib/mbeditor.rb +2 -0
  34. 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
- setResults(filtered.slice(0, 20));
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
- // Auto-clear after 4s for non-error messages
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(basePath() + '/workspace').then(function(res) { return res.data; });
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(basePath() + '/files').then(function(res) { return res.data; });
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(basePath() + '/file', { params: params }).then(function(res) { return res.data; });
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(basePath() + '/file', { path: path, code: code }).then(function(res) { return res.data; });
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(basePath() + '/create_file', { path: path, code: code || '' }).then(function(res) { return res.data; });
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(basePath() + '/create_dir', { path: path }).then(function(res) { return res.data; });
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(basePath() + '/rename', { path: path, new_path: newPath }).then(function(res) { return res.data; });
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(basePath() + '/delete', { data: { path: path } }).then(function(res) { return res.data; });
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(basePath() + '/lint', { path: path, code: code }).then(function(res) { return res.data; });
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(basePath() + '/quick_fix', { path: path, code: code, cop_name: copName }).then(function(res) { return res.data; });
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(basePath() + '/format', { path: path, code: code }).then(function(res) { return res.data; });
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(basePath() + '/test', { path: path }).then(function(res) { return res.data; });
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(basePath() + '/ping', { timeout: 4000 }).then(function(res) { return res.data; });
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(basePath() + '/state').then(function(res) { return res.data; });
92
+ return axios.get(window.mbeditorBasePath() + '/state').then(function(res) { return res.data; });
93
93
  }
94
94
 
95
95
  function saveState(state) {
96
- return axios.post(basePath() + '/state', { state: state }).then(function(res) { return res.data; });
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(basePath() + '/branch_state', { params: { branch: branch } }).then(function(res) { return res.data; });
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
- return axios.post(basePath() + '/branch_state', { branch: branch, state: state }).then(function(res) { return res.data; });
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(basePath() + '/prune_branch_states').then(function(res) { return res.data; });
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(basePath() + '/definition', config).then(function(res) { return res.data; });
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(basePath() + '/git_info')
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(basePath() + '/git_status')
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(basePath() + '/git/diff' + query).then(function(res) {
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(basePath() + '/git/blame?file=' + encodeURIComponent(path)).then(function(res) {
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(basePath() + '/git/file_history?file=' + encodeURIComponent(path)).then(function(res) {
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(basePath() + '/git/commit_graph').then(function(res) {
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(basePath() + '/git/commit_detail?sha=' + encodeURIComponent(sha)).then(function(res) {
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
- // Returns raw MiniSearch results; caller can filter by .type.
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
- return _miniSearch.search(query, { prefix: true, fuzzy: 0.2 });
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
- function projectSearch(query, offset, limit) {
50
- if (!query) return Promise.resolve({ results: [], hasMore: false });
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(basePath() + '/search', {
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 = Array.isArray(data) ? data : (data && data.results || []);
59
- var hasMore = !Array.isArray(data) && !!(data && data.has_more);
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
- var basePath = (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
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 res.data === 'string' ? res.data : '',
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
+ })();