mbeditor 0.13.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  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 +429 -247
  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/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. metadata +12 -2
@@ -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,
@@ -232,6 +288,21 @@ var CollaborationService = (function () {
232
288
  attachRequested: false,
233
289
  degraded: false,
234
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(),
235
306
  // Awareness (slice 5): editors is the LIVE set y-monaco renders remote
236
307
  // carets into; cursorDisposers holds our per-editor cursor->awareness
237
308
  // writers; seenClients tracks peers we've announced ourselves to.
@@ -239,6 +310,11 @@ var CollaborationService = (function () {
239
310
  // follower can track where we've scrolled.
240
311
  awareness: null,
241
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(),
242
318
  boundEditor: null,
243
319
  broadcastAwareness: null,
244
320
  cursorDisposers: new Map(),
@@ -255,6 +331,7 @@ var CollaborationService = (function () {
255
331
  try {
256
332
  room.subscription.perform('doc_update', { update: _u8ToB64(update) });
257
333
  } catch (e) { /* not connected yet / cable down — peers reconcile on next op */ }
334
+ _maybePeriodicSnapshot(room);
258
335
  });
259
336
 
260
337
  room.subscription = WebSocketService.subscribeCollaboration(path, {
@@ -267,6 +344,7 @@ var CollaborationService = (function () {
267
344
  }
268
345
 
269
346
  _rooms[path] = room;
347
+ _recCollab(room, 'join');
270
348
  return true;
271
349
  }
272
350
 
@@ -279,18 +357,56 @@ var CollaborationService = (function () {
279
357
  window.Y.applyUpdate(room.doc, _b64ToU8(data.snapshot), REMOTE);
280
358
  }
281
359
  if (data.deltas && data.deltas.length) {
282
- data.deltas.forEach(function (d) {
283
- 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;
284
365
  });
285
366
  }
286
- room.seedGranted = !!data.seed;
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;
287
377
  room.synced = true;
288
378
  _maybeAttach(room);
289
379
  } else if (data.type === 'doc_update') {
290
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
+ }
291
391
  // A client that deferred attaching (empty room, seed granted to someone
292
392
  // else) is waiting for exactly this: the seeder's content has landed.
293
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);
294
410
  } else if (data.type === 'awareness') {
295
411
  if (data.awareness && room.awareness && window.awarenessProtocol) {
296
412
  // REMOTE origin keeps the outbound handler from echoing peer state back.
@@ -302,6 +418,47 @@ var CollaborationService = (function () {
302
418
  }
303
419
  }
304
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
+
305
462
  // Re-open every room's channel subscription on the current consumer.
306
463
  //
307
464
  // A cable drop tears the consumer down and takes every CollaborationChannel
@@ -315,16 +472,27 @@ var CollaborationService = (function () {
315
472
  function rejoinRooms() {
316
473
  Object.keys(_rooms).forEach(function (path) {
317
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;
318
478
  try { if (room.subscription) room.subscription.unsubscribe(); } catch (e) { /* already dead */ }
479
+ room.rejoining = true;
480
+ room.snapshotRequested = false;
319
481
  room.subscription = WebSocketService.subscribeCollaboration(path, {
320
482
  // Awareness is never server-persisted, so peers lost our caret with the
321
483
  // connection and would not see it again until we happened to move.
322
484
  // The server may have lost the room entirely (restart): re-publish our
323
485
  // content so the next opener late-joins instead of being granted a seed
324
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.
325
492
  connected: function () {
326
493
  _sendLocalAwareness(room);
327
494
  if (room.binding) pushSnapshot(path);
495
+ _recCollab(room, 'reconnect');
328
496
  },
329
497
  received: function (data) { _onMessage(room, data); }
330
498
  });
@@ -346,10 +514,12 @@ var CollaborationService = (function () {
346
514
 
347
515
  // The editor is recreated on every tab switch while the model (and binding)
348
516
  // persist. If the binding already exists, register this fresh editor so its
349
- // 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.
350
519
  if (room.binding) _attachEditorToBinding(room, editor);
520
+ else room.pendingEditors.add(editor);
351
521
 
352
- if (!room.fallbackTimer && !room.binding) {
522
+ if (!room.fallbackTimer && !room.binding && !room.degraded) {
353
523
  // Safety net: if the sync handshake never lands (e.g. channel rejected),
354
524
  // stop waiting and let the natively-loaded content stand.
355
525
  room.fallbackTimer = setTimeout(function () { _fallbackLocal(room); }, 6000);
@@ -359,14 +529,36 @@ var CollaborationService = (function () {
359
529
 
360
530
  function _maybeAttach(room) {
361
531
  if (room.binding || !room.synced || !room.attachRequested || !room.model) return;
362
- // Shared doc still empty and the seed was granted to another client: its
363
- // content is on its way. Binding now would hand the model to an empty Y.Text
364
- // and wipe the buffer, so wait for the first doc_update (or the fallback
365
- // timer, which lets the natively-loaded content stand).
366
- if (room.text.length === 0 && !room.seedGranted && room.model.getValue().length > 0) 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
+ }
367
552
  _doAttach(room);
368
553
  }
369
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
+
370
562
  function _doAttach(room) {
371
563
  var model = room.model;
372
564
  room.lateJoin = room.text.length > 0; // server already had shared state
@@ -378,6 +570,7 @@ var CollaborationService = (function () {
378
570
  // Only ever the client the SERVER picked — two clients each seeding an
379
571
  // empty room merge into two concatenated copies of the file.
380
572
  room.doc.transact(function () { room.text.insert(0, model.getValue()); });
573
+ _recCollab(room, 'seed');
381
574
  }
382
575
 
383
576
  // Awareness carries each participant's caret/selection + identity. Optional:
@@ -396,9 +589,24 @@ var CollaborationService = (function () {
396
589
  // editors added later get an equivalent caret writer from us.
397
590
  room.boundEditor = room.editor || null;
398
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;
399
597
  room.binding = new window.MonacoBinding(room.text, model, room.editors, room.awareness);
598
+ if (preView && model.getValue() !== preValue) room.boundEditor.restoreViewState(preView);
400
599
 
401
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();
402
610
 
403
611
  // Undo scoped to this client's edits only: y-monaco tags model->doc edits with
404
612
  // the binding as origin, so undo can never revert a peer's edit.
@@ -679,6 +887,7 @@ var CollaborationService = (function () {
679
887
  room.fallbackTimer = null;
680
888
  if (room.binding || room.degraded) return;
681
889
  room.degraded = true;
890
+ _recCollab(room, 'degrade');
682
891
  if (room.onSeeded) {
683
892
  try { room.onSeeded(); } catch (e) { /* best-effort */ }
684
893
  }
@@ -693,15 +902,23 @@ var CollaborationService = (function () {
693
902
  return !!(room && room.binding && room.lateJoin);
694
903
  }
695
904
 
696
- // Detach the current editor (tab switch / editor dispose). The binding stays
697
- // bound to the persistent model, so the room and its undo history survive.
698
- 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) {
699
915
  var room = _rooms[path];
700
916
  if (!room) return;
701
- var editor = room.editor;
917
+ editor = editor || room.editor;
702
918
  if (editor) {
703
919
  // Detach this editor from the binding so a disposed editor isn't iterated
704
920
  // and stops broadcasting a stale caret.
921
+ room.pendingEditors.delete(editor);
705
922
  room.editors.delete(editor);
706
923
  var dispose = room.cursorDisposers.get(editor);
707
924
  if (dispose) { dispose(); room.cursorDisposers.delete(editor); }
@@ -715,7 +932,7 @@ var CollaborationService = (function () {
715
932
  room.awareness.setLocalStateField('selection', null);
716
933
  room.awareness.setLocalStateField('viewport', null);
717
934
  }
718
- room.editor = null;
935
+ if (room.editor === editor) room.editor = null;
719
936
  }
720
937
 
721
938
  // Destroy the room entirely. Called when the Monaco model is actually disposed
@@ -728,10 +945,12 @@ var CollaborationService = (function () {
728
945
  room.cursorDisposers.clear();
729
946
  room.viewportDisposers.forEach(function (dispose) { try { dispose(); } catch (e) { /* ignore */ } });
730
947
  room.viewportDisposers.clear();
948
+ room.pendingEditors.clear();
731
949
  if (room.identityUnsub) { try { room.identityUnsub(); } catch (e) { /* ignore */ } }
732
950
  if (room.broadcastAwareness && room.broadcastAwareness.cancel) {
733
951
  room.broadcastAwareness.cancel();
734
952
  }
953
+ _recCollab(room, 'leave');
735
954
  if (room.awareness) {
736
955
  // Tell peers our caret is gone before tearing down (file closed). Remove our
737
956
  // state, then send the resulting (null-state) update directly — the throttled
@@ -766,16 +985,18 @@ var CollaborationService = (function () {
766
985
  }
767
986
 
768
987
  // Push a fresh full snapshot to the server so it can compact the doc store:
769
- // CollaborationDocStore.replace_snapshot swaps the cached snapshot and clears
770
- // the buffered deltas. Called after a manual save, when the shared buffer is
771
- // known to be coherent and matches what just landed on disk. No-op when the
772
- // 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.
773
991
  function pushSnapshot(path) {
774
992
  var room = _rooms[path];
775
- if (!room || !room.subscription || !_globalsReady()) return false;
993
+ if (!room || !room.subscription) return false;
776
994
  try {
777
995
  var snapshot = window.Y.encodeStateAsUpdate(room.doc);
778
- room.subscription.perform('snapshot', { snapshot: _u8ToB64(snapshot) });
996
+ room.subscription.perform('snapshot', {
997
+ snapshot: _u8ToB64(snapshot),
998
+ applied_seq: room.lastServerSeq || 0
999
+ });
779
1000
  return true;
780
1001
  } catch (e) {
781
1002
  return false;
@@ -794,6 +1015,7 @@ var CollaborationService = (function () {
794
1015
  unbindEditor: unbindEditor,
795
1016
  leaveRoom: leaveRoom,
796
1017
  consumesDiskLoad: consumesDiskLoad,
1018
+ contentReady: contentReady,
797
1019
  isBound: isBound,
798
1020
  isAttached: isAttached,
799
1021
  pushSnapshot: pushSnapshot,