@jxsuite/studio 0.35.0 → 0.37.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.
@@ -0,0 +1,903 @@
1
+ /**
2
+ * CollabSession — the bridge between a tab's transactional document and a shared Y.Doc obtained
3
+ * from `platform.collab(docPath)`.
4
+ *
5
+ * Outbound: the transact observer publishes every local transaction's forward JxDocOps into the Y
6
+ * structure tree (un-instrumented and bypass writes reconcile by diff — the Y.Doc doubles as the
7
+ * before-image). Inbound: remote Y transactions convert back into JxDocOps and replay through
8
+ * `applyExternalDocOps`, riding the surgical canvas patcher; unconvertible shapes hard-reconcile
9
+ * the tab from the Y tree. Undo becomes a local-origin-scoped Y.UndoManager registered as the tab's
10
+ * history delegate. While attached, the shared session persists automatically: `dirty` stays false
11
+ * and Cmd+S becomes a serialize→flush.
12
+ *
13
+ * All yjs code sits behind a dynamic import: the unsplit bundle inlines the bytes, but module
14
+ * evaluation defers until a collab session actually attaches.
15
+ */
16
+
17
+ import { effect, onScopeDispose, toRaw } from "../reactivity";
18
+ import { getPlatform } from "../platform";
19
+ import { jsonClone } from "../utils/studio-utils";
20
+ import {
21
+ applyExternalDocOps,
22
+ isBatching,
23
+ setBatchEndNotifier,
24
+ setHistoryDelegate,
25
+ setTransactGate,
26
+ setTransactObserver,
27
+ transactDoc,
28
+ } from "../tabs/transact";
29
+ import type { TransactOrigin } from "../tabs/transact";
30
+ import type { TransactionRecord } from "../tabs/patch-ops";
31
+ import type { Tab } from "../tabs/tab";
32
+ import type { JxDocOp } from "@jxsuite/collab/ops";
33
+ import type { CollabHandle } from "@jxsuite/collab/provider";
34
+ import type { JxMutableNode } from "@jxsuite/schema/types";
35
+ import type * as CollabNS from "@jxsuite/collab";
36
+ import { collabState, registerCollabPath, unregisterCollabPath } from "./collab-state";
37
+
38
+ type CollabModule = typeof CollabNS;
39
+
40
+ /** How long the initial sync may take before falling back to solo editing. */
41
+ const SYNC_TIMEOUT_MS = 8000;
42
+ /** Debounce for the elected reconciler's structure→source mirror. */
43
+ const MIRROR_DEBOUNCE_MS = 800;
44
+
45
+ let collabModulePromise: Promise<CollabModule> | null = null;
46
+
47
+ function loadCollab(): Promise<CollabModule> {
48
+ collabModulePromise ??= import("@jxsuite/collab");
49
+ return collabModulePromise;
50
+ }
51
+
52
+ /** Tabs can open before any platform registers (headless tests); collab simply stays off. */
53
+ function maybePlatform(): ReturnType<typeof getPlatform> | null {
54
+ try {
55
+ return getPlatform();
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Serializer injected at studio init (file-ops' serializeDocument); avoids an import cycle. */
62
+ let _serialize: ((tab: Tab) => Promise<string>) | null = null;
63
+
64
+ export function configureCollabSerializer(fn: ((tab: Tab) => Promise<string>) | null): void {
65
+ _serialize = fn;
66
+ }
67
+
68
+ /** Parser injected at studio init (file-ops' parseSourceForPath) for the source reconciler. */
69
+ export type CollabParser = (
70
+ tab: Tab,
71
+ text: string,
72
+ ) => Promise<{ document: JxMutableNode; frontmatter?: Record<string, unknown> }>;
73
+
74
+ let _parse: CollabParser | null = null;
75
+
76
+ export function configureCollabParser(fn: CollabParser | null): void {
77
+ _parse = fn;
78
+ }
79
+
80
+ /** Status-message sink injected at studio init (a direct statusbar import would cycle). */
81
+ let _notify: (message: string) => void = () => {};
82
+
83
+ export function configureCollabNotifier(fn: ((message: string) => void) | null): void {
84
+ _notify = fn ?? (() => {});
85
+ }
86
+
87
+ interface ActiveSession {
88
+ tab: Tab;
89
+ path: string;
90
+ handle: CollabHandle;
91
+ collab: CollabModule;
92
+ undoManager: InstanceType<CollabModule["UndoManager"]> | null;
93
+ /** The last document root reference produced through transactDoc (bypass-write detection). */
94
+ lastSeenRef: object | null;
95
+ /** Buffered forward ops while an AI batch runs; null outside batches. */
96
+ batchOps: JxDocOp[] | null;
97
+ batchNeedsDiff: boolean;
98
+ /** Guards the frontmatter watcher against echoing remote-applied fields. */
99
+ applyingRemoteFrontmatter: boolean;
100
+ synced: boolean;
101
+ canWrite: boolean;
102
+ /** True while THIS client is in the code view (its structural freeze exempts itself). */
103
+ inSourceMode: boolean;
104
+ mirrorTimer: ReturnType<typeof setTimeout> | null;
105
+ /** Debounce for the source reconciler's Y.Text → structure parse mirror. */
106
+ sourceParseTimer: ReturnType<typeof setTimeout> | null;
107
+ disposers: (() => void)[];
108
+ }
109
+
110
+ interface TabRuntime {
111
+ watcherInstalled: boolean;
112
+ /** Bumped on every detach; in-flight attaches abandon when it moves. */
113
+ generation: number;
114
+ session: ActiveSession | null;
115
+ attaching: Promise<void> | null;
116
+ }
117
+
118
+ const runtimes = new WeakMap<Tab, TabRuntime>();
119
+
120
+ /** Callers hold either the raw tab or a reactive proxy of it; key runtimes by the raw object. */
121
+ function rawTab(tab: Tab): Tab {
122
+ return toRaw(tab as unknown as object) as Tab;
123
+ }
124
+
125
+ function runtimeOf(tab: Tab): TabRuntime | undefined {
126
+ return runtimes.get(rawTab(tab));
127
+ }
128
+
129
+ function runtimeFor(tab: Tab): TabRuntime {
130
+ const key = rawTab(tab);
131
+ let runtime = runtimes.get(key);
132
+ if (!runtime) {
133
+ runtime = { attaching: null, generation: 0, session: null, watcherInstalled: false };
134
+ runtimes.set(key, runtime);
135
+ }
136
+ return runtime;
137
+ }
138
+
139
+ // ─── Global transact hooks (installed once; no-ops for unattached tabs) ──────
140
+
141
+ let hooksInstalled = false;
142
+
143
+ function installGlobalHooks(): void {
144
+ if (hooksInstalled) {
145
+ return;
146
+ }
147
+ hooksInstalled = true;
148
+ setTransactObserver(onTransact);
149
+ setBatchEndNotifier(onBatchEnd);
150
+ // Soft-freeze structural editing while a peer holds source-canonical (remote origins pass —
151
+ // They ARE the reconciler's mirror of the frozen representation).
152
+ setTransactGate((tab) => {
153
+ const session = runtimeOf(tab)?.session;
154
+ if (session?.synced && collabState(tab).sourceCanonical && !session.inSourceMode) {
155
+ _notify("Source editing in progress — structural edits are paused");
156
+ return "source-canonical";
157
+ }
158
+ return null;
159
+ });
160
+ }
161
+
162
+ /** Test hook: uninstall global observers and forget module state. */
163
+ export function resetCollabForTests(): void {
164
+ hooksInstalled = false;
165
+ setTransactObserver(null);
166
+ setBatchEndNotifier(null);
167
+ setTransactGate(null);
168
+ _serialize = null;
169
+ _parse = null;
170
+ _notify = () => {};
171
+ }
172
+
173
+ function onTransact(tab: Tab, record: TransactionRecord, origin: TransactOrigin): void {
174
+ const session = runtimeOf(tab)?.session;
175
+ if (!session || !session.synced) {
176
+ return;
177
+ }
178
+ session.lastSeenRef = toRaw(tab.doc.document) as object;
179
+ if (origin === "remote") {
180
+ // The reconciler keeps source text fresh regardless of WHO edited the structure.
181
+ scheduleMirror(session);
182
+ tab.doc.dirty = false;
183
+ return;
184
+ }
185
+ if (session.canWrite) {
186
+ publishRecord(session, record);
187
+ scheduleMirror(session);
188
+ }
189
+ // The shared session persists automatically; the dirty dot means "unsaved" and stays off.
190
+ tab.doc.dirty = false;
191
+ }
192
+
193
+ function onBatchEnd(tab: Tab): void {
194
+ const session = runtimeOf(tab)?.session;
195
+ if (!session?.synced || session.batchOps === null) {
196
+ return;
197
+ }
198
+ const ops = session.batchOps;
199
+ const needsDiff = session.batchNeedsDiff;
200
+ session.batchOps = null;
201
+ session.batchNeedsDiff = false;
202
+ if (!session.canWrite) {
203
+ return;
204
+ }
205
+ // One Y transaction — one wire message, one undo step for the whole AI run.
206
+ session.undoManager?.stopCapturing();
207
+ if (needsDiff) {
208
+ publishDiff(session);
209
+ } else if (ops.length > 0) {
210
+ try {
211
+ session.collab.applyDocOpsToY(session.handle.doc, ops, session.collab.LOCAL_ORIGIN);
212
+ } catch {
213
+ publishDiff(session);
214
+ }
215
+ }
216
+ session.undoManager?.stopCapturing();
217
+ }
218
+
219
+ function publishRecord(session: ActiveSession, record: TransactionRecord): void {
220
+ const forward = record.docOps.map((pair) => pair.forward);
221
+ if (isBatching()) {
222
+ session.batchOps ??= [];
223
+ if (forward.length === 0) {
224
+ session.batchNeedsDiff = true;
225
+ } else {
226
+ session.batchOps.push(...forward);
227
+ }
228
+ return;
229
+ }
230
+ if (forward.length === 0) {
231
+ // Un-instrumented mutation: the Y tree is the last-synced before-image — diff against it.
232
+ publishDiff(session);
233
+ return;
234
+ }
235
+ try {
236
+ session.collab.applyDocOpsToY(session.handle.doc, forward, session.collab.LOCAL_ORIGIN);
237
+ } catch {
238
+ publishDiff(session);
239
+ }
240
+ }
241
+
242
+ /** Make the Y structure tree match the tab's document (diff when possible, hard replace beyond). */
243
+ function publishDiff(session: ActiveSession): void {
244
+ const { collab, handle, tab } = session;
245
+ const before = collab.yDocToJson(handle.doc);
246
+ const after = jsonClone(toRaw(tab.doc.document)) as JxMutableNode;
247
+ const ops = collab.diffDocs(before, after);
248
+ if (ops === null) {
249
+ collab.replaceYStructure(handle.doc, after, collab.LOCAL_ORIGIN);
250
+ } else if (ops.length > 0) {
251
+ try {
252
+ collab.applyDocOpsToY(handle.doc, ops, collab.LOCAL_ORIGIN);
253
+ } catch {
254
+ collab.replaceYStructure(handle.doc, after, collab.LOCAL_ORIGIN);
255
+ }
256
+ }
257
+ }
258
+
259
+ /** Replace the tab document in place from the Y tree (remote origin, full-render fallback). */
260
+ function reconcileTabFromY(session: ActiveSession): void {
261
+ const next = session.collab.yDocToJson(session.handle.doc);
262
+ transactDoc(
263
+ session.tab,
264
+ (t) => {
265
+ const target = t.doc.document as Record<string, unknown>;
266
+ for (const key of Object.keys(target)) {
267
+ if (!(key in next)) {
268
+ delete target[key];
269
+ }
270
+ }
271
+ Object.assign(target, next);
272
+ },
273
+ { origin: "remote", skipHistory: true },
274
+ );
275
+ }
276
+
277
+ // ─── Source mirror (Phase A: canonical is always "structure") ────────────────
278
+
279
+ /** The elected reconciler: lowest write-capable clientID keeps source Y.Text fresh. */
280
+ function isReconciler(session: ActiveSession): boolean {
281
+ if (!session.canWrite) {
282
+ return false;
283
+ }
284
+ const { awareness } = session.handle;
285
+ let lowest = awareness.clientID;
286
+ for (const [clientId, state] of awareness.getStates()) {
287
+ if ((state as { canWrite?: boolean }).canWrite !== false && clientId < lowest) {
288
+ lowest = clientId;
289
+ }
290
+ }
291
+ return lowest === session.handle.awareness.clientID;
292
+ }
293
+
294
+ function scheduleMirror(session: ActiveSession): void {
295
+ if (!_serialize || !isReconciler(session)) {
296
+ return;
297
+ }
298
+ // While source holds the canonical lock, mirroring runs the OTHER way (parse, not serialize).
299
+ if (session.collab.canonicalOf(session.handle.doc) === "source") {
300
+ return;
301
+ }
302
+ if (session.mirrorTimer) {
303
+ clearTimeout(session.mirrorTimer);
304
+ }
305
+ session.mirrorTimer = setTimeout(() => {
306
+ session.mirrorTimer = null;
307
+ void mirrorNow(session);
308
+ }, MIRROR_DEBOUNCE_MS);
309
+ }
310
+
311
+ /** Serialize the tab and fold the text into source Y.Text (what providers persist and commit). */
312
+ async function mirrorNow(session: ActiveSession): Promise<void> {
313
+ if (!_serialize || !session.synced) {
314
+ return;
315
+ }
316
+ try {
317
+ const text = await _serialize(session.tab);
318
+ session.collab.updateSourceText(session.handle.doc, text, session.collab.MIRROR_ORIGIN);
319
+ } catch {
320
+ // Serialization failures leave the previous mirror in place; the next edit retries.
321
+ }
322
+ }
323
+
324
+ // ─── Save ─────────────────────────────────────────────────────────────────────
325
+
326
+ /** Parse the shared source text back into the structure tree (the source reconciler's duty). */
327
+ async function sourceParseNow(session: ActiveSession): Promise<void> {
328
+ if (!_parse || !session.synced || !session.canWrite) {
329
+ return;
330
+ }
331
+ const { collab, handle, tab } = session;
332
+ const rev = collab.canonicalRevOf(handle.doc);
333
+ const text = collab.sourceText(handle.doc).toString();
334
+ let parsed: Awaited<ReturnType<CollabParser>>;
335
+ try {
336
+ parsed = await _parse(tab, text);
337
+ } catch {
338
+ // Unparseable source: keep the last good structure (the preview goes stale, not wrong).
339
+ return;
340
+ }
341
+ if (!session.synced || collab.canonicalRevOf(handle.doc) !== rev) {
342
+ // The lock flipped while parsing; this mirror was computed against a dead representation.
343
+ return;
344
+ }
345
+ const ops = collab.diffDocs(collab.yDocToJson(handle.doc), parsed.document);
346
+ if (ops === null) {
347
+ collab.replaceYStructure(handle.doc, parsed.document, collab.MIRROR_ORIGIN);
348
+ } else if (ops.length > 0) {
349
+ try {
350
+ collab.applyDocOpsToY(handle.doc, ops, collab.MIRROR_ORIGIN);
351
+ } catch {
352
+ collab.replaceYStructure(handle.doc, parsed.document, collab.MIRROR_ORIGIN);
353
+ }
354
+ }
355
+ const frontmatter = collab.frontmatterMap(handle.doc);
356
+ const next = parsed.frontmatter ?? {};
357
+ handle.doc.transact(() => {
358
+ // Detached copy: keys are deleted while iterating.
359
+ const sharedKeys = [...frontmatter.keys()];
360
+ for (const key of sharedKeys) {
361
+ if (!(key in next)) {
362
+ frontmatter.delete(key);
363
+ }
364
+ }
365
+ for (const [key, value] of Object.entries(next)) {
366
+ if (!collab.deepEqual(frontmatter.get(key), value)) {
367
+ frontmatter.set(key, value);
368
+ }
369
+ }
370
+ }, collab.MIRROR_ORIGIN);
371
+ }
372
+
373
+ /**
374
+ * The code view's co-editing surface for a tab, or null when the tab isn't in a synced session.
375
+ * `enter()` flips the canonical lock to source (seeding the text from the flipper's serialization);
376
+ * `leave()` reverts to structure-canonical when the last source editor departs. `text` is the real
377
+ * Y.Text and `awareness` the connection's Awareness — exactly what y-monaco's MonacoBinding
378
+ * consumes (it owns the awareness `selection` field while bound).
379
+ */
380
+ export function collabSourceContext(tab: Tab): {
381
+ text: unknown;
382
+ awareness: CollabHandle["awareness"];
383
+ localOrigin: unknown;
384
+ readOnly: boolean;
385
+ enter: () => Promise<void>;
386
+ leave: () => void;
387
+ } | null {
388
+ const session = runtimeOf(tab)?.session;
389
+ if (!session?.synced) {
390
+ return null;
391
+ }
392
+ const { collab, handle } = session;
393
+ return {
394
+ awareness: handle.awareness,
395
+ enter: async () => {
396
+ session.inSourceMode = true;
397
+ const local = handle.awareness.getLocalState();
398
+ if (local) {
399
+ handle.awareness.setLocalState({ ...local, mode: "source" });
400
+ }
401
+ if (!session.canWrite) {
402
+ return;
403
+ }
404
+ let serialized: string | null = null;
405
+ if (_serialize) {
406
+ try {
407
+ serialized = await _serialize(tab);
408
+ } catch {
409
+ serialized = null;
410
+ }
411
+ }
412
+ collab.acquireSourceCanonical(
413
+ handle.doc,
414
+ serialized ?? collab.sourceText(handle.doc).toString(),
415
+ collab.LOCAL_ORIGIN,
416
+ );
417
+ },
418
+ leave: () => {
419
+ session.inSourceMode = false;
420
+ const local = handle.awareness.getLocalState();
421
+ if (local) {
422
+ handle.awareness.setLocalState({ ...local, mode: "structure" });
423
+ }
424
+ if (!session.canWrite) {
425
+ return;
426
+ }
427
+ const others = collab.otherSourceEditors(
428
+ handle.awareness,
429
+ session.path,
430
+ handle.awareness.clientID,
431
+ );
432
+ if (others.length === 0) {
433
+ // Freshen the structure mirror once more, then hand the lock back.
434
+ void sourceParseNow(session).then(() => {
435
+ collab.releaseSourceCanonical(handle.doc, collab.LOCAL_ORIGIN);
436
+ });
437
+ }
438
+ },
439
+ localOrigin: collab.LOCAL_ORIGIN,
440
+ readOnly: !session.canWrite,
441
+ text: collab.sourceText(handle.doc),
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Cmd+S for a collab tab: refresh the source mirror and ask the provider to persist now. Returns
447
+ * false when the tab has no active session (caller saves through the file path as usual).
448
+ */
449
+ export async function collabSave(tab: Tab): Promise<boolean> {
450
+ const session = runtimeOf(tab)?.session;
451
+ if (!session?.synced) {
452
+ return false;
453
+ }
454
+ if (session.mirrorTimer) {
455
+ clearTimeout(session.mirrorTimer);
456
+ session.mirrorTimer = null;
457
+ }
458
+ if (session.canWrite) {
459
+ await mirrorNow(session);
460
+ }
461
+ await session.handle.flush();
462
+ return true;
463
+ }
464
+
465
+ /** Refresh mirrors and flush every active session (call before commits). */
466
+ export async function flushAllCollab(): Promise<void> {
467
+ for (const tab of liveTabs) {
468
+ const session = runtimeOf(tab)?.session;
469
+ if (session?.synced) {
470
+ await collabSave(tab);
471
+ }
472
+ }
473
+ }
474
+
475
+ // ─── Attach / detach ─────────────────────────────────────────────────────────
476
+
477
+ /** Tabs with installed watchers (module-scoped so flushAllCollab can walk them). */
478
+ const liveTabs = new Set<Tab>();
479
+
480
+ /**
481
+ * Idempotently wire collaboration for a tab. Installs a per-tab watcher that attaches a session for
482
+ * the tab's file while it is at the top of its document stack, detaches while drilled into a
483
+ * component, and tears everything down when the tab's scope is disposed.
484
+ */
485
+ export function ensureCollab(tab: Tab): void {
486
+ const platform = maybePlatform();
487
+ if (!platform?.collab || !tab.documentPath) {
488
+ return;
489
+ }
490
+ const runtime = runtimeFor(tab);
491
+ if (runtime.watcherInstalled) {
492
+ return;
493
+ }
494
+ runtime.watcherInstalled = true;
495
+ installGlobalHooks();
496
+ liveTabs.add(tab);
497
+ tab.scope.run(() => {
498
+ effect(() => {
499
+ const drilled = (tab.session.documentStack?.length ?? 0) > 0;
500
+ if (drilled) {
501
+ detachSession(tab);
502
+ } else {
503
+ void attachSession(tab);
504
+ }
505
+ });
506
+ onScopeDispose(() => {
507
+ liveTabs.delete(tab);
508
+ detachSession(tab);
509
+ });
510
+ });
511
+ }
512
+
513
+ /** Re-key after a file rename: tear down and re-attach against the new path. */
514
+ export function rekeyCollab(tab: Tab): void {
515
+ const runtime = runtimeOf(tab);
516
+ if (!runtime?.watcherInstalled) {
517
+ ensureCollab(tab);
518
+ return;
519
+ }
520
+ detachSession(tab);
521
+ void attachSession(tab);
522
+ }
523
+
524
+ async function attachSession(tab: Tab): Promise<void> {
525
+ const runtime = runtimeFor(tab);
526
+ if (runtime.session || runtime.attaching) {
527
+ return;
528
+ }
529
+ const { documentPath: path } = tab;
530
+ const platform = maybePlatform();
531
+ if (!path || !platform?.collab) {
532
+ return;
533
+ }
534
+ const { generation } = runtime;
535
+ const state = collabState(tab);
536
+ state.status = "connecting";
537
+ const attempt = (async () => {
538
+ try {
539
+ const [collab, handle] = await Promise.all([loadCollab(), platform.collab!(path)]);
540
+ if (!handle) {
541
+ state.status = "detached";
542
+ return;
543
+ }
544
+ if (runtime.generation !== generation) {
545
+ handle.destroy();
546
+ return;
547
+ }
548
+ let timer: ReturnType<typeof setTimeout> | null = null;
549
+ const timeout = new Promise<never>((_, reject) => {
550
+ timer = setTimeout(() => reject(new Error("collab-sync-timeout")), SYNC_TIMEOUT_MS);
551
+ });
552
+ try {
553
+ await Promise.race([handle.whenSynced, timeout]);
554
+ } finally {
555
+ if (timer) {
556
+ clearTimeout(timer);
557
+ }
558
+ }
559
+ if (runtime.generation !== generation) {
560
+ handle.destroy();
561
+ return;
562
+ }
563
+ runtime.session = createSession(tab, path, collab, handle);
564
+ state.active = true;
565
+ state.status = "synced";
566
+ state.readOnly = !runtime.session.canWrite;
567
+ registerCollabPath(path);
568
+ } catch {
569
+ state.status = "detached";
570
+ state.active = false;
571
+ } finally {
572
+ runtime.attaching = null;
573
+ }
574
+ })();
575
+ runtime.attaching = attempt;
576
+ await attempt;
577
+ }
578
+
579
+ function detachSession(tab: Tab): void {
580
+ const runtime = runtimeOf(tab);
581
+ if (!runtime) {
582
+ return;
583
+ }
584
+ runtime.generation += 1;
585
+ const { session } = runtime;
586
+ runtime.session = null;
587
+ const state = collabState(tab);
588
+ state.active = false;
589
+ state.status = "detached";
590
+ state.peers = [];
591
+ if (!session) {
592
+ return;
593
+ }
594
+ unregisterCollabPath(session.path);
595
+ if (session.mirrorTimer) {
596
+ clearTimeout(session.mirrorTimer);
597
+ }
598
+ if (session.sourceParseTimer) {
599
+ clearTimeout(session.sourceParseTimer);
600
+ }
601
+ for (const dispose of session.disposers) {
602
+ try {
603
+ dispose();
604
+ } catch {
605
+ // Disposal is best-effort; the handle teardown below is what matters.
606
+ }
607
+ }
608
+ setHistoryDelegate(tab, null);
609
+ // Re-base the op-log history on the current document for solo editing.
610
+ tab.history.snapshots = [
611
+ {
612
+ document: jsonClone(toRaw(tab.doc.document)) as Record<string, unknown>,
613
+ selection: tab.session.selection ? [...tab.session.selection] : null,
614
+ },
615
+ ];
616
+ tab.history.index = 0;
617
+ session.undoManager?.destroy();
618
+ session.handle.destroy();
619
+ }
620
+
621
+ function createSession(
622
+ tab: Tab,
623
+ path: string,
624
+ collab: CollabModule,
625
+ handle: CollabHandle,
626
+ ): ActiveSession {
627
+ const identity = handle.identity();
628
+ const session: ActiveSession = {
629
+ applyingRemoteFrontmatter: false,
630
+ batchNeedsDiff: false,
631
+ batchOps: null,
632
+ canWrite: identity ? identity.permission !== "read" : true,
633
+ collab,
634
+ disposers: [],
635
+ handle,
636
+ inSourceMode: false,
637
+ lastSeenRef: toRaw(tab.doc.document) as object,
638
+ mirrorTimer: null,
639
+ path,
640
+ sourceParseTimer: null,
641
+ synced: false,
642
+ tab,
643
+ undoManager: null,
644
+ };
645
+
646
+ // Initial reconcile: first client seeds structure from the parsed doc; later clients adopt the
647
+ // Shared tree when it differs from their local parse.
648
+ const meta = collab.metaMap(handle.doc);
649
+ if (meta.get("structureSeeded") === true) {
650
+ const yJson = collab.yDocToJson(handle.doc);
651
+ const tabJson = jsonClone(toRaw(tab.doc.document)) as JxMutableNode;
652
+ if (!collab.deepEqual(yJson, tabJson)) {
653
+ const ops = collab.diffDocs(tabJson, yJson);
654
+ if (ops === null) {
655
+ session.synced = true;
656
+ reconcileTabFromY(session);
657
+ session.synced = false;
658
+ } else if (ops.length > 0) {
659
+ applyExternalDocOps(tab, ops);
660
+ }
661
+ }
662
+ session.applyingRemoteFrontmatter = true;
663
+ const sharedFrontmatter = collab.frontmatterMap(handle.doc);
664
+ for (const [key, value] of sharedFrontmatter.entries()) {
665
+ tab.doc.content.frontmatter[key] = value as never;
666
+ }
667
+ session.applyingRemoteFrontmatter = false;
668
+ } else if (session.canWrite) {
669
+ collab.seedStructure(handle.doc, jsonClone(toRaw(tab.doc.document)) as JxMutableNode, {
670
+ frontmatter: jsonClone(toRaw(tab.doc.content.frontmatter)),
671
+ origin: collab.SEED_ORIGIN,
672
+ sourceFormat: tab.doc.sourceFormat,
673
+ });
674
+ }
675
+ session.lastSeenRef = toRaw(tab.doc.document) as object;
676
+ tab.doc.dirty = false;
677
+
678
+ // Presence identity + write capability (drives reconciler election).
679
+ handle.awareness.setLocalState({
680
+ canWrite: session.canWrite,
681
+ focusedPath: path,
682
+ structuralSelection: null,
683
+ user: identity
684
+ ? {
685
+ avatarUrl: identity.avatarUrl,
686
+ color: identity.color,
687
+ login: identity.login,
688
+ name: identity.name,
689
+ }
690
+ : {
691
+ color: collab.colorForKey(String(handle.awareness.clientID)),
692
+ login: `guest-${handle.awareness.clientID % 1000}`,
693
+ },
694
+ });
695
+
696
+ // Undo: local-origin-scoped Y.UndoManager replaces the op-log while attached.
697
+ const undoManager = new collab.UndoManager(
698
+ [collab.structureMap(handle.doc), collab.frontmatterMap(handle.doc)],
699
+ { trackedOrigins: new Set([collab.LOCAL_ORIGIN]) },
700
+ );
701
+ session.undoManager = undoManager;
702
+ const onStackAdd = ({ stackItem }: { stackItem: { meta: Map<unknown, unknown> } }) => {
703
+ stackItem.meta.set("selection", tab.session.selection ? [...tab.session.selection] : null);
704
+ };
705
+ const onStackPop = ({ stackItem }: { stackItem: { meta: Map<unknown, unknown> } }) => {
706
+ const selection = stackItem.meta.get("selection") as (string | number)[] | null | undefined;
707
+ tab.session.selection = selection ? [...selection] : null;
708
+ };
709
+ undoManager.on("stack-item-added", onStackAdd);
710
+ undoManager.on("stack-item-popped", onStackPop);
711
+ session.disposers.push(() => {
712
+ undoManager.off("stack-item-added", onStackAdd);
713
+ undoManager.off("stack-item-popped", onStackPop);
714
+ });
715
+ setHistoryDelegate(tab, {
716
+ canRedo: () => undoManager.canRedo(),
717
+ canUndo: () => undoManager.canUndo(),
718
+ redo: () => {
719
+ undoManager.redo();
720
+ },
721
+ undo: () => {
722
+ undoManager.undo();
723
+ },
724
+ });
725
+
726
+ // Inbound: remote structure transactions → JxDocOps → the surgical pipeline.
727
+ const structure = collab.structureMap(handle.doc);
728
+ const structureObserver = (events: unknown, transaction: { origin: unknown }) => {
729
+ if (transaction.origin === collab.LOCAL_ORIGIN || !session.synced) {
730
+ return;
731
+ }
732
+ const ops = collab.yEventsToDocOps(events as never);
733
+ if (ops === null) {
734
+ reconcileTabFromY(session);
735
+ return;
736
+ }
737
+ if (ops.length === 0) {
738
+ return;
739
+ }
740
+ try {
741
+ applyExternalDocOps(tab, ops);
742
+ } catch {
743
+ reconcileTabFromY(session);
744
+ }
745
+ };
746
+ structure.observeDeep(structureObserver);
747
+ session.disposers.push(() => structure.unobserveDeep(structureObserver));
748
+
749
+ // The canonical-representation lock: while source holds it, structural surfaces soft-freeze
750
+ // (Transact gate) and the source reconciler's parses arrive as MIRROR-origin structure changes.
751
+ const tabState = collabState(tab);
752
+ tabState.sourceCanonical = collab.canonicalOf(handle.doc) === "source";
753
+ const lockMeta = collab.metaMap(handle.doc);
754
+ const lockObserver = () => {
755
+ tabState.sourceCanonical = collab.canonicalOf(handle.doc) === "source";
756
+ };
757
+ lockMeta.observe(lockObserver);
758
+ session.disposers.push(() => lockMeta.unobserve(lockObserver));
759
+
760
+ // The source reconciler (lowest write clientID in source mode) parses Y.Text back into the
761
+ // Structure tree on a debounce, so every client's canvas previews source edits live.
762
+ const source = collab.sourceText(handle.doc);
763
+ const sourceObserver = (_event: unknown, transaction: { origin: unknown }) => {
764
+ if (transaction.origin === collab.MIRROR_ORIGIN || !session.synced) {
765
+ return;
766
+ }
767
+ if (collab.canonicalOf(handle.doc) !== "source") {
768
+ return;
769
+ }
770
+ if (!collab.isSourceReconciler(handle.awareness, session.path)) {
771
+ return;
772
+ }
773
+ if (session.sourceParseTimer) {
774
+ clearTimeout(session.sourceParseTimer);
775
+ }
776
+ session.sourceParseTimer = setTimeout(() => {
777
+ session.sourceParseTimer = null;
778
+ void sourceParseNow(session);
779
+ }, 600);
780
+ };
781
+ source.observe(sourceObserver as never);
782
+ session.disposers.push(() => source.unobserve(sourceObserver as never));
783
+
784
+ // Inbound frontmatter (per-field).
785
+ const frontmatter = collab.frontmatterMap(handle.doc);
786
+ const frontmatterObserver = (
787
+ event: { changes: { keys: Map<string, { action: string }> }; target: unknown },
788
+ transaction: { origin: unknown },
789
+ ) => {
790
+ if (transaction.origin === collab.LOCAL_ORIGIN || !session.synced) {
791
+ return;
792
+ }
793
+ session.applyingRemoteFrontmatter = true;
794
+ try {
795
+ for (const [key, change] of event.changes.keys) {
796
+ if (change.action === "delete") {
797
+ delete tab.doc.content.frontmatter[key];
798
+ } else {
799
+ tab.doc.content.frontmatter[key] = frontmatter.get(key) as never;
800
+ }
801
+ }
802
+ } finally {
803
+ session.applyingRemoteFrontmatter = false;
804
+ }
805
+ };
806
+ frontmatter.observe(frontmatterObserver as never);
807
+ session.disposers.push(() => frontmatter.unobserve(frontmatterObserver as never));
808
+
809
+ // Outbound frontmatter: mutateUpdateFrontmatter bypasses transactDoc, so watch the map deeply.
810
+ tab.scope.run(() => {
811
+ let lastJson = JSON.stringify(tab.doc.content.frontmatter);
812
+ effect(() => {
813
+ const json = JSON.stringify(tab.doc.content.frontmatter);
814
+ if (json === lastJson) {
815
+ return;
816
+ }
817
+ lastJson = json;
818
+ if (session.applyingRemoteFrontmatter || !session.synced || !session.canWrite) {
819
+ return;
820
+ }
821
+ const local = JSON.parse(json) as Record<string, unknown>;
822
+ handle.doc.transact(() => {
823
+ // Detached copy: keys are deleted while iterating.
824
+ const sharedKeys = [...frontmatter.keys()];
825
+ for (const key of sharedKeys) {
826
+ if (!(key in local)) {
827
+ frontmatter.delete(key);
828
+ }
829
+ }
830
+ for (const [key, value] of Object.entries(local)) {
831
+ if (!collab.deepEqual(frontmatter.get(key), value)) {
832
+ frontmatter.set(key, value);
833
+ }
834
+ }
835
+ }, collab.LOCAL_ORIGIN);
836
+ scheduleMirror(session);
837
+ });
838
+
839
+ // Publish the local structural selection for remote canvas overlays (peers filter by
840
+ // FocusedPath, so per-doc boxes come free from the one project-level awareness state). The
841
+ // Plain `selection` field is y-monaco's (in-buffer text cursors) — never write it here.
842
+ effect(() => {
843
+ const structuralSelection = tab.session.selection ? [...tab.session.selection] : null;
844
+ const local = handle.awareness.getLocalState();
845
+ if (local) {
846
+ handle.awareness.setLocalState({ ...local, structuralSelection });
847
+ }
848
+ });
849
+
850
+ // Bypass-write net: any doc root swap that did not come through transactDoc (Monaco parse
851
+ // Flush) reconciles by diff. Deferred one microtask so the transact observer marks its own
852
+ // Refs first (the assignment happens mid-transaction, before the observer runs).
853
+ effect(() => {
854
+ const ref = toRaw(tab.doc.document) as object;
855
+ queueMicrotask(() => {
856
+ const current = runtimeOf(tab)?.session;
857
+ if (current !== session || !session.synced || !session.canWrite) {
858
+ return;
859
+ }
860
+ const nowRef = toRaw(tab.doc.document) as object;
861
+ if (nowRef !== ref || nowRef === session.lastSeenRef) {
862
+ return;
863
+ }
864
+ session.lastSeenRef = nowRef;
865
+ publishDiff(session);
866
+ scheduleMirror(session);
867
+ tab.doc.dirty = false;
868
+ });
869
+ });
870
+ });
871
+
872
+ // Presence roster + status.
873
+ const state = collabState(tab);
874
+ const awarenessObserver = () => {
875
+ const peers: { clientId: number; state: never }[] = [];
876
+ for (const [clientId, peerState] of handle.awareness.getStates()) {
877
+ if (clientId !== handle.awareness.clientID && peerState["user"]) {
878
+ peers.push({ clientId, state: peerState as never });
879
+ }
880
+ }
881
+ state.peers = peers;
882
+ };
883
+ awarenessObserver();
884
+ handle.awareness.on("change", awarenessObserver);
885
+ session.disposers.push(
886
+ () => handle.awareness.off("change", awarenessObserver),
887
+ handle.onStatus((status) => {
888
+ if (status === "offline" || status === "connecting") {
889
+ state.status = "offline";
890
+ } else if (session.synced) {
891
+ state.status = "synced";
892
+ }
893
+ }),
894
+ handle.onReset(() => {
895
+ // The server replaced this doc's history: drop the session and re-attach fresh.
896
+ detachSession(tab);
897
+ void attachSession(tab);
898
+ }),
899
+ );
900
+
901
+ session.synced = true;
902
+ return session;
903
+ }