mbeditor 0.13.0 → 0.14.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -72,6 +72,12 @@ var CollaborationService = (function () {
72
72
  // ever on the other end of that CRDT.
73
73
  var _peerPresent = false;
74
74
  var _availabilityListeners = [];
75
+ var _published = false;
76
+ var _dropTimer = null;
77
+ // Long enough to cover a peer reloading their tab, and no longer. Presence is
78
+ // broadcast on a 5s roster, so anything under that expires before the roster
79
+ // could have reported the peer back and the grace buys nothing.
80
+ var PEER_DROP_GRACE = 10000;
75
81
 
76
82
  function _globalsReady() {
77
83
  return typeof window.Y !== 'undefined' &&
@@ -107,19 +113,41 @@ var CollaborationService = (function () {
107
113
  refreshAvailability();
108
114
  }
109
115
 
110
- function refreshAvailability() {
111
- var available = isAvailable();
116
+ function _publishAvailability(available) {
112
117
  // No longer collaborating: close every room so the editor returns to solo
113
118
  // behaviour — persistent undo and external-change detection both key off
114
119
  // there being no live binding.
115
120
  if (!available && Object.keys(_rooms).length) {
116
121
  Object.keys(_rooms).forEach(function (path) { leaveRoom(path); });
117
122
  }
123
+ _published = available;
118
124
  _availabilityListeners.slice().forEach(function (fn) {
119
125
  try { fn(available); } catch (e) { /* a bad listener must not stop the rest */ }
120
126
  });
121
127
  }
122
128
 
129
+ function refreshAvailability() {
130
+ var available = isAvailable();
131
+ if (available && _dropTimer) { clearTimeout(_dropTimer); _dropTimer = null; }
132
+ // A peer normally "leaves" because their tab reloaded or the cable blinked.
133
+ // Acting on that immediately tears every room down AND rebuilds every open
134
+ // editor — EditorPanel's mount effect depends on this value — losing the
135
+ // cursor, selection and scroll position. Hold the previous value until the
136
+ // drop has lasted PEER_DROP_GRACE. `_published` only decides whether we are on
137
+ // a falling edge; it never suppresses a publish, so the current value still
138
+ // goes out on every roster message.
139
+ if (!available && _published) {
140
+ if (!_dropTimer) {
141
+ _dropTimer = setTimeout(function () {
142
+ _dropTimer = null;
143
+ if (!isAvailable()) _publishAvailability(false);
144
+ }, PEER_DROP_GRACE);
145
+ }
146
+ available = true;
147
+ }
148
+ _publishAvailability(available);
149
+ }
150
+
123
151
  // Every condition collaboration depends on, reported separately.
124
152
  //
125
153
  // isAvailable() collapses five things into one boolean, which is useless when
@@ -127,11 +155,16 @@ var CollaborationService = (function () {
127
155
  // the server never advertised, a rejected handshake, or simply nobody else
128
156
  // being here. Someone debugging this on another machine cannot paste a console
129
157
  // snippet back, so the editor has to be able to say which.
158
+ // True when any open file's room fell back to local editing. Surfaced in the
159
+ // diagnostics panel so a silent degrade to single-user mode is visible (#99).
160
+ function _anyDegradedRoom() {
161
+ return Object.keys(_rooms).some(function (path) { return _rooms[path].degraded; });
162
+ }
163
+
130
164
  function diagnostics() {
131
165
  var cableStatus = (typeof WebSocketService !== 'undefined' &&
132
166
  typeof WebSocketService.cableStatus === 'function')
133
167
  ? WebSocketService.cableStatus() : 'unknown';
134
-
135
168
  var checks = [
136
169
  {
137
170
  key: 'libraries',
@@ -167,8 +200,18 @@ var CollaborationService = (function () {
167
200
  key: 'peers',
168
201
  label: 'Another participant connected',
169
202
  ok: _peerPresent,
170
- detail: 'Presence is held per web process. If two people are on different Puma workers ' +
171
- 'they never see each other — run a single worker (WEB_CONCURRENCY=0) while pairing.'
203
+ detail: 'Presence is held per web process. With the default async cable adapter two people on ' +
204
+ 'different Puma workers never see each other; with a cross-process adapter ' +
205
+ '(redis/postgres/solid_cable) they do, but each worker keeps its own document store, so ' +
206
+ 'run a single worker (WEB_CONCURRENCY=0) while pairing.'
207
+ },
208
+ {
209
+ key: 'degraded',
210
+ label: 'Every collaborative file is live',
211
+ ok: !_anyDegradedRoom(),
212
+ detail: 'A file fell back to local editing because the collaboration handshake did not arrive ' +
213
+ 'in time (slow ruby-lsp boot, a busy git wave, a saturated Puma thread pool, or a cable ' +
214
+ 'reconnect). It stays local for the life of the tab, so edits made now will not reach a peer.'
172
215
  }
173
216
  ];
174
217
 
@@ -204,6 +247,18 @@ var CollaborationService = (function () {
204
247
  // Room lifecycle
205
248
  // ---------------------------------------------------------------------------
206
249
 
250
+ // ms is the room's age — the only per-room duration this file has, and what
251
+ // makes the join/seed/attach/defer ordering legible in the trace.
252
+ function _recCollab(room, phase) {
253
+ var audit = window.MbeditorAudit;
254
+ if (!audit) return;
255
+ var peers = 0;
256
+ try {
257
+ if (room.awareness) peers = Math.max(0, room.awareness.getStates().size - 1);
258
+ } catch (e) { /* awareness torn down mid-teardown */ }
259
+ audit.rec(audit.EV.COLLAB, audit.code('collabPhase', phase), peers, Date.now() - room.auditStartedAt);
260
+ }
261
+
207
262
  // Open the Yjs document + channel subscription for a file and start the sync
208
263
  // handshake. Idempotent. Returns true when a live room exists (so the caller
209
264
  // can gate its own behavior), false when collaboration could not activate.
@@ -217,6 +272,7 @@ var CollaborationService = (function () {
217
272
  var doc = new window.Y.Doc();
218
273
  var room = {
219
274
  doc: doc,
275
+ path: path,
220
276
  text: doc.getText('monaco'),
221
277
  subscription: null,
222
278
  synced: false,
@@ -225,10 +281,28 @@ var CollaborationService = (function () {
225
281
  binding: null,
226
282
  undoManager: null,
227
283
  lateJoin: false,
284
+ // Set from the sync handshake: the server grants the right to seed an empty
285
+ // room's shared doc from disk to exactly one client (see claim_seed).
286
+ seedGranted: false,
228
287
  onSeeded: null,
229
288
  attachRequested: false,
230
289
  degraded: false,
231
290
  fallbackTimer: null,
291
+ // Highest server-assigned delta sequence this client has applied. Sent back
292
+ // with each snapshot so the server keeps only the deltas the snapshot does
293
+ // not already contain (#97).
294
+ lastServerSeq: 0,
295
+ // A peer snapshot has been requested and not yet answered. Guards against
296
+ // re-asking on every doc_update while the replay is still incomplete.
297
+ snapshotRequested: false,
298
+ // Counters for the periodic snapshot while dirty, so the server's delta
299
+ // buffer cannot overflow between saves (#97).
300
+ updatesSinceSnapshot: 0,
301
+ lastSnapshotAt: 0,
302
+ // True between rejoinRooms() and the first sync of the new subscription: a
303
+ // room that existed before the drop must never seed again (#96).
304
+ rejoining: false,
305
+ auditStartedAt: Date.now(),
232
306
  // Awareness (slice 5): editors is the LIVE set y-monaco renders remote
233
307
  // carets into; cursorDisposers holds our per-editor cursor->awareness
234
308
  // writers; seenClients tracks peers we've announced ourselves to.
@@ -236,6 +310,11 @@ var CollaborationService = (function () {
236
310
  // follower can track where we've scrolled.
237
311
  awareness: null,
238
312
  editors: new Set(),
313
+ // Editors that bound before the room finished its handshake, so there was
314
+ // no binding to register them with yet. _doAttach folds them in. Without
315
+ // this, the same file open in two panes loses the pane that bound first:
316
+ // it is never added to the live set and never broadcasts its caret (#98).
317
+ pendingEditors: new Set(),
239
318
  boundEditor: null,
240
319
  broadcastAwareness: null,
241
320
  cursorDisposers: new Map(),
@@ -252,6 +331,7 @@ var CollaborationService = (function () {
252
331
  try {
253
332
  room.subscription.perform('doc_update', { update: _u8ToB64(update) });
254
333
  } catch (e) { /* not connected yet / cable down — peers reconcile on next op */ }
334
+ _maybePeriodicSnapshot(room);
255
335
  });
256
336
 
257
337
  room.subscription = WebSocketService.subscribeCollaboration(path, {
@@ -264,6 +344,7 @@ var CollaborationService = (function () {
264
344
  }
265
345
 
266
346
  _rooms[path] = room;
347
+ _recCollab(room, 'join');
267
348
  return true;
268
349
  }
269
350
 
@@ -276,14 +357,56 @@ var CollaborationService = (function () {
276
357
  window.Y.applyUpdate(room.doc, _b64ToU8(data.snapshot), REMOTE);
277
358
  }
278
359
  if (data.deltas && data.deltas.length) {
279
- data.deltas.forEach(function (d) {
280
- if (d) window.Y.applyUpdate(room.doc, _b64ToU8(d), REMOTE);
360
+ data.deltas.forEach(function (d, i) {
361
+ if (!d) return;
362
+ window.Y.applyUpdate(room.doc, _b64ToU8(d), REMOTE);
363
+ var seq = data.delta_seqs && data.delta_seqs[i];
364
+ if (typeof seq === 'number' && seq > room.lastServerSeq) room.lastServerSeq = seq;
281
365
  });
282
366
  }
367
+ if (typeof data.snapshot_seq === 'number' && data.snapshot_seq > room.lastServerSeq) {
368
+ room.lastServerSeq = data.snapshot_seq;
369
+ }
370
+ // A seed is only ever granted on the room's first handshake. On a reconnect
371
+ // the room already existed (rejoining), and taking the grant would seed a
372
+ // second copy of the file into whatever a peer still holds (#96). A
373
+ // degraded room stays local for the life of the tab (#99).
374
+ var firstSync = !room.synced;
375
+ room.seedGranted = !!data.seed && firstSync && !room.rejoining && !room.degraded;
376
+ room.rejoining = false;
283
377
  room.synced = true;
284
378
  _maybeAttach(room);
285
379
  } else if (data.type === 'doc_update') {
286
380
  if (data.update) window.Y.applyUpdate(room.doc, _b64ToU8(data.update), REMOTE);
381
+ if (typeof data.seq === 'number' && data.seq > room.lastServerSeq) {
382
+ room.lastServerSeq = data.seq;
383
+ }
384
+ // One sample a second: a peer typing produces an update per keystroke,
385
+ // and the trace wants the shape of the traffic, not every packet.
386
+ var updatedAt = Date.now();
387
+ if (updatedAt - (room.auditLastUpdate || 0) >= 1000) {
388
+ room.auditLastUpdate = updatedAt;
389
+ _recCollab(room, 'update');
390
+ }
391
+ // A client that deferred attaching (empty room, seed granted to someone
392
+ // else) is waiting for exactly this: the seeder's content has landed.
393
+ _maybeAttach(room);
394
+ } else if (data.type === 'snapshot') {
395
+ // A full state update relayed by a peer (answer to request_snapshot, or a
396
+ // periodic push). Y.applyUpdate is idempotent, so overlap with what we
397
+ // already hold is harmless. This is how a client whose delta replay was
398
+ // incomplete — or that rejoined after a restart — catches up without
399
+ // waiting for the peer's next keystroke (#96, #97).
400
+ if (data.snapshot) window.Y.applyUpdate(room.doc, _b64ToU8(data.snapshot), REMOTE);
401
+ if (typeof data.applied_seq === 'number' && data.applied_seq > room.lastServerSeq) {
402
+ room.lastServerSeq = data.applied_seq;
403
+ }
404
+ room.snapshotRequested = false;
405
+ _maybeAttach(room);
406
+ } else if (data.type === 'request_snapshot') {
407
+ // A peer's replay was incomplete. Answer with our full state if we hold
408
+ // one, so it can attach instead of diverging on stale text.
409
+ if (room.binding && room.text.length > 0) pushSnapshot(room.path);
287
410
  } else if (data.type === 'awareness') {
288
411
  if (data.awareness && room.awareness && window.awarenessProtocol) {
289
412
  // REMOTE origin keeps the outbound handler from echoing peer state back.
@@ -295,6 +418,47 @@ var CollaborationService = (function () {
295
418
  }
296
419
  }
297
420
 
421
+ // True when the applied replay left structs pending on missing dependencies:
422
+ // the delta buffer overflowed (MAX_DELTAS) or a snapshot skipped a concurrent
423
+ // update, so the document is incomplete. Yjs parks such structs in the store
424
+ // until the missing update arrives; attaching now would show stale text and
425
+ // then overwrite the peer's work on the next save (#97).
426
+ function _replayIncomplete(room) {
427
+ try {
428
+ var store = room.doc && room.doc.store;
429
+ return !!(store && (store.pendingStructs || store.pendingDs));
430
+ } catch (e) {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ // Ask the room for a full snapshot. Any bound peer answers via #snapshot; the
436
+ // fallback timer degrades to local if nobody does.
437
+ function _requestSnapshot(room) {
438
+ if (room.snapshotRequested || !room.subscription) return;
439
+ room.snapshotRequested = true;
440
+ try { room.subscription.perform('request_snapshot', {}); } catch (e) { /* cable down */ }
441
+ if (!room.auditDeferred) { room.auditDeferred = true; _recCollab(room, 'defer'); }
442
+ }
443
+
444
+ // Push a full snapshot every so often while attached, so a long typing session
445
+ // cannot overflow the server's delta buffer between saves. Count-based first
446
+ // (keystrokes), time-based as a floor for slow editors.
447
+ var SNAPSHOT_EVERY_UPDATES = 200;
448
+ var SNAPSHOT_EVERY_MS = 30000;
449
+ function _maybePeriodicSnapshot(room) {
450
+ if (!room.binding || !room.subscription) return;
451
+ var now = Date.now();
452
+ room.updatesSinceSnapshot += 1;
453
+ var byCount = room.updatesSinceSnapshot >= SNAPSHOT_EVERY_UPDATES;
454
+ var byTime = room.lastSnapshotAt > 0 && (now - room.lastSnapshotAt) >= SNAPSHOT_EVERY_MS;
455
+ if (!byCount && !byTime) return;
456
+ if (pushSnapshot(room.path)) {
457
+ room.updatesSinceSnapshot = 0;
458
+ room.lastSnapshotAt = now;
459
+ }
460
+ }
461
+
298
462
  // Re-open every room's channel subscription on the current consumer.
299
463
  //
300
464
  // A cable drop tears the consumer down and takes every CollaborationChannel
@@ -308,11 +472,28 @@ var CollaborationService = (function () {
308
472
  function rejoinRooms() {
309
473
  Object.keys(_rooms).forEach(function (path) {
310
474
  var room = _rooms[path];
475
+ // A degraded room is local for the life of the tab; re-subscribing it would
476
+ // let it seed the restarted server from its own model (#99).
477
+ if (room.degraded) return;
311
478
  try { if (room.subscription) room.subscription.unsubscribe(); } catch (e) { /* already dead */ }
479
+ room.rejoining = true;
480
+ room.snapshotRequested = false;
312
481
  room.subscription = WebSocketService.subscribeCollaboration(path, {
313
482
  // Awareness is never server-persisted, so peers lost our caret with the
314
483
  // connection and would not see it again until we happened to move.
315
- connected: function () { _sendLocalAwareness(room); },
484
+ // The server may have lost the room entirely (restart): re-publish our
485
+ // content so the next opener late-joins instead of being granted a seed
486
+ // that would merge a second copy into what we still hold.
487
+ //
488
+ // Only a room that was actually bound may push: it holds converged
489
+ // content. A deferred room must not seed the restarted server from its
490
+ // disk copy — a peer re-pushing a different version would merge into two
491
+ // concatenated copies. It waits for the peer's snapshot instead.
492
+ connected: function () {
493
+ _sendLocalAwareness(room);
494
+ if (room.binding) pushSnapshot(path);
495
+ _recCollab(room, 'reconnect');
496
+ },
316
497
  received: function (data) { _onMessage(room, data); }
317
498
  });
318
499
  });
@@ -333,10 +514,12 @@ var CollaborationService = (function () {
333
514
 
334
515
  // The editor is recreated on every tab switch while the model (and binding)
335
516
  // persist. If the binding already exists, register this fresh editor so its
336
- // caret broadcasts and remote carets render in it.
517
+ // caret broadcasts and remote carets render in it. Otherwise remember it for
518
+ // _doAttach, which folds every pre-handshake editor into the live set.
337
519
  if (room.binding) _attachEditorToBinding(room, editor);
520
+ else room.pendingEditors.add(editor);
338
521
 
339
- if (!room.fallbackTimer && !room.binding) {
522
+ if (!room.fallbackTimer && !room.binding && !room.degraded) {
340
523
  // Safety net: if the sync handshake never lands (e.g. channel rejected),
341
524
  // stop waiting and let the natively-loaded content stand.
342
525
  room.fallbackTimer = setTimeout(function () { _fallbackLocal(room); }, 6000);
@@ -346,18 +529,48 @@ var CollaborationService = (function () {
346
529
 
347
530
  function _maybeAttach(room) {
348
531
  if (room.binding || !room.synced || !room.attachRequested || !room.model) return;
532
+ // Once degraded to local, stay local for the life of the tab. Attaching on a
533
+ // late sync would have the binding replace every edit made in the degraded
534
+ // window with the server's text, with no dirty marker and no undo (#99).
535
+ if (room.degraded) return;
536
+ // An incomplete replay means attaching would show stale text and overwrite
537
+ // the peer's work on save; ask for a snapshot and wait (#97).
538
+ if (_replayIncomplete(room)) {
539
+ _requestSnapshot(room);
540
+ return;
541
+ }
542
+ // Nobody may attach to an empty shared doc except the seed grantee, and only
543
+ // once its own disk content has arrived. Binding to an empty Y.Text wipes the
544
+ // buffer, and a client that attaches empty reports consumesDiskLoad() false —
545
+ // so when its content lands EditorPanel pushes the whole file through the live
546
+ // binding at offset 0, and two clients doing that concatenate the file into
547
+ // itself. Wait for the first doc_update, contentReady(), or the fallback timer.
548
+ if (room.text.length === 0 && !(room.seedGranted && room.model.getValue().length > 0)) {
549
+ if (!room.auditDeferred) { room.auditDeferred = true; _recCollab(room, 'defer'); }
550
+ return;
551
+ }
349
552
  _doAttach(room);
350
553
  }
351
554
 
555
+ // The disk content for a path has landed in the model. A room that deferred
556
+ // above with an empty model can now seed the shared doc from it.
557
+ function contentReady(path) {
558
+ var room = _rooms[path];
559
+ if (room) _maybeAttach(room);
560
+ }
561
+
352
562
  function _doAttach(room) {
353
563
  var model = room.model;
354
564
  room.lateJoin = room.text.length > 0; // server already had shared state
355
565
 
356
- if (!room.lateJoin && model.getValue().length > 0) {
566
+ if (!room.lateJoin && room.seedGranted && model.getValue().length > 0) {
357
567
  // First opener whose disk content is already in the model: seed the shared
358
568
  // doc from it before constructing the binding, otherwise the binding would
359
569
  // overwrite the model to match the (empty) Y.Text and wipe the content.
570
+ // Only ever the client the SERVER picked — two clients each seeding an
571
+ // empty room merge into two concatenated copies of the file.
360
572
  room.doc.transact(function () { room.text.insert(0, model.getValue()); });
573
+ _recCollab(room, 'seed');
361
574
  }
362
575
 
363
576
  // Awareness carries each participant's caret/selection + identity. Optional:
@@ -376,9 +589,24 @@ var CollaborationService = (function () {
376
589
  // editors added later get an equivalent caret writer from us.
377
590
  room.boundEditor = room.editor || null;
378
591
  if (room.boundEditor) room.editors.add(room.boundEditor);
592
+ // The binding's constructor calls model.setValue() whenever the model differs
593
+ // from the converged text, which resets cursor, selection and scroll — the
594
+ // "it jumped to the other person's position" symptom. Put the viewport back.
595
+ var preValue = model.getValue();
596
+ var preView = room.boundEditor ? room.boundEditor.saveViewState() : null;
379
597
  room.binding = new window.MonacoBinding(room.text, model, room.editors, room.awareness);
598
+ if (preView && model.getValue() !== preValue) room.boundEditor.restoreViewState(preView);
380
599
 
381
600
  if (room.awareness) _initAwareness(room);
601
+ _recCollab(room, 'attach');
602
+
603
+ // Fold in every editor that bound during the handshake. boundEditor was
604
+ // added before construction (y-monaco owns its caret writer); the rest get
605
+ // an equivalent writer from _attachEditorToBinding.
606
+ room.pendingEditors.forEach(function (ed) {
607
+ if (ed !== room.boundEditor) _attachEditorToBinding(room, ed);
608
+ });
609
+ room.pendingEditors.clear();
382
610
 
383
611
  // Undo scoped to this client's edits only: y-monaco tags model->doc edits with
384
612
  // the binding as origin, so undo can never revert a peer's edit.
@@ -659,6 +887,7 @@ var CollaborationService = (function () {
659
887
  room.fallbackTimer = null;
660
888
  if (room.binding || room.degraded) return;
661
889
  room.degraded = true;
890
+ _recCollab(room, 'degrade');
662
891
  if (room.onSeeded) {
663
892
  try { room.onSeeded(); } catch (e) { /* best-effort */ }
664
893
  }
@@ -673,15 +902,23 @@ var CollaborationService = (function () {
673
902
  return !!(room && room.binding && room.lateJoin);
674
903
  }
675
904
 
676
- // Detach the current editor (tab switch / editor dispose). The binding stays
677
- // bound to the persistent model, so the room and its undo history survive.
678
- function unbindEditor(path) {
905
+ // Detach one editor (tab switch / editor dispose). The binding stays bound to
906
+ // the persistent model, so the room and its undo history survive.
907
+ //
908
+ // The editor is passed in, not read from room.editor: room.editor is only ever
909
+ // "the most recent editor bound" (used by _doAttach to pick boundEditor), and
910
+ // with the same file open in both panes that is the *other* pane's editor.
911
+ // Detaching that one left the disposed editor in room.editors, where y-monaco
912
+ // iterates it on every awareness change and throws. Callers always have the
913
+ // editor in scope; room.editor remains the fallback for legacy callers.
914
+ function unbindEditor(path, editor) {
679
915
  var room = _rooms[path];
680
916
  if (!room) return;
681
- var editor = room.editor;
917
+ editor = editor || room.editor;
682
918
  if (editor) {
683
919
  // Detach this editor from the binding so a disposed editor isn't iterated
684
920
  // and stops broadcasting a stale caret.
921
+ room.pendingEditors.delete(editor);
685
922
  room.editors.delete(editor);
686
923
  var dispose = room.cursorDisposers.get(editor);
687
924
  if (dispose) { dispose(); room.cursorDisposers.delete(editor); }
@@ -695,7 +932,7 @@ var CollaborationService = (function () {
695
932
  room.awareness.setLocalStateField('selection', null);
696
933
  room.awareness.setLocalStateField('viewport', null);
697
934
  }
698
- room.editor = null;
935
+ if (room.editor === editor) room.editor = null;
699
936
  }
700
937
 
701
938
  // Destroy the room entirely. Called when the Monaco model is actually disposed
@@ -708,10 +945,12 @@ var CollaborationService = (function () {
708
945
  room.cursorDisposers.clear();
709
946
  room.viewportDisposers.forEach(function (dispose) { try { dispose(); } catch (e) { /* ignore */ } });
710
947
  room.viewportDisposers.clear();
948
+ room.pendingEditors.clear();
711
949
  if (room.identityUnsub) { try { room.identityUnsub(); } catch (e) { /* ignore */ } }
712
950
  if (room.broadcastAwareness && room.broadcastAwareness.cancel) {
713
951
  room.broadcastAwareness.cancel();
714
952
  }
953
+ _recCollab(room, 'leave');
715
954
  if (room.awareness) {
716
955
  // Tell peers our caret is gone before tearing down (file closed). Remove our
717
956
  // state, then send the resulting (null-state) update directly — the throttled
@@ -746,16 +985,18 @@ var CollaborationService = (function () {
746
985
  }
747
986
 
748
987
  // Push a fresh full snapshot to the server so it can compact the doc store:
749
- // CollaborationDocStore.replace_snapshot swaps the cached snapshot and clears
750
- // the buffered deltas. Called after a manual save, when the shared buffer is
751
- // known to be coherent and matches what just landed on disk. No-op when the
752
- // file is not collaboratively bound or the wire isn't ready.
988
+ // CollaborationDocStore.replace_snapshot swaps the cached snapshot and keeps
989
+ // only the deltas recorded after `applied_seq` (the newest sequence this client
990
+ // has applied). Called after a manual save and periodically while typing.
753
991
  function pushSnapshot(path) {
754
992
  var room = _rooms[path];
755
- if (!room || !room.subscription || !_globalsReady()) return false;
993
+ if (!room || !room.subscription) return false;
756
994
  try {
757
995
  var snapshot = window.Y.encodeStateAsUpdate(room.doc);
758
- room.subscription.perform('snapshot', { snapshot: _u8ToB64(snapshot) });
996
+ room.subscription.perform('snapshot', {
997
+ snapshot: _u8ToB64(snapshot),
998
+ applied_seq: room.lastServerSeq || 0
999
+ });
759
1000
  return true;
760
1001
  } catch (e) {
761
1002
  return false;
@@ -774,6 +1015,7 @@ var CollaborationService = (function () {
774
1015
  unbindEditor: unbindEditor,
775
1016
  leaveRoom: leaveRoom,
776
1017
  consumesDiskLoad: consumesDiskLoad,
1018
+ contentReady: contentReady,
777
1019
  isBound: isBound,
778
1020
  isAttached: isAttached,
779
1021
  pushSnapshot: pushSnapshot,