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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -1
- data/app/assets/javascripts/mbeditor/application.js +3 -1
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
- 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 +440 -334
- 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/ImportDialog.js +9 -1
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
- 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 +694 -306
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +46 -57
- 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 +29 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
- 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 +762 -297
- 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 +25 -6
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +125 -465
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- 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_globals_service.rb +12 -1
- 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/rubocop_run_service.rb +17 -5
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/schema_service.rb +8 -2
- data/app/services/mbeditor/search_replace_service.rb +19 -2
- data/app/services/mbeditor/test_runner_service.rb +3 -56
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -9
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -1
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +14 -3
- 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
|
|
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,
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
677
|
-
//
|
|
678
|
-
|
|
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
|
-
|
|
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
|
|
750
|
-
// the
|
|
751
|
-
//
|
|
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
|
|
993
|
+
if (!room || !room.subscription) return false;
|
|
756
994
|
try {
|
|
757
995
|
var snapshot = window.Y.encodeStateAsUpdate(room.doc);
|
|
758
|
-
room.subscription.perform('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,
|