@jxsuite/studio 5.5.0 → 5.6.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 (221) hide show
  1. package/LICENSE +1 -1
  2. package/dist/chunks/{2020-g8mwpbkq.js → 2020-zvax9fmb.js} +3 -3
  3. package/dist/chunks/adapter-6zhjbpmd.js +4 -0
  4. package/dist/chunks/cf-account-picker-zfycs4k2.js +4 -0
  5. package/dist/chunks/cf-settings-fpa485q3.js +4 -0
  6. package/dist/chunks/components-b0vh4sq8.js +4 -0
  7. package/dist/chunks/content-source-z5t0d0h9.js +4 -0
  8. package/dist/chunks/convert-to-repeater-x19jr6c7.js +4 -0
  9. package/dist/chunks/editor-7e23n4a8.js +4 -0
  10. package/dist/chunks/entry-commands-5cm6aba2.js +4 -0
  11. package/dist/chunks/extension-sections-y90sa0n4.js +4 -0
  12. package/dist/chunks/file-ops-hv7gf757.js +4 -0
  13. package/dist/chunks/files-kb0epdxf.js +4 -0
  14. package/dist/chunks/format-host-eg1wy2bp.js +4 -0
  15. package/dist/chunks/fs-events-ky3vxfmh.js +4 -0
  16. package/dist/chunks/git-panel-yyg3bzp2.js +4 -0
  17. package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
  18. package/dist/chunks/grid-view-d9mybhn0.js +51 -0
  19. package/dist/chunks/iframe-host-9wdf0hes.js +4 -0
  20. package/dist/chunks/index-2tf0m2ne.js +4 -0
  21. package/dist/chunks/{index-arg188jq.js → index-vnwy9d44.js} +3 -3
  22. package/dist/chunks/{javascript-zcdmye2k.js → javascript-f0nrbdjb.js} +2 -2
  23. package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-nfa90dtf.js} +5 -5
  24. package/dist/chunks/jx-validate-7w11db9v.js +4 -0
  25. package/dist/chunks/layers-7zasrpse.js +4 -0
  26. package/dist/chunks/layers-panel-9nzc4fdq.js +4 -0
  27. package/dist/chunks/lit-html-5qr7nbng.js +4 -0
  28. package/dist/chunks/media-picker-maevn9d4.js +4 -0
  29. package/dist/chunks/menu-wq6q0v3y.js +4 -0
  30. package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-antn79jf.js} +3 -3
  31. package/dist/chunks/monaco-buffer-9ry2rn5x.js +4 -0
  32. package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-k8s40vwg.js} +3 -3
  33. package/dist/chunks/notify-g91gjd6w.js +4 -0
  34. package/dist/chunks/platform-jdwjp2vt.js +4 -0
  35. package/dist/chunks/publish-panel-8yym447p.js +4 -0
  36. package/dist/chunks/quick-search-9bzm9kh3.js +4 -0
  37. package/dist/chunks/references-znssfqvq.js +4 -0
  38. package/dist/chunks/right-panel-czbzktm8.js +4 -0
  39. package/dist/chunks/section-registry-0rtq73qs.js +4 -0
  40. package/dist/chunks/studio-011em5cf.js +5 -0
  41. package/dist/chunks/studio-02y9cb4h.js +6 -0
  42. package/dist/chunks/{studio-7gqyhjns.js → studio-09n3fyy0.js} +4 -4
  43. package/dist/chunks/{studio-xcng8nx7.js → studio-0c3fpdqj.js} +5 -5
  44. package/dist/chunks/studio-0mm38y62.js +5 -0
  45. package/dist/chunks/studio-0nm2skex.js +4 -0
  46. package/dist/chunks/{studio-73czgk6a.js → studio-153mwkyq.js} +4 -4
  47. package/dist/chunks/{studio-f8jyp45j.js → studio-1pxvgx1t.js} +4 -4
  48. package/dist/chunks/studio-27tc8q0h.js +5 -0
  49. package/dist/chunks/studio-28pz4s1k.js +5 -0
  50. package/dist/chunks/{studio-x5esgzct.js → studio-36s6qjgq.js} +4 -4
  51. package/dist/chunks/studio-4yztve2t.js +5 -0
  52. package/dist/chunks/{studio-x9sagq2d.js → studio-5fc0728f.js} +7 -7
  53. package/dist/chunks/{studio-7jazdy6y.js → studio-654fxcr0.js} +4 -4
  54. package/dist/chunks/studio-6exg5z4x.js +4 -0
  55. package/dist/chunks/studio-6k1eswp9.js +5 -0
  56. package/dist/chunks/{studio-5wfrgee9.js → studio-6zyt56ek.js} +4 -4
  57. package/dist/chunks/studio-7djymzt2.js +5 -0
  58. package/dist/chunks/{studio-b4epb26c.js → studio-7w04zw8h.js} +7 -7
  59. package/dist/chunks/studio-805fwp9k.js +5 -0
  60. package/dist/chunks/{studio-k1eaqyzm.js → studio-81twpmzj.js} +4 -4
  61. package/dist/chunks/studio-891t1tv9.js +5 -0
  62. package/dist/chunks/{studio-5n1km79v.js → studio-9n2j9nqk.js} +4 -4
  63. package/dist/chunks/{studio-he2fhrgp.js → studio-9tkaykt5.js} +4 -4
  64. package/dist/chunks/studio-ant1zbp3.js +5 -0
  65. package/dist/chunks/studio-b29h7g7s.js +5 -0
  66. package/dist/chunks/studio-b5rxwkj2.js +5 -0
  67. package/dist/chunks/studio-d007j0z3.js +7 -0
  68. package/dist/chunks/studio-dknr8m7b.js +14 -0
  69. package/dist/chunks/{studio-eq27rrr0.js → studio-dmymvk2j.js} +3 -3
  70. package/dist/chunks/studio-dtmdjmqb.js +5 -0
  71. package/dist/chunks/studio-eb5sty2k.js +5 -0
  72. package/dist/chunks/{studio-hc9q4j24.js → studio-ebt49j3c.js} +5 -5
  73. package/dist/chunks/studio-eegyf5vg.js +5 -0
  74. package/dist/chunks/studio-frv7vjhw.js +5 -0
  75. package/dist/chunks/studio-ft8pfpp0.js +5 -0
  76. package/dist/chunks/{studio-mhhc9btt.js → studio-gj4j9e7k.js} +4 -4
  77. package/dist/chunks/{studio-hf4qdjc8.js → studio-gxna6yta.js} +4 -4
  78. package/dist/chunks/studio-je15p5v8.js +6 -0
  79. package/dist/chunks/{studio-21enxdr8.js → studio-jsr14n1s.js} +4 -4
  80. package/dist/chunks/studio-k9pwvmz8.js +5 -0
  81. package/dist/chunks/{studio-p1eggxcf.js → studio-ks0ecgjx.js} +3 -3
  82. package/dist/chunks/studio-mb8jhtr4.js +64 -0
  83. package/dist/chunks/studio-mf41717j.js +5 -0
  84. package/dist/chunks/{studio-625ezz59.js → studio-mm8x32gg.js} +4 -4
  85. package/dist/chunks/{studio-pkkw32j0.js → studio-n2e133d6.js} +4 -4
  86. package/dist/chunks/{studio-c4pnt1c6.js → studio-njmg0e73.js} +5 -5
  87. package/dist/chunks/studio-nvykrgbj.js +4 -0
  88. package/dist/chunks/studio-nw4q9sdn.js +5 -0
  89. package/dist/chunks/studio-p6avneaq.js +5 -0
  90. package/dist/chunks/studio-pvj970st.js +5 -0
  91. package/dist/chunks/studio-r7yeeqc4.js +5 -0
  92. package/dist/chunks/studio-s85vah66.js +6 -0
  93. package/dist/chunks/studio-sadckcpp.js +7 -0
  94. package/dist/chunks/studio-sgzss898.js +9 -0
  95. package/dist/chunks/{studio-7by9a93k.js → studio-t0v47dpc.js} +4 -4
  96. package/dist/chunks/{studio-jnb285we.js → studio-tj28pxa1.js} +4 -4
  97. package/dist/chunks/{studio-6j2jn55a.js → studio-tzs6njnb.js} +8 -8
  98. package/dist/chunks/studio-vz7b43ng.js +5 -0
  99. package/dist/chunks/studio-w8w9frjy.js +16 -0
  100. package/dist/chunks/{studio-xc8ebsts.js → studio-wr0pq67n.js} +4 -4
  101. package/dist/chunks/{studio-8r4y2x0z.js → studio-y164brht.js} +4 -4
  102. package/dist/chunks/studio-y17505ge.js +928 -0
  103. package/dist/chunks/studio-ymsjnvp9.js +5 -0
  104. package/dist/chunks/studio-z1vdxq0e.js +5 -0
  105. package/dist/chunks/studio-z47mm077.js +10 -0
  106. package/dist/chunks/studio-zkkdbbvd.js +9 -0
  107. package/dist/chunks/{studio-awqghqxv.js → studio-zkvyd3ev.js} +4 -4
  108. package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-eq26t9gs.js} +5 -5
  109. package/dist/chunks/workspace-emt8454d.js +4 -0
  110. package/dist/chunks/ws-client-3gxqp06s.js +4 -0
  111. package/dist/iframe-entry.js +14 -14
  112. package/dist/manifest.json +62 -59
  113. package/dist/studio.css +1 -1
  114. package/dist/studio.js +115 -117
  115. package/package.json +5 -5
  116. package/src/account/action-flow.ts +294 -0
  117. package/src/account/action-required.ts +68 -0
  118. package/src/account-status.ts +29 -1
  119. package/src/browse/library-model.ts +3 -1
  120. package/src/browse/library-preview.ts +6 -2
  121. package/src/canvas/asset-refs.ts +3 -1
  122. package/src/canvas/asset-resolve.ts +79 -1
  123. package/src/canvas/iframe-entry.ts +38 -1
  124. package/src/canvas/iframe-host.ts +85 -0
  125. package/src/canvas/iframe-protocol.ts +13 -0
  126. package/src/files/asset-versions.ts +294 -0
  127. package/src/files/files.ts +61 -9
  128. package/src/files/fs-events.ts +279 -28
  129. package/src/files/media-meta.ts +2 -1
  130. package/src/files/media-paths.ts +5 -2
  131. package/src/files/media-upload.ts +5 -1
  132. package/src/new-project/add-repo-modal.ts +12 -1
  133. package/src/new-project/new-project-modal.ts +12 -1
  134. package/src/panels/git-panel.ts +25 -2
  135. package/src/platform-errors.ts +61 -1
  136. package/src/platforms/cloud.ts +389 -50
  137. package/src/platforms/devserver.ts +17 -1
  138. package/src/publish/pages-service.ts +17 -7
  139. package/src/services/ai-models.ts +30 -6
  140. package/src/services/ai-system-prompt.ts +81 -51
  141. package/src/settings/preferences-accounts.ts +42 -3
  142. package/src/settings/preferences-dialog.ts +8 -1
  143. package/src/studio.ts +18 -1
  144. package/src/surfaces/dialog.ts +5 -0
  145. package/src/surfaces/welcome.json +86 -1
  146. package/src/surfaces/welcome.ts +61 -2
  147. package/src/types.ts +84 -2
  148. package/src/ui/ai-managed-connect.ts +41 -4
  149. package/src/ui/media-picker.ts +2 -1
  150. package/dist/chunks/adapter-bsbs78x9.js +0 -4
  151. package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
  152. package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
  153. package/dist/chunks/components-s51h3b7c.js +0 -4
  154. package/dist/chunks/content-source-ty3be0qg.js +0 -4
  155. package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
  156. package/dist/chunks/editor-t002b41m.js +0 -4
  157. package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
  158. package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
  159. package/dist/chunks/file-ops-78zeytkm.js +0 -4
  160. package/dist/chunks/files-gatzrz8v.js +0 -4
  161. package/dist/chunks/format-host-r59b1651.js +0 -4
  162. package/dist/chunks/fs-events-f8h91ndt.js +0 -4
  163. package/dist/chunks/git-panel-y12zf7nj.js +0 -4
  164. package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
  165. package/dist/chunks/grid-view-tafenfm2.css +0 -1
  166. package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
  167. package/dist/chunks/index-5aghyrfh.js +0 -4
  168. package/dist/chunks/jx-validate-653krkrf.js +0 -4
  169. package/dist/chunks/layers-32p8gtae.js +0 -4
  170. package/dist/chunks/layers-panel-rs58m105.js +0 -4
  171. package/dist/chunks/lit-html-54bmkwz3.js +0 -4
  172. package/dist/chunks/media-picker-5bb04wct.js +0 -4
  173. package/dist/chunks/menu-cbha7w2e.js +0 -4
  174. package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
  175. package/dist/chunks/notify-dc1nz5ar.js +0 -4
  176. package/dist/chunks/platform-2m49s1zf.js +0 -4
  177. package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
  178. package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
  179. package/dist/chunks/references-vx7mbj9f.js +0 -4
  180. package/dist/chunks/right-panel-7jzpbxja.js +0 -4
  181. package/dist/chunks/section-registry-gjryse41.js +0 -4
  182. package/dist/chunks/studio-3znfhcxg.js +0 -5
  183. package/dist/chunks/studio-58qam27v.js +0 -4
  184. package/dist/chunks/studio-6j59fwaw.js +0 -5
  185. package/dist/chunks/studio-7z69g1gx.js +0 -5
  186. package/dist/chunks/studio-93j3v6zx.js +0 -5
  187. package/dist/chunks/studio-9gf6v8zy.js +0 -5
  188. package/dist/chunks/studio-9rhmettf.js +0 -4
  189. package/dist/chunks/studio-ac5aq677.js +0 -10
  190. package/dist/chunks/studio-as65vbns.js +0 -5
  191. package/dist/chunks/studio-bkv0sga4.js +0 -6
  192. package/dist/chunks/studio-bttmqef4.js +0 -5
  193. package/dist/chunks/studio-c3y9d41k.js +0 -5
  194. package/dist/chunks/studio-ccegp7gz.js +0 -9
  195. package/dist/chunks/studio-chxabn98.js +0 -6
  196. package/dist/chunks/studio-cktcdsxz.js +0 -16
  197. package/dist/chunks/studio-cnspfke5.js +0 -5
  198. package/dist/chunks/studio-de3p1ktm.js +0 -5
  199. package/dist/chunks/studio-fsbcn530.js +0 -928
  200. package/dist/chunks/studio-gftga7a3.js +0 -64
  201. package/dist/chunks/studio-hf1rpcq0.js +0 -4
  202. package/dist/chunks/studio-hz02hq70.js +0 -5
  203. package/dist/chunks/studio-jsv3s3s1.js +0 -5
  204. package/dist/chunks/studio-ks7cksh0.js +0 -5
  205. package/dist/chunks/studio-nptx0dbs.js +0 -5
  206. package/dist/chunks/studio-pg0z7k98.js +0 -5
  207. package/dist/chunks/studio-pve0b2f2.js +0 -5
  208. package/dist/chunks/studio-q7zmjv3b.js +0 -6
  209. package/dist/chunks/studio-rpmvw7kj.js +0 -5
  210. package/dist/chunks/studio-rwhss52s.js +0 -5
  211. package/dist/chunks/studio-sbg3g3h0.js +0 -5
  212. package/dist/chunks/studio-sq1bn60g.js +0 -14
  213. package/dist/chunks/studio-vwz7rma2.js +0 -5
  214. package/dist/chunks/studio-wa55e8gf.js +0 -5
  215. package/dist/chunks/studio-x0mzdha2.js +0 -5
  216. package/dist/chunks/studio-xrgpakvc.js +0 -9
  217. package/dist/chunks/studio-xt3djk1w.js +0 -7
  218. package/dist/chunks/studio-yh3nggx1.js +0 -7
  219. package/dist/chunks/studio-zmecp6hq.js +0 -5
  220. package/dist/chunks/workspace-g6dvaete.js +0 -4
  221. package/dist/chunks/ws-client-shf41j5p.js +0 -4
@@ -49,6 +49,7 @@ import { DIALOG_COMMANDS, isDialog, POPOVER_COMMANDS } from "@jxsuite/schema/dia
49
49
  import { isPopover } from "@jxsuite/schema/overlays";
50
50
  import { localeDirection } from "@jxsuite/schema/locale";
51
51
  import { getPlatform, hasPlatform } from "../platform";
52
+ import { assetVersionsEpoch, assetVersionsSnapshot } from "../files/asset-versions";
52
53
  import type {
53
54
  ApplyFormatIntent,
54
55
  CanvasMode,
@@ -90,6 +91,12 @@ interface HostState {
90
91
  canvasUrl: string;
91
92
  ready: boolean;
92
93
  pending: ParentToIframe | null;
94
+ /**
95
+ * The asset-version snapshot this frame last received (`assetVersions`), or null when it has
96
+ * received none since it last said `ready`. Compared by identity — the snapshot is memoized and
97
+ * changes identity only when a media version moves.
98
+ */
99
+ postedVersions: Readonly<Record<string, string>> | null;
93
100
  overlay: OverlayLayer;
94
101
  /** Primary selected path (mirrors `session.selection`'s last entry), for hover de-dupe. */
95
102
  selectionPath: (string | number)[] | null;
@@ -1304,6 +1311,8 @@ export function postPatchToHosts(forwardOps: WireDocOp[], tabId: string | null):
1304
1311
  // Only the host that originated this edit already has the DOM the patch describes. A
1305
1312
  // Split-view panel on the same document did NOT type it and must render normally.
1306
1313
  const echoPaths = echoOrigin?.host === host ? echoOrigin.paths : undefined;
1314
+ // A patch draws nodes too, so it must not resolve media against an older map.
1315
+ syncAssetVersions(host);
1307
1316
  host.channel.post(
1308
1317
  echoPaths
1309
1318
  ? { echoPaths, forwardOps, gen, kind: "patch" }
@@ -1496,6 +1505,74 @@ function ensureSelectionWatch(): void {
1496
1505
  selectionWatch = { stop: () => scope.stop() };
1497
1506
  }
1498
1507
 
1508
+ /**
1509
+ * Post a ready frame the current asset-version snapshot if it holds an older one.
1510
+ *
1511
+ * Called before every render and patch the host sends, on `ready`, and whenever the versions move —
1512
+ * so a frame can never draw a node against a map older than the one Studio holds, and a version
1513
+ * that was forgotten (the bytes changed) never reaches a resolver after the forget. A frame that
1514
+ * has received nothing and would receive an empty map is sent nothing: the frame starts empty, and
1515
+ * a host that never versions anything (desktop, `jx dev`) should not pay a message per frame for
1516
+ * it.
1517
+ */
1518
+ function syncAssetVersions(host: HostState): void {
1519
+ if (!host.ready) {
1520
+ return;
1521
+ }
1522
+ const versions = assetVersionsSnapshot();
1523
+ if (versions === host.postedVersions) {
1524
+ return;
1525
+ }
1526
+ if (host.postedVersions === null && Object.keys(versions).length === 0) {
1527
+ host.postedVersions = versions;
1528
+ return;
1529
+ }
1530
+ host.postedVersions = versions;
1531
+ host.channel.post({ kind: "assetVersions", versions });
1532
+ }
1533
+
1534
+ let assetVersionsWatchStarted = false;
1535
+ let assetVersionsFlushQueued = false;
1536
+
1537
+ /**
1538
+ * Lazily start one watcher that reposts the asset versions to every ready frame when they move.
1539
+ *
1540
+ * Coalesced to a microtask: a Library walk notes one listing per directory, and each would
1541
+ * otherwise post the whole map. Coalescing cannot let a render see an older map, because every
1542
+ * render and patch syncs its own host first.
1543
+ */
1544
+ function ensureAssetVersionsWatch(): void {
1545
+ if (assetVersionsWatchStarted) {
1546
+ return;
1547
+ }
1548
+ assetVersionsWatchStarted = true;
1549
+ const scope = effectScope(true);
1550
+ scope.run(() => {
1551
+ effect(() => {
1552
+ void assetVersionsEpoch.value;
1553
+ if (assetVersionsFlushQueued) {
1554
+ return;
1555
+ }
1556
+ assetVersionsFlushQueued = true;
1557
+ queueMicrotask(() => {
1558
+ assetVersionsFlushQueued = false;
1559
+ publishAssetVersions();
1560
+ });
1561
+ });
1562
+ });
1563
+ }
1564
+
1565
+ /** Bring every ready, connected frame up to the current asset versions. */
1566
+ export function publishAssetVersions(): void {
1567
+ for (const host of liveHosts) {
1568
+ if (!host.iframe.isConnected) {
1569
+ liveHosts.delete(host);
1570
+ continue;
1571
+ }
1572
+ syncAssetVersions(host);
1573
+ }
1574
+ }
1575
+
1499
1576
  /** Lazily start one reactive watcher that re-measures remote peers' selections in every host. */
1500
1577
  let presenceWatchStarted = false;
1501
1578
  let presenceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1841,6 +1918,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
1841
1918
  overlay,
1842
1919
  panReqId: -1,
1843
1920
  pending: null,
1921
+ postedVersions: null,
1844
1922
  idle: null,
1845
1923
  pendingEnterEdit: null,
1846
1924
  pendingMeasures: new Map(),
@@ -1875,6 +1953,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
1875
1953
  liveHosts.add(state);
1876
1954
  ensureSelectionWatch();
1877
1955
  ensurePresenceWatch();
1956
+ ensureAssetVersionsWatch();
1878
1957
  return state;
1879
1958
  }
1880
1959
 
@@ -1988,6 +2067,11 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1988
2067
  if (table) {
1989
2068
  state.channel.post({ chords: table.chords, kind: "keymap", mac: table.mac });
1990
2069
  }
2070
+ /* Then the asset versions, also ahead of the render: the frame resolves media while it
2071
+ renders, and a FIFO channel makes "posted first" mean "known first". A frame that said
2072
+ `ready` again has rebooted and lost what it was sent before. */
2073
+ state.postedVersions = null;
2074
+ syncAssetVersions(state);
1991
2075
  if (state.pending) {
1992
2076
  state.channel.post(state.pending);
1993
2077
  state.pending = null;
@@ -3088,6 +3172,7 @@ export async function mountIframeCanvas(
3088
3172
  function deliverRender(state: HostState, message: ParentToIframe): void {
3089
3173
  canvasPerf.hostRenderPosts += 1;
3090
3174
  if (state.ready) {
3175
+ syncAssetVersions(state);
3091
3176
  state.channel.post(message);
3092
3177
  } else {
3093
3178
  state.pending = message;
@@ -148,6 +148,19 @@ export type ParentToIframe =
148
148
  dialogOpen?: (string | number)[] | null;
149
149
  gen: number;
150
150
  }
151
+ /**
152
+ * The content versions of the project's media, by normalized project path — what the frame folds
153
+ * into every render's `assets` context so a repo-space reference resolves to `<file>?v=<version>`
154
+ * (specs/studio.md §3.4). A path absent here resolves unversioned.
155
+ *
156
+ * A message of its own, not a field on `render`: the map is as large as the project's media and
157
+ * changes far less often than the document does. The host posts it before a frame's first render
158
+ * and again before any render or patch that would otherwise see an older map — the channel is
159
+ * FIFO, so "posted before" is "applied before". Gen-less and render-free: the frame keeps the
160
+ * latest map and re-installs its asset resolver around it, so the next node a patch draws already
161
+ * uses it. A frame built before this message existed ignores it and keeps resolving unversioned.
162
+ */
163
+ | { kind: "assetVersions"; versions: Readonly<Record<string, string>> }
151
164
  // Flip the forced color-scheme preview on the iframe root without re-rendering — a document-level
152
165
  // Idempotent attribute write, deliberately gen-less (like endEdit).
153
166
  | { kind: "setColorScheme"; scheme: "light" | "dark" | null }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Asset versions — which project files the backend vouches for, and by what opaque version.
3
+ *
4
+ * A listing entry may carry `version` (protocol `DirEntry.version`): an opaque token for the file's
5
+ * bytes, equal tokens meaning identical bytes, ABSENT whenever the backend cannot vouch (an
6
+ * uncommitted, dirty or just-uploaded file). The cloud session sets it to the row's git blob sha.
7
+ * Studio appends it to a project-file URL as `?v=<version>` (`withVersion` in
8
+ * `canvas/asset-resolve.ts`), and a host that recognizes the version may answer that URL with an
9
+ * immutable cache lifetime — which is what lets a page full of images repaint from the browser
10
+ * cache instead of revalidating every one (specs/studio.md §3.4).
11
+ *
12
+ * That cache is only safe while the version is TRUE. A stale version names bytes the file no longer
13
+ * has, and an immutable response for it is never re-asked. So this map is built defensively:
14
+ *
15
+ * - **Listings feed it** ({@link beginListing} / {@link noteListing}). Every file entry of a listing
16
+ * either sets its version or, when the entry has none, deletes it — a listing that says nothing
17
+ * about a version is the backend withdrawing it.
18
+ * - **Change forgets** ({@link forgetVersions}). The file-event subscription forgets every path an
19
+ * event names, before the echo filter (Studio's own writes change bytes too), and
20
+ * `markLocalMutation` forgets the paths Studio is about to write.
21
+ * - **A listing never outlives a forget.** Nothing orders an HTTP response against the event socket,
22
+ * so a listing requested BEFORE a write can be answered AFTER the forget for it, carrying the old
23
+ * version. Each listing records the sequence number current when it began, every forget bumps the
24
+ * sequence and stamps the paths (and directory prefixes) it dirtied, and {@link noteListing}
25
+ * ignores any path dirtied after its listing began. The next listing, begun after the forget,
26
+ * decides.
27
+ *
28
+ * The canvas iframe receives {@link assetVersionsSnapshot} as one `assetVersions` message whenever
29
+ * {@link assetVersionsEpoch} moves, rather than a copy on every render. The snapshot holds MEDIA
30
+ * paths only — a `.json` or `.md` file is never loaded by URL from the canvas, and leaving them out
31
+ * keeps that message as small as the media set.
32
+ *
33
+ * Pure module state — it imports nothing but path normalization and the reactivity re-export — so
34
+ * the canvas host can import it without dragging in the file layer.
35
+ *
36
+ * @docs extending/embedding/platform-adapter
37
+ */
38
+
39
+ import { normalizeProjectPath } from "@jxsuite/schema/asset-paths";
40
+ import { shallowRef } from "../reactivity";
41
+ import type { DirEntry } from "../types";
42
+
43
+ /**
44
+ * Extensions whose versions the canvas snapshot carries: images (svg included), fonts, video and
45
+ * audio — what a rendered document loads by URL. Documents and data (`.json`, `.md`, `.csv`) are
46
+ * deliberately absent.
47
+ */
48
+ const MEDIA_EXTENSIONS = new Set([
49
+ // Images
50
+ ".apng",
51
+ ".avif",
52
+ ".bmp",
53
+ ".gif",
54
+ ".ico",
55
+ ".jpeg",
56
+ ".jpg",
57
+ ".png",
58
+ ".svg",
59
+ ".webp",
60
+ // Fonts
61
+ ".eot",
62
+ ".otf",
63
+ ".ttf",
64
+ ".woff",
65
+ ".woff2",
66
+ // Video
67
+ ".m4v",
68
+ ".mov",
69
+ ".mp4",
70
+ ".ogv",
71
+ ".webm",
72
+ // Audio
73
+ ".aac",
74
+ ".flac",
75
+ ".m4a",
76
+ ".mp3",
77
+ ".oga",
78
+ ".ogg",
79
+ ".opus",
80
+ ".wav",
81
+ ]);
82
+
83
+ /** Whether a project path is media the canvas loads by URL. */
84
+ export function isVersionedMediaPath(path: string): boolean {
85
+ const slash = path.lastIndexOf("/");
86
+ const dot = path.lastIndexOf(".");
87
+ return dot > slash + 1 && MEDIA_EXTENSIONS.has(path.slice(dot).toLowerCase());
88
+ }
89
+
90
+ /** Every version a listing has vouched for, keyed by normalized project path. */
91
+ const versions = new Map<string, string>();
92
+ /** Monotonic; bumped by every forget, read by every listing as it begins. */
93
+ let seq = 0;
94
+ /** Listings begun before this sequence number belong to a previous project and are ignored. */
95
+ let floor = 0;
96
+ /** The sequence number at which each path was last forgotten. */
97
+ const dirtiedPaths = new Map<string, number>();
98
+ /** The sequence number at which each directory prefix (`dir/`) was last forgotten. */
99
+ const dirtiedPrefixes = new Map<string, number>();
100
+ /** The memoized canvas snapshot, or null once a media version changed under it. */
101
+ let snapshot: Readonly<Record<string, string>> | null = null;
102
+ /**
103
+ * The one snapshot of a map with no media in it. Shared, so emptying an already-empty map keeps the
104
+ * snapshot's identity and the canvas host — which compares identities — posts nothing: a host that
105
+ * never versions anything (desktop, `jx dev`) never pays a message for it.
106
+ */
107
+ const EMPTY_SNAPSHOT: Readonly<Record<string, string>> = Object.freeze({});
108
+
109
+ /**
110
+ * Bumped whenever the MEDIA versions change — the canvas host watches it to know when to repost
111
+ * {@link assetVersionsSnapshot}. A change to a non-media version does not move it: nothing the
112
+ * canvas holds would differ.
113
+ */
114
+ export const assetVersionsEpoch = shallowRef(0);
115
+
116
+ function mediaChanged(): void {
117
+ snapshot = null;
118
+ assetVersionsEpoch.value += 1;
119
+ }
120
+
121
+ /**
122
+ * Begin a listing, returning the sequence number to pass to {@link noteListing} with its answer.
123
+ * Call it BEFORE the request goes out.
124
+ */
125
+ export function beginListing(): number {
126
+ return seq;
127
+ }
128
+
129
+ /** Whether `path` was forgotten after sequence number `since`. */
130
+ function dirtiedSince(path: string, since: number): boolean {
131
+ const own = dirtiedPaths.get(path);
132
+ if (own !== undefined && own > since) {
133
+ return true;
134
+ }
135
+ for (const [prefix, at] of dirtiedPrefixes) {
136
+ if (at > since && path.startsWith(prefix)) {
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Record what one listing said about its files' versions.
145
+ *
146
+ * A file entry WITH a version sets it; one without deletes any version held, since the backend no
147
+ * longer vouches for those bytes. Directories are ignored. A path forgotten after `since` (the
148
+ * value {@link beginListing} returned when this listing began) is skipped entirely: the listing may
149
+ * predate the change that forgot it.
150
+ */
151
+ export function noteListing(entries: readonly DirEntry[], since: number): void {
152
+ if (since < floor) {
153
+ return;
154
+ }
155
+ let changed = false;
156
+ for (const entry of entries) {
157
+ if (entry.type !== "file") {
158
+ continue;
159
+ }
160
+ const path = normalizeProjectPath(entry.path);
161
+ if (path === "" || dirtiedSince(path, since)) {
162
+ continue;
163
+ }
164
+ const version =
165
+ typeof entry.version === "string" && entry.version !== "" ? entry.version : null;
166
+ const held = versions.get(path);
167
+ if (version === null) {
168
+ if (held === undefined) {
169
+ continue;
170
+ }
171
+ versions.delete(path);
172
+ } else if (held === version) {
173
+ continue;
174
+ } else {
175
+ versions.set(path, version);
176
+ }
177
+ if (isVersionedMediaPath(path)) {
178
+ changed = true;
179
+ }
180
+ }
181
+ if (changed) {
182
+ mediaChanged();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * List through `read` with the listing's versions noted — {@link beginListing} before the request,
188
+ * {@link noteListing} with its answer. Resolves (or rejects) with exactly what `read` did.
189
+ */
190
+ export async function notedListing<T extends readonly DirEntry[]>(
191
+ read: () => Promise<T>,
192
+ ): Promise<T> {
193
+ const since = beginListing();
194
+ const entries = await read();
195
+ noteListing(entries, since);
196
+ return entries;
197
+ }
198
+
199
+ /**
200
+ * Forget the versions of `paths`, and of every file under each of `prefixDirs`, and make sure no
201
+ * listing that began before this call can restore them.
202
+ */
203
+ export function forgetVersions(paths: readonly string[], prefixDirs: readonly string[] = []): void {
204
+ seq += 1;
205
+ let changed = false;
206
+ for (const raw of paths) {
207
+ const path = normalizeProjectPath(raw);
208
+ if (path === "") {
209
+ continue;
210
+ }
211
+ dirtiedPaths.set(path, seq);
212
+ if (versions.delete(path) && isVersionedMediaPath(path)) {
213
+ changed = true;
214
+ }
215
+ }
216
+ for (const raw of prefixDirs) {
217
+ const dir = normalizeProjectPath(raw);
218
+ if (dir === "") {
219
+ continue;
220
+ }
221
+ const prefix = `${dir}/`;
222
+ dirtiedPrefixes.set(prefix, seq);
223
+ for (const path of versions.keys()) {
224
+ if (path.startsWith(prefix)) {
225
+ versions.delete(path);
226
+ if (isVersionedMediaPath(path)) {
227
+ changed = true;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ if (changed) {
233
+ mediaChanged();
234
+ }
235
+ }
236
+
237
+ /** The version a listing vouched for `path`, or undefined when none is held. */
238
+ export function versionOf(path: string): string | undefined {
239
+ return versions.get(normalizeProjectPath(path));
240
+ }
241
+
242
+ /**
243
+ * Every media path's version as a plain record, for the canvas iframe. Memoized: the same object is
244
+ * returned until a media version changes, so identity is a cheap "did anything move" test.
245
+ */
246
+ export function assetVersionsSnapshot(): Readonly<Record<string, string>> {
247
+ if (snapshot) {
248
+ return snapshot;
249
+ }
250
+ const out: Record<string, string> = {};
251
+ let any = false;
252
+ for (const [path, version] of versions) {
253
+ if (isVersionedMediaPath(path)) {
254
+ out[path] = version;
255
+ any = true;
256
+ }
257
+ }
258
+ snapshot = any ? Object.freeze(out) : EMPTY_SNAPSHOT;
259
+ return snapshot;
260
+ }
261
+
262
+ /**
263
+ * Forget EVERY version, and ignore every listing begun before this call when it lands.
264
+ *
265
+ * For a gap in the event stream: a reconnect, or the stream's first open after listings were
266
+ * already noted (§9.2). The events that would have forgotten individual paths are gone, and the
267
+ * versions at risk are not only the file tree's — the Library walk, the media picker and media
268
+ * metadata note directories the tree never loaded, and a re-list of the tree cannot reach them. A
269
+ * version Studio can no longer vouch for has to go, because an immutable response for it is never
270
+ * re-asked. Listings begun after this call vouch afresh.
271
+ *
272
+ * Raising the floor, rather than stamping paths, is what covers a listing that was in flight across
273
+ * the gap: it has no forget recorded against it, and would otherwise put the old version back.
274
+ */
275
+ export function forgetAllVersions(): void {
276
+ const hadMedia = assetVersionsSnapshot() !== EMPTY_SNAPSHOT;
277
+ versions.clear();
278
+ // Every record is older than the new floor, and every listing it could have guarded is below it.
279
+ dirtiedPaths.clear();
280
+ dirtiedPrefixes.clear();
281
+ seq += 1;
282
+ floor = seq;
283
+ if (hadMedia) {
284
+ mediaChanged();
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Drop every version and dirt record — a project switch, and between tests. A listing begun before
290
+ * the reset is ignored when it lands: it lists the project that was closed.
291
+ */
292
+ export function resetAssetVersions(): void {
293
+ forgetAllVersions();
294
+ }
@@ -47,6 +47,7 @@ import { ensureDependenciesInstalled } from "../packages/ensure-deps";
47
47
  import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
48
48
  import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
49
49
  import { markLocalMutation } from "./fs-events";
50
+ import { notedListing, resetAssetVersions } from "./asset-versions";
50
51
  import { ensureIgnoreLayers, isIgnoredEntry, resetIgnoreCache } from "./gitignore";
51
52
  import { SETTINGS } from "../services/settings/definitions";
52
53
  import { readStoredSetting, setSetting } from "../services/settings/kernel";
@@ -78,6 +79,8 @@ import { runActiveReported, runReported } from "../commands/run-reported";
78
79
  import { collectionOfPath } from "../content/entry-model";
79
80
  import { confirmFileDelete, parseSourceForPath, renamePromptMessage } from "./file-ops";
80
81
  import { parseJsonDocument } from "@jxsuite/schema/json-layout";
82
+ // The pure half of the differ: order-insensitive equality with no Yjs behind it.
83
+ import { deepEqual } from "@jxsuite/collab/diff-core";
81
84
  import type { JsonLayout } from "@jxsuite/schema/json-layout";
82
85
  import { invalidateUsages } from "../services/references";
83
86
  import {
@@ -123,7 +126,8 @@ export async function loadDirectory(dirPath: string) {
123
126
  ignore rules synchronously while it builds rows, and a repaint that beat the rules would draw
124
127
  a `node_modules` and then take it away again. Concurrent, because neither needs the other. */
125
128
  const [entries] = await Promise.all([
126
- platform.listDirectory(dirPath),
129
+ // Noted: a listing is where Studio learns which files' bytes the backend vouches for.
130
+ notedListing(() => platform.listDirectory(dirPath)),
127
131
  ensureIgnoreLayers(dirPath),
128
132
  ]);
129
133
  projectState.dirs.set(dirPath, entries);
@@ -161,6 +165,7 @@ export async function loadProject() {
161
165
  void loadFormats();
162
166
  refreshExtensionUi(platform);
163
167
  resetIgnoreCache();
168
+ resetAssetVersions();
164
169
 
165
170
  setProjectState({
166
171
  dirs: new Map(),
@@ -241,6 +246,7 @@ export async function openProject({
241
246
  void loadFormats();
242
247
  refreshExtensionUi(platform);
243
248
  resetIgnoreCache();
249
+ resetAssetVersions();
244
250
 
245
251
  setProjectState({
246
252
  .../** @type {ProjectState} */ projectState,
@@ -2431,12 +2437,6 @@ export async function openFileInPane(paneId: string, path: string): Promise<void
2431
2437
  await openFileInTab(path, { focus: false, paneId, preview: true });
2432
2438
  }
2433
2439
 
2434
- /**
2435
- * Reload an already-open tab from disk without changing the active tab. Used to refresh after AI
2436
- * assistant writes to a file.
2437
- *
2438
- * @param {string} path
2439
- */
2440
2440
  /** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
2441
2441
  export function reloadCleanTab(path: string): void {
2442
2442
  // Co-edited docs never reload from disk: the shared Y.Doc is ahead of the provider's write-back
@@ -2452,25 +2452,77 @@ export function reloadCleanTab(path: string): void {
2452
2452
  }
2453
2453
  }
2454
2454
 
2455
- export async function reloadFileInTab(path: string) {
2455
+ /**
2456
+ * Re-read open tabs after the file-event stream reconnected (`FsSyncContext.onResyncContent`).
2457
+ *
2458
+ * The paths arrive already filtered to clean, non-collab tabs. Each reload is `onlyIfChanged`
2459
+ * because a reconnect almost always changed nothing: an unconditional reload would replace every
2460
+ * open document with an identical one, which is a repaint per tab and an undo boundary nobody
2461
+ * made.
2462
+ */
2463
+ export function reloadTabsIfChanged(paths: string[]): void {
2464
+ for (const path of paths) {
2465
+ void reloadFileInTab(path, { onlyIfChanged: true });
2466
+ }
2467
+ }
2468
+
2469
+ /**
2470
+ * Reload an already-open tab from disk without changing the active tab. Used to refresh after AI
2471
+ * assistant writes to a file, an external change, or a reconnect.
2472
+ *
2473
+ * With `onlyIfChanged`, a file whose parsed document (and frontmatter) equals what the tab already
2474
+ * holds is left alone: no assignment, so no re-render and nothing for undo to see. A JSON file's
2475
+ * layout is still taken from the new text, because a file reformatted on disk with the same content
2476
+ * would otherwise be written back in its old layout on the next save — and the layout renders
2477
+ * nothing.
2478
+ *
2479
+ * Whether to reload is decided about the tab as it was when the read STARTED, and the read is a
2480
+ * round trip (a network one on cloud). So the decision is re-checked after every await and before
2481
+ * anything is assigned: a tab that was clean and took an edit in the meantime, or that joined a
2482
+ * co-editing session, keeps what it now holds. Otherwise the disk copy would land over the author's
2483
+ * newest keystrokes and clear `dirty` — work lost without a trace — and a reconnect resync, which
2484
+ * re-reads every clean tab at the moment the author comes back to the window, is exactly when those
2485
+ * keystrokes arrive. A tab that was ALREADY dirty when the read started is the caller asking for it
2486
+ * to be replaced (an AI write, a rename's reference rewrite), and keeps that meaning.
2487
+ */
2488
+ export async function reloadFileInTab(path: string, opts: { onlyIfChanged?: boolean } = {}) {
2456
2489
  for (const [, tab] of workspace.tabs.entries()) {
2457
2490
  if (tab.documentPath === path) {
2458
2491
  const platform = getPlatform();
2492
+ const wasDirty = tab.doc.dirty;
2493
+ const overtaken = () => (!wasDirty && tab.doc.dirty) || isCollabPath(path);
2459
2494
  try {
2460
2495
  const content = await platform.readFile(path);
2461
2496
  if (!content) {
2462
2497
  return;
2463
2498
  }
2464
2499
  await loadFormats();
2500
+ // Everything below is synchronous except the format parse, which checks again after it.
2501
+ if (overtaken()) {
2502
+ return;
2503
+ }
2465
2504
  if (formatForPath(path)) {
2466
2505
  const { document, frontmatter } = await parseSourceForPath(path, content);
2506
+ if (overtaken()) {
2507
+ return;
2508
+ }
2509
+ if (
2510
+ opts.onlyIfChanged &&
2511
+ deepEqual(document, tab.doc.document) &&
2512
+ deepEqual(frontmatter, tab.doc.content.frontmatter)
2513
+ ) {
2514
+ return;
2515
+ }
2467
2516
  tab.doc.document = document;
2468
2517
  tab.doc.content.frontmatter = frontmatter;
2469
2518
  } else if (path.endsWith(".json")) {
2470
2519
  // The file changed under the tab, so its layout is whatever the new text says.
2471
2520
  const parsed = parseJsonDocument(content);
2472
- tab.doc.document = parsed.document as JxMutableNode;
2473
2521
  tab.doc.layout = parsed.layout;
2522
+ if (opts.onlyIfChanged && deepEqual(parsed.document, tab.doc.document)) {
2523
+ return;
2524
+ }
2525
+ tab.doc.document = parsed.document as JxMutableNode;
2474
2526
  }
2475
2527
  tab.doc.dirty = false;
2476
2528
  } catch (error) {