@jxsuite/studio 3.0.0 → 4.0.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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
  33. package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
  34. package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
  57. package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  60. package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
  81. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  82. package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +108 -24
  86. package/dist/studio.js +2362 -1695
  87. package/package.json +16 -15
  88. package/src/browse/library-pane.ts +57 -3
  89. package/src/canvas/canvas-render.ts +23 -1
  90. package/src/canvas/iframe-patch.ts +47 -21
  91. package/src/canvas/iframe-render.ts +22 -6
  92. package/src/commands/app-commands.ts +4 -0
  93. package/src/commands/command-args.ts +29 -0
  94. package/src/commands/context.ts +15 -1
  95. package/src/commands/defaults.ts +29 -1
  96. package/src/commands/levels.ts +16 -0
  97. package/src/commands/live-context.ts +3 -0
  98. package/src/content/collection-match.ts +267 -0
  99. package/src/content/entry-commands.ts +38 -29
  100. package/src/content/entry-fields.ts +6 -0
  101. package/src/editor/shortcuts.ts +5 -0
  102. package/src/files/file-ops.ts +10 -42
  103. package/src/files/files.ts +485 -59
  104. package/src/files/fs-events.ts +9 -0
  105. package/src/files/gitignore.ts +432 -0
  106. package/src/files/serialize-document.ts +60 -0
  107. package/src/format/convert-file.ts +511 -0
  108. package/src/format/format-choices.ts +199 -0
  109. package/src/grid/grid-view.ts +16 -2
  110. package/src/media/media-open.ts +58 -0
  111. package/src/media/media-pane.ts +310 -0
  112. package/src/new-project/import-tab.ts +332 -160
  113. package/src/new-project/new-project-modal.ts +27 -34
  114. package/src/packages/ensure-deps.ts +14 -7
  115. package/src/packages/jxsuite-update.ts +11 -11
  116. package/src/page-params.ts +10 -46
  117. package/src/panels/activity-bar.ts +45 -16
  118. package/src/panels/ai-chat/attached-context.ts +6 -1
  119. package/src/panels/ai-chat/chat-view.ts +255 -13
  120. package/src/panels/ai-chat/composer.ts +21 -97
  121. package/src/panels/ai-panel.ts +116 -2
  122. package/src/panels/navigator-panels.ts +7 -6
  123. package/src/panels/settings-menu.ts +799 -0
  124. package/src/panels/toolbar.ts +135 -15
  125. package/src/platforms/cloud.ts +20 -0
  126. package/src/platforms/devserver.ts +67 -33
  127. package/src/preview/preview-overlay.ts +226 -0
  128. package/src/services/ai-ask.ts +247 -0
  129. package/src/services/ai-import-tools.ts +494 -0
  130. package/src/services/ai-project-tools.ts +20 -26
  131. package/src/services/ai-system-prompt.ts +88 -2
  132. package/src/services/ai-turn-signal.ts +70 -0
  133. package/src/services/connection.ts +74 -0
  134. package/src/services/context-manager.ts +89 -0
  135. package/src/services/document-assistant.ts +45 -4
  136. package/src/services/import-client.ts +33 -6
  137. package/src/services/import-run.ts +158 -0
  138. package/src/services/import-seed.ts +120 -0
  139. package/src/services/jx-validate.ts +58 -6
  140. package/src/services/project-adoption.ts +71 -0
  141. package/src/services/references.ts +0 -0
  142. package/src/services/settings/definitions.ts +17 -0
  143. package/src/services/tool-executor.ts +34 -2
  144. package/src/settings/contexts-section.ts +45 -3
  145. package/src/settings/dependencies-editor.ts +43 -23
  146. package/src/settings/general-settings.ts +30 -12
  147. package/src/settings/preferences-dialog.ts +18 -41
  148. package/src/settings/preferences-sections.ts +57 -0
  149. package/src/settings/settings-document.ts +65 -9
  150. package/src/studio.ts +33 -6
  151. package/src/tabs/transact.ts +42 -19
  152. package/src/types.ts +103 -5
  153. package/src/ui/ai-model-picker.ts +162 -0
  154. package/src/ui/choice-field.ts +72 -0
  155. package/src/ui/layers.ts +83 -5
  156. package/src/utils/studio-utils.ts +22 -52
  157. package/styles/forced-colors.css +7 -0
  158. package/styles/overlays.css +256 -0
  159. package/styles/panels.css +13 -1
  160. package/styles/shell.css +111 -0
  161. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  162. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  163. package/dist/chunks/files-xcybanxe.js +0 -70
  164. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  165. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  166. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  167. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  168. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  169. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  170. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  171. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  172. package/dist/chunks/studio-c31yb4t6.js +0 -55
  173. 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
 
@@ -28,6 +28,7 @@ import type {
28
28
  GitStatusResult,
29
29
  PackageInfo,
30
30
  ProjectListEntry,
31
+ SiteBuildResult,
31
32
  ProjectSchemasResponse,
32
33
  ReferencesResult,
33
34
  RenameResult,
@@ -765,6 +766,25 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
765
766
  // The GitHub repo IS the remote; nothing to add.
766
767
  },
767
768
 
769
+ /**
770
+ * Open in Browser, cloud edition — a LIVE preview rather than a build.
771
+ *
772
+ * The backend cannot run `jx build`: it never executes project JS, and the compiler needs
773
+ * `sharp`, a bundler and a filesystem, none of which exist in a Worker. What it can do is serve
774
+ * the working tree as a real site on an origin of its own, rendering each page with the same
775
+ * runtime the canvas uses — so the reply names `mode: "live"` and Studio says so, instead of
776
+ * letting a reader assume they are looking at build output.
777
+ *
778
+ * That it is not a build is also the best thing about it: the preview is the tree as it stands,
779
+ * including edits nobody has saved and edits another collaborator is making right now.
780
+ */
781
+ async buildSite() {
782
+ return okJson<SiteBuildResult>(
783
+ await api("/build", { method: "POST" }),
784
+ "The site could not be previewed.",
785
+ );
786
+ },
787
+
768
788
  // ─── Project catalogue & navigation (Studio welcome / New Project UI) ──
769
789
 
770
790
  async listProjects(): Promise<ProjectListEntry[]> {
@@ -28,6 +28,7 @@ import type {
28
28
  ExtensionsInfo,
29
29
  FsEvent,
30
30
  ImportProgressEvent,
31
+ ImportReadyEvent,
31
32
  ImportSiteOptions,
32
33
  ProjectSchemasResponse,
33
34
  ReferencesResult,
@@ -35,6 +36,7 @@ import type {
35
36
  SecretsSetRequest,
36
37
  SecretsSetResponse,
37
38
  SiteBuildResult,
39
+ SitePreviewResult,
38
40
  StarterInfo,
39
41
  } from "../types";
40
42
  import { problemDetail, problemMessage } from "@jxsuite/protocol";
@@ -62,11 +64,26 @@ interface SiteEntry {
62
64
  *
63
65
  * The adapter is stateless apart from `_projectRoot`, which tracks the server-relative project
64
66
  * directory (e.g. "examples/site-demo"). All paths passed INTO PAL methods are project-relative;
65
- * the adapter prefixes them with `_projectRoot` before hitting the server, and strips the prefix
66
- * from responses.
67
+ * the adapter prefixes them with `_projectRoot` before hitting the server.
68
+ *
69
+ * **Coming back, the space depends on the route, and the routes do not all agree** (the contract is
70
+ * `specs/server.md` §4.1). `/__studio/files` echoes what it was asked — server-root-relative — so
71
+ * its replies are stripped. The refactor routes (`file/rename`, `references`) answer in the ACTIVE
72
+ * PROJECT's space, because that is the space their sweep runs in, so their replies are returned
73
+ * untouched. Stripping one of those is not a harmless no-op: it would eat the first segment of any
74
+ * path that begins with the project root's own name.
67
75
  */
68
76
  export function createDevServerPlatform() {
69
77
  let _projectRoot = "";
78
+ /**
79
+ * The in-flight `activate()` for the current root, so a query whose answer depends on the
80
+ * server's project binding can await it.
81
+ *
82
+ * `set projectRoot` fires activation fire-and-forget, and `/__studio/references` is the one route
83
+ * with no `dir` parameter — its sweep root comes solely from the server's activated project. A
84
+ * query landing inside that window would sweep the server's own root instead (issue 239).
85
+ */
86
+ let _activation: Promise<unknown> | null = null;
70
87
  /**
71
88
  * Lazy /__studio/collab capability probe (null = not asked yet). Its result is the subprotocol
72
89
  * negotiation, not a boolean: the probe is the only place the client can learn what envelope the
@@ -126,7 +143,7 @@ export function createDevServerPlatform() {
126
143
  // Fire-and-forget: callers that need the binding in place before reading await activate()
127
144
  // Themselves. The rejection is reported rather than dropped — an unactivated root leaves
128
145
  // Every rootless endpoint (git especially) operating on the server's OWN root.
129
- void this.activate(_projectRoot).catch((error: unknown) => {
146
+ _activation = this.activate(_projectRoot).catch((error: unknown) => {
130
147
  console.error("Project activation failed:", error);
131
148
  });
132
149
  }
@@ -335,8 +352,9 @@ export function createDevServerPlatform() {
335
352
  opts: ImportSiteOptions,
336
353
  onProgress: (evt: ImportProgressEvent) => void,
337
354
  signal?: AbortSignal,
355
+ onReady?: (evt: ImportReadyEvent) => void,
338
356
  ) {
339
- return await streamImport("/__studio/import-site", opts, onProgress, signal);
357
+ return await streamImport("/__studio/import-site", opts, onProgress, signal, onReady);
340
358
  },
341
359
 
342
360
  // ─── File operations ──────────────────────────────────────────────────
@@ -421,21 +439,11 @@ export function createDevServerPlatform() {
421
439
  if (!res.ok) {
422
440
  throw new Error(`Failed to rename: ${from} → ${to}`);
423
441
  }
424
- const report = await readJson<RenameResult>(res);
425
- // Map server-root-relative report paths back to project-relative for the studio.
426
- if (typeof report.from === "string") {
427
- report.from = stripRoot(report.from);
428
- }
429
- if (typeof report.to === "string") {
430
- report.to = stripRoot(report.to);
431
- }
432
- for (const f of report.references?.files ?? []) {
433
- f.path = stripRoot(f.path);
434
- }
435
- for (const e of report.errors ?? []) {
436
- e.path = stripRoot(e.path);
437
- }
438
- return report;
442
+ /* No strip on the way back: the route takes its paths in server space and answers in the
443
+ project's (specs/server.md §4.1). Stripping an already-project-relative path is not merely
444
+ redundant — it corrupts any path whose first segment happens to match the project root's
445
+ own name, which a server-root-relative root makes reachable. */
446
+ return readJson<RenameResult>(res);
439
447
  },
440
448
 
441
449
  /**
@@ -446,6 +454,7 @@ export function createDevServerPlatform() {
446
454
  * @param {{ path?: string; tagName?: string }} target
447
455
  */
448
456
  async findReferences(target: { path?: string; tagName?: string }): Promise<ReferencesResult> {
457
+ await _activation;
449
458
  const params = new URLSearchParams();
450
459
  if (target.path) {
451
460
  params.set("path", serverPath(target.path));
@@ -457,18 +466,8 @@ export function createDevServerPlatform() {
457
466
  if (!res.ok) {
458
467
  throw new Error(`Failed to find references: ${target.path ?? target.tagName ?? ""}`);
459
468
  }
460
- const result = await readJson<ReferencesResult>(res);
461
- // Server-root-relative report paths → project-relative, as renameFile already does.
462
- if (typeof result.path === "string") {
463
- result.path = stripRoot(result.path);
464
- }
465
- for (const f of result.files ?? []) {
466
- f.path = stripRoot(f.path);
467
- }
468
- for (const e of result.errors ?? []) {
469
- e.path = stripRoot(e.path);
470
- }
471
- return result;
469
+ // Project-relative on the way out, exactly as `renameFile` above receives it.
470
+ return readJson<ReferencesResult>(res);
472
471
  },
473
472
 
474
473
  /**
@@ -627,8 +626,8 @@ export function createDevServerPlatform() {
627
626
  return Boolean(data.needsInstall);
628
627
  },
629
628
 
630
- async outdatedPackages() {
631
- const res = await fetch("/__studio/packages/outdated");
629
+ async packageVersions() {
630
+ const res = await fetch("/__studio/packages/versions");
632
631
  if (!res.ok) {
633
632
  return [];
634
633
  }
@@ -1149,6 +1148,41 @@ export function createDevServerPlatform() {
1149
1148
  return (await res.json()) as SiteBuildResult;
1150
1149
  },
1151
1150
 
1151
+ /**
1152
+ * Preview the working tree live, and point this project's open tab at a route.
1153
+ *
1154
+ * The dev server holds the same live origin the desktop launchers do, so the browser Studio
1155
+ * gets the same preview: composed on demand, unsaved edits included.
1156
+ */
1157
+ async previewSite(opts: { route: string }) {
1158
+ const res = await fetch("/__studio/preview", {
1159
+ body: JSON.stringify(opts),
1160
+ headers: { "Content-Type": "application/json" },
1161
+ method: "POST",
1162
+ });
1163
+ if (!res.ok) {
1164
+ const body = await readJson<ErrorBody>(res);
1165
+ throw new Error(problemDetail(body) ?? "The site could not be previewed.");
1166
+ }
1167
+ return (await res.json()) as SitePreviewResult;
1168
+ },
1169
+
1170
+ async setPreviewOverlay(path: string, contents: string) {
1171
+ await fetch("/__studio/preview/overlay", {
1172
+ body: JSON.stringify({ contents, path }),
1173
+ headers: { "Content-Type": "application/json" },
1174
+ method: "POST",
1175
+ });
1176
+ },
1177
+
1178
+ async clearPreviewOverlay(path?: string) {
1179
+ await fetch("/__studio/preview/overlay", {
1180
+ body: JSON.stringify(path === undefined ? {} : { path }),
1181
+ headers: { "Content-Type": "application/json" },
1182
+ method: "DELETE",
1183
+ });
1184
+ },
1185
+
1152
1186
  /**
1153
1187
  * @param {string} name
1154
1188
  * @param {string} url
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Publishing what the author is looking at, so a live preview shows the canvas and not the disk.
3
+ *
4
+ * A live preview composes a page from the project's working TREE, and the tree is what has been
5
+ * saved. The document the author is actually editing lives in a tab's reactive memory and nowhere
6
+ * else, so without this the one thing that makes a live preview worth having — seeing an edit
7
+ * before it is written — would be exactly the thing it could not show.
8
+ *
9
+ * What travels is BYTES, and specifically {@link serializeDocument}'s: the same function `saveFile`
10
+ * writes through, grid controllers, format round-trips, frontmatter and all. That is what makes
11
+ * "what the reader sees" and "what saving would produce" one answer rather than two that drift. A
12
+ * document object would bypass the format layer, and a `.md` page's bytes are not
13
+ * `JSON.stringify(doc)`.
14
+ *
15
+ * **Why an effect and not `transactDoc`.** `transact.ts` has exactly one observer slot and the
16
+ * collab layer owns it. More importantly a transaction hook would MISS three of the most common
17
+ * ways a document changes: a Monaco source commit, a content-entry field edit, and every grid edit
18
+ * route around `transactDoc` entirely. "I edited it in Source and the preview did not change" is
19
+ * the bug this shape avoids.
20
+ *
21
+ * **Why one effect over the whole tab set rather than one per tab.** Reading the map tracks
22
+ * additions, so a tab opened later needs no registration and a tab closed needs no teardown. And
23
+ * the publish is a DIFF against what was last published, which means save, close, discard and undo
24
+ * back to clean all resolve through the same line instead of four hooks that can each be
25
+ * forgotten.
26
+ *
27
+ * **Armed, not always on.** Serializing dirty documents on every edit is not free, and before the
28
+ * author has ever opened a preview there is nothing to publish to. Nothing happens until a preview
29
+ * origin exists.
30
+ *
31
+ * @docs studio/interface
32
+ */
33
+
34
+ import { effect, effectScope } from "../reactivity";
35
+ import { getPlatform, hasPlatform } from "../platform";
36
+ import { serializeDocument } from "../files/serialize-document";
37
+ import { workspace } from "../workspace/workspace";
38
+ import type { Tab } from "../tabs/tab";
39
+
40
+ /**
41
+ * Trailing debounce on a publish.
42
+ *
43
+ * No leading edge: a leading publish would send the first keystroke of a burst and the last, which
44
+ * is two reloads for one thought. The max wait is what keeps a continuous drag from starving the
45
+ * trailing timer forever.
46
+ */
47
+ const PUBLISH_DEBOUNCE_MS = 200;
48
+ const PUBLISH_MAX_WAIT_MS = 500;
49
+
50
+ /** Bytes one document may publish before it is left showing its saved state instead. */
51
+ const MAX_ENTRY_BYTES = 2 * 1024 * 1024;
52
+
53
+ /**
54
+ * Path to the bytes last published for it, so identical bytes cost nothing.
55
+ *
56
+ * The TEXT rather than a digest of it. An edit that lands back on the same bytes — a character
57
+ * typed and deleted — must not cost the reader a reload, and comparing what was sent is exact where
58
+ * a hash would be a collision away from being wrong for no saving anyone can measure. Memory is
59
+ * bounded by the same per-entry cap the publish is.
60
+ */
61
+ const published = new Map<string, string>();
62
+
63
+ let armed = false;
64
+ let scope: ReturnType<typeof effectScope> | null = null;
65
+ let timer: ReturnType<typeof setTimeout> | null = null;
66
+ let burstStartedAt: number | null = null;
67
+ /** The publish in flight, so a flush awaits it rather than racing it. */
68
+ let inFlight: Promise<void> | null = null;
69
+
70
+ /** The documents whose unsaved bytes a preview needs, in the order they were opened. */
71
+ function dirtyTabs(): Tab[] {
72
+ const tabs: Tab[] = [];
73
+ for (const tab of workspace.tabs.values()) {
74
+ /* Dirty only. A clean tab's bytes are on disk by definition, and publishing them would create
75
+ a second source of truth for identical content. */
76
+ if (tab.doc.dirty && tab.documentPath) {
77
+ tabs.push(tab);
78
+ }
79
+ }
80
+ return tabs;
81
+ }
82
+
83
+ /**
84
+ * Publish what changed and retract what no longer applies.
85
+ *
86
+ * A diff rather than a stream of events: whatever reason a document has for no longer being unsaved
87
+ * — saved, closed, discarded, undone back to its file — it is simply absent from this pass, and the
88
+ * retraction follows without anywhere else having to remember to ask for it.
89
+ */
90
+ async function publish(): Promise<void> {
91
+ /* Not `armed` too: `flushPreviewOverlay` refuses when disarmed and `disarmPreviewOverlay` clears
92
+ the pending timer, so there is no path into here without it. A host with no platform at all is
93
+ real though — `getPlatform()` throws rather than answering null. */
94
+ if (!hasPlatform()) {
95
+ return;
96
+ }
97
+ const platform = getPlatform();
98
+ if (!platform.setPreviewOverlay || !platform.clearPreviewOverlay) {
99
+ return;
100
+ }
101
+ const live = new Set<string>();
102
+ for (const tab of dirtyTabs()) {
103
+ const path = tab.documentPath!.replace(/^\.\//, "");
104
+ let text: string;
105
+ try {
106
+ text = await serializeDocument(tab);
107
+ } catch {
108
+ /* A half-typed document that will not serialize is not a reason to stop previewing the layout
109
+ beside it. Skipped, and the next edit that does serialize publishes it. */
110
+ continue;
111
+ }
112
+ if (text.length > MAX_ENTRY_BYTES) {
113
+ continue;
114
+ }
115
+ live.add(path);
116
+ if (published.get(path) === text) {
117
+ continue;
118
+ }
119
+ published.set(path, text);
120
+ await platform.setPreviewOverlay(path, text);
121
+ }
122
+ for (const path of published.keys()) {
123
+ if (!live.has(path)) {
124
+ published.delete(path);
125
+ await platform.clearPreviewOverlay(path);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** Run a publish, coalescing with any already scheduled. */
131
+ function schedule(): void {
132
+ if (!armed) {
133
+ return;
134
+ }
135
+ const now = Date.now();
136
+ burstStartedAt ??= now;
137
+ if (timer) {
138
+ clearTimeout(timer);
139
+ }
140
+ const waited = now - burstStartedAt;
141
+ const delay = Math.max(0, Math.min(PUBLISH_DEBOUNCE_MS, PUBLISH_MAX_WAIT_MS - waited));
142
+ timer = setTimeout(() => {
143
+ timer = null;
144
+ burstStartedAt = null;
145
+ inFlight = publish().finally(() => {
146
+ inFlight = null;
147
+ });
148
+ }, delay);
149
+ }
150
+
151
+ /**
152
+ * Note an edit the reactive effect cannot see.
153
+ *
154
+ * `transactDoc` replaces the document's ROOT REFERENCE on every transaction, and a Monaco source
155
+ * commit assigns a fresh one, so a shallow read catches both. A content-entry field edit writes a
156
+ * key in place on an already-dirty tab, which changes nothing the effect reads. This is the seam
157
+ * for exactly that: an explicit call from the few places that mutate without swapping.
158
+ */
159
+ export function notePreviewOverlayEdit(): void {
160
+ schedule();
161
+ }
162
+
163
+ /**
164
+ * Start publishing, because a preview origin now exists to publish to.
165
+ *
166
+ * Idempotent: `View: Open in Browser` calls it on every invocation and only the first does work.
167
+ */
168
+ export function armPreviewOverlay(): void {
169
+ if (armed) {
170
+ return;
171
+ }
172
+ armed = true;
173
+ scope = effectScope();
174
+ scope.run(() => {
175
+ effect(() => {
176
+ for (const tab of workspace.tabs.values()) {
177
+ /* Two reads, both shallow. `document` is a fresh reference on every transaction — see
178
+ `transactDoc`, which assigns one specifically so effects re-run — so this costs a
179
+ property read rather than a walk of the document. */
180
+ void tab.doc.dirty;
181
+ void tab.doc.document;
182
+ }
183
+ schedule();
184
+ });
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Stop publishing and forget what was published, without retracting it.
190
+ *
191
+ * Called when the window changes project. The published map is keyed by project-relative path, so
192
+ * carrying it across a switch would let one project's `pages/index.json` be read as the answer for
193
+ * another's — and the backend lets go of the old root's overlay on its own re-root anyway.
194
+ */
195
+ export function disarmPreviewOverlay(): void {
196
+ armed = false;
197
+ if (timer) {
198
+ clearTimeout(timer);
199
+ timer = null;
200
+ }
201
+ burstStartedAt = null;
202
+ scope?.stop();
203
+ scope = null;
204
+ published.clear();
205
+ }
206
+
207
+ /**
208
+ * Publish now and wait for it.
209
+ *
210
+ * Two callers, and both are moments where the debounce is exactly wrong: on the way to opening a
211
+ * browser tab, so the page the author is about to look at already carries their last keystroke; and
212
+ * after a save, so the retraction rides with the write instead of arriving a debounce later and
213
+ * costing a second reload.
214
+ */
215
+ export async function flushPreviewOverlay(): Promise<void> {
216
+ if (!armed) {
217
+ return;
218
+ }
219
+ if (timer) {
220
+ clearTimeout(timer);
221
+ timer = null;
222
+ burstStartedAt = null;
223
+ }
224
+ await inFlight;
225
+ await publish();
226
+ }