@jxsuite/studio 10.2.0 → 10.4.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 (272) hide show
  1. package/dist/chunks/{2020-bx702wqa.js → 2020-906vx2dq.js} +3 -3
  2. package/dist/chunks/adapter-hnvad61x.js +4 -0
  3. package/dist/chunks/add-new-flow-v0pm96mr.js +4 -0
  4. package/dist/chunks/cf-account-picker-xb8vvt2h.js +4 -0
  5. package/dist/chunks/cf-settings-mkbcz4z2.js +4 -0
  6. package/dist/chunks/components-w5zqwva1.js +4 -0
  7. package/dist/chunks/content-source-g38w0d3f.js +4 -0
  8. package/dist/chunks/convert-to-repeater-r8sfxjjm.js +4 -0
  9. package/dist/chunks/editor-wvf5e6j9.js +4 -0
  10. package/dist/chunks/entry-commands-t13dyw3c.js +4 -0
  11. package/dist/chunks/extension-sections-vjzw46xb.js +4 -0
  12. package/dist/chunks/file-ops-1sghzgcr.js +4 -0
  13. package/dist/chunks/files-zrqtgt41.js +4 -0
  14. package/dist/chunks/format-host-9gy76erh.js +4 -0
  15. package/dist/chunks/fs-events-45rhg3me.js +4 -0
  16. package/dist/chunks/grid-open-t4fq6bbm.js +4 -0
  17. package/dist/chunks/{grid-view-tghcwrrt.js → grid-view-2f03wcgw.js} +16 -16
  18. package/dist/chunks/icons-gb967qc8.js +4 -0
  19. package/dist/chunks/iframe-host-9aq8ze3q.js +4 -0
  20. package/dist/chunks/image-bake-4afr4y6a.js +4 -0
  21. package/dist/chunks/image-editor-x7t8jbjn.js +4 -0
  22. package/dist/chunks/index-f9qr7kzh.js +4 -0
  23. package/dist/chunks/{index-vr006kbt.js → index-yjrfeks8.js} +3 -3
  24. package/dist/chunks/{javascript-z1g3s9fd.js → javascript-hy2mws8v.js} +3 -3
  25. package/dist/chunks/{jsonMode-7szwg8c4.js → jsonMode-vj30te2h.js} +6 -6
  26. package/dist/chunks/jx-validate-r4ax11e3.js +4 -0
  27. package/dist/chunks/layers-m49nmwvx.js +4 -0
  28. package/dist/chunks/layers-panel-zr92pwdp.js +4 -0
  29. package/dist/chunks/lit-html-k2mb9k0r.js +4 -0
  30. package/dist/chunks/media-library-j74rt0w4.js +4 -0
  31. package/dist/chunks/media-open-mypr0k6b.js +4 -0
  32. package/dist/chunks/media-picker-pemtbsjv.js +4 -0
  33. package/dist/chunks/{monaco-binding-v42w9c1v.js → monaco-binding-a51py33c.js} +3 -3
  34. package/dist/chunks/monaco-buffer-pa0g6ax7.js +4 -0
  35. package/dist/chunks/{monaco-setup-7bffxd6v.js → monaco-setup-0hw9f835.js} +3 -3
  36. package/dist/chunks/notify-bwehrp7q.js +4 -0
  37. package/dist/chunks/parse-hee7ne9b.js +4 -0
  38. package/dist/chunks/platform-kv0k65cy.js +4 -0
  39. package/dist/chunks/publish-panel-vg1n880b.js +4 -0
  40. package/dist/chunks/publish-quick-4fwngwrw.js +4 -0
  41. package/dist/chunks/publish-workspace-jv4w2bx2.js +4 -0
  42. package/dist/chunks/quick-search-nh29rt8a.js +4 -0
  43. package/dist/chunks/references-vqyr5s9x.js +4 -0
  44. package/dist/chunks/right-panel-3ws9f2w0.js +4 -0
  45. package/dist/chunks/source-control-h3hcs5gy.js +4 -0
  46. package/dist/chunks/{studio-j3d2fjqz.js → studio-0939t1q1.js} +4 -4
  47. package/dist/chunks/studio-0gvvqj3h.js +5 -0
  48. package/dist/chunks/studio-0khd8xtx.js +4 -0
  49. package/dist/chunks/{studio-9b7177jq.js → studio-0x6f3nk9.js} +4 -4
  50. package/dist/chunks/studio-1410q5dw.js +5 -0
  51. package/dist/chunks/studio-1ctnw3xv.js +5 -0
  52. package/dist/chunks/studio-1ks5d9y4.js +5 -0
  53. package/dist/chunks/{studio-awxwjawq.js → studio-1y9mab9m.js} +4 -4
  54. package/dist/chunks/studio-2e4hry72.js +6 -0
  55. package/dist/chunks/studio-2kpxm0eh.js +7 -0
  56. package/dist/chunks/studio-2pk7kc7z.js +5 -0
  57. package/dist/chunks/studio-2x972v58.js +5 -0
  58. package/dist/chunks/studio-2zz4qbv9.js +5 -0
  59. package/dist/chunks/{studio-bvs9kpst.js → studio-3na1r48j.js} +8 -8
  60. package/dist/chunks/studio-3pz4t5a2.js +5 -0
  61. package/dist/chunks/studio-3qr08sxq.js +4 -0
  62. package/dist/chunks/{studio-75kcdbsk.js → studio-45mj4jqe.js} +4 -4
  63. package/dist/chunks/studio-4bgvvzhv.js +5 -0
  64. package/dist/chunks/studio-4f7thq0p.js +5 -0
  65. package/dist/chunks/studio-4fnb5648.js +5 -0
  66. package/dist/chunks/studio-4zd6an2b.js +5 -0
  67. package/dist/chunks/{studio-9rbe2xpw.js → studio-5tet77de.js} +3 -3
  68. package/dist/chunks/studio-61rg2cz5.js +5 -0
  69. package/dist/chunks/{studio-ybnwymxw.js → studio-7rrdbvex.js} +4 -4
  70. package/dist/chunks/{studio-sxdb9h3q.js → studio-90zkgne5.js} +7 -7
  71. package/dist/chunks/studio-9c6cbq2c.js +4 -0
  72. package/dist/chunks/studio-9dq7rwc7.js +5 -0
  73. package/dist/chunks/studio-9gr7xs9r.js +6 -0
  74. package/dist/chunks/studio-a88ej93p.js +7 -0
  75. package/dist/chunks/{studio-er5f383a.js → studio-abh9kfdh.js} +4 -4
  76. package/dist/chunks/studio-acer0g8w.js +5 -0
  77. package/dist/chunks/{studio-qh8berdz.js → studio-amaz545p.js} +4 -4
  78. package/dist/chunks/studio-az3wvyh2.js +5 -0
  79. package/dist/chunks/studio-bg8pzprg.js +5 -0
  80. package/dist/chunks/{studio-399a25za.js → studio-bnc50f9h.js} +4 -4
  81. package/dist/chunks/studio-brh8d2pr.js +5 -0
  82. package/dist/chunks/{studio-646nn2ys.js → studio-c2fa783p.js} +5 -5
  83. package/dist/chunks/studio-cfnn70h1.js +5 -0
  84. package/dist/chunks/{studio-rzqaz21g.js → studio-cqxj4fcy.js} +4 -4
  85. package/dist/chunks/studio-cw11h078.js +5 -0
  86. package/dist/chunks/studio-daksw8kh.js +5 -0
  87. package/dist/chunks/studio-dn56z7sn.js +5 -0
  88. package/dist/chunks/studio-egnjpyde.js +9 -0
  89. package/dist/chunks/studio-ejrypvmq.js +5 -0
  90. package/dist/chunks/studio-eqcddvc2.js +928 -0
  91. package/dist/chunks/studio-evq47j2z.js +5 -0
  92. package/dist/chunks/{studio-r7pf0rkv.js → studio-frc16j8v.js} +7 -7
  93. package/dist/chunks/studio-g0hxtcga.js +5 -0
  94. package/dist/chunks/{studio-th7z3fg7.js → studio-g9brbewz.js} +4 -4
  95. package/dist/chunks/studio-gnazwtv3.js +5 -0
  96. package/dist/chunks/studio-hfqpfmpk.js +5 -0
  97. package/dist/chunks/studio-hh394phe.js +5 -0
  98. package/dist/chunks/{studio-pjtntksy.js → studio-j0zf5x2x.js} +4 -4
  99. package/dist/chunks/studio-j5fcp6gj.js +5 -0
  100. package/dist/chunks/studio-jd1qb1st.js +7 -0
  101. package/dist/chunks/studio-jma2q8r9.js +5 -0
  102. package/dist/chunks/studio-k0xzrse2.js +64 -0
  103. package/dist/chunks/studio-k1xqrgm5.js +5 -0
  104. package/dist/chunks/studio-k2evngrj.js +7 -0
  105. package/dist/chunks/studio-k3we3kqb.js +5 -0
  106. package/dist/chunks/studio-knsjyx7n.js +5 -0
  107. package/dist/chunks/studio-mrjtnm62.js +5 -0
  108. package/dist/chunks/{studio-sevbc3xv.js → studio-n9qba0gx.js} +4 -4
  109. package/dist/chunks/{studio-ea74s8c1.js → studio-nvcn2ykc.js} +4 -4
  110. package/dist/chunks/studio-nwf0c32p.js +5 -0
  111. package/dist/chunks/studio-nzemfbkk.js +14 -0
  112. package/dist/chunks/{studio-43y04qck.js → studio-pj216fmn.js} +4 -4
  113. package/dist/chunks/{studio-4g8yb6jn.js → studio-pm17wdc4.js} +4 -4
  114. package/dist/chunks/studio-pwctzka3.js +16 -0
  115. package/dist/chunks/{studio-71m92v9s.js → studio-q80fg1d6.js} +7 -7
  116. package/dist/chunks/studio-q9hx2qp0.js +5 -0
  117. package/dist/chunks/studio-qrzmfaf6.js +5 -0
  118. package/dist/chunks/{studio-86r8hp7r.js → studio-r6mx7vm5.js} +5 -5
  119. package/dist/chunks/studio-rr00ex23.js +7 -0
  120. package/dist/chunks/{studio-4jhw38b2.js → studio-s24mhq0z.js} +4 -4
  121. package/dist/chunks/studio-sce30r7b.js +4 -0
  122. package/dist/chunks/studio-swedpd82.js +5 -0
  123. package/dist/chunks/studio-tmfcn347.js +6 -0
  124. package/dist/chunks/studio-tpxcryf2.js +5 -0
  125. package/dist/chunks/{studio-wc6za3zs.js → studio-v5wktj6f.js} +4 -4
  126. package/dist/chunks/studio-v7a7yb5w.js +9 -0
  127. package/dist/chunks/{studio-kg1abnz0.js → studio-vpevkfxw.js} +4 -4
  128. package/dist/chunks/{studio-881z1xrx.js → studio-wmjmsw0p.js} +4 -4
  129. package/dist/chunks/studio-wqfaqw4m.js +5 -0
  130. package/dist/chunks/{studio-hhp4x8xf.js → studio-xpx91fby.js} +5 -5
  131. package/dist/chunks/studio-xva3hzkv.js +5 -0
  132. package/dist/chunks/{studio-6hzdjfv0.js → studio-xx3r3sb0.js} +4 -4
  133. package/dist/chunks/{studio-cg1bfyhf.js → studio-z3fztxrp.js} +8 -8
  134. package/dist/chunks/studio-z9453ypn.js +6 -0
  135. package/dist/chunks/{tsMode-f54j66ym.js → tsMode-jg1dtphj.js} +7 -7
  136. package/dist/chunks/workspace-6ztqcrsh.js +4 -0
  137. package/dist/chunks/workspace-home-rse9h8a0.js +4 -0
  138. package/dist/chunks/{ws-client-e8kj684q.js → ws-client-arz2qsy9.js} +3 -3
  139. package/dist/iframe-entry.js +13 -13
  140. package/dist/manifest.json +83 -78
  141. package/dist/studio.js +84 -84
  142. package/package.json +11 -11
  143. package/src/browse/library-pane.ts +44 -29
  144. package/src/browse/library-preview.ts +6 -2
  145. package/src/canvas/asset-refs.ts +13 -4
  146. package/src/canvas/asset-resolve.ts +54 -11
  147. package/src/canvas/canvas-render.ts +25 -0
  148. package/src/canvas/iframe-entry.ts +5 -1
  149. package/src/canvas/iframe-host.ts +16 -6
  150. package/src/canvas/iframe-protocol.ts +9 -1
  151. package/src/commands/context.ts +5 -0
  152. package/src/commands/live-context.ts +5 -2
  153. package/src/editor/shortcuts.ts +3 -0
  154. package/src/files/asset-versions.ts +108 -2
  155. package/src/files/file-ops.ts +11 -1
  156. package/src/files/files.ts +111 -48
  157. package/src/files/media-paths.ts +12 -4
  158. package/src/files/media-upload.ts +195 -21
  159. package/src/files/new-file-name.ts +39 -1
  160. package/src/media/image-bake.ts +164 -0
  161. package/src/media/image-editor.ts +846 -0
  162. package/src/media/image-format.ts +262 -0
  163. package/src/media/image-recipe.ts +651 -0
  164. package/src/media/media-commands.ts +65 -1
  165. package/src/media/media-library-model.ts +1 -1
  166. package/src/media/media-library.ts +175 -15
  167. package/src/media/media-open.ts +156 -3
  168. package/src/media/media-pane.ts +8 -4
  169. package/src/panels/pane-context.ts +6 -1
  170. package/src/panels/tab-strip.ts +12 -3
  171. package/src/studio.ts +13 -2
  172. package/src/surfaces/image-editor.json +1390 -0
  173. package/src/surfaces/image-editor.ts +272 -0
  174. package/src/surfaces/inspector-dock.json +3 -3
  175. package/src/surfaces/media-library.json +129 -1
  176. package/src/surfaces/media-library.ts +17 -2
  177. package/src/types.ts +2 -1
  178. package/src/workspace/tab-workspace.ts +2 -1
  179. package/src/workspace/workspace-switch.ts +3 -1
  180. package/src/workspace/workspace.ts +22 -1
  181. package/dist/chunks/adapter-pp011m5x.js +0 -4
  182. package/dist/chunks/add-new-flow-ecqmzz76.js +0 -4
  183. package/dist/chunks/cf-account-picker-c07znkqt.js +0 -4
  184. package/dist/chunks/cf-settings-0v2bmqy0.js +0 -4
  185. package/dist/chunks/components-yn0dn2pq.js +0 -4
  186. package/dist/chunks/content-source-g0j41yy6.js +0 -4
  187. package/dist/chunks/convert-to-repeater-sbjk7tgn.js +0 -4
  188. package/dist/chunks/editor-xz02jeh1.js +0 -4
  189. package/dist/chunks/entry-commands-kmt02crp.js +0 -4
  190. package/dist/chunks/extension-sections-0rprf12d.js +0 -4
  191. package/dist/chunks/file-ops-mexj5qed.js +0 -4
  192. package/dist/chunks/files-b2tcppdd.js +0 -4
  193. package/dist/chunks/format-host-btsevv0c.js +0 -4
  194. package/dist/chunks/fs-events-r0apejva.js +0 -4
  195. package/dist/chunks/grid-open-n2a8xkht.js +0 -4
  196. package/dist/chunks/icons-853n6qt3.js +0 -4
  197. package/dist/chunks/iframe-host-gx0ypyh3.js +0 -4
  198. package/dist/chunks/index-bs4k9tr6.js +0 -4
  199. package/dist/chunks/jx-validate-ymf10teh.js +0 -4
  200. package/dist/chunks/layers-p9nfya38.js +0 -4
  201. package/dist/chunks/layers-panel-tt1hzhhf.js +0 -4
  202. package/dist/chunks/lit-html-x18n2sfz.js +0 -4
  203. package/dist/chunks/media-library-0n45gv8r.js +0 -4
  204. package/dist/chunks/media-picker-s09at95z.js +0 -4
  205. package/dist/chunks/monaco-buffer-een2ea0v.js +0 -4
  206. package/dist/chunks/notify-qwb8radt.js +0 -4
  207. package/dist/chunks/parse-v77xtwv7.js +0 -4
  208. package/dist/chunks/platform-8jnez0wr.js +0 -4
  209. package/dist/chunks/publish-panel-aysnj611.js +0 -4
  210. package/dist/chunks/publish-quick-kpbqs9mz.js +0 -4
  211. package/dist/chunks/publish-workspace-nd0a8q99.js +0 -4
  212. package/dist/chunks/quick-search-vg0jkhk2.js +0 -4
  213. package/dist/chunks/references-a7v6a0e2.js +0 -4
  214. package/dist/chunks/right-panel-cvk1vxx5.js +0 -4
  215. package/dist/chunks/source-control-fsqzc6he.js +0 -4
  216. package/dist/chunks/studio-05jfwbmf.js +0 -5
  217. package/dist/chunks/studio-0q5jwddh.js +0 -9
  218. package/dist/chunks/studio-0w2dr0q8.js +0 -5
  219. package/dist/chunks/studio-1tzpds97.js +0 -9
  220. package/dist/chunks/studio-216b27sv.js +0 -4
  221. package/dist/chunks/studio-2ejhqns4.js +0 -5
  222. package/dist/chunks/studio-2r90cts3.js +0 -16
  223. package/dist/chunks/studio-399xknkb.js +0 -6
  224. package/dist/chunks/studio-4ncmwqw5.js +0 -5
  225. package/dist/chunks/studio-5atwjgkj.js +0 -7
  226. package/dist/chunks/studio-5frqk7c0.js +0 -5
  227. package/dist/chunks/studio-6cwj6xrx.js +0 -5
  228. package/dist/chunks/studio-78pywnp2.js +0 -6
  229. package/dist/chunks/studio-886t6ztg.js +0 -5
  230. package/dist/chunks/studio-8wnvtr4z.js +0 -5
  231. package/dist/chunks/studio-8z66jk4z.js +0 -5
  232. package/dist/chunks/studio-b2qpt0xy.js +0 -14
  233. package/dist/chunks/studio-c71806xz.js +0 -7
  234. package/dist/chunks/studio-cbwks69x.js +0 -928
  235. package/dist/chunks/studio-cg710sk9.js +0 -5
  236. package/dist/chunks/studio-cw0wb9qk.js +0 -7
  237. package/dist/chunks/studio-cyheys7k.js +0 -5
  238. package/dist/chunks/studio-ea5m9kg5.js +0 -5
  239. package/dist/chunks/studio-efdxjwyp.js +0 -64
  240. package/dist/chunks/studio-egmw2837.js +0 -5
  241. package/dist/chunks/studio-f746039f.js +0 -5
  242. package/dist/chunks/studio-fmdymd9v.js +0 -5
  243. package/dist/chunks/studio-gcm12z00.js +0 -6
  244. package/dist/chunks/studio-gj98m8gy.js +0 -7
  245. package/dist/chunks/studio-hasbgjah.js +0 -5
  246. package/dist/chunks/studio-hz5n7x27.js +0 -5
  247. package/dist/chunks/studio-jeraxdgv.js +0 -7
  248. package/dist/chunks/studio-jvgek9xt.js +0 -5
  249. package/dist/chunks/studio-jws1a87n.js +0 -5
  250. package/dist/chunks/studio-k2sz13zg.js +0 -5
  251. package/dist/chunks/studio-k3m4jkdk.js +0 -5
  252. package/dist/chunks/studio-kg5ya5r2.js +0 -5
  253. package/dist/chunks/studio-khwbgrtg.js +0 -5
  254. package/dist/chunks/studio-mx6wzh25.js +0 -5
  255. package/dist/chunks/studio-mxs3ar9t.js +0 -5
  256. package/dist/chunks/studio-nm4szey4.js +0 -5
  257. package/dist/chunks/studio-nqxqf8p2.js +0 -4
  258. package/dist/chunks/studio-qn7z4krm.js +0 -5
  259. package/dist/chunks/studio-qyed0ah6.js +0 -5
  260. package/dist/chunks/studio-r4bcdwhq.js +0 -5
  261. package/dist/chunks/studio-rtfnhrdg.js +0 -5
  262. package/dist/chunks/studio-s5tzw84t.js +0 -5
  263. package/dist/chunks/studio-t51y8v1d.js +0 -5
  264. package/dist/chunks/studio-t98vq4r6.js +0 -5
  265. package/dist/chunks/studio-tsxpqvh0.js +0 -6
  266. package/dist/chunks/studio-v1yascv2.js +0 -5
  267. package/dist/chunks/studio-wxq6v5sv.js +0 -5
  268. package/dist/chunks/studio-y60sts62.js +0 -4
  269. package/dist/chunks/studio-zndrqawb.js +0 -5
  270. package/dist/chunks/studio-zwaxn5s2.js +0 -5
  271. package/dist/chunks/workspace-home-x1787wpv.js +0 -4
  272. package/dist/chunks/workspace-zpe1np43.js +0 -4
@@ -98,6 +98,22 @@ let floor = 0;
98
98
  const dirtiedPaths = new Map<string, number>();
99
99
  /** The sequence number at which each directory prefix (`dir/`) was last forgotten. */
100
100
  const dirtiedPrefixes = new Map<string, number>();
101
+ /**
102
+ * Studio's own repaint keys, by normalized project path: a token minted when Studio has just
103
+ * written a path's bytes itself, which every asset URL builder appends as `"r"` (see
104
+ * {@link noteLocalWrite}). A map of its own, so nothing that reads a backend version changes
105
+ * meaning.
106
+ */
107
+ const revisions = new Map<string, string>();
108
+ /** Counts the repaint keys minted this session. */
109
+ let revisionCount = 0;
110
+ /**
111
+ * The session the keys are minted in, so a key cannot repeat across a reload: a revalidating cache
112
+ * still holds `?r=1` from the last session, for bytes that may be older than this session's.
113
+ */
114
+ const REVISION_SESSION = Date.now().toString(36);
115
+ /** The memoized repaint-key snapshot, or null once a key changed under it. */
116
+ let revisionsSnapshot: Readonly<Record<string, string>> | null = null;
101
117
  /** The memoized canvas snapshot, or null once a media version changed under it. */
102
118
  let snapshot: Readonly<Record<string, string>> | null = null;
103
119
  /**
@@ -119,6 +135,28 @@ function mediaChanged(): void {
119
135
  assetVersionsEpoch.value += 1;
120
136
  }
121
137
 
138
+ /**
139
+ * Mint a path's repaint key again, if it holds one, reporting whether a media path's did. The key
140
+ * has a URL's cached pixels behind it, so when the bytes change by any other door (a source-control
141
+ * discard, say) the old key would go on showing the picture they replaced.
142
+ */
143
+ function remintRevision(path: string): boolean {
144
+ if (!revisions.has(path)) {
145
+ return false;
146
+ }
147
+ revisionCount += 1;
148
+ revisions.set(path, `${REVISION_SESSION}.${revisionCount.toString(36)}`);
149
+ revisionsSnapshot = null;
150
+ return isVersionedMediaPath(path);
151
+ }
152
+
153
+ /** Drop a path's repaint key, if Studio holds one. */
154
+ function dropRevision(path: string): void {
155
+ if (revisions.delete(path)) {
156
+ revisionsSnapshot = null;
157
+ }
158
+ }
159
+
122
160
  /**
123
161
  * Begin a listing, returning the sequence number to pass to {@link noteListing} with its answer.
124
162
  * Call it BEFORE the request goes out.
@@ -173,6 +211,9 @@ export function noteListing(entries: readonly DirEntry[], since: number): void {
173
211
  continue;
174
212
  } else {
175
213
  versions.set(path, version);
214
+ /* A version the host vouches for supersedes Studio's own repaint key: it names the same new
215
+ bytes, and it is the one a host may cache. */
216
+ dropRevision(path);
176
217
  }
177
218
  if (isVersionedMediaPath(path)) {
178
219
  changed = true;
@@ -212,6 +253,9 @@ export function forgetVersions(paths: readonly string[], prefixDirs: readonly st
212
253
  if (versions.delete(path) && isVersionedMediaPath(path)) {
213
254
  changed = true;
214
255
  }
256
+ if (remintRevision(path)) {
257
+ changed = true;
258
+ }
215
259
  }
216
260
  for (const raw of prefixDirs) {
217
261
  const dir = normalizeProjectPath(raw);
@@ -220,6 +264,11 @@ export function forgetVersions(paths: readonly string[], prefixDirs: readonly st
220
264
  }
221
265
  const prefix = `${dir}/`;
222
266
  dirtiedPrefixes.set(prefix, seq);
267
+ for (const path of revisions.keys()) {
268
+ if (path.startsWith(prefix) && remintRevision(path)) {
269
+ changed = true;
270
+ }
271
+ }
223
272
  for (const path of versions.keys()) {
224
273
  if (path.startsWith(prefix)) {
225
274
  versions.delete(path);
@@ -286,9 +335,66 @@ export function forgetAllVersions(): void {
286
335
  }
287
336
 
288
337
  /**
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.
338
+ * Record that Studio itself has just written new bytes to `path`, and return the repaint key minted
339
+ * for it (studio.md §3.4).
340
+ *
341
+ * The version above only exists where a host vouches for one, which is the cloud's repo space
342
+ * alone. Everywhere else an asset URL carries no key at all, so after an in-place overwrite (the
343
+ * Media workspace's Replace, the Image editor's save) the URL is byte-identical before and after,
344
+ * and every `<img>` already decoded in the document keeps the old pixels. This key is the second,
345
+ * clearly separate channel: a monotonic token both asset-URL builders append as `"r"` in BOTH of
346
+ * their branches. It is deliberately not `v`, because §3.4 binds that name to a version the host
347
+ * issued and may answer immutably, and a token Studio minted is neither. It is dropped when a
348
+ * listing vouches for a version, which then names the same bytes.
349
+ */
350
+ export function noteLocalWrite(path: string): string | undefined {
351
+ const key = normalizeProjectPath(path);
352
+ if (key === "") {
353
+ return undefined;
354
+ }
355
+ revisionCount += 1;
356
+ const token = `${REVISION_SESSION}.${revisionCount.toString(36)}`;
357
+ revisions.set(key, token);
358
+ revisionsSnapshot = null;
359
+ if (isVersionedMediaPath(key)) {
360
+ // The canvas host watches the same epoch, so a frame is reposted the new keys.
361
+ mediaChanged();
362
+ }
363
+ return token;
364
+ }
365
+
366
+ /** The repaint key Studio minted for `path`, or undefined when Studio has not written it. */
367
+ export function localRevisionOf(path: string): string | undefined {
368
+ return revisions.get(normalizeProjectPath(path));
369
+ }
370
+
371
+ /** Whether Studio holds a repaint key for any path. */
372
+ export function hasLocalRevisions(): boolean {
373
+ return revisions.size > 0;
374
+ }
375
+
376
+ /**
377
+ * Every repaint key as a plain record, for the canvas iframe. Memoized like
378
+ * {@link assetVersionsSnapshot}, so identity is a cheap "did anything move" test.
379
+ */
380
+ export function localRevisionsSnapshot(): Readonly<Record<string, string>> {
381
+ if (revisionsSnapshot) {
382
+ return revisionsSnapshot;
383
+ }
384
+ revisionsSnapshot =
385
+ revisions.size === 0 ? EMPTY_SNAPSHOT : Object.freeze(Object.fromEntries(revisions));
386
+ return revisionsSnapshot;
387
+ }
388
+
389
+ /**
390
+ * Drop every version, dirt record and repaint key — a project switch, and between tests. A listing
391
+ * begun before the reset is ignored when it lands: it lists the project that was closed.
291
392
  */
292
393
  export function resetAssetVersions(): void {
293
394
  forgetAllVersions();
395
+ if (revisions.size > 0) {
396
+ revisions.clear();
397
+ revisionsSnapshot = null;
398
+ mediaChanged();
399
+ }
294
400
  }
@@ -23,6 +23,8 @@
23
23
 
24
24
  import { loadUsages, usageWarning } from "../services/references";
25
25
  import { isMediaFile } from "./media-upload";
26
+ import { IMAGE_MODE, imageEditorDirty, saveImage } from "../media/image-editor";
27
+ import { canvasModeOfTab } from "../canvas/canvas-surface";
26
28
  import { loadMediaUsages } from "./media-usage";
27
29
  import { showConfirmDialog } from "../ui/layers";
28
30
  import { locateDocument } from "../services/code-services";
@@ -312,7 +314,9 @@ function reportSaved(tab: Tab) {
312
314
  * `source` alternate has the same stub behind it — `canvas-render.ts`'s `sourceContent` falls
313
315
  * through to the same `JSON.stringify` when no format class claims the path — so a guard reading
314
316
  * `canvasMode === "media"` would have left the one media type that offers a text editor still able
315
- * to overwrite itself with a placeholder it never showed anyone.
317
+ * to overwrite itself with a placeholder it never showed anyone. It stays keyed on the file now
318
+ * that an image can be edited (§9.5): the Image editor writes through its own door, `saveImage`,
319
+ * and the stub document is still not something any mode may serialize.
316
320
  */
317
321
  function hasSerializableDocument(tab: Tab): boolean {
318
322
  return !isMediaFile(tab.documentPath ?? "");
@@ -373,6 +377,12 @@ export async function saveFile(tab: Tab | null = activeTab.value): Promise<boole
373
377
  from one that worked. `warn` rather than `error` — nothing is broken and there is nothing to fix;
374
378
  the file is simply not the kind of thing Save writes. */
375
379
  if (!hasSerializableDocument(tab)) {
380
+ /* An image with an edit has a write of its own (§9.5), and ⌘S is the same key for the same
381
+ intent there. Asked by the edit as well as the mode, so the close prompt's Save writes a
382
+ pending crop even from the read-only viewer it was left behind in. */
383
+ if (canvasModeOfTab(tab) === IMAGE_MODE || imageEditorDirty(tab)) {
384
+ return saveImage(tab);
385
+ }
376
386
  notify.warn(
377
387
  `${tab.documentPath ?? "This file"} is a media file, so there is no document for Save to write.`,
378
388
  atTabPath(tab, {
@@ -75,6 +75,7 @@ import {
75
75
  } from "../workspace/workspace";
76
76
  import { openCsvGridTab, openPagesGrid } from "../grid/grid-open";
77
77
  import { isViewableMedia, openMediaTab } from "../media/media-open";
78
+ import { imageFileChanged } from "../media/image-editor";
78
79
  import { activeRegistry } from "../commands/active-registry";
79
80
  import { runActiveReported, runReported } from "../commands/run-reported";
80
81
  import { collectionOfPath } from "../content/entry-model";
@@ -99,6 +100,7 @@ import {
99
100
  composeNewFileName,
100
101
  extensionOfName,
101
102
  OTHER_FORMAT,
103
+ renameRefusal,
102
104
  validateNewFileName,
103
105
  } from "./new-file-name";
104
106
  import type { FormatChoice, NewFileNameCheck } from "./new-file-name";
@@ -1510,47 +1512,44 @@ function releaseFileTreeDnD(): void {
1510
1512
  * @param {() => void} renderLeftPanel
1511
1513
  */
1512
1514
  async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel: () => void) {
1513
- const platform = getPlatform();
1514
- markLocalMutation(oldPath, newPath);
1515
+ const name = newPath.split("/").pop() ?? newPath;
1516
+ const newParent = parentDir(newPath);
1517
+ const failure = { path: oldPath, source: "Files" };
1518
+ let listed: DirEntry[];
1515
1519
  try {
1516
- const report = await platform.renameFile(oldPath, newPath);
1517
- // The refactor pass just rewrote references project-wide, and `markLocalMutation` suppresses
1518
- // The watcher echo that would otherwise say so — hence the explicit drop, as in `renameFile`.
1519
- // Without it a drag-move leaves every usage count in the session answering about the old path.
1520
- invalidateUsages();
1521
-
1522
- // Update open tabs referencing the moved path
1523
- for (const [id] of workspace.tabs.entries()) {
1524
- if (id === oldPath) {
1525
- renameTab(oldPath, newPath, newPath);
1526
- } else if (id.startsWith(`${oldPath}/`)) {
1527
- const newTabPath = newPath + id.slice(oldPath.length);
1528
- renameTab(id, newTabPath, newTabPath);
1529
- }
1530
- }
1531
-
1532
- // Refresh affected directories
1533
- const oldParent = parentDir(oldPath);
1534
- const newParent = parentDir(newPath);
1535
- await loadDirectory(oldParent);
1536
- if (newParent !== oldParent) {
1537
- await loadDirectory(newParent);
1538
- }
1539
-
1520
+ listed = await getPlatform().listDirectory(newParent);
1521
+ } catch (error) {
1522
+ // A row was dropped on, so the folder exists: a failed listing says nothing about what is in it.
1523
+ notify.error(`Could not move ${oldPath}.`, {
1524
+ ...failure,
1525
+ detail: `The folder could not be read to check for ${name}: ${errorMessage(error)}`,
1526
+ });
1527
+ return;
1528
+ }
1529
+ /* A move is the same filesystem rename a Rename is, so it replaces its destination in silence.
1530
+ Without regard to case, as `renameRefusal` compares: a case-insensitive disk takes one for the
1531
+ other. A dragged dotfile needs no refusal of its own, since only a host that lists dotfiles can
1532
+ show a row to drag. */
1533
+ const lower = name.toLowerCase();
1534
+ const occupant = listed.find((entry) => entry.name.toLowerCase() === lower);
1535
+ if (occupant !== undefined) {
1536
+ const where = newParent === "." ? "The project root" : `${newParent}/`;
1537
+ notify.error(`Could not move ${oldPath}.`, {
1538
+ ...failure,
1539
+ detail: `${where} already has ${occupant.name}, and a move would replace it.`,
1540
+ });
1541
+ return;
1542
+ }
1543
+ try {
1544
+ const report = await commitRename(oldPath, newPath);
1540
1545
  // Auto-expand target directory
1541
1546
  if (newParent !== ".") {
1542
1547
  requireProjectState().expanded.add(newParent);
1543
1548
  }
1544
-
1545
- reloadRewrittenTabs(report, newPath);
1546
1549
  renderLeftPanel();
1547
1550
  notifyMoveOutcome(`Moved to ${newPath}`, report, newPath);
1548
1551
  } catch (error) {
1549
- notify.error(`Could not move ${oldPath}.`, {
1550
- detail: errorMessage(error),
1551
- path: oldPath,
1552
- source: "Files",
1553
- });
1552
+ notify.error(`Could not move ${oldPath}.`, { ...failure, detail: errorMessage(error) });
1554
1553
  }
1555
1554
  }
1556
1555
 
@@ -2052,14 +2051,21 @@ async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
2052
2051
  * is typed rather than after it is confirmed — the refactor pass is about to rewrite every one of
2053
2052
  * those references, and silently doing that much work was the previous behaviour.
2054
2053
  *
2055
- * @param {string} currentName @param {string} path @returns {Promise<string | null>}
2054
+ * @param currentName The entry's name, which the field opens on.
2055
+ * @param path The entry being renamed.
2056
+ * @param taken The names in its folder, which the field refuses.
2057
+ * @returns The new name, or `null` when the dialog was dismissed.
2056
2058
  */
2057
- async function showRenameFileDialog(currentName: string, path: string): Promise<string | null> {
2059
+ async function showRenameFileDialog(
2060
+ currentName: string,
2061
+ path: string,
2062
+ taken: ReadonlySet<string>,
2063
+ ): Promise<string | null> {
2058
2064
  const message = await renamePromptMessage(path);
2059
2065
  const promptOptions: PromptDialogOptions = {
2060
2066
  confirmLabel: "Rename",
2061
2067
  select: "stem",
2062
- validate: (v) => (v.trim() ? "" : "Enter a file name."),
2068
+ validate: (v) => renameRefusal(path, v, taken),
2063
2069
  value: currentName,
2064
2070
  };
2065
2071
  if (message !== undefined) {
@@ -2069,7 +2075,7 @@ async function showRenameFileDialog(currentName: string, path: string): Promise<
2069
2075
  }
2070
2076
 
2071
2077
  /** Build the status-bar message for a rename, summarising any reference/tag rewrites. */
2072
- function renameStatus(newName: string, report: RenameResult): string {
2078
+ export function renameStatus(newName: string, report: RenameResult): string {
2073
2079
  const refs = report.references;
2074
2080
  const tagNote = report.tag ? `; tag → <${report.tag.to}> (${report.tag.refsUpdated})` : "";
2075
2081
  if (refs && refs.refsUpdated > 0) {
@@ -2154,30 +2160,85 @@ export async function settleRename(from: string, to: string, report: RenameResul
2154
2160
  if (toDir !== fromDir) {
2155
2161
  await loadDirectory(toDir);
2156
2162
  }
2157
- if (requireProjectState().selectedPath === from) {
2158
- requireProjectState().selectedPath = to;
2163
+ const state = requireProjectState();
2164
+ if (state.selectedPath === from) {
2165
+ state.selectedPath = to;
2166
+ } else if (state.selectedPath?.startsWith(`${from}/`)) {
2167
+ state.selectedPath = to + state.selectedPath.slice(from.length);
2159
2168
  }
2160
2169
  reloadRewrittenTabs(report, to);
2161
2170
  }
2162
2171
 
2172
+ /**
2173
+ * Rename a file on the backend and catch the app up: the one door a rename goes through, whichever
2174
+ * surface asked for the name (the Files panel, the Media workspace).
2175
+ *
2176
+ * The tab is re-keyed rather than reopened, so whatever it holds (unsaved edits, a pending image
2177
+ * crop) stays with the file. Throws what the backend threw; what to say about it is the caller's.
2178
+ *
2179
+ * @param from - The path before the rename.
2180
+ * @param to - The path after it.
2181
+ * @returns What the refactor pass rewrote.
2182
+ */
2183
+ export async function commitRename(from: string, to: string): Promise<RenameResult> {
2184
+ markLocalMutation(from, to);
2185
+ const report = await getPlatform().renameFile(from, to);
2186
+ // A copy of the keys, since a re-key edits the map being walked. `from` may be a folder.
2187
+ const openIds = [...workspace.tabs.keys()];
2188
+ for (const id of openIds) {
2189
+ if (id === from || id.startsWith(`${from}/`)) {
2190
+ const moved = to + id.slice(from.length);
2191
+ renameTab(id, moved, moved);
2192
+ }
2193
+ }
2194
+ await settleRename(from, to, report);
2195
+ return report;
2196
+ }
2197
+
2198
+ /**
2199
+ * The names beside the entry at `path`, for {@link renameRefusal} to judge a new name against.
2200
+ *
2201
+ * Not `listTakenNames`, which answers empty for a folder that may not exist yet. This one holds the
2202
+ * entry, so a failed listing says nothing about what a rename would overwrite, and it makes the
2203
+ * rename refuse rather than guess.
2204
+ *
2205
+ * @param path The entry about to be renamed.
2206
+ * @param source Names the flow in the Problem.
2207
+ * @returns The names, or `null` when the folder could not be read; the reason has been reported.
2208
+ */
2209
+ export async function listRenameSiblings(
2210
+ path: string,
2211
+ source: string,
2212
+ ): Promise<Set<string> | null> {
2213
+ try {
2214
+ const listed = await getPlatform().listDirectory(parentDirOf(path));
2215
+ return new Set(listed.map((entry) => entry.name));
2216
+ } catch (error) {
2217
+ notify.error(`Could not rename ${path.split("/").pop() ?? path}.`, {
2218
+ detail: `The folder could not be read to check the new name: ${errorMessage(error)}`,
2219
+ path,
2220
+ source,
2221
+ });
2222
+ return null;
2223
+ }
2224
+ }
2225
+
2163
2226
  async function renameFile(
2164
2227
  entry: { name: string; path: string; type: string },
2165
2228
  renderLeftPanel: () => void,
2166
2229
  ) {
2167
- const newName = await showRenameFileDialog(entry.name, entry.path);
2230
+ const parentDirPath = parentDirOf(entry.path);
2231
+ const taken = await listRenameSiblings(entry.path, "Files");
2232
+ if (taken === null) {
2233
+ return;
2234
+ }
2235
+ const newName = await showRenameFileDialog(entry.name, entry.path, taken);
2168
2236
  if (!newName || newName === entry.name) {
2169
2237
  return;
2170
2238
  }
2171
- const parentDirPath = parentDirOf(entry.path);
2172
2239
  const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
2173
- markLocalMutation(entry.path, newPath);
2174
2240
  try {
2175
- const platform = getPlatform();
2176
- const report = await platform.renameFile(entry.path, newPath);
2177
- if (workspace.tabs.has(entry.path)) {
2178
- renameTab(entry.path, newPath, newPath);
2179
- }
2180
- await settleRename(entry.path, newPath, report);
2241
+ const report = await commitRename(entry.path, newPath);
2181
2242
  renderLeftPanel();
2182
2243
  notifyMoveOutcome(renameStatus(newName, report), report, newPath);
2183
2244
  } catch (error) {
@@ -2446,6 +2507,8 @@ export function reloadCleanTab(path: string): void {
2446
2507
  if (isCollabPath(path)) {
2447
2508
  return;
2448
2509
  }
2510
+ // An image's editor holds the file's bytes rather than a document, and reads them again itself.
2511
+ imageFileChanged(path);
2449
2512
  for (const [, tab] of workspace.tabs.entries()) {
2450
2513
  if (tab.documentPath === path && !tab.doc.dirty) {
2451
2514
  void reloadFileInTab(path);
@@ -38,8 +38,8 @@
38
38
 
39
39
  import { encodeProjectPath, normalizeProjectPath, PUBLIC_DIR } from "@jxsuite/schema/asset-paths";
40
40
  import { contentMountFor, hostAssetDeclarations, projectLocales } from "../canvas/asset-refs";
41
- import { withVersion } from "../canvas/asset-resolve";
42
- import { versionOf } from "./asset-versions";
41
+ import { withRevision, withVersion } from "../canvas/asset-resolve";
42
+ import { localRevisionOf, versionOf } from "./asset-versions";
43
43
  import { loopbackAssetSrc } from "../canvas/canvas-origin";
44
44
  import { projectState } from "../store";
45
45
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
@@ -134,10 +134,18 @@ export function previewFileSrc(path: string): string {
134
134
  host serves the FILE, at its own path, under the declared base — versioned when a listing
135
135
  vouched for its bytes, so a host that recognizes the version can let the browser cache it. */
136
136
  const { space, fileBaseUrl } = hostAssetDeclarations();
137
+ /* Studio's own repaint key rides in BOTH branches (§3.4). The site branch is the one that needs
138
+ it most: it carries no version at all, so after an in-place overwrite its URL would be
139
+ byte-identical and every thumbnail already decoded would keep the old pixels. */
140
+ const revision = localRevisionOf(normalized);
137
141
  if (space === "repo") {
138
- return withVersion(`${fileBaseUrl}${encodeProjectPath(normalized)}`, versionOf(normalized));
142
+ return withRevision(
143
+ withVersion(`${fileBaseUrl}${encodeProjectPath(normalized)}`, versionOf(normalized)),
144
+ revision,
145
+ );
139
146
  }
140
147
  /* Encoded per segment for the `src` only. `mediaSiteUrl` is also what an author's document is
141
148
  given, and stays as typed; a `#` or `?` in a filename would cut this URL short. */
142
- return loopbackAssetSrc(`/${encodeProjectPath(mediaSiteUrl(normalized).slice(1))}`);
149
+ const encoded = encodeProjectPath(mediaSiteUrl(normalized).slice(1));
150
+ return withRevision(loopbackAssetSrc(`/${encoded}`), revision);
143
151
  }