@jxsuite/studio 0.36.0 → 0.37.1
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.
- package/dist/iframe-entry.js +18 -9
- package/dist/iframe-entry.js.map +4 -4
- package/dist/studio.js +11525 -2877
- package/dist/studio.js.map +64 -17
- package/package.json +4 -2
- package/src/canvas/canvas-render.ts +85 -12
- package/src/canvas/iframe-host.ts +85 -0
- package/src/canvas/iframe-overlay.ts +30 -1
- package/src/collab/collab-session.ts +903 -0
- package/src/collab/collab-state.ts +71 -0
- package/src/collab/monaco-cursors.ts +69 -0
- package/src/collab/presence-chips.ts +58 -0
- package/src/files/file-ops.ts +6 -0
- package/src/files/files.ts +6 -0
- package/src/panels/git-panel.ts +5 -0
- package/src/panels/toolbar.ts +13 -3
- package/src/platforms/cloud.ts +31 -0
- package/src/platforms/devserver.ts +37 -0
- package/src/studio.ts +17 -0
- package/src/tabs/doc-op-apply.ts +5 -81
- package/src/tabs/patch-ops.ts +7 -23
- package/src/tabs/transact.ts +138 -12
- package/src/types.ts +8 -0
- package/src/workspace/workspace.ts +4 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive per-tab collab state, kept OUTSIDE the Tab type: UI reads it through collabState(tab),
|
|
3
|
+
* and non-collab builds never touch it. The path registry answers "is this file co-edited?" for the
|
|
4
|
+
* fs-sync/reload guards without importing the session machinery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { reactive } from "../reactivity";
|
|
8
|
+
import type { Tab } from "../tabs/tab";
|
|
9
|
+
import type { CollabAwarenessState } from "@jxsuite/collab/awareness-types";
|
|
10
|
+
|
|
11
|
+
export type CollabTabStatus = "detached" | "connecting" | "synced" | "offline";
|
|
12
|
+
|
|
13
|
+
export interface PeerPresence {
|
|
14
|
+
clientId: number;
|
|
15
|
+
state: CollabAwarenessState;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TabCollabState {
|
|
19
|
+
status: CollabTabStatus;
|
|
20
|
+
/** True once a session is attached and past its initial sync. */
|
|
21
|
+
active: boolean;
|
|
22
|
+
readOnly: boolean;
|
|
23
|
+
/** Other clients' awareness states (this project connection, all docs). */
|
|
24
|
+
peers: PeerPresence[];
|
|
25
|
+
/**
|
|
26
|
+
* True while source holds the canonical lock (someone is co-editing the code view): structural
|
|
27
|
+
* surfaces soft-freeze and the canvas previews the source reconciler's parses.
|
|
28
|
+
*/
|
|
29
|
+
sourceCanonical: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const states = new WeakMap<Tab, TabCollabState>();
|
|
33
|
+
const attachedPaths = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
export function collabState(tab: Tab): TabCollabState {
|
|
36
|
+
let state = states.get(tab);
|
|
37
|
+
if (!state) {
|
|
38
|
+
state = reactive({
|
|
39
|
+
active: false,
|
|
40
|
+
peers: [],
|
|
41
|
+
readOnly: false,
|
|
42
|
+
sourceCanonical: false,
|
|
43
|
+
status: "detached",
|
|
44
|
+
}) as TabCollabState;
|
|
45
|
+
states.set(tab, state);
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isCollabActive(tab: Tab): boolean {
|
|
51
|
+
return states.get(tab)?.active === true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Track which project-relative paths have live sessions (fs-event/reload suppression). */
|
|
55
|
+
export function registerCollabPath(path: string): void {
|
|
56
|
+
attachedPaths.set(path, (attachedPaths.get(path) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function unregisterCollabPath(path: string): void {
|
|
60
|
+
const count = attachedPaths.get(path) ?? 0;
|
|
61
|
+
if (count <= 1) {
|
|
62
|
+
attachedPaths.delete(path);
|
|
63
|
+
} else {
|
|
64
|
+
attachedPaths.set(path, count - 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True while some tab co-edits this path — its Y.Doc is ahead of any provider write-back. */
|
|
69
|
+
export function isCollabPath(path: string): boolean {
|
|
70
|
+
return attachedPaths.has(path.replaceAll("\\", "/"));
|
|
71
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote cursor colors for y-monaco. MonacoBinding decorates other clients' text cursors with
|
|
3
|
+
* per-client class names (`yRemoteSelection-<id>`, `yRemoteSelectionHead-<id>`) but ships no
|
|
4
|
+
* styling — this module maintains one injected <style> element with a rule set per remote client,
|
|
5
|
+
* colored from the presence palette (`user.color`) and labeled with the author's name on hover of
|
|
6
|
+
* the cursor head.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The slice of y-protocols Awareness the style manager uses (duck-typed; stays yjs-free). */
|
|
10
|
+
export interface AwarenessLike {
|
|
11
|
+
clientID: number;
|
|
12
|
+
getStates: () => Map<number, unknown>;
|
|
13
|
+
on: (event: "change", cb: () => void) => void;
|
|
14
|
+
off: (event: "change", cb: () => void) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PeerStateShape {
|
|
18
|
+
user?: { color?: string; name?: string; login?: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** CSS-escape for the content string (names come from GitHub logins/display names). */
|
|
22
|
+
function cssString(value: string): string {
|
|
23
|
+
return `"${value.replaceAll("\\", String.raw`\\`).replaceAll('"', String.raw`\"`)}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One client's rules: translucent selection band, solid caret, name flag above the caret. */
|
|
27
|
+
export function cursorRulesFor(clientId: number, color: string, label: string): string {
|
|
28
|
+
return [
|
|
29
|
+
`.yRemoteSelection-${clientId}{background-color:${color}44;}`,
|
|
30
|
+
`.yRemoteSelectionHead-${clientId}{position:absolute;border-left:2px solid ${color};height:100%;box-sizing:border-box;}`,
|
|
31
|
+
`.yRemoteSelectionHead-${clientId}::after{content:${cssString(label)};position:absolute;top:-1.2em;left:-2px;padding:0 4px;border-radius:3px 3px 3px 0;font:10px/1.4 sans-serif;color:#fff;white-space:nowrap;background-color:${color};opacity:0;transition:opacity .15s;pointer-events:none;}`,
|
|
32
|
+
`.yRemoteSelectionHead-${clientId}:hover::after{opacity:1;}`,
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Full stylesheet text for every remote client with a presence identity. */
|
|
37
|
+
export function cursorStylesheet(awareness: AwarenessLike): string {
|
|
38
|
+
const rules: string[] = [];
|
|
39
|
+
for (const [clientId, raw] of awareness.getStates()) {
|
|
40
|
+
if (clientId === awareness.clientID) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const { user } = raw as PeerStateShape;
|
|
44
|
+
if (!user?.color) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
rules.push(cursorRulesFor(clientId, user.color, user.name ?? user.login ?? String(clientId)));
|
|
48
|
+
}
|
|
49
|
+
return rules.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keep an injected <style> element in sync with the awareness roster while a code view is bound.
|
|
54
|
+
* Returns the disposer (removes the element and the listener).
|
|
55
|
+
*/
|
|
56
|
+
export function attachCursorStyles(awareness: AwarenessLike, doc: Document): () => void {
|
|
57
|
+
const style = doc.createElement("style");
|
|
58
|
+
style.dataset["jxCollabCursors"] = "true";
|
|
59
|
+
doc.head.append(style);
|
|
60
|
+
const refresh = () => {
|
|
61
|
+
style.textContent = cursorStylesheet(awareness);
|
|
62
|
+
};
|
|
63
|
+
refresh();
|
|
64
|
+
awareness.on("change", refresh);
|
|
65
|
+
return () => {
|
|
66
|
+
awareness.off("change", refresh);
|
|
67
|
+
style.remove();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence chips — who else is in this co-editing session, plus the sync-status pill that replaces
|
|
3
|
+
* the dirty dot for collab tabs. Pure lit templates over collabState(tab); the toolbar includes
|
|
4
|
+
* them and its render effect tracks the underlying reactive state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html, nothing } from "lit-html";
|
|
8
|
+
import type { TemplateResult } from "lit-html";
|
|
9
|
+
import { collabState } from "./collab-state";
|
|
10
|
+
import type { PeerPresence } from "./collab-state";
|
|
11
|
+
import type { Tab } from "../tabs/tab";
|
|
12
|
+
|
|
13
|
+
function initialOf(peer: PeerPresence): string {
|
|
14
|
+
const name = peer.state.user.name ?? peer.state.user.login;
|
|
15
|
+
return (name[0] ?? "?").toUpperCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function titleOf(peer: PeerPresence, docPath: string | null): string {
|
|
19
|
+
const { user } = peer.state;
|
|
20
|
+
const who = user.name ? `${user.name} (${user.login})` : user.login;
|
|
21
|
+
const here = peer.state.focusedPath && peer.state.focusedPath === docPath;
|
|
22
|
+
return here ? who : `${who} — ${peer.state.focusedPath ?? "browsing"}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
26
|
+
connecting: "Connecting…",
|
|
27
|
+
offline: "Offline — changes sync on reconnect",
|
|
28
|
+
synced: "Live",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Chips + status pill for the toolbar; `nothing` while the tab isn't co-editing. */
|
|
32
|
+
export function presenceChipsTemplate(tab: Tab | null): TemplateResult | typeof nothing {
|
|
33
|
+
if (!tab) {
|
|
34
|
+
return nothing;
|
|
35
|
+
}
|
|
36
|
+
const state = collabState(tab);
|
|
37
|
+
if (state.status === "detached") {
|
|
38
|
+
return nothing;
|
|
39
|
+
}
|
|
40
|
+
const label = STATUS_LABEL[state.status] ?? state.status;
|
|
41
|
+
return html`
|
|
42
|
+
<div class="jx-presence" title=${label}>
|
|
43
|
+
<span class="jx-presence-status" data-status=${state.status}>${label}</span>
|
|
44
|
+
${state.peers.map(
|
|
45
|
+
(peer) => html`
|
|
46
|
+
<span
|
|
47
|
+
class="jx-presence-chip"
|
|
48
|
+
style="background:${peer.state.user.color}"
|
|
49
|
+
title=${titleOf(peer, tab.documentPath)}
|
|
50
|
+
>${peer.state.user.avatarUrl
|
|
51
|
+
? html`<img src=${peer.state.user.avatarUrl} alt=${initialOf(peer)} />`
|
|
52
|
+
: initialOf(peer)}</span
|
|
53
|
+
>
|
|
54
|
+
`,
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
`;
|
|
58
|
+
}
|
package/src/files/file-ops.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { statusMessage } from "../panels/statusbar";
|
|
|
13
13
|
import { validateComponentSlots } from "../services/cem-export";
|
|
14
14
|
import { getPlatform } from "../platform";
|
|
15
15
|
import { activeTab, openTab } from "../workspace/workspace";
|
|
16
|
+
import { collabSave } from "../collab/collab-session";
|
|
16
17
|
import { isEditing, stopEditing } from "../editor/inline-edit";
|
|
17
18
|
import {
|
|
18
19
|
defaultContentFormat,
|
|
@@ -162,6 +163,11 @@ export async function saveFile() {
|
|
|
162
163
|
return;
|
|
163
164
|
}
|
|
164
165
|
try {
|
|
166
|
+
// A co-edited tab persists through its provider (a direct file write would reset the room).
|
|
167
|
+
if (await collabSave(tab)) {
|
|
168
|
+
savedMessage(tab, "Synced");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
165
171
|
const output = await serializeDocument(tab);
|
|
166
172
|
|
|
167
173
|
if (tab.documentPath) {
|
package/src/files/files.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { ensureDependenciesInstalled } from "../packages/ensure-deps";
|
|
|
20
20
|
import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
|
|
21
21
|
import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
|
|
22
22
|
import { markLocalMutation } from "./fs-events";
|
|
23
|
+
import { isCollabPath } from "../collab/collab-state";
|
|
23
24
|
import {
|
|
24
25
|
draggable,
|
|
25
26
|
dropTargetForElements,
|
|
@@ -1078,6 +1079,11 @@ export async function openFileInTab(path: string) {
|
|
|
1078
1079
|
*/
|
|
1079
1080
|
/** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
|
|
1080
1081
|
export function reloadCleanTab(path: string): void {
|
|
1082
|
+
// Co-edited docs never reload from disk: the shared Y.Doc is ahead of the provider's write-back
|
|
1083
|
+
// (Which is what produced this event), and genuine external changes arrive as a collab reset.
|
|
1084
|
+
if (isCollabPath(path)) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1081
1087
|
for (const [, tab] of workspace.tabs.entries()) {
|
|
1082
1088
|
if (tab.documentPath === path && !tab.doc.dirty) {
|
|
1083
1089
|
void reloadFileInTab(path);
|
package/src/panels/git-panel.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { html, nothing } from "lit-html";
|
|
8
8
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
9
|
+
import { flushAllCollab } from "../collab/collab-session";
|
|
9
10
|
import type {
|
|
10
11
|
GitBranchesResult,
|
|
11
12
|
GitDiffState,
|
|
@@ -263,6 +264,9 @@ export function renderGitPanel(
|
|
|
263
264
|
return;
|
|
264
265
|
}
|
|
265
266
|
updateUi("gitCommitMessage", "");
|
|
267
|
+
// Fold co-editing sessions into the backend's tree first so the commit never misses
|
|
268
|
+
// Trailing keystrokes (the mirror is debounced).
|
|
269
|
+
await flushAllCollab();
|
|
266
270
|
await gitAction("gitCommit", msg);
|
|
267
271
|
};
|
|
268
272
|
|
|
@@ -275,6 +279,7 @@ export function renderGitPanel(
|
|
|
275
279
|
updateUi("gitCommitMessage", "");
|
|
276
280
|
updateUi("gitLoading", true);
|
|
277
281
|
updateUi("gitError", null);
|
|
282
|
+
await flushAllCollab();
|
|
278
283
|
const plat = getPlatform();
|
|
279
284
|
try {
|
|
280
285
|
await plat.gitCommit(msg);
|
package/src/panels/toolbar.ts
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
import { html, render as litRender, nothing } from "lit-html";
|
|
8
8
|
import { openPublishPanel } from "../publish/publish-panel";
|
|
9
9
|
import { updateSession } from "../store";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
canRedo as tabCanRedo,
|
|
12
|
+
canUndo as tabCanUndo,
|
|
13
|
+
redo as tabRedo,
|
|
14
|
+
undo as tabUndo,
|
|
15
|
+
} from "../tabs/transact";
|
|
16
|
+
import { collabState } from "../collab/collab-state";
|
|
17
|
+
import { presenceChipsTemplate } from "../collab/presence-chips";
|
|
11
18
|
import { effect, effectScope } from "../reactivity";
|
|
12
19
|
import { activeTab } from "../workspace/workspace";
|
|
13
20
|
import { applyPanelCollapse, view } from "../view";
|
|
@@ -101,6 +108,8 @@ export function mount(rootEl: HTMLElement, ctx: ToolbarCtx) {
|
|
|
101
108
|
void tab.session.ui.gitStatus;
|
|
102
109
|
void tab.history.index;
|
|
103
110
|
void tab.history.snapshots.length;
|
|
111
|
+
void collabState(tab).status;
|
|
112
|
+
void collabState(tab).peers.length;
|
|
104
113
|
}
|
|
105
114
|
render();
|
|
106
115
|
});
|
|
@@ -322,8 +331,8 @@ function toolbarTemplate() {
|
|
|
322
331
|
}
|
|
323
332
|
|
|
324
333
|
const allowedModes = new Set(tab.capabilities.modes);
|
|
325
|
-
const canUndo = tab
|
|
326
|
-
const canRedo = tab
|
|
334
|
+
const canUndo = tabCanUndo(tab);
|
|
335
|
+
const canRedo = tabCanRedo(tab);
|
|
327
336
|
const canSave = tab.doc.dirty;
|
|
328
337
|
|
|
329
338
|
const S = {
|
|
@@ -495,6 +504,7 @@ function toolbarTemplate() {
|
|
|
495
504
|
${toolbarIconMap["sp-icon-redo"]}<span class="tb-label">Redo</span>
|
|
496
505
|
</sp-action-button>
|
|
497
506
|
</sp-action-group>
|
|
507
|
+
${presenceChipsTemplate(tab)}
|
|
498
508
|
<div class="tb-spacer"></div>
|
|
499
509
|
<sp-action-button
|
|
500
510
|
class="tb-search-trigger"
|
package/src/platforms/cloud.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
|
|
17
17
|
import { serializeJxMarkdown } from "@jxsuite/parser/serialize";
|
|
18
18
|
import markdownClassDef from "@jxsuite/parser/Markdown.class.json";
|
|
19
|
+
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
19
20
|
import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
20
21
|
import type {
|
|
21
22
|
DirEntry,
|
|
@@ -197,6 +198,11 @@ export function parseRootKey(root: string): CloudProject | null {
|
|
|
197
198
|
export function createCloudPlatform(project: CloudProject | null): StudioPlatform {
|
|
198
199
|
const base = project ? sessionBase(project) : "";
|
|
199
200
|
const root = project ? `${project.owner}/${project.repo}` : "";
|
|
201
|
+
/**
|
|
202
|
+
* One multiplexed collab socket per session; per-doc handles come from openDoc. Memoized as a
|
|
203
|
+
* promise so concurrent first opens share the connection instead of racing two sockets.
|
|
204
|
+
*/
|
|
205
|
+
let collabConnection: Promise<WsCollabConnection> | null = null;
|
|
200
206
|
|
|
201
207
|
function api(path: string, init?: RequestInit): Promise<Response> {
|
|
202
208
|
if (!project) {
|
|
@@ -341,6 +347,31 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
341
347
|
// Directories exist implicitly in the virtual tree (created on write).
|
|
342
348
|
},
|
|
343
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Realtime co-editing over the gateway's /collab WebSocket (rooms keyed by project-relative
|
|
352
|
+
* path, per the shared ProjectSession working tree). Backends without the endpoint (or with the
|
|
353
|
+
* flag off) refuse the upgrade and Studio degrades to solo editing. The wire client's
|
|
354
|
+
* evaluation defers behind the dynamic import until a doc opens.
|
|
355
|
+
*/
|
|
356
|
+
async collab(docPath: string) {
|
|
357
|
+
if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
collabConnection ??= (async () => {
|
|
361
|
+
const { createWsCollabConnection } = await import("@jxsuite/collab/client");
|
|
362
|
+
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
363
|
+
return createWsCollabConnection({
|
|
364
|
+
hydratePath: async (path) => {
|
|
365
|
+
// The DO has no GitHub token on a WS message; a plain read hydrates + caches the row.
|
|
366
|
+
await api(`/file?path=${encodeURIComponent(path)}`);
|
|
367
|
+
},
|
|
368
|
+
url: `${scheme}://${location.host}${base}/collab`,
|
|
369
|
+
});
|
|
370
|
+
})();
|
|
371
|
+
const connection = await collabConnection;
|
|
372
|
+
return connection.openDoc(docPath);
|
|
373
|
+
},
|
|
374
|
+
|
|
344
375
|
/**
|
|
345
376
|
* Live session events over the gateway WebSocket. Reconnects with a small backoff; the DO
|
|
346
377
|
* pushes {kind:"fs"} batches for file mutations (including those from other tabs) and
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { streamImport } from "../services/import-client";
|
|
12
|
+
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
12
13
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
13
14
|
import type {
|
|
14
15
|
DirEntry,
|
|
@@ -46,6 +47,13 @@ interface SiteEntry {
|
|
|
46
47
|
*/
|
|
47
48
|
export function createDevServerPlatform() {
|
|
48
49
|
let _projectRoot = "";
|
|
50
|
+
/** Lazy /__studio/collab capability probe (null = not asked yet). */
|
|
51
|
+
let _collabProbe: Promise<boolean> | null = null;
|
|
52
|
+
/**
|
|
53
|
+
* One multiplexed collab socket per page; per-doc handles come from openDoc. Memoized as a
|
|
54
|
+
* promise so concurrent first opens share the connection instead of racing two sockets.
|
|
55
|
+
*/
|
|
56
|
+
let _collabConnection: Promise<WsCollabConnection> | null = null;
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* Prefix a project-relative path with the active project root for server API calls.
|
|
@@ -390,6 +398,35 @@ export function createDevServerPlatform() {
|
|
|
390
398
|
};
|
|
391
399
|
},
|
|
392
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Realtime co-editing over the dev server's /__studio/collab endpoint (rooms keyed by
|
|
403
|
+
* server-root-relative path). Probes capability once — older servers without the endpoint
|
|
404
|
+
* degrade to solo editing; the wire client's evaluation defers behind the dynamic import until
|
|
405
|
+
* a doc opens.
|
|
406
|
+
*/
|
|
407
|
+
async collab(docPath: string) {
|
|
408
|
+
if (typeof WebSocket === "undefined" || typeof location === "undefined") {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (_collabProbe === null) {
|
|
412
|
+
_collabProbe = fetch("/__studio/collab")
|
|
413
|
+
.then((res) => res.ok)
|
|
414
|
+
.catch(() => false);
|
|
415
|
+
}
|
|
416
|
+
if (!(await _collabProbe)) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
_collabConnection ??= (async () => {
|
|
420
|
+
const { createWsCollabConnection } = await import("@jxsuite/collab/client");
|
|
421
|
+
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
422
|
+
return createWsCollabConnection({
|
|
423
|
+
url: `${scheme}://${location.host}/__studio/collab`,
|
|
424
|
+
});
|
|
425
|
+
})();
|
|
426
|
+
const connection = await _collabConnection;
|
|
427
|
+
return connection.openDoc(serverPath(docPath));
|
|
428
|
+
},
|
|
429
|
+
|
|
393
430
|
/** @param {string} _path */
|
|
394
431
|
async createDirectory(_path: string) {
|
|
395
432
|
// The server creates directories implicitly when writing files.
|
package/src/studio.ts
CHANGED
|
@@ -77,6 +77,11 @@ import {
|
|
|
77
77
|
setupTreeKeyboard,
|
|
78
78
|
} from "./files/files";
|
|
79
79
|
import { startFsSync } from "./files/fs-events";
|
|
80
|
+
import {
|
|
81
|
+
configureCollabNotifier,
|
|
82
|
+
configureCollabParser,
|
|
83
|
+
configureCollabSerializer,
|
|
84
|
+
} from "./collab/collab-session";
|
|
80
85
|
import { renderImportsTemplate } from "./panels/imports-panel";
|
|
81
86
|
import { renderHeadTemplate } from "./panels/head-panel";
|
|
82
87
|
import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
|
|
@@ -571,6 +576,18 @@ function safeRenderRightPanel() {
|
|
|
571
576
|
// Now that renderers are registered, bootstrap
|
|
572
577
|
registerFunctionCompletions();
|
|
573
578
|
|
|
579
|
+
// Collab sessions serialize/parse through the format host when mirroring between the structure
|
|
580
|
+
// Tree and the shared source text, and surface freezes via the status bar.
|
|
581
|
+
configureCollabSerializer(serializeDocument);
|
|
582
|
+
configureCollabParser(async (tab, text) => {
|
|
583
|
+
if (tab.documentPath && formatForPath(tab.documentPath)) {
|
|
584
|
+
const parsed = await parseSourceForPath(tab.documentPath, text);
|
|
585
|
+
return { document: parsed.document as JxMutableNode, frontmatter: parsed.frontmatter };
|
|
586
|
+
}
|
|
587
|
+
return { document: JSON.parse(text) as JxMutableNode };
|
|
588
|
+
});
|
|
589
|
+
configureCollabNotifier(statusMessage);
|
|
590
|
+
|
|
574
591
|
let fsUnsub: (() => void) | null = null;
|
|
575
592
|
/** (Re)subscribe the sidebar to backend filesystem events for the active project. */
|
|
576
593
|
function ensureFsSync() {
|
package/src/tabs/doc-op-apply.ts
CHANGED
|
@@ -4,86 +4,10 @@
|
|
|
4
4
|
* from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
|
|
5
5
|
* canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
7
|
+
* The implementation is the canonical one in `@jxsuite/collab/ops` (yjs-free, so the slim
|
|
8
|
+
* canvas-iframe bundle imports it without dragging yjs in); this module re-exports it so both sides
|
|
9
|
+
* of the frame boundary — and the collab bridge — replay ops through one code path.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
15
|
-
|
|
16
|
-
/** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
|
|
17
|
-
function jsonClone<T>(value: T): T {
|
|
18
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
|
|
19
|
-
return JSON.parse(JSON.stringify(value)) as T;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
|
|
23
|
-
export function cloneValue<T>(v: T): T {
|
|
24
|
-
return v === undefined || v === null ? v : (jsonClone(v as object) as T);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
|
|
28
|
-
export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
29
|
-
// Defense-in-depth: a path that resolves to a children array (rather than a node) would
|
|
30
|
-
// Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
|
|
31
|
-
// Nothing renders. Callers must pass a node; fail loudly if they don't.
|
|
32
|
-
if (Array.isArray(node)) {
|
|
33
|
-
throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
|
|
34
|
-
}
|
|
35
|
-
if (!node.children) {
|
|
36
|
-
node.children = [];
|
|
37
|
-
}
|
|
38
|
-
if (!Array.isArray(node.children)) {
|
|
39
|
-
throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
|
|
40
|
-
}
|
|
41
|
-
return node.children;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
|
|
46
|
-
* aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
|
|
47
|
-
*/
|
|
48
|
-
export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
49
|
-
switch (op.op) {
|
|
50
|
-
case "set-key": {
|
|
51
|
-
const node = getNodeAtPath(doc, op.path);
|
|
52
|
-
if (!node) {
|
|
53
|
-
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
54
|
-
}
|
|
55
|
-
const target = node as Record<string, unknown>;
|
|
56
|
-
if (op.value === undefined) {
|
|
57
|
-
delete target[op.key];
|
|
58
|
-
} else {
|
|
59
|
-
target[op.key] = cloneValue(op.value);
|
|
60
|
-
}
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
case "insert-child": {
|
|
64
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
65
|
-
childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
case "remove-child": {
|
|
69
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
70
|
-
childArray(parent).splice(op.index, 1);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
case "set-child": {
|
|
74
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
75
|
-
childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
case "move-child": {
|
|
79
|
-
const fromParent = getNodeAtPath(doc, op.fromParentPath);
|
|
80
|
-
const toParent = getNodeAtPath(doc, op.toParentPath);
|
|
81
|
-
const [node] = childArray(fromParent).splice(op.fromIndex, 1);
|
|
82
|
-
childArray(toParent).splice(op.toIndex, 0, node!);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
default: {
|
|
86
|
-
throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
12
|
+
export { applyDocOpToDoc, childArray, cloneValue } from "@jxsuite/collab/ops";
|
|
13
|
+
export type { JxDocOp } from "@jxsuite/collab/ops";
|
package/src/tabs/patch-ops.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* document as the single source of truth.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// oxlint-disable-next-line unicorn/prefer-export-from -- JxDocOpPair is also used locally (TransactionRecord)
|
|
9
|
+
import type { JxDocOpPair } from "@jxsuite/collab/ops";
|
|
8
10
|
import type { JxPath } from "../state";
|
|
9
11
|
import type { Tab } from "./tab.js";
|
|
10
12
|
|
|
@@ -31,30 +33,12 @@ export type JxPatchOp =
|
|
|
31
33
|
/**
|
|
32
34
|
* Value-carrying document mutation, replayable in either direction. Mutators record a
|
|
33
35
|
* forward/inverse pair per change; history applies them for surgical undo/redo and for
|
|
34
|
-
* materializing states from checkpoints — without whole-document snapshots per edit.
|
|
36
|
+
* materializing states from checkpoints — without whole-document snapshots per edit. The definition
|
|
37
|
+
* is canonical in `@jxsuite/collab/ops` (the collab bridge mirrors the same ops into a shared
|
|
38
|
+
* Y.Doc); re-exported here so studio call sites keep their import path.
|
|
35
39
|
*/
|
|
36
|
-
export type JxDocOp
|
|
37
|
-
|
|
38
|
-
| { op: "set-key"; path: JxPath; key: string; value?: unknown }
|
|
39
|
-
/** Insert a node at parentPath.children[index]. */
|
|
40
|
-
| { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
|
|
41
|
-
/** Remove parentPath.children[index]. */
|
|
42
|
-
| { op: "remove-child"; parentPath: JxPath; index: number }
|
|
43
|
-
/** Replace parentPath.children[index] with node. */
|
|
44
|
-
| { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
|
|
45
|
-
/** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
|
|
46
|
-
| {
|
|
47
|
-
op: "move-child";
|
|
48
|
-
fromParentPath: JxPath;
|
|
49
|
-
fromIndex: number;
|
|
50
|
-
toParentPath: JxPath;
|
|
51
|
-
toIndex: number;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export interface JxDocOpPair {
|
|
55
|
-
forward: JxDocOp;
|
|
56
|
-
inverse: JxDocOp;
|
|
57
|
-
}
|
|
40
|
+
export type { JxDocOp } from "@jxsuite/collab/ops";
|
|
41
|
+
export type { JxDocOpPair };
|
|
58
42
|
|
|
59
43
|
/** Everything recorded during one transaction. */
|
|
60
44
|
export interface TransactionRecord {
|