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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
- data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
- data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
- data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +38 -39
- data/app/assets/javascripts/mbeditor/git_service.js +15 -1
- data/app/assets/javascripts/mbeditor/history_service.js +5 -13
- data/app/assets/javascripts/mbeditor/search_service.js +9 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
- data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
- data/app/assets/stylesheets/mbeditor/application.css +6 -1
- data/app/assets/stylesheets/mbeditor/editor.css +642 -248
- data/app/assets/stylesheets/mbeditor/glass.css +163 -0
- data/app/assets/stylesheets/mbeditor/themes.css +90 -30
- data/app/channels/mbeditor/collaboration_channel.rb +17 -4
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +117 -439
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
- data/app/services/mbeditor/editor_state_service.rb +14 -51
- data/app/services/mbeditor/file_history_service.rb +222 -0
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
- data/app/services/mbeditor/lint_service.rb +137 -0
- data/app/services/mbeditor/locked_json_file.rb +67 -0
- data/app/services/mbeditor/process_runner.rb +32 -0
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/search_replace_service.rb +8 -0
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -1
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -0
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- 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
|
|
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.
|
|
171
|
-
'
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
697
|
-
//
|
|
698
|
-
|
|
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
|
-
|
|
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
|
|
770
|
-
// the
|
|
771
|
-
//
|
|
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
|
|
993
|
+
if (!room || !room.subscription) return false;
|
|
776
994
|
try {
|
|
777
995
|
var snapshot = window.Y.encodeStateAsUpdate(room.doc);
|
|
778
|
-
room.subscription.perform('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,
|