@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,360 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Publish panel — the one-click Cloudflare Pages publish flow, driven by the
4
+ * PAL's cf* members. States: unsupported platform → info; no credential →
5
+ * connect (hosted OAuth via cfConnect, or an API-token form backed by
6
+ * cf-settings); connected without `build.deploy` → create-and-connect form;
7
+ * connected → deployment status (publishing rides every commit).
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import { html } from "lit-html";
13
+ import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
14
+ import { getPlatform } from "../platform";
15
+ import { getCfToken, setCfToken } from "../services/cf-settings";
16
+ import { projectState } from "../store";
17
+ import type { CfConnection } from "../types";
18
+ import { openModal } from "../ui/layers";
19
+ import type { CfAccount, PagesDeploymentInfo } from "./pages-service";
20
+ import {
21
+ connectDeploy,
22
+ latestDeployment,
23
+ listAccounts,
24
+ platformSupportsPublish,
25
+ writeDeployConfig,
26
+ } from "./pages-service";
27
+
28
+ const PAGES_APP_INSTALL_URL = "https://github.com/apps/cloudflare-pages/installations/new";
29
+
30
+ let _handle: ReturnType<typeof openModal> | null = null;
31
+ let _connection: CfConnection | null | "loading" = "loading";
32
+ let _accounts: CfAccount[] = [];
33
+ let _deployment: PagesDeploymentInfo | null = null;
34
+ let _error = "";
35
+ let _busy = false;
36
+ let _form = { accountId: "", branch: "main", owner: "", projectName: "", repo: "" };
37
+
38
+ function currentConfig(): ProjectConfig | null {
39
+ return (projectState?.projectConfig as ProjectConfig | undefined) ?? null;
40
+ }
41
+
42
+ function currentDeploy(): DeployConfig | undefined {
43
+ return currentConfig()?.build?.deploy;
44
+ }
45
+
46
+ function deriveSlug(name: string): string {
47
+ return (
48
+ name
49
+ .toLowerCase()
50
+ .replaceAll(/[^a-z0-9]+/g, "-")
51
+ .replaceAll(/^-+|-+$/g, "")
52
+ .slice(0, 58) || "site"
53
+ );
54
+ }
55
+
56
+ /** Prefill owner/repo from cloud roots ("owner/repo"); blank elsewhere. */
57
+ function prefillRepo(): { owner: string; repo: string } {
58
+ const root = getPlatform().projectRoot;
59
+ const match = /^([\w.-]+)\/([\w.-]+)$/.exec(root);
60
+ return match ? { owner: match[1]!, repo: match[2]! } : { owner: "", repo: "" };
61
+ }
62
+
63
+ async function loadConnection(): Promise<void> {
64
+ _connection = "loading";
65
+ render();
66
+ try {
67
+ _connection = (await getPlatform().cfConnection?.()) ?? null;
68
+ if (_connection?.connected) {
69
+ _accounts = await listAccounts();
70
+ _form.accountId = _connection.accountId ?? _accounts[0]?.id ?? "";
71
+ const deploy = currentDeploy();
72
+ if (deploy) {
73
+ _deployment = await latestDeployment(deploy);
74
+ }
75
+ }
76
+ } catch (error) {
77
+ _connection = null;
78
+ _error = error instanceof Error ? error.message : String(error);
79
+ }
80
+ render();
81
+ }
82
+
83
+ async function saveToken(host: HTMLElement): Promise<void> {
84
+ const input = host.querySelector<HTMLInputElement>("#cf-token-input");
85
+ setCfToken(input?.value ?? "");
86
+ _error = "";
87
+ await loadConnection();
88
+ }
89
+
90
+ async function hostedConnect(): Promise<void> {
91
+ _busy = true;
92
+ render();
93
+ try {
94
+ await getPlatform().cfConnect?.();
95
+ } catch (error) {
96
+ _error = error instanceof Error ? error.message : String(error);
97
+ }
98
+ _busy = false;
99
+ await loadConnection();
100
+ }
101
+
102
+ async function submitConnect(): Promise<void> {
103
+ const config = currentConfig();
104
+ if (!config) {
105
+ return;
106
+ }
107
+ if (!_form.projectName || !_form.owner || !_form.repo || !_form.accountId) {
108
+ _error = "Account, project name, and the GitHub owner/repo are all required.";
109
+ render();
110
+ return;
111
+ }
112
+ _busy = true;
113
+ _error = "";
114
+ render();
115
+ try {
116
+ const deploy = await connectDeploy(config, {
117
+ accountId: _form.accountId,
118
+ owner: _form.owner,
119
+ productionBranch: _form.branch || "main",
120
+ projectName: _form.projectName,
121
+ repo: _form.repo,
122
+ });
123
+ _deployment = await latestDeployment(deploy).catch(() => null);
124
+ } catch (error) {
125
+ _error = error instanceof Error ? error.message : String(error);
126
+ }
127
+ _busy = false;
128
+ render();
129
+ }
130
+
131
+ async function disconnect(): Promise<void> {
132
+ const config = currentConfig();
133
+ if (!config) {
134
+ return;
135
+ }
136
+ _busy = true;
137
+ render();
138
+ try {
139
+ await writeDeployConfig(config, null);
140
+ _deployment = null;
141
+ } catch (error) {
142
+ _error = error instanceof Error ? error.message : String(error);
143
+ }
144
+ _busy = false;
145
+ render();
146
+ }
147
+
148
+ function close(): void {
149
+ _handle?.close();
150
+ _handle = null;
151
+ }
152
+
153
+ function fieldRow(label: string, input: unknown) {
154
+ return html`
155
+ <label class="publish-field">
156
+ <span>${label}</span>
157
+ ${input}
158
+ </label>
159
+ `;
160
+ }
161
+
162
+ function credentialTpl() {
163
+ const platform = getPlatform();
164
+ if (platform.cfConnect) {
165
+ return html`
166
+ <p>Connect your Cloudflare account to publish this site.</p>
167
+ <sp-button ?disabled=${_busy} @click=${() => void hostedConnect()}>
168
+ Connect Cloudflare
169
+ </sp-button>
170
+ `;
171
+ }
172
+ return html`
173
+ <p>
174
+ Paste a Cloudflare API token (permissions: Account Settings Read, Pages Read/Write). It is
175
+ stored locally and only sent to the same-origin proxy.
176
+ </p>
177
+ ${fieldRow(
178
+ "API token",
179
+ html`<sp-textfield
180
+ id="cf-token-input"
181
+ type="password"
182
+ value=${getCfToken()}
183
+ placeholder="cf_..."
184
+ ></sp-textfield>`,
185
+ )}
186
+ <sp-button ?disabled=${_busy} @click=${(e: Event) => void saveToken(hostOf(e))}>
187
+ Verify & Connect
188
+ </sp-button>
189
+ `;
190
+ }
191
+
192
+ function hostOf(e: Event): HTMLElement {
193
+ return (e.target as HTMLElement).closest(".publish-modal") ?? document.body;
194
+ }
195
+
196
+ function connectFormTpl() {
197
+ return html`
198
+ <p>
199
+ Create a Cloudflare Pages project connected to this repository. Every commit then builds and
200
+ publishes automatically (<code>bunx jx build</code>).
201
+ </p>
202
+ ${fieldRow(
203
+ "Account",
204
+ html`
205
+ <sp-picker
206
+ value=${_form.accountId}
207
+ @change=${(e: Event) => {
208
+ _form.accountId = (e.target as HTMLInputElement).value;
209
+ }}
210
+ >
211
+ ${_accounts.map((a) => html`<sp-menu-item value=${a.id}>${a.name}</sp-menu-item>`)}
212
+ </sp-picker>
213
+ `,
214
+ )}
215
+ ${fieldRow(
216
+ "Pages project name",
217
+ html`<sp-textfield
218
+ value=${_form.projectName}
219
+ @input=${(e: Event) => {
220
+ _form.projectName = (e.target as HTMLInputElement).value;
221
+ }}
222
+ ></sp-textfield>`,
223
+ )}
224
+ ${fieldRow(
225
+ "GitHub owner",
226
+ html`<sp-textfield
227
+ value=${_form.owner}
228
+ @input=${(e: Event) => {
229
+ _form.owner = (e.target as HTMLInputElement).value;
230
+ }}
231
+ ></sp-textfield>`,
232
+ )}
233
+ ${fieldRow(
234
+ "GitHub repository",
235
+ html`<sp-textfield
236
+ value=${_form.repo}
237
+ @input=${(e: Event) => {
238
+ _form.repo = (e.target as HTMLInputElement).value;
239
+ }}
240
+ ></sp-textfield>`,
241
+ )}
242
+ ${fieldRow(
243
+ "Production branch",
244
+ html`<sp-textfield
245
+ value=${_form.branch}
246
+ @input=${(e: Event) => {
247
+ _form.branch = (e.target as HTMLInputElement).value;
248
+ }}
249
+ ></sp-textfield>`,
250
+ )}
251
+ <sp-button ?disabled=${_busy} @click=${() => void submitConnect()}>
252
+ ${_busy ? "Connecting…" : "Create & Connect"}
253
+ </sp-button>
254
+ `;
255
+ }
256
+
257
+ function statusTpl(deploy: DeployConfig) {
258
+ return html`
259
+ <p>
260
+ Connected to Pages project <strong>${deploy.projectName}</strong>
261
+ ${deploy.productionUrl
262
+ ? html` —
263
+ <a href=${deploy.productionUrl} target="_blank" rel="noreferrer">
264
+ ${deploy.productionUrl}
265
+ </a>`
266
+ : ""}
267
+ </p>
268
+ ${_deployment
269
+ ? html`
270
+ <p>
271
+ Latest deployment: <strong>${_deployment.stage}: ${_deployment.status}</strong>
272
+ (${_deployment.environment}) —
273
+ <a href=${_deployment.url} target="_blank" rel="noreferrer">preview</a>
274
+ </p>
275
+ `
276
+ : html`<p>No deployments yet — the first commit after connecting triggers one.</p>`}
277
+ <p class="publish-hint">Publishing happens automatically on every commit.</p>
278
+ <div class="publish-actions">
279
+ <sp-button variant="secondary" ?disabled=${_busy} @click=${() => void loadConnection()}>
280
+ Refresh
281
+ </sp-button>
282
+ <sp-button variant="negative" ?disabled=${_busy} @click=${() => void disconnect()}>
283
+ Disconnect
284
+ </sp-button>
285
+ </div>
286
+ `;
287
+ }
288
+
289
+ function bodyTpl() {
290
+ if (!platformSupportsPublish()) {
291
+ return html`
292
+ <p>
293
+ This platform cannot reach the Cloudflare API. Publish by committing and pushing — your host
294
+ builds <code>bunx jx build</code> and serves <code>dist/</code>.
295
+ </p>
296
+ `;
297
+ }
298
+ if (_connection === "loading") {
299
+ return html`<p>Checking Cloudflare connection…</p>`;
300
+ }
301
+ if (!_connection?.connected) {
302
+ return credentialTpl();
303
+ }
304
+ const deploy = currentDeploy();
305
+ return deploy ? statusTpl(deploy) : connectFormTpl();
306
+ }
307
+
308
+ function errorTpl() {
309
+ if (!_error) {
310
+ return "";
311
+ }
312
+ const needsPagesApp = /github/i.test(_error) && /app|install|source|repo/i.test(_error);
313
+ return html`
314
+ <p class="publish-error">
315
+ ${_error}
316
+ ${needsPagesApp
317
+ ? html` — if the Cloudflare Pages GitHub App is not installed on the repository,
318
+ <a href=${PAGES_APP_INSTALL_URL} target="_blank" rel="noreferrer">install it</a> and
319
+ retry.`
320
+ : ""}
321
+ </p>
322
+ `;
323
+ }
324
+
325
+ function render(): void {
326
+ const tpl = html`
327
+ <div class="new-project-modal publish-modal">
328
+ <div class="new-project-modal-header">
329
+ <h2 class="new-project-modal-title">Publish</h2>
330
+ <sp-action-button size="s" quiet @click=${close}>✕</sp-action-button>
331
+ </div>
332
+ <div class="new-project-modal-body">${bodyTpl()} ${errorTpl()}</div>
333
+ </div>
334
+ `;
335
+ if (_handle) {
336
+ _handle.update(tpl);
337
+ } else {
338
+ _handle = openModal(tpl);
339
+ }
340
+ }
341
+
342
+ /** Open the publish modal for the active project. */
343
+ export function openPublishPanel(): void {
344
+ const config = currentConfig();
345
+ const { owner, repo } = prefillRepo();
346
+ _connection = "loading";
347
+ _accounts = [];
348
+ _deployment = null;
349
+ _error = "";
350
+ _busy = false;
351
+ _form = {
352
+ accountId: "",
353
+ branch: "main",
354
+ owner,
355
+ projectName: currentDeploy()?.projectName ?? deriveSlug(config?.name ?? ""),
356
+ repo,
357
+ };
358
+ render();
359
+ void loadConnection();
360
+ }
@@ -11,6 +11,7 @@
11
11
  * @license MIT
12
12
  */
13
13
 
14
+ import type { AiModelsResponse } from "@jxsuite/protocol";
14
15
  import { getPlatform } from "../platform";
15
16
  import { getBaseUrl, getOpenAiKey } from "./ai-settings";
16
17
 
@@ -20,10 +21,35 @@ export interface AiModel {
20
21
  }
21
22
 
22
23
  let cache: AiModel[] | null = null;
24
+ let proxyConfigured = false;
25
+ let proxyManaged = false;
26
+ let proxyDefaultModel = "";
23
27
 
24
28
  /** Drop the cached model list (call after credentials/endpoint changes). */
25
29
  export function invalidateModelCache() {
26
30
  cache = null;
31
+ proxyConfigured = false;
32
+ proxyManaged = false;
33
+ proxyDefaultModel = "";
34
+ }
35
+
36
+ /**
37
+ * Whether the proxy reported itself configured on the last fetch — true when the backend holds
38
+ * credentials (managed platforms, OPENAI_API_KEY env), so the assistant works without a locally
39
+ * stored key.
40
+ */
41
+ export function isProxyConfigured(): boolean {
42
+ return proxyConfigured;
43
+ }
44
+
45
+ /** Whether the platform manages AI credentials itself (cloud Workers AI). */
46
+ export function isManagedProxy(): boolean {
47
+ return proxyManaged;
48
+ }
49
+
50
+ /** The proxy's preferred model id ("" when it does not declare one). */
51
+ export function getProxyDefaultModel(): string {
52
+ return proxyDefaultModel;
27
53
  }
28
54
 
29
55
  /**
@@ -58,7 +84,10 @@ export async function fetchAvailableModels(
58
84
  if (!resp.ok) {
59
85
  throw new Error(`HTTP ${resp.status}`);
60
86
  }
61
- const data = (await resp.json()) as { models?: { id: string; name?: string }[] };
87
+ const data = (await resp.json()) as Partial<AiModelsResponse>;
88
+ proxyConfigured = data.configured === true;
89
+ proxyManaged = data.managed === true;
90
+ proxyDefaultModel = data.defaultModel ?? "";
62
91
  cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
63
92
  return cache;
64
93
  }
@@ -0,0 +1,58 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Cf-settings — local persistence for the Cloudflare publish connection on
4
+ * platforms without a hosted OAuth broker (dev server, desktop). The API
5
+ * token is stored like the AI key: localStorage first, mirrored through the
6
+ * platform settings backend where one exists. It only ever leaves the machine
7
+ * to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
8
+ * api.cloudflare.com (not CORS-enabled, hence the proxy).
9
+ *
10
+ * @license MIT
11
+ */
12
+
13
+ import { persistSettings } from "./settings-store";
14
+
15
+ const TOKEN_STORAGE = "jx.cf.token";
16
+ const ACCOUNT_STORAGE = "jx.cf.accountId";
17
+
18
+ function read(key: string): string {
19
+ try {
20
+ return globalThis.localStorage?.getItem(key) ?? "";
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ function write(key: string, value: string): void {
27
+ try {
28
+ const trimmed = (value || "").trim();
29
+ if (trimmed) {
30
+ globalThis.localStorage?.setItem(key, trimmed);
31
+ } else {
32
+ globalThis.localStorage?.removeItem(key);
33
+ }
34
+ } catch {
35
+ // Storage unavailable (private mode) — the connection just won't persist.
36
+ }
37
+ persistSettings();
38
+ }
39
+
40
+ /** The stored Cloudflare API token, or "" when not connected. */
41
+ export function getCfToken(): string {
42
+ return read(TOKEN_STORAGE);
43
+ }
44
+
45
+ /** Persist (or clear, with a blank value) the Cloudflare API token. */
46
+ export function setCfToken(token: string): void {
47
+ write(TOKEN_STORAGE, token);
48
+ }
49
+
50
+ /** The selected Cloudflare account id, or "" when none chosen yet. */
51
+ export function getCfAccountId(): string {
52
+ return read(ACCOUNT_STORAGE);
53
+ }
54
+
55
+ /** Persist (or clear) the selected Cloudflare account id. */
56
+ export function setCfAccountId(accountId: string): void {
57
+ write(ACCOUNT_STORAGE, accountId);
58
+ }
@@ -14,7 +14,13 @@
14
14
  import { getPlatform, hasPlatform } from "../platform";
15
15
 
16
16
  /** The localStorage keys mirrored into the platform's user-settings store. */
17
- export const PERSISTED_SETTINGS_KEYS = ["jx.ai.openaiKey", "jx.ai.baseUrl", "jx.ai.model"] as const;
17
+ export const PERSISTED_SETTINGS_KEYS = [
18
+ "jx.ai.openaiKey",
19
+ "jx.ai.baseUrl",
20
+ "jx.ai.model",
21
+ "jx.cf.token",
22
+ "jx.cf.accountId",
23
+ ] as const;
18
24
 
19
25
  /** Read a localStorage value defensively, treating unavailable/throwing storage as empty. */
20
26
  function readLocal(key: string): string {
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";
@@ -126,6 +131,7 @@ import {
126
131
  } from "./panels/block-action-bar";
127
132
  import { initCssData } from "./panels/style-utils";
128
133
  import { initQuickSearch } from "./panels/quick-search";
134
+ import { hydrateProjectList } from "./project-list";
129
135
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
130
136
  import { hydrateSettings } from "./services/settings-store";
131
137
  import { initWelcome } from "./panels/welcome-screen";
@@ -570,6 +576,18 @@ function safeRenderRightPanel() {
570
576
  // Now that renderers are registered, bootstrap
571
577
  registerFunctionCompletions();
572
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
+
573
591
  let fsUnsub: (() => void) | null = null;
574
592
  /** (Re)subscribe the sidebar to backend filesystem events for the active project. */
575
593
  function ensureFsSync() {
@@ -744,6 +762,13 @@ void hydrateRecentProjects().then(() => {
744
762
  render();
745
763
  });
746
764
 
765
+ // Hydrate the platform's project catalogue (dev server sites, cloud projects), then refresh the
766
+ // Welcome screen, which reads it synchronously. No-op on platforms without listProjects.
767
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
768
+ void hydrateProjectList().then(() => {
769
+ render();
770
+ });
771
+
747
772
  // Hydrate user settings (AI connection parameters) from the backend store, then re-render so
748
773
  // Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
749
774
  // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
@@ -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
- * Dependency-light on purpose: it pulls only the pure path helper from `../state` and clones values
8
- * with a local JSON round-trip, so the slim canvas-iframe bundle can import it without dragging in
9
- * the editor's reactive store / format host.
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
- import { getNodeAtPath } from "../state";
13
- import type { JxDocOp } from "./patch-ops";
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";
@@ -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
- /** Set (or delete, when value is undefined) a key on the node at path. */
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 {