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,690 @@
|
|
|
1
|
+
// CollaborationService — realtime collaborative editing (slice 4/9).
|
|
2
|
+
//
|
|
3
|
+
// Manages one Yjs document per open file and a custom ActionCable provider built
|
|
4
|
+
// on WebSocketService's shared consumer, with a y-monaco MonacoBinding rendering
|
|
5
|
+
// converged text. The "provider" is just the CollaborationChannel subscription
|
|
6
|
+
// plus two glue listeners: outbound (doc.on('update') -> doc_update action) and
|
|
7
|
+
// inbound (received -> Y.applyUpdate). Bytes cross the wire as base64 strings;
|
|
8
|
+
// the server (CollaborationDocStore) stores them opaquely.
|
|
9
|
+
//
|
|
10
|
+
// Activation rides on *participant* presence, not merely on the cable being up:
|
|
11
|
+
// with nobody else connected the module is inert and the editor behaves exactly as
|
|
12
|
+
// it does without ActionCable (no binding, native undo, external-change detection
|
|
13
|
+
// intact). See the gating section for why cable availability alone is not enough.
|
|
14
|
+
//
|
|
15
|
+
// Scope of slice 4: content convergence + late-join + first-opener seed +
|
|
16
|
+
// local-origin-scoped undo. Slice 5 (#56) adds awareness — remote carets,
|
|
17
|
+
// selections and labelled participant identity — layered onto the same binding.
|
|
18
|
+
// Save/snapshot reconciliation (#57) is still out of scope.
|
|
19
|
+
var CollaborationService = (function () {
|
|
20
|
+
// Per-path room. Persistent across tab switches (the Monaco model is reused):
|
|
21
|
+
// { doc, text, subscription, synced, model, editor, binding, undoManager,
|
|
22
|
+
// lateJoin, onSeeded, attachRequested, degraded, fallbackTimer,
|
|
23
|
+
// awareness, editors, cursorDisposers, identityUnsub, seenClients }
|
|
24
|
+
var _rooms = {};
|
|
25
|
+
|
|
26
|
+
// Identity tag marking updates that arrived from the wire, so the outbound
|
|
27
|
+
// sender never echoes them back and the local UndoManager never tracks them.
|
|
28
|
+
var REMOTE = {};
|
|
29
|
+
|
|
30
|
+
// Follow mode (slice 8): the presence client_id of the participant whose
|
|
31
|
+
// scroll/viewport we're tracking, or null when navigating independently. Set
|
|
32
|
+
// by setFollow()/clearFollow(); read on every awareness change to snap the
|
|
33
|
+
// live editor to their viewport. The matching file-open is driven separately
|
|
34
|
+
// by MbeditorApp from the presence roster's current_file.
|
|
35
|
+
var _followedClientId = null;
|
|
36
|
+
|
|
37
|
+
var IMAGE_RE = /\.(png|jpe?g|gif|svg|ico|webp|bmp|avif)$/i;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// base64 <-> Uint8Array (Yjs updates are Uint8Array; ActionCable carries JSON)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function _u8ToB64(u8) {
|
|
43
|
+
var CHUNK = 0x8000;
|
|
44
|
+
var parts = [];
|
|
45
|
+
for (var i = 0; i < u8.length; i += CHUNK) {
|
|
46
|
+
parts.push(String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK)));
|
|
47
|
+
}
|
|
48
|
+
return btoa(parts.join(''));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _b64ToU8(b64) {
|
|
52
|
+
var bin = atob(b64);
|
|
53
|
+
var len = bin.length;
|
|
54
|
+
var u8 = new Uint8Array(len);
|
|
55
|
+
for (var i = 0; i < len; i++) u8[i] = bin.charCodeAt(i);
|
|
56
|
+
return u8;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Gating
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// Is anyone else actually connected? Fed by MbeditorApp from the presence
|
|
64
|
+
// roster, which is the only signal that distinguishes "cable is up" from
|
|
65
|
+
// "someone is pairing with me".
|
|
66
|
+
//
|
|
67
|
+
// Collaboration MUST NOT activate on cable availability alone. Action Cable is
|
|
68
|
+
// up in a normal dev setup, so gating on it put every solo user into full
|
|
69
|
+
// collaboration mode: persistent undo (HistoryService + the Phase-2 replay) was
|
|
70
|
+
// suspended for every file, and external on-disk changes were suppressed
|
|
71
|
+
// outright, because both defer to the CRDT once a room is bound. Nobody was
|
|
72
|
+
// ever on the other end of that CRDT.
|
|
73
|
+
var _peerPresent = false;
|
|
74
|
+
var _availabilityListeners = [];
|
|
75
|
+
|
|
76
|
+
function _globalsReady() {
|
|
77
|
+
return typeof window.Y !== 'undefined' &&
|
|
78
|
+
typeof window.MonacoBinding !== 'undefined' &&
|
|
79
|
+
typeof WebSocketService !== 'undefined' &&
|
|
80
|
+
WebSocketService.isCableAvailable() &&
|
|
81
|
+
_peerPresent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Is collaboration available at all right now? Flips when a peer joins or
|
|
85
|
+
// leaves (the Yjs globals and the cable come up before that, asynchronously).
|
|
86
|
+
// EditorPanel subscribes via onAvailabilityChange so an already-open tab joins
|
|
87
|
+
// the room the moment someone arrives — path-independent, no per-file checks.
|
|
88
|
+
function isAvailable() {
|
|
89
|
+
return _globalsReady();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Called by MbeditorApp on every presence roster message.
|
|
93
|
+
//
|
|
94
|
+
// Availability is two conditions, not one: a peer is present AND the cable is
|
|
95
|
+
// up. Deliberately no "last notified" cache to suppress repeats — the cable half
|
|
96
|
+
// changes without passing through here (a drop, a reconnect), so a remembered
|
|
97
|
+
// value desyncs from reality and then the next genuine change looks like no
|
|
98
|
+
// change at all, leaving the editor stranded in single-user mode with a peer
|
|
99
|
+
// sitting right there. That is a real bug this cost me: `available` read true
|
|
100
|
+
// while no room had been created, because the edge had been swallowed.
|
|
101
|
+
//
|
|
102
|
+
// Instead this publishes the current value every time it is called, which is on
|
|
103
|
+
// every roster message. Listeners are expected to be idempotent — the React ones
|
|
104
|
+
// bail on an unchanged value — so the steady state costs a boolean compare.
|
|
105
|
+
function setPeerPresent(present) {
|
|
106
|
+
_peerPresent = !!present;
|
|
107
|
+
refreshAvailability();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function refreshAvailability() {
|
|
111
|
+
var available = isAvailable();
|
|
112
|
+
// No longer collaborating: close every room so the editor returns to solo
|
|
113
|
+
// behaviour — persistent undo and external-change detection both key off
|
|
114
|
+
// there being no live binding.
|
|
115
|
+
if (!available && Object.keys(_rooms).length) {
|
|
116
|
+
Object.keys(_rooms).forEach(function (path) { leaveRoom(path); });
|
|
117
|
+
}
|
|
118
|
+
_availabilityListeners.slice().forEach(function (fn) {
|
|
119
|
+
try { fn(available); } catch (e) { /* a bad listener must not stop the rest */ }
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Subscribe to availability transitions. Returns an unsubscribe function.
|
|
124
|
+
function onAvailabilityChange(fn) {
|
|
125
|
+
_availabilityListeners.push(fn);
|
|
126
|
+
return function () {
|
|
127
|
+
_availabilityListeners = _availabilityListeners.filter(function (f) { return f !== fn; });
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Synchronous predicate: should this path participate in collaboration at all?
|
|
132
|
+
// Excludes virtual/preview/image paths. EditorPanel uses this up-front to decide
|
|
133
|
+
// whether to suspend its persistent-undo machinery.
|
|
134
|
+
function isEnabledFor(path) {
|
|
135
|
+
if (!path) return false;
|
|
136
|
+
if (path.indexOf('diff://') === 0 || path.indexOf('combined-diff://') === 0) return false;
|
|
137
|
+
if (path.indexOf('::preview') !== -1) return false;
|
|
138
|
+
if (IMAGE_RE.test(path)) return false;
|
|
139
|
+
return _globalsReady();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Room lifecycle
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
// Open the Yjs document + channel subscription for a file and start the sync
|
|
147
|
+
// handshake. Idempotent. Returns true when a live room exists (so the caller
|
|
148
|
+
// can gate its own behavior), false when collaboration could not activate.
|
|
149
|
+
function ensureRoom(path) {
|
|
150
|
+
// Gate first, then reuse. Checking `_rooms` first would keep reporting an
|
|
151
|
+
// active room after the last peer left, so the caller would never fall back
|
|
152
|
+
// out of collaboration mode.
|
|
153
|
+
if (!isEnabledFor(path)) return false;
|
|
154
|
+
if (_rooms[path]) return true;
|
|
155
|
+
|
|
156
|
+
var doc = new window.Y.Doc();
|
|
157
|
+
var room = {
|
|
158
|
+
doc: doc,
|
|
159
|
+
text: doc.getText('monaco'),
|
|
160
|
+
subscription: null,
|
|
161
|
+
synced: false,
|
|
162
|
+
model: null,
|
|
163
|
+
editor: null,
|
|
164
|
+
binding: null,
|
|
165
|
+
undoManager: null,
|
|
166
|
+
lateJoin: false,
|
|
167
|
+
onSeeded: null,
|
|
168
|
+
attachRequested: false,
|
|
169
|
+
degraded: false,
|
|
170
|
+
fallbackTimer: null,
|
|
171
|
+
// Awareness (slice 5): editors is the LIVE set y-monaco renders remote
|
|
172
|
+
// carets into; cursorDisposers holds our per-editor cursor->awareness
|
|
173
|
+
// writers; seenClients tracks peers we've announced ourselves to.
|
|
174
|
+
// viewportDisposers (slice 8): per-editor scroll->awareness writers, so a
|
|
175
|
+
// follower can track where we've scrolled.
|
|
176
|
+
awareness: null,
|
|
177
|
+
editors: new Set(),
|
|
178
|
+
boundEditor: null,
|
|
179
|
+
broadcastAwareness: null,
|
|
180
|
+
cursorDisposers: new Map(),
|
|
181
|
+
viewportDisposers: new Map(),
|
|
182
|
+
identityUnsub: null,
|
|
183
|
+
seenClients: new Set()
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Outbound: relay local updates (seed, local edits, local undo/redo) to peers.
|
|
187
|
+
// Updates that came from the wire carry the REMOTE origin and are not re-sent.
|
|
188
|
+
doc.on('update', function (update, origin) {
|
|
189
|
+
if (origin === REMOTE) return;
|
|
190
|
+
if (!room.subscription) return;
|
|
191
|
+
try {
|
|
192
|
+
room.subscription.perform('doc_update', { update: _u8ToB64(update) });
|
|
193
|
+
} catch (e) { /* not connected yet / cable down — peers reconcile on next op */ }
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
room.subscription = WebSocketService.subscribeCollaboration(path, {
|
|
197
|
+
received: function (data) { _onMessage(room, data); }
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!room.subscription) {
|
|
201
|
+
doc.destroy();
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_rooms[path] = room;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _onMessage(room, data) {
|
|
210
|
+
if (!data || !data.type) return;
|
|
211
|
+
if (data.type === 'sync') {
|
|
212
|
+
// One-shot handshake. Apply any existing server state BEFORE binding so the
|
|
213
|
+
// binding can distinguish first-opener (empty) from late-join (non-empty).
|
|
214
|
+
if (data.snapshot) {
|
|
215
|
+
window.Y.applyUpdate(room.doc, _b64ToU8(data.snapshot), REMOTE);
|
|
216
|
+
}
|
|
217
|
+
if (data.deltas && data.deltas.length) {
|
|
218
|
+
data.deltas.forEach(function (d) {
|
|
219
|
+
if (d) window.Y.applyUpdate(room.doc, _b64ToU8(d), REMOTE);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
room.synced = true;
|
|
223
|
+
_maybeAttach(room);
|
|
224
|
+
} else if (data.type === 'doc_update') {
|
|
225
|
+
if (data.update) window.Y.applyUpdate(room.doc, _b64ToU8(data.update), REMOTE);
|
|
226
|
+
} else if (data.type === 'awareness') {
|
|
227
|
+
if (data.awareness && room.awareness && window.awarenessProtocol) {
|
|
228
|
+
// REMOTE origin keeps the outbound handler from echoing peer state back.
|
|
229
|
+
window.awarenessProtocol.applyAwarenessUpdate(
|
|
230
|
+
room.awareness, _b64ToU8(data.awareness), REMOTE
|
|
231
|
+
);
|
|
232
|
+
_announceToNewPeers(room);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Register the current editor/model for a room and request a binding. Called on
|
|
238
|
+
// every editor (re)creation (tab switch recreates the editor; the model and the
|
|
239
|
+
// Yjs doc persist). opts.onSeeded is invoked once the binding has set the model,
|
|
240
|
+
// so EditorPanel can rebaseline its clean-state.
|
|
241
|
+
function bindEditor(path, editor, model, opts) {
|
|
242
|
+
var room = _rooms[path];
|
|
243
|
+
if (!room) return;
|
|
244
|
+
room.editor = editor;
|
|
245
|
+
room.model = model;
|
|
246
|
+
room.onSeeded = (opts && opts.onSeeded) || room.onSeeded;
|
|
247
|
+
room.attachRequested = true;
|
|
248
|
+
_applyUndoKeybindings(room, editor);
|
|
249
|
+
|
|
250
|
+
// The editor is recreated on every tab switch while the model (and binding)
|
|
251
|
+
// persist. If the binding already exists, register this fresh editor so its
|
|
252
|
+
// caret broadcasts and remote carets render in it.
|
|
253
|
+
if (room.binding) _attachEditorToBinding(room, editor);
|
|
254
|
+
|
|
255
|
+
if (!room.fallbackTimer && !room.binding) {
|
|
256
|
+
// Safety net: if the sync handshake never lands (e.g. channel rejected),
|
|
257
|
+
// stop waiting and let the natively-loaded content stand.
|
|
258
|
+
room.fallbackTimer = setTimeout(function () { _fallbackLocal(room); }, 6000);
|
|
259
|
+
}
|
|
260
|
+
_maybeAttach(room);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _maybeAttach(room) {
|
|
264
|
+
if (room.binding || !room.synced || !room.attachRequested || !room.model) return;
|
|
265
|
+
_doAttach(room);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _doAttach(room) {
|
|
269
|
+
var model = room.model;
|
|
270
|
+
room.lateJoin = room.text.length > 0; // server already had shared state
|
|
271
|
+
|
|
272
|
+
if (!room.lateJoin && model.getValue().length > 0) {
|
|
273
|
+
// First opener whose disk content is already in the model: seed the shared
|
|
274
|
+
// doc from it before constructing the binding, otherwise the binding would
|
|
275
|
+
// overwrite the model to match the (empty) Y.Text and wipe the content.
|
|
276
|
+
room.doc.transact(function () { room.text.insert(0, model.getValue()); });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Awareness carries each participant's caret/selection + identity. Optional:
|
|
280
|
+
// if the y-protocols global is missing the editor still converges content,
|
|
281
|
+
// just without remote cursors (matches slice-4 behaviour).
|
|
282
|
+
room.awareness = window.awarenessProtocol
|
|
283
|
+
? new window.awarenessProtocol.Awareness(room.doc)
|
|
284
|
+
: null;
|
|
285
|
+
|
|
286
|
+
// Pass the room's LIVE editors set. y-monaco reads it lazily on every awareness
|
|
287
|
+
// change, so editors added on a later tab switch start rendering remote carets
|
|
288
|
+
// without rebuilding the binding. The set must be NON-EMPTY at construction —
|
|
289
|
+
// y-monaco registers its caret-render listener (and the attach-time editor's
|
|
290
|
+
// caret writer) inside a forEach over the set, so an empty set would render
|
|
291
|
+
// nothing. The attach-time editor (boundEditor) is therefore owned by y-monaco;
|
|
292
|
+
// editors added later get an equivalent caret writer from us.
|
|
293
|
+
room.boundEditor = room.editor || null;
|
|
294
|
+
if (room.boundEditor) room.editors.add(room.boundEditor);
|
|
295
|
+
room.binding = new window.MonacoBinding(room.text, model, room.editors, room.awareness);
|
|
296
|
+
|
|
297
|
+
if (room.awareness) _initAwareness(room);
|
|
298
|
+
|
|
299
|
+
// Undo scoped to this client's edits only: y-monaco tags model->doc edits with
|
|
300
|
+
// the binding as origin, so undo can never revert a peer's edit.
|
|
301
|
+
room.undoManager = new window.Y.UndoManager(room.text, {
|
|
302
|
+
trackedOrigins: new Set([room.binding])
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// y-monaco owns the bound editor's caret writer but never fires it until the
|
|
306
|
+
// first cursor move — seed the initial caret so peers see us right away.
|
|
307
|
+
if (room.awareness && room.boundEditor) {
|
|
308
|
+
_writeSelection(room, room.boundEditor);
|
|
309
|
+
_wireEditorViewport(room, room.boundEditor);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (room.fallbackTimer) { clearTimeout(room.fallbackTimer); room.fallbackTimer = null; }
|
|
313
|
+
if (room.onSeeded) {
|
|
314
|
+
try { room.onSeeded(); } catch (e) { /* rebaseline is best-effort */ }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Awareness (remote carets, selections, participant identity)
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function _userState() {
|
|
323
|
+
var id = (typeof CollaborationIdentity !== 'undefined')
|
|
324
|
+
? CollaborationIdentity.get()
|
|
325
|
+
: { name: 'Anonymous', color: '#888888', clientId: null };
|
|
326
|
+
// clientId ties this awareness state back to the presence roster entry, so a
|
|
327
|
+
// follower can pick our viewport out of the per-file awareness states.
|
|
328
|
+
return { name: id.name, color: id.color, clientId: id.clientId };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Wire a room's awareness once, at binding construction: publish our identity,
|
|
332
|
+
// relay local awareness changes to peers (throttled), keep identity live, and
|
|
333
|
+
// re-render the per-participant caret styles whenever any state changes.
|
|
334
|
+
function _initAwareness(room) {
|
|
335
|
+
// Throttle ALL local awareness broadcasts at the outbound boundary (vendored
|
|
336
|
+
// lodash) — this covers both our own caret writer and y-monaco's caret writer
|
|
337
|
+
// for the attach-time editor, so a fast-moving cursor never floods the channel.
|
|
338
|
+
// Trailing edge guarantees the final resting position lands.
|
|
339
|
+
var send = function () { _sendLocalAwareness(room); };
|
|
340
|
+
room.broadcastAwareness = (window._ && window._.throttle)
|
|
341
|
+
? window._.throttle(send, 50, { leading: true, trailing: true })
|
|
342
|
+
: send;
|
|
343
|
+
|
|
344
|
+
room.awareness.setLocalStateField('user', _userState());
|
|
345
|
+
|
|
346
|
+
room.identityUnsub = (typeof CollaborationIdentity !== 'undefined')
|
|
347
|
+
? CollaborationIdentity.onChange(function () {
|
|
348
|
+
if (room.awareness) room.awareness.setLocalStateField('user', _userState());
|
|
349
|
+
})
|
|
350
|
+
: null;
|
|
351
|
+
|
|
352
|
+
// Outbound: peer-applied state carries the REMOTE origin and is never echoed.
|
|
353
|
+
room.awareness.on('update', function (_changes, origin) {
|
|
354
|
+
if (origin === REMOTE) return;
|
|
355
|
+
room.broadcastAwareness();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
room.awareness.on('change', _renderCursorStyles);
|
|
359
|
+
// Follow mode: when a tracked peer's viewport (or presence in this room)
|
|
360
|
+
// changes, snap our editor to match.
|
|
361
|
+
room.awareness.on('change', _applyFollow);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Encode our own current awareness state and relay it to peers (one-shot).
|
|
365
|
+
function _sendLocalAwareness(room) {
|
|
366
|
+
if (!room.awareness || !room.subscription) return;
|
|
367
|
+
try {
|
|
368
|
+
var update = window.awarenessProtocol.encodeAwarenessUpdate(
|
|
369
|
+
room.awareness, [room.awareness.clientID]
|
|
370
|
+
);
|
|
371
|
+
room.subscription.perform('awareness', { awareness: _u8ToB64(update) });
|
|
372
|
+
} catch (e) { /* not connected yet — peers reconcile on the next change */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Register an editor with the binding's live set so remote carets render in it.
|
|
376
|
+
// The attach-time editor (boundEditor) already has its caret writer from y-monaco;
|
|
377
|
+
// editors added later (tab switch) get an equivalent writer from us. Idempotent.
|
|
378
|
+
function _attachEditorToBinding(room, editor) {
|
|
379
|
+
if (!room.binding || !editor || room.editors.has(editor)) return;
|
|
380
|
+
room.editors.add(editor);
|
|
381
|
+
|
|
382
|
+
if (room.awareness) {
|
|
383
|
+
if (editor !== room.boundEditor) _wireEditorCursor(room, editor);
|
|
384
|
+
_wireEditorViewport(room, editor);
|
|
385
|
+
// Force y-monaco to paint existing peers' carets into this newly added
|
|
386
|
+
// editor (its decoration pass reads the live set but only runs on change).
|
|
387
|
+
try { room.awareness.emit('change', [{ added: [], updated: [], removed: [] }, 'local']); } catch (e) {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Write an editor's current caret/selection into awareness (Yjs relative
|
|
392
|
+
// positions so peers resolve it against their own copy of the text).
|
|
393
|
+
function _writeSelection(room, editor) {
|
|
394
|
+
if (!room.awareness || editor.getModel() !== room.model) return;
|
|
395
|
+
var sel = editor.getSelection();
|
|
396
|
+
if (!sel) return;
|
|
397
|
+
try {
|
|
398
|
+
var start = room.model.getOffsetAt(sel.getStartPosition());
|
|
399
|
+
var end = room.model.getOffsetAt(sel.getEndPosition());
|
|
400
|
+
room.awareness.setLocalStateField('selection', {
|
|
401
|
+
anchor: window.Y.createRelativePositionFromTypeIndex(room.text, start),
|
|
402
|
+
head: window.Y.createRelativePositionFromTypeIndex(room.text, end)
|
|
403
|
+
});
|
|
404
|
+
} catch (e) { /* position encoding unavailable — caret just won't render */ }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Mirror an editor's caret/selection into awareness on every move. Unthrottled
|
|
408
|
+
// here — the outbound broadcast is what's throttled (see _initAwareness).
|
|
409
|
+
function _wireEditorCursor(room, editor) {
|
|
410
|
+
var disposable = editor.onDidChangeCursorSelection(function () {
|
|
411
|
+
_writeSelection(room, editor);
|
|
412
|
+
});
|
|
413
|
+
room.cursorDisposers.set(editor, function () {
|
|
414
|
+
try { disposable.dispose(); } catch (e) { /* ignore */ }
|
|
415
|
+
});
|
|
416
|
+
_writeSelection(room, editor); // publish the initial caret position immediately
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Follow mode (slice 8): publish our viewport; track a followed peer's viewport
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
// Write an editor's top visible line into awareness. A line number (not a pixel
|
|
424
|
+
// scrollTop) survives differences in font size / zoom / wrapping between peers.
|
|
425
|
+
function _writeViewport(room, editor) {
|
|
426
|
+
if (!room.awareness || editor.getModel() !== room.model) return;
|
|
427
|
+
try {
|
|
428
|
+
var ranges = editor.getVisibleRanges();
|
|
429
|
+
if (!ranges || !ranges.length) return;
|
|
430
|
+
room.awareness.setLocalStateField('viewport', { top: ranges[0].startLineNumber });
|
|
431
|
+
} catch (e) { /* viewport unavailable — a follower just won't track this editor */ }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Mirror an editor's viewport into awareness on every scroll. Unthrottled here —
|
|
435
|
+
// the outbound broadcast is throttled (see _initAwareness), like the caret.
|
|
436
|
+
// Wired for EVERY editor including the bound one: y-monaco owns the bound
|
|
437
|
+
// editor's caret writer but never its viewport.
|
|
438
|
+
function _wireEditorViewport(room, editor) {
|
|
439
|
+
if (typeof editor.onDidScrollChange !== 'function') return;
|
|
440
|
+
var disposable = editor.onDidScrollChange(function () { _writeViewport(room, editor); });
|
|
441
|
+
room.viewportDisposers.set(editor, function () {
|
|
442
|
+
try { disposable.dispose(); } catch (e) { /* ignore */ }
|
|
443
|
+
});
|
|
444
|
+
_writeViewport(room, editor); // publish the initial viewport immediately
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Snap our live editor(s) to the followed peer's viewport. Runs on every
|
|
448
|
+
// awareness change in any room; cheap (small participant counts) and idempotent
|
|
449
|
+
// — re-applying the same scroll position is a no-op. No-op when not following.
|
|
450
|
+
function _applyFollow() {
|
|
451
|
+
if (!_followedClientId) return;
|
|
452
|
+
Object.keys(_rooms).forEach(function (path) {
|
|
453
|
+
var room = _rooms[path];
|
|
454
|
+
if (!room.awareness || room.editors.size === 0) return;
|
|
455
|
+
var mine = room.awareness.clientID;
|
|
456
|
+
room.awareness.getStates().forEach(function (state, cid) {
|
|
457
|
+
if (cid === mine) return;
|
|
458
|
+
var user = state && state.user;
|
|
459
|
+
if (!user || user.clientId !== _followedClientId) return;
|
|
460
|
+
var vp = state.viewport;
|
|
461
|
+
if (!vp || typeof vp.top !== 'number') return;
|
|
462
|
+
room.editors.forEach(function (editor) {
|
|
463
|
+
try { editor.setScrollTop(editor.getTopForLineNumber(vp.top)); }
|
|
464
|
+
catch (e) { /* editor disposed / API missing — skip */ }
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Begin tracking a participant's viewport (by presence client_id). The matching
|
|
471
|
+
// file-open is MbeditorApp's job (from the roster's current_file); here we only
|
|
472
|
+
// snap scroll to their already-known viewport, if any.
|
|
473
|
+
function setFollow(clientId) {
|
|
474
|
+
_followedClientId = clientId || null;
|
|
475
|
+
_applyFollow();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Resume independent navigation.
|
|
479
|
+
function clearFollow() {
|
|
480
|
+
_followedClientId = null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// When a peer we haven't greeted appears, re-broadcast our own state so a late
|
|
484
|
+
// joiner sees our caret without waiting for us to move (awareness is not
|
|
485
|
+
// server-persisted; the channel only relays live deltas).
|
|
486
|
+
function _announceToNewPeers(room) {
|
|
487
|
+
if (!room.awareness) return;
|
|
488
|
+
var mine = room.awareness.clientID;
|
|
489
|
+
var fresh = false;
|
|
490
|
+
room.awareness.getStates().forEach(function (_state, cid) {
|
|
491
|
+
if (cid !== mine && !room.seenClients.has(cid)) {
|
|
492
|
+
room.seenClients.add(cid);
|
|
493
|
+
fresh = true;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
if (fresh) _sendLocalAwareness(room);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Rebuild the per-participant caret/selection CSS from every room's awareness.
|
|
500
|
+
// Client IDs are globally unique, so one shared <style> element covers all open
|
|
501
|
+
// files. Re-runs on any awareness change (cheap; small participant counts).
|
|
502
|
+
var _styleEl = null;
|
|
503
|
+
function _renderCursorStyles() {
|
|
504
|
+
if (typeof document === 'undefined') return;
|
|
505
|
+
if (!_styleEl) {
|
|
506
|
+
_styleEl = document.createElement('style');
|
|
507
|
+
_styleEl.id = 'mbeditor-collab-cursors';
|
|
508
|
+
document.head.appendChild(_styleEl);
|
|
509
|
+
}
|
|
510
|
+
var seen = {};
|
|
511
|
+
var css = '';
|
|
512
|
+
Object.keys(_rooms).forEach(function (path) {
|
|
513
|
+
var room = _rooms[path];
|
|
514
|
+
if (!room.awareness) return;
|
|
515
|
+
var mine = room.awareness.clientID;
|
|
516
|
+
room.awareness.getStates().forEach(function (state, cid) {
|
|
517
|
+
if (cid === mine || seen[cid]) return;
|
|
518
|
+
var user = state && state.user;
|
|
519
|
+
if (!user || !user.color) return;
|
|
520
|
+
seen[cid] = true;
|
|
521
|
+
// Both of these came off the wire from another machine and are about to
|
|
522
|
+
// become stylesheet text, so both are escaped at this boundary.
|
|
523
|
+
var color = CollaborationIdentity.safeColor(user.color);
|
|
524
|
+
var name = _cssString(user.name || 'Anonymous');
|
|
525
|
+
css +=
|
|
526
|
+
'.yRemoteSelection-' + cid + '{background-color:' + _hexToRgba(color, 0.30) + ';}' +
|
|
527
|
+
'.yRemoteSelectionHead-' + cid + '{position:absolute;border-left:' + color +
|
|
528
|
+
' solid 2px;border-bottom:' + color + ' solid 2px;height:100%;box-sizing:border-box;}' +
|
|
529
|
+
'.yRemoteSelectionHead-' + cid + '::after{position:absolute;content:"' + name +
|
|
530
|
+
'";white-space:nowrap;top:-1.4em;left:-2px;font-size:11px;line-height:normal;' +
|
|
531
|
+
'background-color:' + color + ';color:#1e1e2e;padding:0 4px;border-radius:3px;' +
|
|
532
|
+
'font-family:sans-serif;z-index:10;pointer-events:none;}';
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
_styleEl.textContent = css;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function _hexToRgba(hex, alpha) {
|
|
539
|
+
var h = hex.replace('#', '');
|
|
540
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
541
|
+
var n = parseInt(h, 16);
|
|
542
|
+
if (isNaN(n)) return 'rgba(136,136,136,' + alpha + ')';
|
|
543
|
+
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function _cssString(s) {
|
|
547
|
+
// Neutralise quotes/backslashes/newlines so the name can't break out of the
|
|
548
|
+
// CSS string literal in the generated ::after content.
|
|
549
|
+
return String(s).replace(/[\\"\n\r]/g, function (c) {
|
|
550
|
+
return c === '\n' || c === '\r' ? ' ' : '\\' + c;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function _applyUndoKeybindings(room, editor) {
|
|
555
|
+
if (!editor || typeof editor.addCommand !== 'function' || !window.monaco) return;
|
|
556
|
+
var KeyMod = window.monaco.KeyMod;
|
|
557
|
+
var KeyCode = window.monaco.KeyCode;
|
|
558
|
+
var undo = function () { if (room.undoManager) room.undoManager.undo(); };
|
|
559
|
+
var redo = function () { if (room.undoManager) room.undoManager.redo(); };
|
|
560
|
+
// Override native Monaco undo/redo so they route through the Yjs UndoManager.
|
|
561
|
+
// The commands die with the editor on dispose, so no explicit teardown.
|
|
562
|
+
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyZ, undo);
|
|
563
|
+
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyY, redo);
|
|
564
|
+
editor.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, redo);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function _fallbackLocal(room) {
|
|
568
|
+
room.fallbackTimer = null;
|
|
569
|
+
if (room.binding || room.degraded) return;
|
|
570
|
+
room.degraded = true;
|
|
571
|
+
if (room.onSeeded) {
|
|
572
|
+
try { room.onSeeded(); } catch (e) { /* best-effort */ }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// True when the room owns the file's content (a late-join applied shared state):
|
|
577
|
+
// EditorPanel must then NOT apply the disk content at open, which would clobber it.
|
|
578
|
+
// Later external on-disk edits are filtered earlier, at the change-detection source
|
|
579
|
+
// (MbeditorApp.checkOpenTabsForExternalChanges), which skips any collab-bound file.
|
|
580
|
+
function consumesDiskLoad(path) {
|
|
581
|
+
var room = _rooms[path];
|
|
582
|
+
return !!(room && room.binding && room.lateJoin);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Detach the current editor (tab switch / editor dispose). The binding stays
|
|
586
|
+
// bound to the persistent model, so the room and its undo history survive.
|
|
587
|
+
function unbindEditor(path) {
|
|
588
|
+
var room = _rooms[path];
|
|
589
|
+
if (!room) return;
|
|
590
|
+
var editor = room.editor;
|
|
591
|
+
if (editor) {
|
|
592
|
+
// Detach this editor from the binding so a disposed editor isn't iterated
|
|
593
|
+
// and stops broadcasting a stale caret.
|
|
594
|
+
room.editors.delete(editor);
|
|
595
|
+
var dispose = room.cursorDisposers.get(editor);
|
|
596
|
+
if (dispose) { dispose(); room.cursorDisposers.delete(editor); }
|
|
597
|
+
var disposeVp = room.viewportDisposers.get(editor);
|
|
598
|
+
if (disposeVp) { disposeVp(); room.viewportDisposers.delete(editor); }
|
|
599
|
+
}
|
|
600
|
+
// No editor is showing this file in this pane now — drop our caret/viewport so
|
|
601
|
+
// peers don't render a frozen cursor or a follower track a stale scroll. The
|
|
602
|
+
// doc/binding/awareness survive (the file is still open); a re-bind republishes.
|
|
603
|
+
if (room.awareness && room.editors.size === 0) {
|
|
604
|
+
room.awareness.setLocalStateField('selection', null);
|
|
605
|
+
room.awareness.setLocalStateField('viewport', null);
|
|
606
|
+
}
|
|
607
|
+
room.editor = null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Destroy the room entirely. Called when the Monaco model is actually disposed
|
|
611
|
+
// (file no longer open in any pane), via tab_manager.
|
|
612
|
+
function leaveRoom(path) {
|
|
613
|
+
var room = _rooms[path];
|
|
614
|
+
if (!room) return;
|
|
615
|
+
if (room.fallbackTimer) { clearTimeout(room.fallbackTimer); room.fallbackTimer = null; }
|
|
616
|
+
room.cursorDisposers.forEach(function (dispose) { try { dispose(); } catch (e) { /* ignore */ } });
|
|
617
|
+
room.cursorDisposers.clear();
|
|
618
|
+
room.viewportDisposers.forEach(function (dispose) { try { dispose(); } catch (e) { /* ignore */ } });
|
|
619
|
+
room.viewportDisposers.clear();
|
|
620
|
+
if (room.identityUnsub) { try { room.identityUnsub(); } catch (e) { /* ignore */ } }
|
|
621
|
+
if (room.broadcastAwareness && room.broadcastAwareness.cancel) {
|
|
622
|
+
room.broadcastAwareness.cancel();
|
|
623
|
+
}
|
|
624
|
+
if (room.awareness) {
|
|
625
|
+
// Tell peers our caret is gone before tearing down (file closed). Remove our
|
|
626
|
+
// state, then send the resulting (null-state) update directly — the throttled
|
|
627
|
+
// path can't be trusted to flush before destroy().
|
|
628
|
+
try {
|
|
629
|
+
window.awarenessProtocol.removeAwarenessStates(
|
|
630
|
+
room.awareness, [room.awareness.clientID], 'leave'
|
|
631
|
+
);
|
|
632
|
+
_sendLocalAwareness(room);
|
|
633
|
+
} catch (e) { /* ignore */ }
|
|
634
|
+
try { room.awareness.destroy(); } catch (e) { /* ignore */ }
|
|
635
|
+
room.awareness = null;
|
|
636
|
+
}
|
|
637
|
+
try { if (room.undoManager) room.undoManager.destroy(); } catch (e) { /* ignore */ }
|
|
638
|
+
try { if (room.binding) room.binding.destroy(); } catch (e) { /* ignore */ }
|
|
639
|
+
try { if (room.subscription) room.subscription.unsubscribe(); } catch (e) { /* ignore */ }
|
|
640
|
+
try { if (room.doc) room.doc.destroy(); } catch (e) { /* ignore */ }
|
|
641
|
+
delete _rooms[path];
|
|
642
|
+
_renderCursorStyles();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isBound(path) {
|
|
646
|
+
return !!_rooms[path];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// True once the y-monaco binding is live for a path: the room is sharing the
|
|
650
|
+
// model's content with peers. Distinct from isBound (which is true as soon as
|
|
651
|
+
// the room/subscription exists, before the sync handshake attaches a binding).
|
|
652
|
+
function isAttached(path) {
|
|
653
|
+
var room = _rooms[path];
|
|
654
|
+
return !!(room && room.binding);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Push a fresh full snapshot to the server so it can compact the doc store:
|
|
658
|
+
// CollaborationDocStore.replace_snapshot swaps the cached snapshot and clears
|
|
659
|
+
// the buffered deltas. Called after a manual save, when the shared buffer is
|
|
660
|
+
// known to be coherent and matches what just landed on disk. No-op when the
|
|
661
|
+
// file is not collaboratively bound or the wire isn't ready.
|
|
662
|
+
function pushSnapshot(path) {
|
|
663
|
+
var room = _rooms[path];
|
|
664
|
+
if (!room || !room.subscription || !_globalsReady()) return false;
|
|
665
|
+
try {
|
|
666
|
+
var snapshot = window.Y.encodeStateAsUpdate(room.doc);
|
|
667
|
+
room.subscription.perform('snapshot', { snapshot: _u8ToB64(snapshot) });
|
|
668
|
+
return true;
|
|
669
|
+
} catch (e) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
isAvailable: isAvailable,
|
|
676
|
+
setPeerPresent: setPeerPresent,
|
|
677
|
+
onAvailabilityChange: onAvailabilityChange,
|
|
678
|
+
isEnabledFor: isEnabledFor,
|
|
679
|
+
ensureRoom: ensureRoom,
|
|
680
|
+
bindEditor: bindEditor,
|
|
681
|
+
unbindEditor: unbindEditor,
|
|
682
|
+
leaveRoom: leaveRoom,
|
|
683
|
+
consumesDiskLoad: consumesDiskLoad,
|
|
684
|
+
isBound: isBound,
|
|
685
|
+
isAttached: isAttached,
|
|
686
|
+
pushSnapshot: pushSnapshot,
|
|
687
|
+
setFollow: setFollow,
|
|
688
|
+
clearFollow: clearFollow
|
|
689
|
+
};
|
|
690
|
+
})();
|