mbeditor 0.11.0 → 0.12.0
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 +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- metadata +15 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Turns a drop's DataTransfer into a flat list of { file, relativePath }
|
|
4
|
+
// entries and packs them into the multipart body the /import endpoint wants.
|
|
5
|
+
//
|
|
6
|
+
// Directory drops are walked with webkitGetAsEntry — non-standard, but the
|
|
7
|
+
// only way any browser exposes a dropped folder. Where it is missing we fall
|
|
8
|
+
// back to dataTransfer.files, which is flat: files import, folders vanish.
|
|
9
|
+
var FileImport = (function () {
|
|
10
|
+
// Mirrors EditorsController::IMPORT_MAX_FILES. Trimming client-side means a
|
|
11
|
+
// stray node_modules drop reports a clear message instead of a 422 — and
|
|
12
|
+
// keeps the batch under Rack's 128-file-part multipart limit, which would
|
|
13
|
+
// otherwise reject the request as a 500 before the server guard can run.
|
|
14
|
+
var MAX_ENTRIES = 100;
|
|
15
|
+
|
|
16
|
+
function hasExternalFiles(dataTransfer) {
|
|
17
|
+
if (!dataTransfer || !dataTransfer.types) return false;
|
|
18
|
+
return Array.prototype.indexOf.call(dataTransfer.types, 'Files') !== -1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// MUST be called synchronously from the drop handler: DataTransferItem
|
|
22
|
+
// objects are neutered the moment the handler returns, so every entry is
|
|
23
|
+
// pulled off the item list up front and only then walked asynchronously.
|
|
24
|
+
function collectEntries(dataTransfer) {
|
|
25
|
+
var items = dataTransfer.items;
|
|
26
|
+
var roots = [];
|
|
27
|
+
var supportsEntries = typeof DataTransferItem !== 'undefined' &&
|
|
28
|
+
DataTransferItem.prototype.webkitGetAsEntry;
|
|
29
|
+
|
|
30
|
+
if (items && items.length && supportsEntries) {
|
|
31
|
+
for (var i = 0; i < items.length; i++) {
|
|
32
|
+
if (items[i].kind !== 'file') continue;
|
|
33
|
+
var entry = items[i].webkitGetAsEntry();
|
|
34
|
+
if (entry) roots.push(entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (roots.length === 0) {
|
|
39
|
+
var flat = [];
|
|
40
|
+
var files = dataTransfer.files || [];
|
|
41
|
+
for (var j = 0; j < files.length; j++) {
|
|
42
|
+
flat.push({ file: files[j], relativePath: files[j].name });
|
|
43
|
+
}
|
|
44
|
+
return Promise.resolve({
|
|
45
|
+
entries: flat.slice(0, MAX_ENTRIES),
|
|
46
|
+
truncated: flat.length > MAX_ENTRIES,
|
|
47
|
+
foldersSkipped: !!(items && items.length > flat.length)
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var collected = [];
|
|
52
|
+
return walkAll(roots, collected).then(function () {
|
|
53
|
+
return {
|
|
54
|
+
entries: collected.slice(0, MAX_ENTRIES),
|
|
55
|
+
truncated: collected.length > MAX_ENTRIES,
|
|
56
|
+
foldersSkipped: false
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function walkAll(entries, out) {
|
|
62
|
+
return entries.reduce(function (chain, entry) {
|
|
63
|
+
return chain.then(function () { return walk(entry, out); });
|
|
64
|
+
}, Promise.resolve());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walk(entry, out) {
|
|
68
|
+
if (entry.isFile) {
|
|
69
|
+
return new Promise(function (resolve) {
|
|
70
|
+
entry.file(function (file) {
|
|
71
|
+
out.push({ file: file, relativePath: stripLeadingSlash(entry.fullPath) });
|
|
72
|
+
resolve();
|
|
73
|
+
}, function () { resolve(); });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!entry.isDirectory) return Promise.resolve();
|
|
77
|
+
|
|
78
|
+
var reader = entry.createReader();
|
|
79
|
+
// readEntries hands back at most ~100 children per call and signals the
|
|
80
|
+
// end of the directory with an empty batch, so it has to be called until
|
|
81
|
+
// it runs dry rather than once.
|
|
82
|
+
var readBatch = function () {
|
|
83
|
+
return new Promise(function (resolve) {
|
|
84
|
+
reader.readEntries(function (batch) { resolve(batch); }, function () { resolve([]); });
|
|
85
|
+
}).then(function (batch) {
|
|
86
|
+
if (!batch || batch.length === 0) return null;
|
|
87
|
+
return walkAll(batch, out).then(readBatch);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
return readBatch();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stripLeadingSlash(p) {
|
|
94
|
+
return String(p || '').replace(/^\/+/, '');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// '' as the folder means the workspace root.
|
|
98
|
+
function joinPath(folder, relativePath) {
|
|
99
|
+
var rel = stripLeadingSlash(relativePath);
|
|
100
|
+
if (!folder) return rel;
|
|
101
|
+
return folder.replace(/\/+$/, '') + '/' + rel;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildFormData(entries, targetFolderPath, onConflict) {
|
|
105
|
+
var fd = new FormData();
|
|
106
|
+
fd.append('on_conflict', onConflict);
|
|
107
|
+
entries.forEach(function (e) {
|
|
108
|
+
fd.append('files[]', e.file, e.file.name);
|
|
109
|
+
fd.append('paths[]', joinPath(targetFolderPath, e.relativePath));
|
|
110
|
+
});
|
|
111
|
+
return fd;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Match conflict occurrences from the end of the original entry list. If
|
|
115
|
+
// two dropped files map to one previously-free path, pass one imports the
|
|
116
|
+
// first and reports only the later occurrence as conflicting; retrying every
|
|
117
|
+
// entry with that path would duplicate the already-imported file.
|
|
118
|
+
function conflictedEntries(entries, targetFolderPath, conflicts) {
|
|
119
|
+
var remaining = {};
|
|
120
|
+
(conflicts || []).forEach(function(c) {
|
|
121
|
+
var key = '$' + c.path;
|
|
122
|
+
remaining[key] = (remaining[key] || 0) + 1;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
var retry = [];
|
|
126
|
+
for (var i = entries.length - 1; i >= 0; i--) {
|
|
127
|
+
var targetKey = '$' + joinPath(targetFolderPath, entries[i].relativePath);
|
|
128
|
+
if (!remaining[targetKey]) continue;
|
|
129
|
+
retry.unshift(entries[i]);
|
|
130
|
+
remaining[targetKey] -= 1;
|
|
131
|
+
}
|
|
132
|
+
return retry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
MAX_ENTRIES: MAX_ENTRIES,
|
|
137
|
+
hasExternalFiles: hasExternalFiles,
|
|
138
|
+
collectEntries: collectEntries,
|
|
139
|
+
joinPath: joinPath,
|
|
140
|
+
buildFormData: buildFormData,
|
|
141
|
+
conflictedEntries: conflictedEntries
|
|
142
|
+
};
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
// Expose globally for sprockets require
|
|
146
|
+
window.FileImport = FileImport;
|
|
@@ -70,6 +70,15 @@ var FileService = (function () {
|
|
|
70
70
|
return axios.post(window.mbeditorBasePath() + '/create_file', { path: path, code: code || '' }).then(function(res) { return res.data; });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Multipart import of files dragged in from outside the browser. The
|
|
74
|
+
// default 30 s axios timeout is too tight for a large drop on a slow disk,
|
|
75
|
+
// so this one call gets a longer leash.
|
|
76
|
+
function importFiles(formData) {
|
|
77
|
+
return axios.post(window.mbeditorBasePath() + '/import', formData, {
|
|
78
|
+
timeout: 120000
|
|
79
|
+
}).then(function (res) { return res.data; });
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
function createDir(path) {
|
|
74
83
|
return axios.post(window.mbeditorBasePath() + '/create_dir', { path: path }).then(function(res) { return res.data; });
|
|
75
84
|
}
|
|
@@ -224,15 +233,50 @@ var FileService = (function () {
|
|
|
224
233
|
// | 'completion'; line/character are Monaco 1-based. The server answers in
|
|
225
234
|
// provider-ready shapes, or { fallback: true } when the LSP can't answer in
|
|
226
235
|
// time (caller then uses the legacy grep/Ripper path).
|
|
227
|
-
|
|
236
|
+
// extraBody carries per-method request params (e.g. formatting's tab_size);
|
|
237
|
+
// extraOptions is axios config (e.g. a longer timeout).
|
|
238
|
+
function rubyLspRequest(lspMethod, path, content, line, character, extraOptions, extraBody) {
|
|
228
239
|
var config = Object.assign({ timeout: 6000 }, extraOptions || {});
|
|
229
|
-
|
|
240
|
+
var body = Object.assign({
|
|
230
241
|
lsp_method: lspMethod,
|
|
231
242
|
path: path,
|
|
232
243
|
content: content,
|
|
233
244
|
line: line,
|
|
234
245
|
character: character
|
|
235
|
-
},
|
|
246
|
+
}, extraBody || {});
|
|
247
|
+
return axios.post(window.mbeditorBasePath() + '/ruby_lsp', body, config)
|
|
248
|
+
.then(function(res) { return res.data; });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ActiveRecord models and their associations. Generating this eager-loads the
|
|
252
|
+
// host app, so the timeout is generous and it is only requested on demand.
|
|
253
|
+
function getModelGraph(force) {
|
|
254
|
+
return axios.get(window.mbeditorBasePath() + '/model_graph', {
|
|
255
|
+
params: force ? { refresh: 1 } : {},
|
|
256
|
+
timeout: 60000
|
|
257
|
+
}).then(function (res) { return res.data; });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Exceptions raised by the host app, newest first. The cable push is the
|
|
261
|
+
// live path; this seeds the panel and covers hosts without ActionCable.
|
|
262
|
+
function getExceptions() {
|
|
263
|
+
return axios.get(window.mbeditorBasePath() + '/exceptions')
|
|
264
|
+
.then(function (res) { return res.data; });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function clearExceptions() {
|
|
268
|
+
return axios["delete"](window.mbeditorBasePath() + '/exceptions')
|
|
269
|
+
.then(function (res) { return res.data; });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Rename a Ruby constant across the workspace. openPaths lists every file
|
|
273
|
+
// with a live editor model: the server returns their edits for Monaco to
|
|
274
|
+
// apply (undoable, marks the tab dirty) and writes the rest to disk itself.
|
|
275
|
+
function rubyRename(path, content, line, character, newName, openPaths) {
|
|
276
|
+
return axios.post(window.mbeditorBasePath() + '/ruby_rename', {
|
|
277
|
+
path: path, content: content, line: line, character: character,
|
|
278
|
+
new_name: newName, open_paths: openPaths || []
|
|
279
|
+
}, { timeout: 35000 }).then(function (res) { return res.data; });
|
|
236
280
|
}
|
|
237
281
|
|
|
238
282
|
// Whole-document Ruby diagnostics from ruby-lsp (RuboCop offenses + Prism
|
|
@@ -283,6 +327,7 @@ var FileService = (function () {
|
|
|
283
327
|
getFileChunk: getFileChunk,
|
|
284
328
|
saveFile: saveFile,
|
|
285
329
|
createFile: createFile,
|
|
330
|
+
importFiles: importFiles,
|
|
286
331
|
createDir: createDir,
|
|
287
332
|
renamePath: renamePath,
|
|
288
333
|
deletePath: deletePath,
|
|
@@ -310,6 +355,10 @@ var FileService = (function () {
|
|
|
310
355
|
getJsProgramFile: getJsProgramFile,
|
|
311
356
|
rubyLspRequest: rubyLspRequest,
|
|
312
357
|
lspDiagnostics: lspDiagnostics,
|
|
358
|
+
rubyRename: rubyRename,
|
|
359
|
+
getModelGraph: getModelGraph,
|
|
360
|
+
getExceptions: getExceptions,
|
|
361
|
+
clearExceptions: clearExceptions,
|
|
313
362
|
getRelatedFiles: getRelatedFiles,
|
|
314
363
|
getModelSchema: getModelSchema,
|
|
315
364
|
getChangelog: getChangelog
|
|
@@ -61,6 +61,10 @@ var TabManager = (function () {
|
|
|
61
61
|
entry.model.dispose();
|
|
62
62
|
}
|
|
63
63
|
delete window.__mbeditorModels[candidate];
|
|
64
|
+
// The evicted file is not open in any pane — tear down its collaboration room.
|
|
65
|
+
if (typeof CollaborationService !== 'undefined') {
|
|
66
|
+
CollaborationService.leaveRoom(candidate);
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -141,7 +145,32 @@ var TabManager = (function () {
|
|
|
141
145
|
});
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
// Single slot holding "where the cursor was before the last jump".
|
|
149
|
+
// PageUp/PageDown swap the cursor between this slot and its current spot.
|
|
150
|
+
// ponytail: two-position cycle, upgrade to a real back/forward stack if asked
|
|
151
|
+
var _jumpOrigin = null;
|
|
152
|
+
|
|
153
|
+
function _snapshotPosition() {
|
|
154
|
+
var editor = window.__mbeditorActiveEditor;
|
|
155
|
+
var pos = editor && editor.getPosition ? editor.getPosition() : null;
|
|
156
|
+
if (!pos) return null;
|
|
157
|
+
var state = EditorStore.getState();
|
|
158
|
+
var pane = state.panes.find(function(p) { return p.id === state.focusedPaneId; });
|
|
159
|
+
var tab = pane && pane.tabs.find(function(t) { return t.id === pane.activeTabId; });
|
|
160
|
+
if (!tab || !tab.path) return null;
|
|
161
|
+
return { path: tab.path, name: tab.name, line: pos.lineNumber, col: pos.column };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toggleJumpOrigin() {
|
|
165
|
+
var target = _jumpOrigin;
|
|
166
|
+
if (!target) return;
|
|
167
|
+
// openTab re-snapshots the current position into _jumpOrigin below,
|
|
168
|
+
// which is exactly the swap we want.
|
|
169
|
+
openTab(target.path, target.name, target.line, null, false, target.col);
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
function openTab(path, name, line, forcePaneId, isSoftOpen, col) {
|
|
173
|
+
if (line) _jumpOrigin = _snapshotPosition() || _jumpOrigin;
|
|
145
174
|
var state = EditorStore.getState();
|
|
146
175
|
var paneId = forcePaneId || state.focusedPaneId;
|
|
147
176
|
var pane = state.panes.find(function(p) { return p.id === paneId; });
|
|
@@ -360,8 +389,23 @@ var TabManager = (function () {
|
|
|
360
389
|
axios.get(window.mbeditorBasePath() + '/git/combined_diff', { params: { scope: scope || 'local' } })
|
|
361
390
|
.then(function(res) {
|
|
362
391
|
var data = res.data;
|
|
392
|
+
var text = typeof data === 'string' ? data : (data && data.diff) || '';
|
|
393
|
+
// The server reports what it compared against, and says so when it
|
|
394
|
+
// could not work out a base at all — otherwise an empty diff looks
|
|
395
|
+
// identical to "no changes" and hides the real problem.
|
|
396
|
+
var headers = res.headers || {};
|
|
397
|
+
var baseRef = headers['x-mbeditor-diff-base'];
|
|
398
|
+
var diffError = headers['x-mbeditor-diff-error'];
|
|
399
|
+
|
|
400
|
+
if (diffError) {
|
|
401
|
+
text = '# ' + diffError + '\n';
|
|
402
|
+
} else if (baseRef && text.trim() === '') {
|
|
403
|
+
text = '# No changes compared to ' + baseRef + '.\n';
|
|
404
|
+
}
|
|
405
|
+
|
|
363
406
|
_updateTab(paneId, tabId, {
|
|
364
|
-
combinedDiffText:
|
|
407
|
+
combinedDiffText: text,
|
|
408
|
+
combinedDiffBase: baseRef || null,
|
|
365
409
|
combinedDiffLoaded: true
|
|
366
410
|
});
|
|
367
411
|
})
|
|
@@ -434,6 +478,10 @@ var TabManager = (function () {
|
|
|
434
478
|
_entry.model.dispose();
|
|
435
479
|
}
|
|
436
480
|
delete window.__mbeditorModels[path];
|
|
481
|
+
// File is no longer open in any pane — tear down its collaboration room.
|
|
482
|
+
if (typeof CollaborationService !== 'undefined') {
|
|
483
|
+
CollaborationService.leaveRoom(path);
|
|
484
|
+
}
|
|
437
485
|
}
|
|
438
486
|
}
|
|
439
487
|
}
|
|
@@ -617,6 +665,7 @@ var TabManager = (function () {
|
|
|
617
665
|
reorderTabInPane: reorderTabInPane,
|
|
618
666
|
moveTabToPane: moveTabToPane,
|
|
619
667
|
clearGotoLine: clearGotoLine,
|
|
668
|
+
toggleJumpOrigin: toggleJumpOrigin,
|
|
620
669
|
closeAllTabsInPane: closeAllTabsInPane,
|
|
621
670
|
closeOtherTabsInPane: closeOtherTabsInPane,
|
|
622
671
|
closeSavedTabsInPane: closeSavedTabsInPane,
|
|
@@ -11,6 +11,8 @@ var WebSocketService = (function () {
|
|
|
11
11
|
var _subscription = null;
|
|
12
12
|
var _connected = false;
|
|
13
13
|
var _filesChangedCallbacks = [];
|
|
14
|
+
var _fileSavedCallbacks = [];
|
|
15
|
+
var _presenceCallbacks = [];
|
|
14
16
|
var _logLinesCallbacks = [];
|
|
15
17
|
var _serverSupportsWs = false;
|
|
16
18
|
var _reconnectTimer = null;
|
|
@@ -117,8 +119,14 @@ var WebSocketService = (function () {
|
|
|
117
119
|
if (!data) return;
|
|
118
120
|
if (data.type === 'files_changed') {
|
|
119
121
|
_emitFilesChanged(data);
|
|
122
|
+
} else if (data.type === 'file_saved') {
|
|
123
|
+
_emitFileSaved(data);
|
|
124
|
+
} else if (data.type === 'presence') {
|
|
125
|
+
_emitPresence(data);
|
|
120
126
|
} else if (data.type === 'log') {
|
|
121
127
|
_emitLogLines(data);
|
|
128
|
+
} else if (data.type === 'exception') {
|
|
129
|
+
_emitException(data);
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
132
|
}
|
|
@@ -136,12 +144,33 @@ var WebSocketService = (function () {
|
|
|
136
144
|
});
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
function _emitFileSaved(data) {
|
|
148
|
+
_fileSavedCallbacks.forEach(function (fn) {
|
|
149
|
+
try { fn(data); } catch (e) { /* ignore */ }
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _emitPresence(data) {
|
|
154
|
+
_presenceCallbacks.forEach(function (fn) {
|
|
155
|
+
try { fn(data); } catch (e) { /* ignore */ }
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
function _emitLogLines(data) {
|
|
140
160
|
_logLinesCallbacks.forEach(function (fn) {
|
|
141
161
|
try { fn(data); } catch (e) { /* ignore */ }
|
|
142
162
|
});
|
|
143
163
|
}
|
|
144
164
|
|
|
165
|
+
// Host-app exceptions go out as a DOM event rather than a callback list:
|
|
166
|
+
// the Problems panel mounts and unmounts, so it subscribes and unsubscribes
|
|
167
|
+
// with its own lifecycle rather than registering here at boot.
|
|
168
|
+
function _emitException(data) {
|
|
169
|
+
try {
|
|
170
|
+
window.dispatchEvent(new CustomEvent('mbeditor:exception', { detail: data }));
|
|
171
|
+
} catch (e) { /* CustomEvent unavailable; the panel still polls on open */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
145
174
|
// ---------------------------------------------------------------------------
|
|
146
175
|
// Public API
|
|
147
176
|
// ---------------------------------------------------------------------------
|
|
@@ -172,6 +201,38 @@ var WebSocketService = (function () {
|
|
|
172
201
|
return _connected;
|
|
173
202
|
}
|
|
174
203
|
|
|
204
|
+
// Returns true when ActionCable is loaded and the server advertised cable
|
|
205
|
+
// support. Collaboration uses this as its up-front "is the feature available?"
|
|
206
|
+
// gate, independent of whether the EditorChannel handshake has completed yet.
|
|
207
|
+
function isCableAvailable() {
|
|
208
|
+
return _serverSupportsWs && _isActionCableAvailable();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Open a CollaborationChannel subscription for one file on the SHARED consumer
|
|
212
|
+
// (the same connection the EditorChannel uses). Returns the subscription, or
|
|
213
|
+
// null when cable is unavailable so callers can stay in single-user mode.
|
|
214
|
+
// handlers: { connected, received, rejected, disconnected } — all optional.
|
|
215
|
+
function subscribeCollaboration(path, handlers) {
|
|
216
|
+
if (!isCableAvailable()) return null;
|
|
217
|
+
handlers = handlers || {};
|
|
218
|
+
try {
|
|
219
|
+
if (!_consumer) {
|
|
220
|
+
_consumer = _getConsumer();
|
|
221
|
+
}
|
|
222
|
+
return _consumer.subscriptions.create(
|
|
223
|
+
{ channel: 'Mbeditor::CollaborationChannel', path: path },
|
|
224
|
+
{
|
|
225
|
+
connected: function () { if (handlers.connected) handlers.connected(); },
|
|
226
|
+
disconnected: function () { if (handlers.disconnected) handlers.disconnected(); },
|
|
227
|
+
rejected: function () { if (handlers.rejected) handlers.rejected(); },
|
|
228
|
+
received: function (data) { if (handlers.received) handlers.received(data); }
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
175
236
|
// Register a callback to be invoked when the server broadcasts files_changed.
|
|
176
237
|
function onFilesChanged(fn) {
|
|
177
238
|
_filesChangedCallbacks.push(fn);
|
|
@@ -182,6 +243,28 @@ var WebSocketService = (function () {
|
|
|
182
243
|
_filesChangedCallbacks = _filesChangedCallbacks.filter(function (f) { return f !== fn; });
|
|
183
244
|
}
|
|
184
245
|
|
|
246
|
+
// Register a callback to be invoked when a peer saves a file (file_saved).
|
|
247
|
+
// The callback receives the broadcast data, including the relative `path`.
|
|
248
|
+
function onFileSaved(fn) {
|
|
249
|
+
_fileSavedCallbacks.push(fn);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Remove a previously registered file_saved callback.
|
|
253
|
+
function offFileSaved(fn) {
|
|
254
|
+
_fileSavedCallbacks = _fileSavedCallbacks.filter(function (f) { return f !== fn; });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Register a callback for presence heartbeats/leaves relayed on the global
|
|
258
|
+
// stream. The callback receives {type:'presence', status, client_id, ...}.
|
|
259
|
+
function onPresence(fn) {
|
|
260
|
+
_presenceCallbacks.push(fn);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Remove a previously registered presence callback.
|
|
264
|
+
function offPresence(fn) {
|
|
265
|
+
_presenceCallbacks = _presenceCallbacks.filter(function (f) { return f !== fn; });
|
|
266
|
+
}
|
|
267
|
+
|
|
185
268
|
// Register a callback invoked when the server transmits a { type: 'log' } message.
|
|
186
269
|
function onLogLines(fn) {
|
|
187
270
|
_logLinesCallbacks.push(fn);
|
|
@@ -207,9 +290,15 @@ var WebSocketService = (function () {
|
|
|
207
290
|
connect: connect,
|
|
208
291
|
disconnect: disconnect,
|
|
209
292
|
isConnected: isConnected,
|
|
293
|
+
isCableAvailable: isCableAvailable,
|
|
294
|
+
subscribeCollaboration: subscribeCollaboration,
|
|
210
295
|
perform: perform,
|
|
211
296
|
onFilesChanged: onFilesChanged,
|
|
212
297
|
offFilesChanged: offFilesChanged,
|
|
298
|
+
onFileSaved: onFileSaved,
|
|
299
|
+
offFileSaved: offFileSaved,
|
|
300
|
+
onPresence: onPresence,
|
|
301
|
+
offPresence: offPresence,
|
|
213
302
|
onLogLines: onLogLines,
|
|
214
303
|
offLogLines: offLogLines
|
|
215
304
|
};
|