@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.
@@ -79,22 +79,64 @@ export type JxNodeValue =
79
79
 
80
80
  // ─── Transactional layer ─────────────────────────────────────────────────────
81
81
 
82
+ /**
83
+ * Where a transaction came from: a direct user/tool edit, the undo/redo machinery replaying
84
+ * recorded ops, or a remote collaborator's ops applied locally. Observers use this to avoid
85
+ * republishing what they themselves applied.
86
+ */
87
+ export type TransactOrigin = "user" | "history" | "remote";
88
+
89
+ /**
90
+ * Module-level hook invoked at the end of every transactDoc (after the canvas patch apply and the
91
+ * dirty mark). Registered by the collab layer to mirror local edits into a shared document; at most
92
+ * one observer exists. `record.docOps` may be empty for un-instrumented mutations — observers must
93
+ * handle that (e.g. by diffing).
94
+ */
95
+ export type TransactObserver = (
96
+ tab: Tab,
97
+ record: TransactionRecord,
98
+ origin: TransactOrigin,
99
+ ) => void;
100
+
101
+ let _transactObserver: TransactObserver | null = null;
102
+
103
+ export function setTransactObserver(fn: TransactObserver | null) {
104
+ _transactObserver = fn;
105
+ }
106
+
107
+ /**
108
+ * Module-level gate consulted at transactDoc entry: return a refusal reason to block the mutation
109
+ * (the collab layer soft-freezes structural editing while a peer holds source-canonical), or null
110
+ * to proceed. Remote-origin transactions always pass — they ARE the frozen representation's
111
+ * mirror.
112
+ */
113
+ export type TransactGate = (tab: Tab, origin: TransactOrigin) => string | null;
114
+
115
+ let _transactGate: TransactGate | null = null;
116
+
117
+ export function setTransactGate(fn: TransactGate | null) {
118
+ _transactGate = fn;
119
+ }
120
+
82
121
  /**
83
122
  * Apply a document mutation transactionally: push to history and mark dirty. The mutationFn
84
123
  * receives the tab and should mutate tab.doc.document in place.
85
124
  *
86
125
  * @param {Tab | null} tab
87
126
  * @param {(tab: Tab) => void} mutationFn
88
- * @param {{ skipHistory?: boolean }} [opts]
127
+ * @param {{ skipHistory?: boolean; origin?: TransactOrigin }} [opts]
89
128
  */
90
129
  export function transactDoc(
91
130
  tab: Tab | null,
92
131
  mutationFn: (tab: Tab) => void,
93
- { skipHistory = false }: { skipHistory?: boolean } = {},
132
+ { skipHistory = false, origin = "user" }: { skipHistory?: boolean; origin?: TransactOrigin } = {},
94
133
  ) {
95
134
  if (!tab) {
96
135
  return;
97
136
  }
137
+ if (origin !== "remote" && _transactGate?.(tab, origin)) {
138
+ return;
139
+ }
98
140
  const selectionBefore = tab.session.selection ? [...tab.session.selection] : null;
99
141
  beginRecording();
100
142
  let record: TransactionRecord;
@@ -141,6 +183,8 @@ export function transactDoc(
141
183
  }
142
184
 
143
185
  tab.doc.dirty = true;
186
+
187
+ _transactObserver?.(tab, record, origin);
144
188
  }
145
189
 
146
190
  /**
@@ -215,16 +259,38 @@ function materializeState(
215
259
  *
216
260
  * @param {Tab | null} tab
217
261
  * @param {(doc: JxMutableNode) => void} fn
218
- * @param {{ skipHistory?: boolean }} [opts]
262
+ * @param {{ skipHistory?: boolean; origin?: TransactOrigin }} [opts]
219
263
  */
220
264
  export function transact(
221
265
  tab: Tab | null,
222
266
  fn: (doc: JxMutableNode) => void,
223
- opts?: { skipHistory?: boolean },
267
+ opts?: { skipHistory?: boolean; origin?: TransactOrigin },
224
268
  ) {
225
269
  transactDoc(tab, (t) => fn(t.doc.document), opts);
226
270
  }
227
271
 
272
+ /**
273
+ * Apply externally-produced doc ops (a remote collaborator's edits) through the normal transaction
274
+ * pipeline: the canvas patches surgically exactly as it does for undo/redo replay, panels' effects
275
+ * fire, and — because the origin is "remote" — the transact observer will not republish them.
276
+ * History is skipped; while a collab session is attached, undo is delegated (see
277
+ * setHistoryDelegate) and local-only.
278
+ *
279
+ * @param {Tab} tab
280
+ * @param {JxDocOp[]} ops
281
+ */
282
+ export function applyExternalDocOps(tab: Tab, ops: JxDocOp[]) {
283
+ transactDoc(
284
+ tab,
285
+ (t) => {
286
+ for (const op of ops) {
287
+ applyDocOp(t, op);
288
+ }
289
+ },
290
+ { origin: "remote", skipHistory: true },
291
+ );
292
+ }
293
+
228
294
  // ─── Document-op application ─────────────────────────────────────────────────
229
295
 
230
296
  /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
@@ -289,26 +355,40 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
289
355
 
290
356
  let _batchTab: Tab | null = null;
291
357
 
358
+ /** Module-level hook invoked when a batch closes (collab flushes its buffered ops as one step). */
359
+ export type BatchEndNotifier = (tab: Tab) => void;
360
+
361
+ let _batchEndNotifier: BatchEndNotifier | null = null;
362
+
363
+ export function setBatchEndNotifier(fn: BatchEndNotifier | null) {
364
+ _batchEndNotifier = fn;
365
+ }
366
+
292
367
  export function beginBatch(tab: Tab | null) {
293
368
  _batchTab = tab;
294
369
  }
295
370
 
296
371
  export function endBatch() {
297
- if (_batchTab) {
298
- const raw = toRaw(_batchTab.doc.document);
372
+ const tab = _batchTab;
373
+ // A history delegate owns undo grouping while registered; the snapshot push would be dead weight.
374
+ if (tab && !_historyDelegates.has(tab)) {
375
+ const raw = toRaw(tab.doc.document);
299
376
  const snapshot = {
300
377
  document: jsonClone(raw),
301
- selection: _batchTab.session.selection ? [..._batchTab.session.selection] : null,
378
+ selection: tab.session.selection ? [...tab.session.selection] : null,
302
379
  };
303
- const truncated = _batchTab.history.snapshots.slice(0, _batchTab.history.index + 1);
380
+ const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
304
381
  truncated.push(snapshot);
305
382
  if (truncated.length > HISTORY_LIMIT) {
306
383
  truncated.shift();
307
384
  }
308
- _batchTab.history.snapshots = truncated;
309
- _batchTab.history.index = truncated.length - 1;
385
+ tab.history.snapshots = truncated;
386
+ tab.history.index = truncated.length - 1;
310
387
  }
311
388
  _batchTab = null;
389
+ if (tab) {
390
+ _batchEndNotifier?.(tab);
391
+ }
312
392
  }
313
393
 
314
394
  export function isBatching(): boolean {
@@ -317,6 +397,42 @@ export function isBatching(): boolean {
317
397
 
318
398
  // ─── Undo / Redo ─────────────────────────────────────────────────────────────
319
399
 
400
+ /**
401
+ * Per-tab replacement for the built-in op-log history. While a delegate is registered (a collab
402
+ * session's Y.UndoManager), undo/redo route to it and the snapshot history is bypassed — keeping
403
+ * this module free of any yjs knowledge.
404
+ */
405
+ export interface HistoryDelegate {
406
+ undo: (tab: Tab) => void;
407
+ redo: (tab: Tab) => void;
408
+ canUndo: (tab: Tab) => boolean;
409
+ canRedo: (tab: Tab) => boolean;
410
+ }
411
+
412
+ const _historyDelegates = new WeakMap<Tab, HistoryDelegate>();
413
+
414
+ export function setHistoryDelegate(tab: Tab, delegate: HistoryDelegate | null) {
415
+ if (delegate) {
416
+ _historyDelegates.set(tab, delegate);
417
+ } else {
418
+ _historyDelegates.delete(tab);
419
+ }
420
+ }
421
+
422
+ export function getHistoryDelegate(tab: Tab): HistoryDelegate | null {
423
+ return _historyDelegates.get(tab) ?? null;
424
+ }
425
+
426
+ export function canUndo(tab: Tab): boolean {
427
+ const delegate = _historyDelegates.get(tab);
428
+ return delegate ? delegate.canUndo(tab) : tab.history.index > 0;
429
+ }
430
+
431
+ export function canRedo(tab: Tab): boolean {
432
+ const delegate = _historyDelegates.get(tab);
433
+ return delegate ? delegate.canRedo(tab) : tab.history.index < tab.history.snapshots.length - 1;
434
+ }
435
+
320
436
  /** Restore a materialized snapshot state (full-render path). */
321
437
  function restoreState(tab: Tab, index: number) {
322
438
  const { snapshots } = tab.history;
@@ -345,6 +461,11 @@ function assertHistoryConsistency(tab: Tab, index: number) {
345
461
 
346
462
  /** @param {Tab} tab */
347
463
  export function undo(tab: Tab) {
464
+ const delegate = _historyDelegates.get(tab);
465
+ if (delegate) {
466
+ delegate.undo(tab);
467
+ return;
468
+ }
348
469
  if (tab.history.index <= 0) {
349
470
  return;
350
471
  }
@@ -360,7 +481,7 @@ export function undo(tab: Tab) {
360
481
  applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
361
482
  }
362
483
  },
363
- { skipHistory: true },
484
+ { origin: "history", skipHistory: true },
364
485
  );
365
486
  tab.session.selection = entry.selectionBefore ? [...toRaw(entry.selectionBefore)] : null;
366
487
  tab.history.index -= 1;
@@ -374,6 +495,11 @@ export function undo(tab: Tab) {
374
495
 
375
496
  /** @param {Tab} tab */
376
497
  export function redo(tab: Tab) {
498
+ const delegate = _historyDelegates.get(tab);
499
+ if (delegate) {
500
+ delegate.redo(tab);
501
+ return;
502
+ }
377
503
  if (tab.history.index >= tab.history.snapshots.length - 1) {
378
504
  return;
379
505
  }
@@ -387,7 +513,7 @@ export function redo(tab: Tab) {
387
513
  applyDocOp(t, toRaw(op) as JxDocOp);
388
514
  }
389
515
  },
390
- { skipHistory: true },
516
+ { origin: "history", skipHistory: true },
391
517
  );
392
518
  tab.session.selection = entry.selection ? [...toRaw(entry.selection)] : null;
393
519
  tab.history.index += 1;
package/src/types.ts CHANGED
@@ -1,168 +1,59 @@
1
1
  /// <reference lib="dom" />
2
- import type {
3
- JsonValue as SchemaJsonValue,
4
- JxMutableNode,
5
- JxPath,
6
- ProjectConfig,
7
- } from "@jxsuite/schema/types";
8
-
9
- // ─── Git & Platform Types ───────────────────────────────────────────────────
10
-
11
- export interface GitFileStatus {
12
- status: string;
13
- path: string;
14
- staged?: boolean;
15
- }
16
-
17
- export interface GitStatusResult {
18
- branch: string;
19
- files: GitFileStatus[];
20
- ahead: number;
21
- behind: number;
22
- isRepo: boolean;
23
- remotes: string[];
24
- }
25
-
26
- export interface GitBranchesResult {
27
- current: string;
28
- branches: string[];
29
- }
30
-
31
- export interface GitLogEntry {
32
- hash: string;
33
- message: string;
34
- author: string;
35
- date: string;
36
- }
37
-
38
- export interface ComponentSlotMeta {
39
- name: string;
40
- description?: string;
41
- fallback?: (JxMutableNode | string)[];
42
- }
43
-
44
- export interface ComponentMeta {
45
- tagName: string;
46
- $id?: string | null;
47
- path: string;
48
- props?: { name: string; type?: string; default?: JsonValue; [k: string]: unknown }[];
49
- slots?: ComponentSlotMeta[];
50
- hasElements?: boolean;
51
- }
52
-
53
- export interface PackageInfo {
54
- name: string;
55
- version: string;
56
- /** True when the dependency lives in `devDependencies` rather than `dependencies`. */
57
- dev?: boolean;
58
- }
59
-
60
- /** A dependency with a newer version available, as reported by `bun outdated` / the npm registry. */
61
- export interface OutdatedInfo {
62
- name: string;
63
- /** The version range pinned in package.json (e.g. "^0.19.0"). */
64
- current: string;
65
- /** The newest published version. */
66
- latest: string;
67
- /** The newest version satisfying the current range, if known. */
68
- wanted?: string;
69
- dev?: boolean;
70
- }
71
-
72
- /** Result of a package mutation that runs `bun install` (install / set-versions). */
73
- export interface PackageOpResult {
74
- ok: boolean;
75
- /** Combined stdout/stderr from the bun invocation, surfaced to the user on failure. */
76
- log?: string;
77
- }
78
-
79
- /** Desktop app/build info surfaced in the About screen. */
80
- export interface AppInfo {
81
- version: string;
82
- channel: string;
83
- hash: string;
84
- /** Human-readable update status (e.g. "Up to date", "Update available"), if known. */
85
- updateStatus?: string;
86
- }
87
-
88
- export interface CodeServiceResult {
89
- code?: string;
90
- diagnostics?: unknown[];
91
- [key: string]: unknown;
92
- }
2
+ import type { CollabHandle } from "@jxsuite/collab/provider";
3
+ import type { JxMutableNode, JxPath, ProjectConfig } from "@jxsuite/schema/types";
93
4
 
94
- export interface DirEntry {
95
- name: string;
96
- path: string;
97
- type: "file" | "directory";
98
- size?: number;
99
- modified?: string;
100
- }
101
-
102
- /** A filesystem change pushed from the backend (project-relative, forward-slashed path). */
103
- export interface FsEvent {
104
- type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
105
- path: string;
106
- isDir: boolean;
107
- }
108
-
109
- /** Result of a rename, including the references rewritten across the project (refactor report). */
110
- export interface RenameResult {
111
- ok: boolean;
112
- from: string;
113
- to: string;
114
- isDir?: boolean;
115
- references?: {
116
- filesChanged: number;
117
- refsUpdated: number;
118
- files: { path: string; count: number }[];
119
- };
120
- errors?: { path: string; error: string }[];
121
- tag?: { from: string; to: string; filesChanged: number; refsUpdated: number };
122
- tagSkipped?: string;
123
- error?: string;
124
- }
125
-
126
- /** A starter template surfaced in the New Project picker (mirrors @jxsuite/starters StarterMeta). */
127
- export interface StarterInfo {
128
- id: string;
129
- name: string;
130
- industry: string;
131
- tagline: string;
132
- description: string;
133
- features: string[];
134
- accent: string;
135
- /** Preview image as a self-contained `data:` URI. */
136
- thumbnail: string;
137
- }
5
+ // ─── Wire types (the Studio Backend Protocol) ───────────────────────────────
6
+ /* The request/response shapes every backend serves live in @jxsuite/protocol;
7
+ re-exported here so existing `../types` imports keep working. */
138
8
 
139
- /** A progress line from the AI-guided site import (mirrors @jxsuite/import ImportProgressEvent). */
140
- export interface ImportProgressEvent {
141
- phase: string;
142
- message: string;
143
- current?: number;
144
- total?: number;
145
- }
9
+ import type {
10
+ AppInfo,
11
+ CfConnection,
12
+ CodeServiceResult,
13
+ ComponentMeta,
14
+ DirEntry,
15
+ FsEvent,
16
+ GitBranchesResult,
17
+ GitLogEntry,
18
+ GitStatusResult,
19
+ ImportProgressEvent,
20
+ ImportSiteOptions,
21
+ OutdatedInfo,
22
+ PackageInfo,
23
+ PackageOpResult,
24
+ ProjectListEntry,
25
+ RecentProjectEntry,
26
+ RenameResult,
27
+ StarterInfo,
28
+ } from "@jxsuite/protocol";
146
29
 
147
- /** Options for {@link StudioPlatform.importSite}. */
148
- export interface ImportSiteOptions {
149
- /** The live site to clone; must be http(s). */
150
- url: string;
151
- /** Display name for the new project. */
152
- name: string;
153
- /** Destination directory (platform-interpreted: project-relative on the dev server). */
154
- directory: string;
155
- /** Max crawl depth; 0 = single page. */
156
- depth: number;
157
- /** Max pages to capture. */
158
- maxPages: number;
159
- /** Refine component/prop names with the LLM (requires a key). */
160
- aiComponents: boolean;
161
- /** OpenAI-compatible credentials, from the user's AI settings. */
162
- apiKey?: string;
163
- baseUrl?: string;
164
- model?: string;
165
- }
30
+ export type {
31
+ AiModelInfo,
32
+ AiModelsResponse,
33
+ AppInfo,
34
+ CfConnection,
35
+ CodeServiceResult,
36
+ ComponentMeta,
37
+ ComponentSlotMeta,
38
+ DirEntry,
39
+ ErrorBody,
40
+ FsEvent,
41
+ GitBranchesResult,
42
+ GitFileStatus,
43
+ GitLogEntry,
44
+ GitStatusResult,
45
+ ImportProgressEvent,
46
+ ImportSiteOptions,
47
+ JsonValue,
48
+ OutdatedInfo,
49
+ PackageInfo,
50
+ PackageOpResult,
51
+ ProjectListEntry,
52
+ PullRequestInfo,
53
+ RecentProjectEntry,
54
+ RenameResult,
55
+ StarterInfo,
56
+ } from "@jxsuite/protocol";
166
57
 
167
58
  export interface StudioPlatform {
168
59
  id: string;
@@ -326,6 +217,49 @@ export interface StudioPlatform {
326
217
  getRecentProjects?: () => Promise<RecentProjectEntry[]>;
327
218
  /** Persist the full recent-projects list to the backend store. */
328
219
  saveRecentProjects?: (projects: RecentProjectEntry[]) => Promise<void>;
220
+ // ─── Project catalogue (platforms that can enumerate openable projects) ─────
221
+ /**
222
+ * Enumerate every project this platform can open — the dev server's sites under its root, a cloud
223
+ * platform's remote projects. Entry `root` values re-open through the same paths as recent
224
+ * projects (openRecentProject). Absent on desktop, where the OS file system is the catalogue and
225
+ * projects are found via the native picker instead.
226
+ */
227
+ listProjects?: () => Promise<ProjectListEntry[]>;
228
+ /**
229
+ * Open a realtime co-editing session for a project-relative document path. Optional: platforms
230
+ * without a collab backend omit it and Studio edits solo with file-level saves. Resolves null
231
+ * when the backend refuses a room for this doc (binary, oversized). The returned handle's Y.Doc
232
+ * starts empty and fills from the provider — see `@jxsuite/collab/provider` for the contract.
233
+ */
234
+ collab?: (docPath: string) => Promise<CollabHandle | null>;
235
+ // ─── Identity & hosting connections (publish surface) ───────────────────────
236
+ /** The signed-in user's identity, when the platform has one (cloud). */
237
+ getUser?: () => Promise<{ login: string; name?: string; avatarUrl?: string } | null>;
238
+ /**
239
+ * Open a pull request for the current branch. Cloud platforms implement it against their session;
240
+ * local platforms omit it and Studio falls back to a direct GitHub API call with the user's
241
+ * stored token.
242
+ */
243
+ createPullRequest?: (opts: {
244
+ title: string;
245
+ body?: string;
246
+ head?: string;
247
+ base?: string;
248
+ }) => Promise<{ url: string; number: number }>;
249
+ /** Current Cloudflare connection state, when the platform can broker one. */
250
+ cfConnection?: () => Promise<CfConnection | null>;
251
+ /**
252
+ * Interactively connect a Cloudflare account (hosted OAuth on the cloud platform). Local
253
+ * platforms omit it — the publish UI collects an API token instead and verifies via
254
+ * cfConnection.
255
+ */
256
+ cfConnect?: () => Promise<CfConnection | null>;
257
+ /**
258
+ * Allowlisted Cloudflare API passthrough (accounts, Pages projects and deployments). The backend
259
+ * injects credentials — an OAuth token on the cloud platform, the user's pasted API token locally
260
+ * — so no secret ever rides in the request body.
261
+ */
262
+ cfApi?: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>;
329
263
  // ─── User settings (backend-persisted; undefined on dev-server) ─────────────
330
264
  /**
331
265
  * Read the user-level settings map (e.g. the AI connection parameters) from a backend store
@@ -337,13 +271,6 @@ export interface StudioPlatform {
337
271
  saveSettings?: (settings: Record<string, string>) => Promise<void>;
338
272
  }
339
273
 
340
- /** A recently-opened project, keyed by its re-openable `root` (platform-specific). */
341
- export interface RecentProjectEntry {
342
- name: string;
343
- root: string;
344
- timestamp: number;
345
- }
346
-
347
274
  // ─── Studio Types ───────────────────────────────────────────────────────────
348
275
 
349
276
  export interface CanvasPanel {
@@ -409,9 +336,3 @@ export interface ProjectState {
409
336
  projectDirs?: string[];
410
337
  [key: string]: unknown;
411
338
  }
412
-
413
- /**
414
- * A JSON document value, or `undefined` to signal property removal in the mutators. Re-uses the
415
- * schema's precise recursive JSON model.
416
- */
417
- export type JsonValue = SchemaJsonValue | undefined;
@@ -1,4 +1,5 @@
1
1
  import { computed, reactive, toRaw } from "../reactivity";
2
+ import { ensureCollab, rekeyCollab } from "../collab/collab-session";
2
3
  import { createTab, disposeTab } from "../tabs/tab";
3
4
  import type { Tab } from "../tabs/tab";
4
5
 
@@ -96,6 +97,7 @@ export function openTab(opts: {
96
97
  workspace.tabs.set(tab.id, tab);
97
98
  workspace.tabOrder.push(tab.id);
98
99
  workspace.activeTabId = tab.id;
100
+ ensureCollab(tab);
99
101
  return tab;
100
102
  }
101
103
 
@@ -155,6 +157,7 @@ export function replaceAllTabs(newTabOpts: {
155
157
  workspace.tabs.set(newTab.id, newTab);
156
158
  workspace.activeTabId = newTab.id;
157
159
  workspace.tabOrder = [newTab.id];
160
+ ensureCollab(newTab);
158
161
 
159
162
  for (const id of oldIds) {
160
163
  if (id === newTab.id) {
@@ -201,4 +204,5 @@ export function renameTab(oldId: string, newId: string, newDocumentPath: string)
201
204
  if (workspace.activeTabId === oldId) {
202
205
  workspace.activeTabId = newId;
203
206
  }
207
+ rekeyCollab(tab);
204
208
  }