@jxsuite/studio 3.0.0 → 4.1.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.
Files changed (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
@@ -41,12 +41,13 @@ import { openQuickSearch } from "./quick-search";
41
41
  import { showPromptDialog } from "../ui/layers";
42
42
  import { getPlatform, hasPlatform } from "../platform";
43
43
  import { getPreviewNavigateHandler } from "../canvas/preview-navigate";
44
+ import { armPreviewOverlay, flushPreviewOverlay } from "../preview/preview-overlay";
44
45
  import { documentUrlPattern, dynamicRouteParams } from "../page-params";
45
46
  import { getNodeAtPath, nodeLabel, projectState } from "../store";
46
47
  import { activeRegistry } from "../commands/active-registry";
47
48
  import { notify } from "../services/notify";
48
49
  import type { Tab } from "../tabs/tab";
49
- import type { SiteBuildResult } from "../types";
50
+ import type { SiteBuildResult, SitePreviewResult, StudioPlatform } from "../types";
50
51
  import type { CommandRegistry } from "../commands/registry";
51
52
  import type { EffectScope } from "@vue/reactivity";
52
53
  import type { TemplateResult } from "lit-html";
@@ -253,27 +254,96 @@ export async function runOpenInBrowser() {
253
254
  notify.warn(target.reason, { key: OPEN_IN_BROWSER, source: "Preview" });
254
255
  return;
255
256
  }
256
- /* Build first, because the reader is about to see the OUTPUT and the author is looking at the
257
- DOCUMENT. Without this they differ by however long it has been since anyone ran a build —
258
- which for most projects is "always", since nothing in Studio had ever written the output at
259
- all. A preview that quietly shows last week's page is worse than one that says it cannot open.
260
-
261
- The build is also what NAMES the origin. The built site is served on a port of its own, because
262
- the editor's own paths mean the project's sources while a built page means its output by the
263
- same paths — so a backend that cannot build cannot preview either, and says so instead of
264
- sending the reader to an address where half the site is the wrong file. */
265
257
  const platform = hasPlatform() ? getPlatform() : null;
266
- if (!platform?.buildSite) {
267
- notify.warn("This backend cannot build a preview of the site.", {
258
+ if (!platform?.previewSite && !platform?.buildSite) {
259
+ notify.warn("This backend cannot preview the site.", {
260
+ key: OPEN_IN_BROWSER,
261
+ source: "Preview",
262
+ });
263
+ return;
264
+ }
265
+
266
+ /* A LIVE preview is what this action is for, and a build is the fallback for a backend that
267
+ cannot do one. The two answer different questions: "what does this page look like at its real
268
+ URL?" wants the tree as it stands, and "does my site build?" is `Build Site`. */
269
+ if (platform.previewSite) {
270
+ await runLivePreview(platform.previewSite, target.path);
271
+ return;
272
+ }
273
+ await runBuiltPreview(platform.buildSite!, target.path);
274
+ }
275
+
276
+ /**
277
+ * Preview the working tree, and open a tab only if one is not already showing this project.
278
+ *
279
+ * The overlay is flushed FIRST, and that ordering is the whole point: the page the author is about
280
+ * to look at should carry the keystroke they just typed, not the one before the debounce.
281
+ */
282
+ async function runLivePreview(
283
+ previewSite: NonNullable<StudioPlatform["previewSite"]>,
284
+ path: string,
285
+ ) {
286
+ armPreviewOverlay();
287
+ await flushPreviewOverlay();
288
+ notify.info("Opening a live preview…", { key: OPEN_IN_BROWSER, source: "Preview" });
289
+ let result: SitePreviewResult;
290
+ try {
291
+ result = await previewSite({ route: path });
292
+ } catch (error) {
293
+ notify.error(`The site could not be previewed: ${errorText(error)}`, {
268
294
  key: OPEN_IN_BROWSER,
269
295
  source: "Preview",
270
296
  });
271
297
  return;
272
298
  }
299
+ if (!result.url) {
300
+ notify.warn("This backend serves no preview of the site.", {
301
+ key: OPEN_IN_BROWSER,
302
+ source: "Preview",
303
+ });
304
+ return;
305
+ }
306
+
307
+ /* `reused` is honoured, and honouring it is the feature. A tab already holding this project's
308
+ preview has been pointed at the route; opening another would leave the author with two tabs on
309
+ one project, which is exactly what retargeting the first one exists to prevent. What it costs
310
+ is that the browser does not come forward — no page can raise a background tab, and handing the
311
+ URL to the OS again would open a duplicate rather than switch to it — so the notification says
312
+ where to look. A reader who CLOSED their tab is not stuck here: the stream closes with it, so
313
+ `reused` comes back false and a fresh tab opens without anyone having to ask. */
314
+ if (result.reused) {
315
+ notify.info(`Updated your preview tab — switch to your browser to see ${path}`, {
316
+ key: OPEN_IN_BROWSER,
317
+ source: "Preview",
318
+ });
319
+ return;
320
+ }
321
+
322
+ if (result.errors.length > 0) {
323
+ notify.warn(`Previewed with ${result.errors.length} problem(s): ${result.errors[0]}`, {
324
+ key: OPEN_IN_BROWSER,
325
+ source: "Preview",
326
+ });
327
+ } else {
328
+ notify.success(
329
+ `Opened a live preview of ${result.routes} page(s) — rendered from your working tree, not a build.`,
330
+ { key: OPEN_IN_BROWSER, source: "Preview" },
331
+ );
332
+ }
333
+ openUrlExternally(`${result.url}${path}`);
334
+ }
335
+
336
+ /**
337
+ * The compiler-backed path, for a backend that declares no live preview.
338
+ *
339
+ * Kept so nothing regresses on a backend that predates `previewSite`: the cloud adapter answers
340
+ * `buildSite` with `mode: "live"` of its own, and `jx dev` answers it with a real build.
341
+ */
342
+ async function runBuiltPreview(buildSite: NonNullable<StudioPlatform["buildSite"]>, path: string) {
273
343
  notify.info("Building the site…", { key: OPEN_IN_BROWSER, source: "Preview" });
274
344
  let result: SiteBuildResult;
275
345
  try {
276
- result = await platform.buildSite();
346
+ result = await buildSite();
277
347
  } catch (error) {
278
348
  notify.error(`The site could not be built: ${errorText(error)}`, {
279
349
  key: OPEN_IN_BROWSER,
@@ -288,20 +358,70 @@ export async function runOpenInBrowser() {
288
358
  });
289
359
  return;
290
360
  }
361
+ /* A live preview is not a build and the report must not say it was: what it renders is the
362
+ working tree, unsaved edits and all, assembled in the reader's browser rather than compiled. */
363
+ const live = result.mode === "live";
291
364
  if (result.errors.length > 0) {
292
365
  /* Named, and the page still opens. A partial build produced pages, and the author can see the
293
366
  one they asked for while reading what failed — which is the whole difference between a
294
367
  preview and a build log. */
295
- notify.warn(`The site built with ${result.errors.length} error(s): ${result.errors[0]}`, {
368
+ const verb = live ? "previewed" : "built";
369
+ notify.warn(`The site ${verb} with ${result.errors.length} error(s): ${result.errors[0]}`, {
296
370
  key: OPEN_IN_BROWSER,
297
371
  source: "Preview",
298
372
  });
373
+ } else if (live) {
374
+ notify.success(
375
+ `Opened a live preview of ${result.routes} page(s) — rendered from your working tree, not a build.`,
376
+ { key: OPEN_IN_BROWSER, source: "Preview" },
377
+ );
299
378
  } else {
300
379
  notify.success(`Built ${result.routes} page(s).`, { key: OPEN_IN_BROWSER, source: "Preview" });
301
380
  }
302
- openUrlExternally(`${result.url}${target.path}`);
381
+ openUrlExternally(`${result.url}${path}`);
382
+ }
383
+
384
+ /**
385
+ * Run `Build Site`, the compiler-backed answer to "does my site build?".
386
+ *
387
+ * `View: Open in Browser` stopped meaning this when it started previewing the working tree, and the
388
+ * question did not stop being worth asking: a build runs the bundler, the image pipeline and every
389
+ * emitter, and a live preview runs none of them. So it keeps its own verb, its own report, and the
390
+ * output origin it always opened.
391
+ */
392
+ export async function runBuildSite() {
393
+ const platform = hasPlatform() ? getPlatform() : null;
394
+ if (!platform?.buildSite) {
395
+ notify.warn("This backend cannot build the site.", { key: BUILD_SITE, source: "Build" });
396
+ return;
397
+ }
398
+ notify.info("Building the site…", { key: BUILD_SITE, source: "Build" });
399
+ let result: SiteBuildResult;
400
+ try {
401
+ result = await platform.buildSite();
402
+ } catch (error) {
403
+ notify.error(`The site could not be built: ${errorText(error)}`, {
404
+ key: BUILD_SITE,
405
+ source: "Build",
406
+ });
407
+ return;
408
+ }
409
+ if (result.errors.length > 0) {
410
+ notify.warn(`Built with ${result.errors.length} error(s): ${result.errors[0]}`, {
411
+ key: BUILD_SITE,
412
+ source: "Build",
413
+ });
414
+ return;
415
+ }
416
+ notify.success(`Built ${result.routes} page(s), ${result.files} file(s).`, {
417
+ key: BUILD_SITE,
418
+ source: "Build",
419
+ });
303
420
  }
304
421
 
422
+ /** One notification key for the build, kept apart so a build report never replaces a preview's. */
423
+ const BUILD_SITE = "project.buildSite";
424
+
305
425
  /** One notification key for the whole flow, so building → built → failed replaces rather than piles. */
306
426
  const OPEN_IN_BROWSER = "view.openInBrowser";
307
427
 
@@ -6,9 +6,9 @@
6
6
  * `/api/v1/ai/chat`. The session is pre-bound to one (repo, branch) — the shell picks the project
7
7
  * before Studio boots — while project-less mode (null) powers the /studio welcome screen.
8
8
  *
9
- * Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory/importSite,
10
- * package install/outdated/set-versions (package ops are manifest-only edits), multi-window,
11
- * gitClone, resolveClass, component discovery, code services.
9
+ * Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory, package
10
+ * install/outdated/set-versions (package ops are manifest-only edits), multi-window, gitClone,
11
+ * resolveClass, component discovery, code services.
12
12
  */
13
13
 
14
14
  import { negotiateCollab } from "@jxsuite/collab/negotiate";
@@ -16,8 +16,12 @@ import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
16
16
  import type { WsCollabConnection } from "@jxsuite/collab/client";
17
17
  import type { ProjectConfig } from "@jxsuite/schema/types";
18
18
  import { componentMetaFrom } from "@jxsuite/schema/component-meta";
19
+ import { streamImport } from "../services/import-client";
19
20
  import type {
20
21
  AccountStatus,
22
+ CfAccountSummary,
23
+ CfConnection,
24
+ CfConnectOutcome,
21
25
  ComponentMeta,
22
26
  CreateProjectDestination,
23
27
  DirEntry,
@@ -26,8 +30,12 @@ import type {
26
30
  GitBranchesResult,
27
31
  GitLogEntry,
28
32
  GitStatusResult,
33
+ ImportProgressEvent,
34
+ ImportReadyEvent,
35
+ ImportSiteOptions,
29
36
  PackageInfo,
30
37
  ProjectListEntry,
38
+ SiteBuildResult,
31
39
  ProjectSchemasResponse,
32
40
  ReferencesResult,
33
41
  RenameResult,
@@ -123,12 +131,16 @@ export function projectRootKey(project: CloudProject): string {
123
131
  return `${project.owner}/${project.repo}@${project.branch}`;
124
132
  }
125
133
 
126
- /** Current hosted Cloudflare connection; null when none is brokered yet. */
127
- async function fetchCfConnection(): Promise<{
128
- connected: boolean;
129
- accountId?: string | undefined;
130
- accountName?: string | undefined;
131
- } | null> {
134
+ /**
135
+ * Current hosted Cloudflare connection; null when none is brokered yet.
136
+ *
137
+ * A LAPSED row is not a healthy one. This reader used to answer `{connected: true}` for any body
138
+ * whose `connected` flag was set and drop `needsReconnect` on the floor, so a row whose grant had
139
+ * expired came back indistinguishable from one that works — which is how `cfConnect`'s poll came to
140
+ * close the popup the moment it opened, over a row from a previous session, while the user was
141
+ * still on Cloudflare's login page. Every diagnostic the broker sends now passes straight through.
142
+ */
143
+ async function fetchCfConnection(): Promise<CfConnection | null> {
132
144
  const res = await fetch("/api/v1/cf/connection", { credentials: "include" });
133
145
  if (!res.ok) {
134
146
  return null;
@@ -137,7 +149,15 @@ async function fetchCfConnection(): Promise<{
137
149
  connected: boolean;
138
150
  accountId?: string | null;
139
151
  accountName?: string | null;
152
+ needsReconnect?: boolean;
153
+ needsAccount?: boolean;
154
+ code?: CfConnection["code"];
155
+ reason?: string;
156
+ hasRefreshToken?: boolean;
157
+ expiresAt?: number;
140
158
  };
159
+ /* Cloud never emits {connected: false} to the PAL — no brokered row at all is `null`, and a row
160
+ that cannot be used arrives as connected + needsReconnect. See CfConnection. */
141
161
  if (!body.connected) {
142
162
  return null;
143
163
  }
@@ -145,10 +165,131 @@ async function fetchCfConnection(): Promise<{
145
165
  connected: true,
146
166
  ...(body.accountId ? { accountId: body.accountId } : {}),
147
167
  ...(body.accountName ? { accountName: body.accountName } : {}),
168
+ ...(body.needsReconnect ? { needsReconnect: true } : {}),
169
+ ...(body.needsAccount ? { needsAccount: true } : {}),
170
+ ...(body.code ? { code: body.code } : {}),
171
+ ...(body.reason ? { reason: body.reason } : {}),
172
+ ...(body.hasRefreshToken === undefined ? {} : { hasRefreshToken: body.hasRefreshToken }),
173
+ ...(body.expiresAt === undefined ? {} : { expiresAt: body.expiresAt }),
148
174
  };
149
175
  }
150
176
 
151
- type CfConnectionInfo = Awaited<ReturnType<typeof fetchCfConnection>>;
177
+ interface CfConnectFlow {
178
+ popup: Window | null;
179
+ promise: Promise<CfConnectOutcome | null>;
180
+ }
181
+
182
+ /**
183
+ * The connect flow currently running, or null.
184
+ *
185
+ * Module-level rather than per-platform because the popup's TARGET NAME ("cf-connect") is global to
186
+ * the browsing context: a second `window.open` on that name re-uses the first flow's popup, and the
187
+ * first flow's cleanup then closes it out from under the second — the user watches their half-typed
188
+ * Cloudflare login vanish. Joining the in-flight promise is the only answer that leaves both
189
+ * callers correct.
190
+ */
191
+ let cfConnectFlow: CfConnectFlow | null = null;
192
+
193
+ /**
194
+ * Drive one hosted OAuth connect to a {@link CfConnectOutcome}. See `cfConnect` for the semantics;
195
+ * this lives at module scope so the single-flight handle can too.
196
+ */
197
+ async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | null> {
198
+ /* The baseline is read BEFORE the popup opens, and it is what keeps the poll honest: a row that
199
+ was already healthy proves nothing about THIS flow, so the poll must not settle on one. Reading
200
+ it after the popup opened would race the callback and could capture the new row as "old". */
201
+ const baseline = await fetchCfConnection().catch(() => null);
202
+ const healthyBaseline = Boolean(baseline?.connected && !baseline.needsReconnect);
203
+ const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
204
+ if (!popup) {
205
+ /* Popup blocked: the whole page is now navigating to the broker. Not a failure — the caller
206
+ must render nothing at all rather than an error it will never get to show. */
207
+ location.assign("/api/v1/cf/connect");
208
+ return { status: "redirect" };
209
+ }
210
+ handle.popup = popup;
211
+ const deadline = Date.now() + 180_000;
212
+ return new Promise<CfConnectOutcome>((resolve, reject) => {
213
+ let timer = 0;
214
+ const cleanup = () => {
215
+ window.removeEventListener("message", onMessage);
216
+ window.clearTimeout(timer);
217
+ if (!popup.closed) {
218
+ popup.close();
219
+ }
220
+ };
221
+ const settle = (outcome: CfConnectOutcome) => {
222
+ cleanup();
223
+ resolve(outcome);
224
+ };
225
+ const fail = (reason: string) => {
226
+ cleanup();
227
+ reject(new Error(reason));
228
+ };
229
+ /** A usable row: brokered, and its grant has not lapsed. */
230
+ const usable = (connection: CfConnection | null): connection is CfConnection =>
231
+ Boolean(connection?.connected && !connection.needsReconnect);
232
+ const onMessage = (event: MessageEvent) => {
233
+ if (event.origin !== location.origin) {
234
+ return;
235
+ }
236
+ const data = event.data as { source?: string; status?: string; reason?: string | null };
237
+ if (!data || data.source !== "jx-cf") {
238
+ return;
239
+ }
240
+ if (data.status === "error") {
241
+ fail(data.reason ?? "Cloudflare authorization failed");
242
+ return;
243
+ }
244
+ /* Any other status — "connected", "pick-account", or one a shell of another vintage sends —
245
+ is a CLAIM that the callback ran, and the broker row is what adjudicates it. */
246
+ void fetchCfConnection().then(
247
+ (connection) => {
248
+ settle(usable(connection) ? { connection, status: "connected" } : { status: "timeout" });
249
+ },
250
+ /* This promise had no rejection handler, so one network blip left the listener installed,
251
+ the timer armed and the popup open forever. */
252
+ () => {
253
+ fail("Cloudflare connected, but the connection could not be confirmed");
254
+ },
255
+ );
256
+ };
257
+ window.addEventListener("message", onMessage);
258
+ const poll = async () => {
259
+ if (Date.now() > deadline) {
260
+ settle({ status: "timeout" });
261
+ return;
262
+ }
263
+ let connection: CfConnection | null = null;
264
+ let answered = true;
265
+ try {
266
+ connection = await fetchCfConnection();
267
+ } catch {
268
+ answered = false; // A blip is not an answer: re-arm and ask again.
269
+ }
270
+ /* The poll is a FALLBACK for shells whose postMessage never arrives, so it may only settle on
271
+ proof that THIS flow stored a fresh token: a usable row that was NOT already usable at the
272
+ baseline. For a reconnect, `needsReconnect` flipping false is that proof; for a connection
273
+ that was healthy all along nothing the poll can see is, so it must never settle — the relay
274
+ or the popup-closed path answers that case instead. */
275
+ if (answered && usable(connection) && !healthyBaseline) {
276
+ settle({ connection, status: "connected" });
277
+ return;
278
+ }
279
+ if (popup.closed) {
280
+ const final = await fetchCfConnection().catch(() => null);
281
+ settle(usable(final) ? { connection: final, status: "connected" } : { status: "canceled" });
282
+ return;
283
+ }
284
+ timer = window.setTimeout(() => {
285
+ void poll();
286
+ }, 1500);
287
+ };
288
+ timer = window.setTimeout(() => {
289
+ void poll();
290
+ }, 1500);
291
+ });
292
+ }
152
293
 
153
294
  /** Parse an "owner/repo@branch" root key; null when malformed. */
154
295
  export function parseRootKey(root: string): CloudProject | null {
@@ -765,6 +906,25 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
765
906
  // The GitHub repo IS the remote; nothing to add.
766
907
  },
767
908
 
909
+ /**
910
+ * Open in Browser, cloud edition — a LIVE preview rather than a build.
911
+ *
912
+ * The backend cannot run `jx build`: it never executes project JS, and the compiler needs
913
+ * `sharp`, a bundler and a filesystem, none of which exist in a Worker. What it can do is serve
914
+ * the working tree as a real site on an origin of its own, rendering each page with the same
915
+ * runtime the canvas uses — so the reply names `mode: "live"` and Studio says so, instead of
916
+ * letting a reader assume they are looking at build output.
917
+ *
918
+ * That it is not a build is also the best thing about it: the preview is the tree as it stands,
919
+ * including edits nobody has saved and edits another collaborator is making right now.
920
+ */
921
+ async buildSite() {
922
+ return okJson<SiteBuildResult>(
923
+ await api("/build", { method: "POST" }),
924
+ "The site could not be previewed.",
925
+ );
926
+ },
927
+
768
928
  // ─── Project catalogue & navigation (Studio welcome / New Project UI) ──
769
929
 
770
930
  async listProjects(): Promise<ProjectListEntry[]> {
@@ -876,6 +1036,33 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
876
1036
  };
877
1037
  },
878
1038
 
1039
+ /**
1040
+ * AI-guided site import, streamed from the PLATFORM's import route rather than a session one.
1041
+ *
1042
+ * Deliberately not routed through `api()`: importing is how a cloud project comes into
1043
+ * existence, so the one mode it has to work in is the project-less hub, where `api()` rejects
1044
+ * before a request is ever made and `base` is "".
1045
+ *
1046
+ * The path is same-origin and relative, so `streamImport`'s plain `fetch` carries the session
1047
+ * cookie under the default `same-origin` credentials mode — the same authority every
1048
+ * `/api/v1/*` call here sends `credentials: "include"` for, with no cloud-shaped option added
1049
+ * to the shared client. A bring-your-own-key run rides on the headers that client already sets,
1050
+ * so the backend sees `X-Api-Key` / `X-Api-Base-URL` exactly as the OSS server does.
1051
+ *
1052
+ * `onReady` is forwarded and this backend never calls it, which is a difference worth naming:
1053
+ * adopting the project mid-run would navigate this page to the editor and abort the request
1054
+ * still writing the import, so the stream carries no `ready` line and the caller adopts on
1055
+ * `done`. That is the same path a backend older than `ready` has always taken.
1056
+ */
1057
+ async importSite(
1058
+ opts: ImportSiteOptions,
1059
+ onProgress: (evt: ImportProgressEvent) => void,
1060
+ signal?: AbortSignal,
1061
+ onReady?: (evt: ImportReadyEvent) => void,
1062
+ ) {
1063
+ return await streamImport("/api/v1/import/site", opts, onProgress, signal, onReady);
1064
+ },
1065
+
879
1066
  // ─── Identity & Cloudflare publish surface ──────────────────────────────
880
1067
 
881
1068
  async getUser() {
@@ -981,71 +1168,75 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
981
1168
  * OAuth errors (denial, invalid_scope misregistration) REJECT with the real reason instead of
982
1169
  * timing out. A 1.5s poll remains as fallback for older shells / blocked message delivery, and
983
1170
  * popup-blocked browsers fall back to a full-page redirect.
1171
+ *
1172
+ * Promise semantics, which are narrower than they look:
1173
+ *
1174
+ * - **Resolves `null`** only under the SSR guard, where there is no window to open.
1175
+ * - **Resolves an outcome** for all four real endings — `connected`, `redirect` (popup blocked,
1176
+ * page navigating away), `canceled` (popup closed with nothing stored), `timeout` (180s).
1177
+ * - **Rejects** only on a relayed OAuth error, or on a relayed success this platform could not
1178
+ * confirm against the broker.
1179
+ *
1180
+ * Concurrent calls JOIN the flow already running rather than starting a second one: both would
1181
+ * open the same "cf-connect" target, and the first one's cleanup would then close the popup the
1182
+ * second is waiting on.
984
1183
  */
985
- async cfConnect() {
1184
+ cfConnect() {
986
1185
  if (typeof window === "undefined" || typeof location === "undefined") {
987
- return null;
1186
+ return Promise.resolve(null);
988
1187
  }
989
- const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
990
- if (!popup) {
991
- location.assign("/api/v1/cf/connect");
992
- return null;
1188
+ const running = cfConnectFlow;
1189
+ if (running) {
1190
+ try {
1191
+ running.popup?.focus();
1192
+ } catch {
1193
+ // A cross-origin popup may refuse focus; joining the flow is what mattered.
1194
+ }
1195
+ return running.promise;
993
1196
  }
994
- const deadline = Date.now() + 180_000;
995
- return new Promise<CfConnectionInfo>((resolve, reject) => {
996
- let timer = 0;
997
- const cleanup = () => {
998
- window.removeEventListener("message", onMessage);
999
- window.clearTimeout(timer);
1000
- if (!popup.closed) {
1001
- popup.close();
1002
- }
1003
- };
1004
- const settle = (connection: CfConnectionInfo) => {
1005
- cleanup();
1006
- resolve(connection);
1007
- };
1008
- const fail = (reason: string) => {
1009
- cleanup();
1010
- reject(new Error(reason));
1011
- };
1012
- const onMessage = (event: MessageEvent) => {
1013
- if (event.origin !== location.origin) {
1014
- return;
1015
- }
1016
- const data = event.data as { source?: string; status?: string; reason?: string | null };
1017
- if (!data || data.source !== "jx-cf") {
1018
- return;
1019
- }
1020
- if (data.status === "error") {
1021
- fail(data.reason ?? "Cloudflare authorization failed");
1022
- return;
1023
- }
1024
- void fetchCfConnection().then(settle);
1025
- };
1026
- window.addEventListener("message", onMessage);
1027
- const poll = async () => {
1028
- if (Date.now() > deadline) {
1029
- settle(null);
1030
- return;
1031
- }
1032
- const connection = await fetchCfConnection();
1033
- if (connection) {
1034
- settle(connection);
1035
- return;
1036
- }
1037
- if (popup.closed) {
1038
- settle(await fetchCfConnection());
1039
- return;
1040
- }
1041
- timer = window.setTimeout(() => {
1042
- void poll();
1043
- }, 1500);
1044
- };
1045
- timer = window.setTimeout(() => {
1046
- void poll();
1047
- }, 1500);
1197
+ const handle: CfConnectFlow = { popup: null, promise: Promise.resolve(null) };
1198
+ cfConnectFlow = handle;
1199
+ handle.promise = runCfConnect(handle).finally(() => {
1200
+ if (cfConnectFlow === handle) {
1201
+ cfConnectFlow = null;
1202
+ }
1203
+ });
1204
+ return handle.promise;
1205
+ },
1206
+
1207
+ /**
1208
+ * Every Cloudflare account this connection can reach (the account picker's rows).
1209
+ *
1210
+ * A non-OK body is the broker's own unusable-connection payload — `{error, code}` naming a
1211
+ * lapsed grant or a connection that never existed — so its sentence is the one worth showing.
1212
+ */
1213
+ async cfAccounts() {
1214
+ const res = await fetch("/api/v1/cf/accounts", { credentials: "include" });
1215
+ return okJson<CfAccountSummary[]>(res, "Failed to list Cloudflare accounts");
1216
+ },
1217
+
1218
+ /** Store the chosen account on the brokered connection. */
1219
+ async cfSelectAccount(account: { id: string; name?: string }) {
1220
+ const res = await fetch("/api/v1/cf/select-account", {
1221
+ body: JSON.stringify({ accountId: account.id, accountName: account.name }),
1222
+ credentials: "include",
1223
+ headers: { "Content-Type": "application/json" },
1224
+ method: "POST",
1048
1225
  });
1226
+ if (!res.ok) {
1227
+ throw new Error(await errorMessage(res, "Failed to select a Cloudflare account"));
1228
+ }
1229
+ },
1230
+
1231
+ /** Forget the brokered connection (the broker revokes its tokens upstream). */
1232
+ async cfDisconnect() {
1233
+ const res = await fetch("/api/v1/cf/connection", {
1234
+ credentials: "include",
1235
+ method: "DELETE",
1236
+ });
1237
+ if (!res.ok) {
1238
+ throw new Error(await errorMessage(res, "Failed to disconnect Cloudflare"));
1239
+ }
1049
1240
  },
1050
1241
 
1051
1242
  /** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */