@jxsuite/studio 5.5.0 → 5.7.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 (244) hide show
  1. package/LICENSE +1 -1
  2. package/dist/chunks/{2020-g8mwpbkq.js → 2020-cs2sb9t1.js} +3 -3
  3. package/dist/chunks/adapter-y5gdq19c.js +4 -0
  4. package/dist/chunks/cf-account-picker-882a7ecm.js +4 -0
  5. package/dist/chunks/cf-settings-20dznc3j.js +4 -0
  6. package/dist/chunks/components-q037dya7.js +4 -0
  7. package/dist/chunks/content-source-37x3vsap.js +4 -0
  8. package/dist/chunks/convert-to-repeater-grfep2w8.js +4 -0
  9. package/dist/chunks/editor-j5sq149q.js +4 -0
  10. package/dist/chunks/entry-commands-c5h3nqck.js +4 -0
  11. package/dist/chunks/extension-sections-dp8endhx.js +4 -0
  12. package/dist/chunks/file-ops-937cvqk6.js +4 -0
  13. package/dist/chunks/files-tznwe1p1.js +4 -0
  14. package/dist/chunks/format-host-e2pjvd25.js +4 -0
  15. package/dist/chunks/fs-events-fv64ak3s.js +4 -0
  16. package/dist/chunks/git-panel-gb713ttc.js +4 -0
  17. package/dist/chunks/grid-view-5amhg604.js +51 -0
  18. package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
  19. package/dist/chunks/iframe-host-t819mcg5.js +4 -0
  20. package/dist/chunks/{index-arg188jq.js → index-9ydfe04y.js} +3 -3
  21. package/dist/chunks/index-m0x5ztax.js +4 -0
  22. package/dist/chunks/{javascript-zcdmye2k.js → javascript-d9pqgc2p.js} +2 -2
  23. package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-800yvbem.js} +5 -5
  24. package/dist/chunks/jx-validate-zc9zp77s.js +4 -0
  25. package/dist/chunks/layers-4bsr82wz.js +4 -0
  26. package/dist/chunks/layers-panel-b6shnaax.js +4 -0
  27. package/dist/chunks/lit-html-cfn61dn7.js +4 -0
  28. package/dist/chunks/media-picker-ej0kdh1j.js +4 -0
  29. package/dist/chunks/menu-rtxf9chb.js +4 -0
  30. package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-62dtmgbf.js} +3 -3
  31. package/dist/chunks/monaco-buffer-nn4dtx27.js +4 -0
  32. package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-tj13ndtm.js} +3 -3
  33. package/dist/chunks/notify-ahs57a2z.js +4 -0
  34. package/dist/chunks/platform-v9xyfkbx.js +4 -0
  35. package/dist/chunks/publish-panel-bq36xcmj.js +4 -0
  36. package/dist/chunks/quick-search-zm00h9dh.js +4 -0
  37. package/dist/chunks/references-99ws0b0m.js +4 -0
  38. package/dist/chunks/right-panel-pq2f71q3.js +4 -0
  39. package/dist/chunks/section-registry-b2yzp9sw.js +4 -0
  40. package/dist/chunks/studio-01kbbqg3.js +928 -0
  41. package/dist/chunks/{studio-hf4qdjc8.js → studio-01qza1n3.js} +4 -4
  42. package/dist/chunks/{studio-pkkw32j0.js → studio-03wwfvyt.js} +4 -4
  43. package/dist/chunks/{studio-7gqyhjns.js → studio-0bbxhhce.js} +4 -4
  44. package/dist/chunks/studio-0k9mvpgj.js +14 -0
  45. package/dist/chunks/studio-0scebvb9.js +6 -0
  46. package/dist/chunks/studio-1a99w39s.js +5 -0
  47. package/dist/chunks/studio-1wvfxkyy.js +5 -0
  48. package/dist/chunks/studio-1x8x26k3.js +5 -0
  49. package/dist/chunks/studio-222rqyyc.js +5 -0
  50. package/dist/chunks/studio-4xppwncr.js +4 -0
  51. package/dist/chunks/{studio-xcng8nx7.js → studio-54g077x3.js} +5 -5
  52. package/dist/chunks/studio-56z7evx4.js +10 -0
  53. package/dist/chunks/studio-5e9zsamh.js +9 -0
  54. package/dist/chunks/studio-65fhspdd.js +5 -0
  55. package/dist/chunks/studio-693nhyky.js +5 -0
  56. package/dist/chunks/{studio-7jazdy6y.js → studio-7pgy3xaf.js} +4 -4
  57. package/dist/chunks/studio-888knnkc.js +5 -0
  58. package/dist/chunks/studio-9mqfamps.js +5 -0
  59. package/dist/chunks/studio-a7xqhc3n.js +5 -0
  60. package/dist/chunks/studio-aje966s9.js +5 -0
  61. package/dist/chunks/studio-aqmph6k4.js +5 -0
  62. package/dist/chunks/studio-c6w3tq7t.js +5 -0
  63. package/dist/chunks/{studio-6j2jn55a.js → studio-c6zdm25z.js} +8 -8
  64. package/dist/chunks/{studio-f8jyp45j.js → studio-c9d3pxf7.js} +4 -4
  65. package/dist/chunks/studio-ceb34dxj.js +5 -0
  66. package/dist/chunks/studio-cvbkkfqb.js +5 -0
  67. package/dist/chunks/studio-cxt150pj.js +7 -0
  68. package/dist/chunks/studio-dkn3x3hz.js +5 -0
  69. package/dist/chunks/studio-e6re3rk1.js +5 -0
  70. package/dist/chunks/{studio-5n1km79v.js → studio-edbtvcd1.js} +4 -4
  71. package/dist/chunks/{studio-xc8ebsts.js → studio-eh0rac07.js} +4 -4
  72. package/dist/chunks/{studio-625ezz59.js → studio-evwgp1ym.js} +4 -4
  73. package/dist/chunks/{studio-he2fhrgp.js → studio-f195h2hq.js} +4 -4
  74. package/dist/chunks/studio-fqq890ap.js +4 -0
  75. package/dist/chunks/studio-frb0qbf7.js +6 -0
  76. package/dist/chunks/studio-gfn1kpxj.js +6 -0
  77. package/dist/chunks/studio-gh97gngt.js +5 -0
  78. package/dist/chunks/{studio-awqghqxv.js → studio-gp02nqnw.js} +4 -4
  79. package/dist/chunks/{studio-x9sagq2d.js → studio-hjbg8d5y.js} +7 -7
  80. package/dist/chunks/studio-j6gnhvaa.js +5 -0
  81. package/dist/chunks/studio-mwxgyn9h.js +7 -0
  82. package/dist/chunks/{studio-x5esgzct.js → studio-n78gjfy8.js} +4 -4
  83. package/dist/chunks/studio-nbxqcgvf.js +5 -0
  84. package/dist/chunks/{studio-p1eggxcf.js → studio-nt06yybj.js} +4 -4
  85. package/dist/chunks/{studio-hc9q4j24.js → studio-p7c1agz0.js} +5 -5
  86. package/dist/chunks/{studio-8r4y2x0z.js → studio-pwqfmt4p.js} +4 -4
  87. package/dist/chunks/{studio-5wfrgee9.js → studio-r3kaka4d.js} +4 -4
  88. package/dist/chunks/studio-semd1s8s.js +5 -0
  89. package/dist/chunks/studio-skzanb0k.js +5 -0
  90. package/dist/chunks/studio-spf2skgc.js +5 -0
  91. package/dist/chunks/{studio-b4epb26c.js → studio-st75kn99.js} +7 -7
  92. package/dist/chunks/{studio-eq27rrr0.js → studio-sxc0zvpt.js} +3 -3
  93. package/dist/chunks/studio-t1eedsyx.js +4 -0
  94. package/dist/chunks/{studio-73czgk6a.js → studio-tp642n5g.js} +4 -4
  95. package/dist/chunks/studio-tw7rre6s.js +5 -0
  96. package/dist/chunks/{studio-7by9a93k.js → studio-tzegmy4w.js} +4 -4
  97. package/dist/chunks/studio-vavmwshr.js +5 -0
  98. package/dist/chunks/studio-wma2c6g8.js +5 -0
  99. package/dist/chunks/studio-x92tgnee.js +64 -0
  100. package/dist/chunks/studio-xk0z6msk.js +5 -0
  101. package/dist/chunks/{studio-c4pnt1c6.js → studio-xry7tk3z.js} +5 -5
  102. package/dist/chunks/studio-xta2vxp6.js +9 -0
  103. package/dist/chunks/{studio-21enxdr8.js → studio-xy3tv3az.js} +4 -4
  104. package/dist/chunks/studio-y7jehjrp.js +5 -0
  105. package/dist/chunks/{studio-k1eaqyzm.js → studio-yr04mv2j.js} +4 -4
  106. package/dist/chunks/{studio-mhhc9btt.js → studio-zd4kayn2.js} +4 -4
  107. package/dist/chunks/studio-zpnk016a.js +16 -0
  108. package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-k9s9fcag.js} +5 -5
  109. package/dist/chunks/workspace-b69mgm0e.js +4 -0
  110. package/dist/chunks/ws-client-0ja08haa.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 +121 -123
  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/canvas/stage-scrollbar.ts +7 -7
  127. package/src/files/asset-versions.ts +294 -0
  128. package/src/files/file-ops.ts +5 -8
  129. package/src/files/files.ts +61 -9
  130. package/src/files/fs-events.ts +279 -28
  131. package/src/files/media-meta.ts +2 -1
  132. package/src/files/media-paths.ts +5 -2
  133. package/src/files/media-upload.ts +5 -1
  134. package/src/new-project/add-repo-modal.ts +12 -1
  135. package/src/new-project/new-project-modal.ts +12 -1
  136. package/src/panels/git-panel.ts +25 -2
  137. package/src/panels/jump-bar.ts +132 -158
  138. package/src/panels/pane-context.ts +8 -8
  139. package/src/panels/pane-grid.ts +18 -26
  140. package/src/panels/style-panel.ts +2 -2
  141. package/src/platform-errors.ts +61 -1
  142. package/src/platforms/cloud.ts +389 -50
  143. package/src/platforms/devserver.ts +17 -1
  144. package/src/publish/pages-service.ts +17 -7
  145. package/src/services/ai-models.ts +30 -6
  146. package/src/services/ai-system-prompt.ts +81 -51
  147. package/src/services/context-manager.ts +41 -70
  148. package/src/services/document-assistant.ts +12 -3
  149. package/src/services/harness/chat-reducer.ts +115 -0
  150. package/src/services/harness/turn-hooks.ts +50 -0
  151. package/src/services/tool-executor.ts +69 -288
  152. package/src/services/tool-outcomes.ts +12 -10
  153. package/src/settings/preferences-accounts.ts +42 -3
  154. package/src/settings/preferences-dialog.ts +8 -1
  155. package/src/studio.ts +20 -6
  156. package/src/surfaces/block-action-bar.json +6 -1
  157. package/src/surfaces/dialog.ts +5 -0
  158. package/src/surfaces/jump-bar.json +9 -12
  159. package/src/surfaces/jump-bar.ts +15 -16
  160. package/src/surfaces/pane-context.json +2 -2
  161. package/src/surfaces/pane-grid.json +8 -25
  162. package/src/surfaces/pane-grid.ts +7 -8
  163. package/src/surfaces/statusbar.json +71 -3
  164. package/src/surfaces/statusbar.ts +66 -139
  165. package/src/surfaces/style-panel.json +3 -3
  166. package/src/surfaces/style-panel.ts +11 -1
  167. package/src/surfaces/welcome.json +86 -1
  168. package/src/surfaces/welcome.ts +61 -2
  169. package/src/types.ts +84 -2
  170. package/src/ui/ai-managed-connect.ts +41 -4
  171. package/src/ui/media-picker.ts +2 -1
  172. package/dist/chunks/adapter-bsbs78x9.js +0 -4
  173. package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
  174. package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
  175. package/dist/chunks/components-s51h3b7c.js +0 -4
  176. package/dist/chunks/content-source-ty3be0qg.js +0 -4
  177. package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
  178. package/dist/chunks/editor-t002b41m.js +0 -4
  179. package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
  180. package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
  181. package/dist/chunks/file-ops-78zeytkm.js +0 -4
  182. package/dist/chunks/files-gatzrz8v.js +0 -4
  183. package/dist/chunks/format-host-r59b1651.js +0 -4
  184. package/dist/chunks/fs-events-f8h91ndt.js +0 -4
  185. package/dist/chunks/git-panel-y12zf7nj.js +0 -4
  186. package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
  187. package/dist/chunks/grid-view-tafenfm2.css +0 -1
  188. package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
  189. package/dist/chunks/index-5aghyrfh.js +0 -4
  190. package/dist/chunks/jx-validate-653krkrf.js +0 -4
  191. package/dist/chunks/layers-32p8gtae.js +0 -4
  192. package/dist/chunks/layers-panel-rs58m105.js +0 -4
  193. package/dist/chunks/lit-html-54bmkwz3.js +0 -4
  194. package/dist/chunks/media-picker-5bb04wct.js +0 -4
  195. package/dist/chunks/menu-cbha7w2e.js +0 -4
  196. package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
  197. package/dist/chunks/notify-dc1nz5ar.js +0 -4
  198. package/dist/chunks/platform-2m49s1zf.js +0 -4
  199. package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
  200. package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
  201. package/dist/chunks/references-vx7mbj9f.js +0 -4
  202. package/dist/chunks/right-panel-7jzpbxja.js +0 -4
  203. package/dist/chunks/section-registry-gjryse41.js +0 -4
  204. package/dist/chunks/studio-3znfhcxg.js +0 -5
  205. package/dist/chunks/studio-58qam27v.js +0 -4
  206. package/dist/chunks/studio-6j59fwaw.js +0 -5
  207. package/dist/chunks/studio-7z69g1gx.js +0 -5
  208. package/dist/chunks/studio-93j3v6zx.js +0 -5
  209. package/dist/chunks/studio-9gf6v8zy.js +0 -5
  210. package/dist/chunks/studio-9rhmettf.js +0 -4
  211. package/dist/chunks/studio-ac5aq677.js +0 -10
  212. package/dist/chunks/studio-as65vbns.js +0 -5
  213. package/dist/chunks/studio-bkv0sga4.js +0 -6
  214. package/dist/chunks/studio-bttmqef4.js +0 -5
  215. package/dist/chunks/studio-c3y9d41k.js +0 -5
  216. package/dist/chunks/studio-ccegp7gz.js +0 -9
  217. package/dist/chunks/studio-chxabn98.js +0 -6
  218. package/dist/chunks/studio-cktcdsxz.js +0 -16
  219. package/dist/chunks/studio-cnspfke5.js +0 -5
  220. package/dist/chunks/studio-de3p1ktm.js +0 -5
  221. package/dist/chunks/studio-fsbcn530.js +0 -928
  222. package/dist/chunks/studio-gftga7a3.js +0 -64
  223. package/dist/chunks/studio-hf1rpcq0.js +0 -4
  224. package/dist/chunks/studio-hz02hq70.js +0 -5
  225. package/dist/chunks/studio-jnb285we.js +0 -5
  226. package/dist/chunks/studio-jsv3s3s1.js +0 -5
  227. package/dist/chunks/studio-ks7cksh0.js +0 -5
  228. package/dist/chunks/studio-nptx0dbs.js +0 -5
  229. package/dist/chunks/studio-pg0z7k98.js +0 -5
  230. package/dist/chunks/studio-pve0b2f2.js +0 -5
  231. package/dist/chunks/studio-q7zmjv3b.js +0 -6
  232. package/dist/chunks/studio-rpmvw7kj.js +0 -5
  233. package/dist/chunks/studio-rwhss52s.js +0 -5
  234. package/dist/chunks/studio-sbg3g3h0.js +0 -5
  235. package/dist/chunks/studio-sq1bn60g.js +0 -14
  236. package/dist/chunks/studio-vwz7rma2.js +0 -5
  237. package/dist/chunks/studio-wa55e8gf.js +0 -5
  238. package/dist/chunks/studio-x0mzdha2.js +0 -5
  239. package/dist/chunks/studio-xrgpakvc.js +0 -9
  240. package/dist/chunks/studio-xt3djk1w.js +0 -7
  241. package/dist/chunks/studio-yh3nggx1.js +0 -7
  242. package/dist/chunks/studio-zmecp6hq.js +0 -5
  243. package/dist/chunks/workspace-g6dvaete.js +0 -4
  244. package/dist/chunks/ws-client-shf41j5p.js +0 -4
@@ -6,21 +6,49 @@
6
6
  * without a DOM). `startFsSync` is the only impure part: it feature-detects the platform's
7
7
  * `subscribeFileEvents`, debounces bursts, drops the echoes of the user's own local mutations, and
8
8
  * triggers a single re-render through the existing left-panel render path (no imperative DOM).
9
+ *
10
+ * It also answers the transport's `onResync`, which is the platform admitting that its event stream
11
+ * has a gap (a reconnect, or the stream's first open after listings were made) or never carried the
12
+ * change (a commit). Events are deltas; a gap in deltas cannot be patched by more deltas, so a
13
+ * resync re-reads what this window has cached.
9
14
  */
10
15
 
11
16
  import { getPlatform } from "../platform";
12
17
  import { reloadIgnoreCache, touchesGitignore } from "./gitignore";
13
18
  import { invalidateUsages } from "../services/references";
19
+ import { forgetAllVersions, forgetVersions, notedListing } from "./asset-versions";
20
+ import { isCollabPath } from "../collab/collab-state";
14
21
  import { projectState } from "../store";
15
- import type { DirEntry, FsEvent } from "../types";
22
+ import { workspace } from "../workspace/workspace";
23
+ import type { DirEntry, FsEvent, FsResyncReason, StudioPlatform } from "../types";
16
24
 
17
25
  const RECENT_MS = 1500;
26
+ /**
27
+ * How wide each resync reason's pass is: a coalesced burst runs the widest one it saw. A reconnect
28
+ * re-derives everything; an open forgets the content versions and re-lists; a commit re-lists.
29
+ */
30
+ const RESYNC_RANK: Record<FsResyncReason, number> = { commit: 0, open: 1, reconnect: 2 };
31
+ /**
32
+ * How long a resync request waits for company. A flapping network reconnects several times in a
33
+ * second, and a commit notice can arrive on the heels of a reconnect; each resync re-lists every
34
+ * loaded directory, so a burst is coalesced into one pass.
35
+ */
36
+ const RESYNC_DEBOUNCE_MS = 250;
37
+ /** Listings in flight at once during a resync — a deep expanded tree is many round trips. */
38
+ const RELIST_CONCURRENCY = 8;
18
39
  const recentLocal = new Map<string, number>();
19
40
 
20
41
  const norm = (p: string) => p.replaceAll("\\", "/");
21
42
 
22
- /** Mark paths the user just mutated locally so the watcher's echo of them is ignored briefly. */
43
+ /**
44
+ * Mark paths the user just mutated locally so the watcher's echo of them is ignored briefly.
45
+ *
46
+ * Their content versions are forgotten too, each path as a file AND as a directory (a rename or a
47
+ * delete may name either): the bytes are about to change, and a version that outlived them would
48
+ * let an immutable cache serve the old ones.
49
+ */
23
50
  export function markLocalMutation(...paths: string[]): void {
51
+ forgetVersions(paths, paths);
24
52
  const expiry = Date.now() + RECENT_MS;
25
53
  for (const p of paths) {
26
54
  if (p) {
@@ -114,6 +142,146 @@ export function applyFsEvents(
114
142
  return changedDirs;
115
143
  }
116
144
 
145
+ /**
146
+ * Forget the content versions every event in a batch could have invalidated: the file an `add`,
147
+ * `change` or `unlink` names, and everything under a removed directory.
148
+ */
149
+ export function forgetEventVersions(events: readonly FsEvent[]): void {
150
+ const paths: string[] = [];
151
+ const dirs: string[] = [];
152
+ for (const event of events) {
153
+ if (event.type === "unlinkDir") {
154
+ dirs.push(event.path);
155
+ } else if (event.type !== "addDir") {
156
+ paths.push(event.path);
157
+ }
158
+ }
159
+ if (paths.length > 0 || dirs.length > 0) {
160
+ forgetVersions(paths, dirs);
161
+ }
162
+ }
163
+
164
+ /** Whether a cached listing still holds exactly the entries a snapshot of it took. */
165
+ function sameEntries(current: DirEntry[] | undefined, snapshot: DirEntry[]): boolean {
166
+ return (
167
+ current !== undefined &&
168
+ current.length === snapshot.length &&
169
+ current.every((entry, index) => entry === snapshot[index])
170
+ );
171
+ }
172
+
173
+ /** Reads of one directory a resync makes before it lets newer changes have the last word. */
174
+ const RELIST_ATTEMPTS = 2;
175
+
176
+ /**
177
+ * Re-list every directory `dirs` has cached and replace its entries in place.
178
+ *
179
+ * Only directories already in the cache are read: the tree's lazy loading is the authority on what
180
+ * is worth fetching, and a resync that grew the cache would turn one reconnect into a crawl of the
181
+ * project. A directory whose listing FAILS is dropped rather than kept — after a gap in the event
182
+ * stream, "this directory vanished" is the likeliest reason, and a stale listing of a directory
183
+ * that no longer exists is the very thing a resync exists to remove. Expanding it again re-reads
184
+ * it.
185
+ *
186
+ * Live events keep landing while the listings are in flight, and Studio's own create / rename /
187
+ * delete re-load their parent, so a listing can come back OLDER than the cache it would replace:
188
+ * nothing orders an HTTP response against a message on the event socket. Each directory is
189
+ * therefore snapshotted before its read and the listing applied only if the cache still matches the
190
+ * snapshot. A directory that moved is read again — that read starts after the change, so it
191
+ * includes it — and if it moves again under the second read the cache keeps what the newer changes
192
+ * made of it. Identity alone could not tell: `applyFsEvents` edits a listing in place. For the same
193
+ * reason a directory that left the cache mid-read (an `unlinkDir`) stays gone: the event is newer
194
+ * than the read.
195
+ */
196
+ export async function relistLoadedDirs(
197
+ dirs: Map<string, DirEntry[]>,
198
+ listDirectory: StudioPlatform["listDirectory"],
199
+ ): Promise<void> {
200
+ const keys = [...dirs.keys()];
201
+ let next = 0;
202
+ const relist = async (dir: string) => {
203
+ for (let attempt = 0; attempt < RELIST_ATTEMPTS; attempt += 1) {
204
+ const before = dirs.get(dir);
205
+ if (!before) {
206
+ return;
207
+ }
208
+ const snapshot = [...before];
209
+ let entries: DirEntry[] | null;
210
+ try {
211
+ entries = await listDirectory(dir);
212
+ } catch {
213
+ entries = null;
214
+ }
215
+ if (!dirs.has(dir)) {
216
+ return;
217
+ }
218
+ if (!sameEntries(dirs.get(dir), snapshot)) {
219
+ continue;
220
+ }
221
+ if (entries) {
222
+ dirs.set(dir, entries);
223
+ } else {
224
+ dirs.delete(dir);
225
+ }
226
+ return;
227
+ }
228
+ };
229
+ const worker = async () => {
230
+ while (next < keys.length) {
231
+ const dir = keys[next]!;
232
+ next += 1;
233
+ await relist(dir);
234
+ }
235
+ };
236
+ await Promise.all(Array.from({ length: Math.min(RELIST_CONCURRENCY, keys.length) }, worker));
237
+ }
238
+
239
+ /**
240
+ * Modes only a tab whose `document` is a placeholder offers: the media viewer, a CSV grid, the
241
+ * Library. Spelled out as strings, not imported, because each constant's module drags its whole
242
+ * editor in behind it.
243
+ */
244
+ const STUB_DOCUMENT_MODES = new Set(["media", "grid", "manage"]);
245
+
246
+ /**
247
+ * Whether a tab's `document` is a placeholder rather than its parsed file. Such a tab reads its
248
+ * file through its own channel, if at all, so a re-read through `readFile` has nothing to refresh —
249
+ * and is worse than waste: the cloud session's `/file` route refuses a binary file (415), and the
250
+ * failed reload would raise "could not reload" for a file that never changed. A git comparison stub
251
+ * is recognised by offering `git-diff` and NOTHING else, since every document tab offers it too.
252
+ */
253
+ function holdsPlaceholder(modes: string[]): boolean {
254
+ return (
255
+ modes.some((mode) => STUB_DOCUMENT_MODES.has(mode)) ||
256
+ (modes.length > 0 && modes.every((mode) => mode === "git-diff"))
257
+ );
258
+ }
259
+
260
+ /**
261
+ * The open tabs a reconnect may refresh from disk: clean ones holding a parsed document, and none
262
+ * that are co-edited.
263
+ *
264
+ * A dirty tab holds the author's unsaved work, which a reload would discard. A co-edited one never
265
+ * reloads from disk at all — its shared document is ahead of whatever the provider wrote back (the
266
+ * same rule `reloadCleanTab` applies to a single event). A stub tab has no parsed document to
267
+ * refresh ({@link holdsPlaceholder}).
268
+ */
269
+ export function cleanOpenTabPaths(): string[] {
270
+ const paths = new Set<string>();
271
+ for (const tab of workspace.tabs.values()) {
272
+ const path = tab.documentPath;
273
+ if (
274
+ path &&
275
+ !tab.doc.dirty &&
276
+ !isCollabPath(path) &&
277
+ !holdsPlaceholder(tab.capabilities.modes)
278
+ ) {
279
+ paths.add(path);
280
+ }
281
+ }
282
+ return [...paths];
283
+ }
284
+
117
285
  export interface FsSyncContext {
118
286
  renderLeftPanel: () => void;
119
287
  /** Optional hook for an external content change to an open file (e.g. reload a clean tab). */
@@ -127,6 +295,12 @@ export interface FsSyncContext {
127
295
  * scope.
128
296
  */
129
297
  invalidateDerivedCaches?: () => void;
298
+ /**
299
+ * After a reconnect, the clean, non-collab open tabs whose files may have changed while the
300
+ * stream was down. The handler re-reads them; it should leave a tab whose content did not change
301
+ * alone, since a reconnect usually changed nothing and a reload churns undo and repaints.
302
+ */
303
+ onResyncContent?: (paths: string[]) => void;
130
304
  }
131
305
 
132
306
  /**
@@ -141,6 +315,8 @@ export function startFsSync(ctx: FsSyncContext): () => void {
141
315
  }
142
316
  let pending: FsEvent[] = [];
143
317
  let timer: ReturnType<typeof setTimeout> | null = null;
318
+ let resyncReason: FsResyncReason | null = null;
319
+ let resyncTimer: ReturnType<typeof setTimeout> | null = null;
144
320
 
145
321
  const flush = () => {
146
322
  timer = null;
@@ -163,30 +339,105 @@ export function startFsSync(ctx: FsSyncContext): () => void {
163
339
  }
164
340
  };
165
341
 
166
- return platform.subscribeFileEvents((events) => {
167
- // Before the echo filter, deliberately. `isRecentLocal` drops the events Studio caused, which
168
- // Is right for the tree (it already repainted) and wrong for a DERIVED cache — Studio's own
169
- // Write changes what the project contains exactly as much as anyone else's does.
170
- //
171
- // Every cache keyed on "what files exist" is dropped here, in one place, because they answer
172
- // One event. Each one had an invalidator and no caller, and each stale answer is visible: the
173
- // Link-target picker offering a route whose page was deleted, the layout picker attributing a
174
- // Removed layout's `$head` to the open page, a `$paths` enumeration listing entries that are
175
- // Gone.
176
- invalidateUsages();
177
- ctx.invalidateDerivedCaches?.();
178
- /* A `.gitignore` governs the whole tree beneath it, so it is handled HERE rather than in the
179
- batch below: the echo filter drops the events Studio caused, and an author who just edited
180
- their own `.gitignore` in Studio is precisely the one waiting to see the tree change. It also
181
- never reaches `applyFsEvents` — a dotfile is in no listing, so no directory reads as changed
182
- and no repaint would follow. Hence the explicit one, once the rules are back. */
183
- if (touchesGitignore(events.map((event) => event.path))) {
184
- void reloadIgnoreCache().then(() => ctx.renderLeftPanel());
185
- }
186
- pending.push(...events);
187
- if (timer) {
188
- clearTimeout(timer);
189
- }
190
- timer = setTimeout(flush, 50);
191
- });
342
+ /*
343
+ * A reconnect lost an unknown set of events, so it re-derives everything the events would have
344
+ * kept current: the caches keyed on which files exist (dropped, exactly as one event drops them),
345
+ * the `.gitignore` rules, every cached listing, and the open documents. A commit lost nothing —
346
+ * the DO simply sends no per-file events for one — so it re-reads the listings alone. An open is
347
+ * the stream going live after the first listings were already made; it re-reads them too, since
348
+ * a change broadcast before the socket joined reached nobody. (Each gap's content versions were
349
+ * already forgotten when it was reported — see `requestResync`.)
350
+ */
351
+ const resync = async () => {
352
+ resyncTimer = null;
353
+ const reason = resyncReason;
354
+ resyncReason = null;
355
+ const state = projectState;
356
+ if (!state || !reason) {
357
+ return;
358
+ }
359
+ /* Noted, so a commit — which makes dirty files clean — hands their versions back, and a gap's
360
+ re-list vouches again for the tree's share of what it forgot. */
361
+ const relist = relistLoadedDirs(state.dirs, (dir) =>
362
+ notedListing(() => platform.listDirectory(dir)),
363
+ );
364
+ if (reason === "reconnect") {
365
+ invalidateUsages();
366
+ ctx.invalidateDerivedCaches?.();
367
+ await Promise.all([relist, reloadIgnoreCache()]);
368
+ } else {
369
+ await relist;
370
+ }
371
+ // The project changed under the listings: they belong to a tree nobody is drawing any more.
372
+ if (projectState !== state) {
373
+ return;
374
+ }
375
+ if (reason === "reconnect") {
376
+ ctx.onResyncContent?.(cleanOpenTabPaths());
377
+ }
378
+ ctx.renderLeftPanel();
379
+ };
380
+
381
+ const requestResync = (reason: FsResyncReason) => {
382
+ /* A gap in the stream — a reconnect, or the first open — forgets EVERY content version, at once
383
+ rather than after the debounce: the events that would have forgotten single paths are gone,
384
+ and many versions were learned outside the tree (the Library walk, the media picker, media
385
+ metadata), where no re-list of the loaded directories reaches. A version Studio can no longer
386
+ vouch for may be answered from an immutable cache with the old bytes, so it cannot wait. The
387
+ floor this raises also discards any listing still in flight across the gap. The re-list
388
+ below, and every later listing, vouch afresh. A commit loses no events, so it keeps them. */
389
+ if (reason !== "commit") {
390
+ forgetAllVersions();
391
+ }
392
+ // A wider pass includes a narrower one's, so the widest reason wins a coalesced burst.
393
+ if (!resyncReason || RESYNC_RANK[reason] > RESYNC_RANK[resyncReason]) {
394
+ resyncReason = reason;
395
+ }
396
+ if (resyncTimer) {
397
+ clearTimeout(resyncTimer);
398
+ }
399
+ resyncTimer = setTimeout(() => {
400
+ void resync();
401
+ }, RESYNC_DEBOUNCE_MS);
402
+ };
403
+
404
+ const unsubscribe = platform.subscribeFileEvents(
405
+ (events) => {
406
+ // Before the echo filter, deliberately. `isRecentLocal` drops the events Studio caused, which
407
+ // Is right for the tree (it already repainted) and wrong for a DERIVED cache — Studio's own
408
+ // Write changes what the project contains exactly as much as anyone else's does.
409
+ //
410
+ // Every cache keyed on "what files exist" is dropped here, in one place, because they answer
411
+ // One event. Each one had an invalidator and no caller, and each stale answer is visible: the
412
+ // Link-target picker offering a route whose page was deleted, the layout picker attributing a
413
+ // Removed layout's `$head` to the open page, a `$paths` enumeration listing entries that are
414
+ // Gone.
415
+ invalidateUsages();
416
+ ctx.invalidateDerivedCaches?.();
417
+ // Content versions are derived state of the same kind, and Studio's own writes change bytes:
418
+ // A version kept past its echo would let an immutable cache answer with the old file.
419
+ forgetEventVersions(events);
420
+ /* A `.gitignore` governs the whole tree beneath it, so it is handled HERE rather than in the
421
+ batch below: the echo filter drops the events Studio caused, and an author who just edited
422
+ their own `.gitignore` in Studio is precisely the one waiting to see the tree change. It also
423
+ never reaches `applyFsEvents` — a dotfile is in no listing, so no directory reads as changed
424
+ and no repaint would follow. Hence the explicit one, once the rules are back. */
425
+ if (touchesGitignore(events.map((event) => event.path))) {
426
+ void reloadIgnoreCache().then(() => ctx.renderLeftPanel());
427
+ }
428
+ pending.push(...events);
429
+ if (timer) {
430
+ clearTimeout(timer);
431
+ }
432
+ timer = setTimeout(flush, 50);
433
+ },
434
+ { onResync: requestResync },
435
+ );
436
+ return () => {
437
+ if (resyncTimer) {
438
+ clearTimeout(resyncTimer);
439
+ resyncTimer = null;
440
+ }
441
+ unsubscribe();
442
+ };
192
443
  }
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { getPlatform } from "../platform";
27
27
  import { baseName, dirName, normalizeProjectPath } from "./media-paths";
28
+ import { notedListing } from "./asset-versions";
28
29
  import { extensionOf, mediaKind } from "./media-upload";
29
30
  import type { AssetKind } from "./media-upload";
30
31
  import type { DirEntry } from "../types";
@@ -126,7 +127,7 @@ export async function loadMediaMeta(path: string): Promise<MediaMeta> {
126
127
  const pending = (async (): Promise<MediaMeta> => {
127
128
  let entry: DirEntry | undefined;
128
129
  try {
129
- const listing = await getPlatform().listDirectory(dirName(key));
130
+ const listing = await notedListing(() => getPlatform().listDirectory(dirName(key)));
130
131
  entry = listing.find((item) => normalizeProjectPath(item.path) === key);
131
132
  } catch {
132
133
  // Unreadable or not yet created — the size stays unknown rather than becoming zero.
@@ -38,6 +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
43
  import { loopbackAssetSrc } from "../canvas/canvas-origin";
42
44
  import { projectState } from "../store";
43
45
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
@@ -126,10 +128,11 @@ export function previewFileSrc(path: string): string {
126
128
  return path;
127
129
  }
128
130
  /* In repo space nothing answers a site URL, so the site URL is the wrong question entirely: the
129
- host serves the FILE, at its own path, under the declared base. */
131
+ host serves the FILE, at its own path, under the declared base — versioned when a listing
132
+ vouched for its bytes, so a host that recognizes the version can let the browser cache it. */
130
133
  const { space, fileBaseUrl } = hostAssetDeclarations();
131
134
  if (space === "repo") {
132
- return `${fileBaseUrl}${encodeProjectPath(normalized)}`;
135
+ return withVersion(`${fileBaseUrl}${encodeProjectPath(normalized)}`, versionOf(normalized));
133
136
  }
134
137
  return loopbackAssetSrc(mediaSiteUrl(normalized));
135
138
  }
@@ -20,6 +20,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
20
20
  import { getPlatform, hasPlatform } from "../platform";
21
21
  import { notify } from "../services/notify";
22
22
  import { activeTab } from "../workspace/workspace";
23
+ import { forgetVersions, notedListing } from "./asset-versions";
23
24
 
24
25
  // ─── File classification ─────────────────────────────────────────────────────
25
26
 
@@ -245,7 +246,8 @@ export async function uploadAssets(
245
246
 
246
247
  let taken: Set<string>;
247
248
  try {
248
- const existing = await platform.listDirectory(dir);
249
+ // The upload directory is a media directory, so its listing vouches for versions as well.
250
+ const existing = await notedListing(() => platform.listDirectory(dir));
249
251
  taken = new Set(existing.map((entry) => entry.name));
250
252
  } catch {
251
253
  taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
@@ -273,6 +275,8 @@ export async function uploadAssets(
273
275
  hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
274
276
  reference that goes into the document has to name what is really there. */
275
277
  const { path } = await platform.uploadFile(requested, file);
278
+ // New bytes at `path`: whatever version a listing held for it no longer names them.
279
+ forgetVersions([path]);
276
280
  uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
277
281
  } catch (error) {
278
282
  notify.error(`Could not upload ${file.name}.`, {
@@ -31,6 +31,7 @@
31
31
  * closer nothing in the app called was left over from drawing the box by hand.
32
32
  */
33
33
 
34
+ import { actIfRequired } from "../account/action-flow";
34
35
  import { errorMessage } from "@jxsuite/schema/parse";
35
36
  import {
36
37
  getAccountStatus,
@@ -167,7 +168,11 @@ function settle(result: { root: string } | null): void {
167
168
  handle?.close();
168
169
  }
169
170
 
170
- async function chooseRepo(fullName: string) {
171
+ /**
172
+ * Adopt the picked repository. A refusal that offers an action (desktop.md §10.4) is offered, and
173
+ * an action that is done runs the import again, once.
174
+ */
175
+ async function chooseRepo(fullName: string, retried = false): Promise<void> {
171
176
  if (_importing) {
172
177
  return;
173
178
  }
@@ -187,6 +192,12 @@ async function chooseRepo(fullName: string) {
187
192
  _error = "This platform cannot import repositories.";
188
193
  } catch (error) {
189
194
  _error = errorMessage(error);
195
+ _importing = "";
196
+ redraw();
197
+ if (!retried && (await actIfRequired(error))) {
198
+ await chooseRepo(fullName, true);
199
+ }
200
+ return;
190
201
  }
191
202
  _importing = "";
192
203
  redraw();
@@ -44,6 +44,7 @@ import { enumArg, enumProperty } from "../commands/command-args";
44
44
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
45
45
  import { getPlatform } from "../platform";
46
46
  import { installUrlOf } from "../platform-errors";
47
+ import { actIfRequired } from "../account/action-flow";
47
48
  import { hasAiCredentials } from "../services/ai-models";
48
49
  import { setPendingAgentPrompt } from "../services/agent-seed";
49
50
  import { adoptCreatedProject } from "../services/project-adoption";
@@ -500,10 +501,17 @@ function goBack(): void {
500
501
  redraw();
501
502
  }
502
503
 
503
- /** Create the project from the chosen source, then run `after` with the result. */
504
+ /**
505
+ * Create the project from the chosen source, then run `after` with the result.
506
+ *
507
+ * A refusal that offers an action (desktop.md §10.4) is offered over the modal, and an action that
508
+ * is done runs the create again — once, so a platform that still refuses afterwards leaves its
509
+ * error standing rather than asking forever.
510
+ */
504
511
  async function create(
505
512
  source: { starter: string } | { template: string },
506
513
  after: (result: { root: string; config: ProjectConfig }) => void | Promise<void>,
514
+ retried = false,
507
515
  ): Promise<void> {
508
516
  const destination = validateParams();
509
517
  if (!destination) {
@@ -522,6 +530,9 @@ async function create(
522
530
  _creating = false;
523
531
  captureError(error);
524
532
  redraw();
533
+ if (!retried && (await actIfRequired(error))) {
534
+ await create(source, after, true);
535
+ }
525
536
  }
526
537
  }
527
538
 
@@ -342,8 +342,9 @@ export async function signInToGithub(): Promise<void> {
342
342
  /**
343
343
  * @param {string} action
344
344
  * @param {unknown} [body]
345
+ * @returns {Promise<boolean>} Whether the action succeeded — its failure is already on screen.
345
346
  */
346
- async function gitAction(action: string, body?: unknown) {
347
+ async function gitAction(action: string, body?: unknown): Promise<boolean> {
347
348
  const plat = getPlatform() as Record<string, (...args: unknown[]) => Promise<unknown>> &
348
349
  StudioPlatform;
349
350
  shell.git.loading = true;
@@ -351,9 +352,23 @@ async function gitAction(action: string, body?: unknown) {
351
352
  try {
352
353
  await plat[action]!(body);
353
354
  await refreshGitStatus();
355
+ return true;
354
356
  } catch (error) {
355
357
  shell.git.error = errorMessage(error);
356
358
  shell.git.loading = false;
359
+ return false;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Put a message back in the field after the commit that took it failed — unless the user has
365
+ * already started typing another. The field is cleared before the call so a second click cannot
366
+ * commit the same text twice, which used to mean a refused commit (a conflict, a backend that asks
367
+ * for an action first) silently threw the message away with it.
368
+ */
369
+ function restoreCommitMessage(message: string): void {
370
+ if (!shell.git.commitMessage) {
371
+ shell.git.commitMessage = message;
357
372
  }
358
373
  }
359
374
 
@@ -578,7 +593,9 @@ async function doCommit(): Promise<void> {
578
593
  // Fold co-editing sessions into the backend's tree first so the commit never misses trailing
579
594
  // Keystrokes (the mirror is debounced).
580
595
  await flushAllCollab();
581
- await gitAction("gitCommit", message);
596
+ if (!(await gitAction("gitCommit", message))) {
597
+ restoreCommitMessage(message);
598
+ }
582
599
  }
583
600
 
584
601
  /** Commit and push, as one operation with one loading state and one error. */
@@ -592,13 +609,19 @@ async function doCommitAndSync(): Promise<void> {
592
609
  shell.git.error = null;
593
610
  await flushAllCollab();
594
611
  const plat = getPlatform();
612
+ let committed = false;
595
613
  try {
596
614
  await plat.gitCommit(message);
615
+ committed = true;
597
616
  await plat.gitPush();
598
617
  await refreshGitStatus();
599
618
  } catch (error) {
600
619
  shell.git.error = errorMessage(error);
601
620
  shell.git.loading = false;
621
+ // A push that failed after the commit landed must not offer the same message again.
622
+ if (!committed) {
623
+ restoreCommitMessage(message);
624
+ }
602
625
  }
603
626
  }
604
627