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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +465 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. 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
+ })();