@jxsuite/studio 6.0.0 → 6.1.1

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 (208) hide show
  1. package/dist/chunks/2020-va2wwra4.js +4 -0
  2. package/dist/chunks/adapter-f93pafd7.js +4 -0
  3. package/dist/chunks/{cf-account-picker-bb2yq946.js → cf-account-picker-af1h381v.js} +3 -3
  4. package/dist/chunks/cf-settings-125tdm0w.js +4 -0
  5. package/dist/chunks/components-ax2f1fnn.js +4 -0
  6. package/dist/chunks/content-source-108aqtk1.js +4 -0
  7. package/dist/chunks/{convert-to-repeater-vqkt7s3s.js → convert-to-repeater-ry5rmppr.js} +3 -3
  8. package/dist/chunks/editor-pfgdmkb0.js +4 -0
  9. package/dist/chunks/entry-commands-t0k1q0kx.js +4 -0
  10. package/dist/chunks/{extension-sections-8beq9ac3.js → extension-sections-6mwh2hf8.js} +3 -3
  11. package/dist/chunks/file-ops-meej5mqf.js +4 -0
  12. package/dist/chunks/files-44vp8a2s.js +4 -0
  13. package/dist/chunks/format-host-7q1petb9.js +4 -0
  14. package/dist/chunks/fs-events-h2mwnxaz.js +4 -0
  15. package/dist/chunks/git-panel-zngbymvd.js +4 -0
  16. package/dist/chunks/{grid-view-ca10def6.js → grid-view-sx9vynsp.js} +15 -15
  17. package/dist/chunks/icons-p44xhjf0.js +4 -0
  18. package/dist/chunks/iframe-host-qk3ycq87.js +4 -0
  19. package/dist/chunks/{index-9ydfe04y.js → index-2veqmnhr.js} +3 -3
  20. package/dist/chunks/index-wkyfjeqz.js +4 -0
  21. package/dist/chunks/{javascript-d9pqgc2p.js → javascript-8nbvs12s.js} +3 -3
  22. package/dist/chunks/jsonMode-s8kt9dsj.js +13 -0
  23. package/dist/chunks/jx-validate-psgd7vsx.js +4 -0
  24. package/dist/chunks/layers-panel-1kq2jyfk.js +4 -0
  25. package/dist/chunks/layers-xd92fg8d.js +4 -0
  26. package/dist/chunks/lit-html-ac4cwwr4.js +4 -0
  27. package/dist/chunks/media-picker-0svzkdyb.js +4 -0
  28. package/dist/chunks/menu-5c55ea3d.js +4 -0
  29. package/dist/chunks/{monaco-binding-62dtmgbf.js → monaco-binding-z6r70pdt.js} +3 -3
  30. package/dist/chunks/monaco-buffer-fz6gq5y7.js +4 -0
  31. package/dist/chunks/monaco-setup-q4axkgj7.js +4 -0
  32. package/dist/chunks/notify-0xgtn8q1.js +4 -0
  33. package/dist/chunks/platform-v1qctbe6.js +4 -0
  34. package/dist/chunks/publish-panel-ym1fmgq5.js +4 -0
  35. package/dist/chunks/quick-search-a54e6t5n.js +4 -0
  36. package/dist/chunks/references-gmqp82fs.js +4 -0
  37. package/dist/chunks/right-panel-v7t9awbk.js +4 -0
  38. package/dist/chunks/section-registry-vhyf3zsr.js +4 -0
  39. package/dist/chunks/studio-0bhx5hz6.js +5 -0
  40. package/dist/chunks/studio-0k1ddnyy.js +6 -0
  41. package/dist/chunks/studio-1b04pd0j.js +64 -0
  42. package/dist/chunks/{studio-sxc0zvpt.js → studio-1q1sh2ax.js} +3 -3
  43. package/dist/chunks/studio-4dwbj6gb.js +5 -0
  44. package/dist/chunks/studio-4tanh828.js +5 -0
  45. package/dist/chunks/{studio-fqq890ap.js → studio-55h4mstj.js} +3 -3
  46. package/dist/chunks/studio-5tnvjkz4.js +5 -0
  47. package/dist/chunks/studio-60dqgb0c.js +5 -0
  48. package/dist/chunks/studio-61dp09qg.js +9 -0
  49. package/dist/chunks/studio-63epcrj2.js +5 -0
  50. package/dist/chunks/studio-74k29dy9.js +5 -0
  51. package/dist/chunks/{studio-qa73881a.js → studio-7vg6jcd3.js} +4 -4
  52. package/dist/chunks/studio-7zx31knw.js +9 -0
  53. package/dist/chunks/studio-8bxk97km.js +5 -0
  54. package/dist/chunks/{studio-edbtvcd1.js → studio-8grwxtqk.js} +4 -4
  55. package/dist/chunks/studio-933e30n4.js +5 -0
  56. package/dist/chunks/{studio-rr4xkv81.js → studio-98wnwhbg.js} +4 -4
  57. package/dist/chunks/studio-9mccjwjh.js +14 -0
  58. package/dist/chunks/studio-a2ke1e4t.js +5 -0
  59. package/dist/chunks/studio-e3a2a6v3.js +4 -0
  60. package/dist/chunks/{studio-pwqfmt4p.js → studio-e81q1vb9.js} +4 -4
  61. package/dist/chunks/{studio-7pgy3xaf.js → studio-epgrmqqm.js} +4 -4
  62. package/dist/chunks/studio-g1shz58g.js +5 -0
  63. package/dist/chunks/studio-gf0fz03c.js +5 -0
  64. package/dist/chunks/studio-gjtpe62q.js +6 -0
  65. package/dist/chunks/{studio-tzegmy4w.js → studio-gkskhg7m.js} +4 -4
  66. package/dist/chunks/studio-gx4dnjzd.js +5 -0
  67. package/dist/chunks/{studio-8xcwewjq.js → studio-h46vtzk8.js} +4 -4
  68. package/dist/chunks/studio-j03zga3y.js +9 -0
  69. package/dist/chunks/{studio-4r0sjtx8.js → studio-jhpxeckz.js} +4 -4
  70. package/dist/chunks/{studio-zd4kayn2.js → studio-k01r7ykp.js} +4 -4
  71. package/dist/chunks/{studio-03wwfvyt.js → studio-kggey1jt.js} +4 -4
  72. package/dist/chunks/{studio-hnvnfbdv.js → studio-kk7h30ne.js} +6 -6
  73. package/dist/chunks/{studio-01qza1n3.js → studio-kt1zqney.js} +4 -4
  74. package/dist/chunks/{studio-r3kaka4d.js → studio-m3cwhvye.js} +4 -4
  75. package/dist/chunks/{studio-evwgp1ym.js → studio-mcayt6hz.js} +4 -4
  76. package/dist/chunks/{studio-5vy2wfm5.js → studio-myjcghv3.js} +4 -4
  77. package/dist/chunks/studio-n8bxwc3y.js +4 -0
  78. package/dist/chunks/{studio-ztvcxm3w.js → studio-ngm8gvpb.js} +4 -4
  79. package/dist/chunks/{studio-01kbbqg3.js → studio-nj5a01jy.js} +196 -196
  80. package/dist/chunks/{studio-xkfs0dp2.js → studio-pcgz8heb.js} +4 -4
  81. package/dist/chunks/{studio-0bbxhhce.js → studio-pre0skq6.js} +4 -4
  82. package/dist/chunks/{studio-n78gjfy8.js → studio-qcsq0t15.js} +4 -4
  83. package/dist/chunks/studio-qkstvve9.js +5 -0
  84. package/dist/chunks/studio-qv1pwd3c.js +5 -0
  85. package/dist/chunks/{studio-rmn2dz59.js → studio-rc6dvxjb.js} +5 -5
  86. package/dist/chunks/{studio-st75kn99.js → studio-rv11kfwa.js} +7 -7
  87. package/dist/chunks/studio-s8dmmz66.js +5 -0
  88. package/dist/chunks/studio-sp5xzbj4.js +6 -0
  89. package/dist/chunks/{studio-tmeq6mjb.js → studio-szsjbj0g.js} +4 -4
  90. package/dist/chunks/studio-t1xzawhr.js +16 -0
  91. package/dist/chunks/{studio-mk00zd5d.js → studio-t6b6qe31.js} +4 -4
  92. package/dist/chunks/{studio-bftp4n4b.js → studio-tdcprbqb.js} +4 -4
  93. package/dist/chunks/{studio-ge5fqjth.js → studio-vrmcjf9g.js} +5 -5
  94. package/dist/chunks/studio-vyc0b4he.js +10 -0
  95. package/dist/chunks/{studio-semd1s8s.js → studio-w0cgf6jg.js} +4 -4
  96. package/dist/chunks/{studio-gp02nqnw.js → studio-w3hskzqk.js} +4 -4
  97. package/dist/chunks/{studio-1wvfxkyy.js → studio-wszjyh0j.js} +4 -4
  98. package/dist/chunks/studio-xgrgcdyh.js +9 -0
  99. package/dist/chunks/{studio-y7jehjrp.js → studio-xz2yxfcb.js} +4 -4
  100. package/dist/chunks/{studio-aje966s9.js → studio-y6n42tgs.js} +4 -4
  101. package/dist/chunks/{studio-yr04mv2j.js → studio-yb560vxr.js} +4 -4
  102. package/dist/chunks/{studio-tp642n5g.js → studio-yn9f8vtx.js} +4 -4
  103. package/dist/chunks/studio-yw05w8f0.js +7 -0
  104. package/dist/chunks/{studio-cbr9czkn.js → studio-z16sgavc.js} +4 -4
  105. package/dist/chunks/studio-zey7zsdw.js +12 -0
  106. package/dist/chunks/{studio-1q6amtph.js → studio-zyqn40w4.js} +4 -4
  107. package/dist/chunks/tsMode-2frah6h1.js +14 -0
  108. package/dist/chunks/workspace-zt81g1ms.js +4 -0
  109. package/dist/chunks/ws-client-n2bh4hkf.js +4 -0
  110. package/dist/iframe-entry.js +13 -13
  111. package/dist/manifest.json +62 -62
  112. package/dist/studio.js +87 -87
  113. package/dist/workers/ts.worker.js +3 -3
  114. package/package.json +10 -10
  115. package/src/canvas/canvas-render.ts +4 -1
  116. package/src/canvas/canvas-utils.ts +73 -15
  117. package/src/canvas/iframe-host.ts +67 -21
  118. package/src/canvas/iframe-inline-edit.ts +22 -4
  119. package/src/canvas/iframe-interaction.ts +7 -1
  120. package/src/canvas/iframe-protocol.ts +3 -0
  121. package/src/canvas/iframe-render.ts +5 -2
  122. package/src/commands/defaults.ts +4 -1
  123. package/src/commands/live-context.ts +3 -1
  124. package/src/editor/shortcuts.ts +55 -27
  125. package/src/files/media-paths.ts +3 -1
  126. package/src/new-project/add-repo-modal.ts +459 -59
  127. package/src/panels/editors.ts +3 -1
  128. package/src/panels/style-panel.ts +36 -11
  129. package/src/platforms/cloud.ts +95 -18
  130. package/src/platforms/devserver.ts +47 -12
  131. package/src/publish/pages-service.ts +7 -0
  132. package/src/publish/publish-panel.ts +16 -2
  133. package/src/studio.ts +14 -2
  134. package/src/surfaces/add-repo.json +721 -224
  135. package/src/surfaces/add-repo.ts +196 -31
  136. package/src/surfaces/library-pane.ts +6 -0
  137. package/src/surfaces/rail.json +126 -73
  138. package/src/surfaces/rail.ts +31 -5
  139. package/src/tabs/typing.ts +31 -0
  140. package/src/types.ts +42 -3
  141. package/src/utils/edit-display.ts +30 -1
  142. package/src/utils/project-dir.ts +24 -0
  143. package/src/workspace/workspace.ts +7 -0
  144. package/dist/chunks/2020-cs2sb9t1.js +0 -4
  145. package/dist/chunks/adapter-y5gdq19c.js +0 -4
  146. package/dist/chunks/cf-settings-20dznc3j.js +0 -4
  147. package/dist/chunks/components-q037dya7.js +0 -4
  148. package/dist/chunks/content-source-t0q184js.js +0 -4
  149. package/dist/chunks/editor-j5sq149q.js +0 -4
  150. package/dist/chunks/entry-commands-fxzgcc4k.js +0 -4
  151. package/dist/chunks/file-ops-2n77759h.js +0 -4
  152. package/dist/chunks/files-fjy7zpxt.js +0 -4
  153. package/dist/chunks/format-host-wj3mf3n8.js +0 -4
  154. package/dist/chunks/fs-events-7kcgn4e9.js +0 -4
  155. package/dist/chunks/git-panel-7z2mpt2d.js +0 -4
  156. package/dist/chunks/icons-qbh8akb1.js +0 -4
  157. package/dist/chunks/iframe-host-a0zjd6xp.js +0 -4
  158. package/dist/chunks/index-m0x5ztax.js +0 -4
  159. package/dist/chunks/jsonMode-800yvbem.js +0 -13
  160. package/dist/chunks/jx-validate-zc9zp77s.js +0 -4
  161. package/dist/chunks/layers-p677nrh5.js +0 -4
  162. package/dist/chunks/layers-panel-y4w1dq90.js +0 -4
  163. package/dist/chunks/lit-html-cfn61dn7.js +0 -4
  164. package/dist/chunks/media-picker-xg7yjpz9.js +0 -4
  165. package/dist/chunks/menu-fpekzj6w.js +0 -4
  166. package/dist/chunks/monaco-buffer-nn4dtx27.js +0 -4
  167. package/dist/chunks/monaco-setup-tj13ndtm.js +0 -4
  168. package/dist/chunks/notify-ahs57a2z.js +0 -4
  169. package/dist/chunks/platform-v9xyfkbx.js +0 -4
  170. package/dist/chunks/publish-panel-xtbfgg2r.js +0 -4
  171. package/dist/chunks/quick-search-d3a6m7j3.js +0 -4
  172. package/dist/chunks/references-99ws0b0m.js +0 -4
  173. package/dist/chunks/right-panel-31vbk5xp.js +0 -4
  174. package/dist/chunks/section-registry-b2yzp9sw.js +0 -4
  175. package/dist/chunks/studio-1a99w39s.js +0 -5
  176. package/dist/chunks/studio-3ck2a5c4.js +0 -10
  177. package/dist/chunks/studio-4fde4a0x.js +0 -5
  178. package/dist/chunks/studio-4jj9gme2.js +0 -6
  179. package/dist/chunks/studio-4m50vh74.js +0 -16
  180. package/dist/chunks/studio-4xppwncr.js +0 -4
  181. package/dist/chunks/studio-5d7wfmnp.js +0 -5
  182. package/dist/chunks/studio-5e9zsamh.js +0 -9
  183. package/dist/chunks/studio-5gs9th4b.js +0 -5
  184. package/dist/chunks/studio-7qvs7v0m.js +0 -5
  185. package/dist/chunks/studio-84rtc120.js +0 -5
  186. package/dist/chunks/studio-aekhcdzw.js +0 -7
  187. package/dist/chunks/studio-aqmph6k4.js +0 -5
  188. package/dist/chunks/studio-c6zdm25z.js +0 -9
  189. package/dist/chunks/studio-capk0583.js +0 -9
  190. package/dist/chunks/studio-chdmwwzw.js +0 -5
  191. package/dist/chunks/studio-cp1gbff9.js +0 -14
  192. package/dist/chunks/studio-cqmtbg2p.js +0 -5
  193. package/dist/chunks/studio-e47215hh.js +0 -5
  194. package/dist/chunks/studio-fbx6twps.js +0 -6
  195. package/dist/chunks/studio-fthwn4kf.js +0 -5
  196. package/dist/chunks/studio-g8dsj3d2.js +0 -6
  197. package/dist/chunks/studio-gjknvzam.js +0 -9
  198. package/dist/chunks/studio-hjbg8d5y.js +0 -12
  199. package/dist/chunks/studio-nbxqcgvf.js +0 -5
  200. package/dist/chunks/studio-t1eedsyx.js +0 -4
  201. package/dist/chunks/studio-vavmwshr.js +0 -5
  202. package/dist/chunks/studio-x5g1xk77.js +0 -5
  203. package/dist/chunks/studio-x92tgnee.js +0 -64
  204. package/dist/chunks/studio-xg1942as.js +0 -5
  205. package/dist/chunks/studio-zev8ssp1.js +0 -5
  206. package/dist/chunks/tsMode-k9s9fcag.js +0 -14
  207. package/dist/chunks/workspace-5vmgsagj.js +0 -4
  208. package/dist/chunks/ws-client-0ja08haa.js +0 -4
@@ -1471,13 +1471,32 @@ function buildEditor(
1471
1471
  match, and a menu that offers unmatchable states is a menu people stop reading. What the
1472
1472
  element already DECLARES and the active selector are unioned in after, so nothing an author has
1473
1473
  written can drop out of it. */
1474
- const selectorOptions = [
1475
- ...new Set([
1476
- ...selectorsForNode(node),
1477
- ...declaredSelectors,
1478
- ...(activeSelector ? [activeSelector] : []),
1479
- ]),
1480
- ];
1474
+ /* In Project Styles the coordinate is a TAG PATH (`a`, `table th`) and its states nest under it
1475
+ (`a :hover`). The menu offered the ROOT's states instead, and picking one replaced the tag, so
1476
+ `:hover` styled the document element and a project had no way to write `a:hover` at all. Here
1477
+ the states are the tag's own, spelled as the path they write. */
1478
+ const stylebookPath =
1479
+ stylebookSelector && isTagPath(stylebookSelector) ? stylebookSelector : null;
1480
+ const stylebookLeaf = stylebookPath?.split(" ").at(-1) ?? null;
1481
+ const stylebookDeclared = stylebookPath
1482
+ ? Object.keys(resolveNestedTagStyle(style, stylebookPath)).filter((k) => isNestedSelector(k))
1483
+ : [];
1484
+ const selectorOptions = stylebookPath
1485
+ ? [
1486
+ ...new Set([
1487
+ ...[...selectorsForNode({ tagName: stylebookLeaf ?? "div" }), ...stylebookDeclared].map(
1488
+ (state) => `${stylebookPath} ${state}`,
1489
+ ),
1490
+ ...(activeSelector && activeSelector !== stylebookPath ? [activeSelector] : []),
1491
+ ]),
1492
+ ]
1493
+ : [
1494
+ ...new Set([
1495
+ ...selectorsForNode(node),
1496
+ ...declaredSelectors,
1497
+ ...(activeSelector ? [activeSelector] : []),
1498
+ ]),
1499
+ ];
1481
1500
 
1482
1501
  // ── The Target Line (§6.2) ─────────────────────────────────────────────────
1483
1502
  const stylebookTag = stylebookTagOf(stylebookSelector);
@@ -1486,15 +1505,19 @@ function buildEditor(
1486
1505
  setTargetLine({
1487
1506
  segments: targetSegments(elementLabel, mediaTab, mediaNames.length > 0, schemeLayer),
1488
1507
  selector: {
1489
- value: activeSelector,
1508
+ // The bare tag path IS the base rule in Project Styles, so it reads as the base entry.
1509
+ value: stylebookPath && activeSelector === stylebookPath ? null : activeSelector,
1490
1510
  options: selectorOptions,
1491
- declared: declaredSelectors,
1511
+ declared: stylebookPath
1512
+ ? new Set(stylebookDeclared.map((state) => `${stylebookPath} ${state}`))
1513
+ : declaredSelectors,
1492
1514
  onSelect: (value) => {
1493
1515
  const current = activeTab.value;
1494
1516
  if (!current) {
1495
1517
  return;
1496
1518
  }
1497
- current.session.ui.activeSelector = value;
1519
+ // Clearing the state in Project Styles returns to the tag, never to the document root.
1520
+ current.session.ui.activeSelector = value ?? stylebookPath;
1498
1521
  /* §6.2's own rule: a control that selects a rendering context has to change the rendering,
1499
1522
  or it is a control over a label. `:hover` gets away with not doing this because you can
1500
1523
  hover the element; `:popover-open` cannot, because a closed popover is not on the screen
@@ -1523,7 +1546,9 @@ function buildEditor(
1523
1546
  isNestedSelector(v.trim()) ? "" : 'A selector must start with ":", ".", "&" or "[".',
1524
1547
  }).then((value) => {
1525
1548
  if (value && activeTab.value) {
1526
- activeTab.value.session.ui.activeSelector = value.trim();
1549
+ activeTab.value.session.ui.activeSelector = stylebookPath
1550
+ ? `${stylebookPath} ${value.trim()}`
1551
+ : value.trim();
1527
1552
  }
1528
1553
  });
1529
1554
  },
@@ -48,6 +48,7 @@ import type {
48
48
  ReferencesResult,
49
49
  RenameResult,
50
50
  RepoInfo,
51
+ RepoProjects,
51
52
  StarterInfo,
52
53
  StudioPlatform,
53
54
  } from "../types";
@@ -56,12 +57,21 @@ import type { ReadFilesRequest, ReadFilesResult, UploadResult } from "@jxsuite/p
56
57
  import { reportActionRequired } from "../account/action-required";
57
58
  import { offeredActions, retryHint } from "../platform-errors";
58
59
  import { createReadBatcher } from "./read-batcher";
60
+ import { normalizeProjectDir } from "../utils/project-dir";
59
61
  import type { BatchAnswer } from "./read-batcher";
60
62
 
63
+ // Re-exported for the cloud shell and its tests, which spell folders through this entry.
64
+ export { normalizeProjectDir } from "../utils/project-dir";
65
+
61
66
  export interface CloudProject {
62
67
  owner: string;
63
68
  repo: string;
64
69
  branch: string;
70
+ /**
71
+ * The folder holding the project's `project.json`, relative to the repository root and already
72
+ * normalized ({@link normalizeProjectDir}); absent or `""` when the project IS the repository.
73
+ */
74
+ dir?: string;
65
75
  }
66
76
 
67
77
  interface ProjectInfoWire {
@@ -213,12 +223,45 @@ async function reportRefusal(res: Response): Promise<void> {
213
223
  });
214
224
  }
215
225
 
216
- /** Editor URL for a project session (mirrors the shell's route). */
226
+ /**
227
+ * The revision a session reads: the branch, then `:` and the project folder when there is one.
228
+ *
229
+ * Git's own `<rev>:<path>` notation, and unambiguous for the reason git can use it: `:` is one of
230
+ * the characters a ref name may never contain (git-check-ref-format), so the FIRST colon always
231
+ * ends the branch, however many slashes the branch or the folder holds.
232
+ */
233
+ export function revisionSpec(project: CloudProject): string {
234
+ return project.dir ? `${project.branch}:${project.dir}` : project.branch;
235
+ }
236
+
237
+ /** Split a {@link revisionSpec} back into its branch and folder; null when either half is unusable. */
238
+ function parseRevisionSpec(spec: string): { branch: string; dir?: string } | null {
239
+ const colon = spec.indexOf(":");
240
+ const branch = colon === -1 ? spec : spec.slice(0, colon);
241
+ const dir = colon === -1 ? "" : normalizeProjectDir(spec.slice(colon + 1));
242
+ if (!branch || dir === null) {
243
+ return null;
244
+ }
245
+ return dir ? { branch, dir } : { branch };
246
+ }
247
+
248
+ /**
249
+ * Editor URL for a project session (mirrors the shell's route):
250
+ * `/edit/<owner>/<repo>@<branch>[:<folder>]`. The branch is one encoded component, so its slashes
251
+ * travel as `%2F`; the folder keeps its slashes, so a subfolder project's address reads as the path
252
+ * it is.
253
+ */
217
254
  export function editUrl(project: CloudProject): string {
218
- return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
255
+ const folder = project.dir
256
+ ? `:${project.dir
257
+ .split("/")
258
+ .map((segment) => encodeURIComponent(segment))
259
+ .join("/")}`
260
+ : "";
261
+ return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}${folder}`;
219
262
  }
220
263
 
221
- /** Parse an /edit/:owner/:repo@:branch path (editUrl's inverse); null when it is not one. */
264
+ /** Parse an /edit/:owner/:repo@:branch[:dir] path (editUrl's inverse); null when it is not one. */
222
265
  export function parseEditPath(pathname: string): CloudProject | null {
223
266
  /* Asset routers may normalize "@" to "%40", so decode the whole path first
224
267
  (branch slashes survive: `.+` spans them). */
@@ -232,22 +275,27 @@ export function parseEditPath(pathname: string): CloudProject | null {
232
275
  if (!match) {
233
276
  return null;
234
277
  }
235
- const [, owner, repo, branch] = match;
236
- if (!owner || !repo || !branch) {
278
+ const [, owner, repo, spec] = match;
279
+ const revision = spec ? parseRevisionSpec(spec) : null;
280
+ if (!owner || !repo || !revision) {
237
281
  return null;
238
282
  }
239
- return { owner, repo, branch };
283
+ return { owner, repo, ...revision };
240
284
  }
241
285
 
242
- /** Gateway base path for a project session. */
286
+ /**
287
+ * Gateway base path for a project session. The revision spec — branch and folder — is ONE encoded
288
+ * segment, so the gateway's route shape is the same for a subfolder project as for a repository,
289
+ * and the folder's slashes cannot be mistaken for the session's own sub-path.
290
+ */
243
291
  export function sessionBase(project: CloudProject): string {
244
- const { owner, repo, branch } = project;
245
- return `/api/v1/p/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(branch)}/studio`;
292
+ const { owner, repo } = project;
293
+ return `/api/v1/p/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(revisionSpec(project))}/studio`;
246
294
  }
247
295
 
248
- /** Catalogue/recents root key for a project: "owner/repo@branch". */
296
+ /** Catalogue/recents root key for a project: "owner/repo@branch", plus ":dir" for a subfolder. */
249
297
  export function projectRootKey(project: CloudProject): string {
250
- return `${project.owner}/${project.repo}@${project.branch}`;
298
+ return `${project.owner}/${project.repo}@${revisionSpec(project)}`;
251
299
  }
252
300
 
253
301
  /**
@@ -559,17 +607,18 @@ function awaitAction(popup: Window): Promise<ActionOutcome> {
559
607
  });
560
608
  }
561
609
 
562
- /** Parse an "owner/repo@branch" root key; null when malformed. */
610
+ /** Parse an "owner/repo@branch[:dir]" root key; null when malformed. */
563
611
  export function parseRootKey(root: string): CloudProject | null {
564
612
  const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
565
613
  if (!match) {
566
614
  return null;
567
615
  }
568
- const [, owner, repo, branch] = match;
569
- if (!owner || !repo || !branch) {
616
+ const [, owner, repo, spec] = match;
617
+ const revision = spec ? parseRevisionSpec(spec) : null;
618
+ if (!owner || !repo || !revision) {
570
619
  return null;
571
620
  }
572
- return { owner, repo, branch };
621
+ return { owner, repo, ...revision };
573
622
  }
574
623
 
575
624
  /** Every project this account can open, straight off the wire; [] when the catalogue is unreachable. */
@@ -1558,13 +1607,40 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1558
1607
  }));
1559
1608
  },
1560
1609
 
1561
- /** Adopt an existing repo as a Jx project; resolves to its catalogue root key. */
1562
- async importProject(opts: { owner: string; name: string }) {
1610
+ /**
1611
+ * The folders of a repository that hold a project.json, read from its default branch. The
1612
+ * platform drops vendored folders and other tools' `project.json` (Nx's), and says when the
1613
+ * repository was too large to list in full.
1614
+ */
1615
+ async listRepoProjects(repo: { owner: string; name: string }): Promise<RepoProjects> {
1616
+ const res = await fetch(
1617
+ `/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/projects`,
1618
+ { credentials: "include" },
1619
+ );
1620
+ const found = await okJson<RepoProjects>(res, "Failed to look for projects");
1621
+ /* Canonical on the way in, so a location the reader picks is spelled the way the root key,
1622
+ the editor URL and the session will spell it; anything that climbs out is not offered. */
1623
+ const locations: RepoProjects["locations"] = [];
1624
+ for (const location of found.locations) {
1625
+ const dir = normalizeProjectDir(location.dir);
1626
+ if (dir !== null) {
1627
+ locations.push(location.name ? { dir, name: location.name } : { dir });
1628
+ }
1629
+ }
1630
+ return { branch: found.branch, locations, truncated: found.truncated === true };
1631
+ },
1632
+
1633
+ /** Adopt an existing repo (or one folder of it) as a Jx project; resolves to its root key. */
1634
+ async importProject(opts: { owner: string; name: string; dir?: string }) {
1635
+ const dir = normalizeProjectDir(opts.dir ?? "");
1636
+ if (dir === null) {
1637
+ throw new Error("The project folder must stay inside the repository.");
1638
+ }
1563
1639
  const res = await fetch("/api/v1/projects/import", {
1564
1640
  method: "POST",
1565
1641
  credentials: "include",
1566
1642
  headers: { "Content-Type": "application/json" },
1567
- body: JSON.stringify(opts),
1643
+ body: JSON.stringify({ owner: opts.owner, name: opts.name, ...(dir ? { dir } : {}) }),
1568
1644
  });
1569
1645
  const imported = await okJson<{ owner: string; name: string; defaultBranch: string }>(
1570
1646
  res,
@@ -1575,6 +1651,7 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1575
1651
  owner: imported.owner,
1576
1652
  repo: imported.name,
1577
1653
  branch: imported.defaultBranch,
1654
+ ...(dir ? { dir } : {}),
1578
1655
  }),
1579
1656
  };
1580
1657
  },
@@ -98,6 +98,50 @@ export function createDevServerPlatform() {
98
98
  */
99
99
  let _collabConnection: Promise<WsCollabConnection> | null = null;
100
100
 
101
+ /**
102
+ * Bind the server to `root` — the binding its rootless routes and its static fallback (every
103
+ * media thumbnail, every canvas image) resolve against.
104
+ *
105
+ * **The binding is one per SERVER, not per tab.** So an empty root posts nothing: a Studio tab
106
+ * opened with no project used to post `{ root: "" }` at boot, which unbound the server for every
107
+ * other tab and turned their images into 404s. And a tab re-claims it (see
108
+ * {@link reclaimBinding}) whenever it could have been taken.
109
+ */
110
+ async function postActivate(root: string): Promise<void> {
111
+ if (!root) {
112
+ return;
113
+ }
114
+ const res = await fetch("/__studio/activate", {
115
+ body: JSON.stringify({ root }),
116
+ headers: { "Content-Type": "application/json" },
117
+ method: "POST",
118
+ });
119
+ if (!res.ok) {
120
+ const body = (await readJson<ErrorBody>(res).catch(() => ({}))) as ErrorBody;
121
+ throw new Error(
122
+ `Could not open ${root}: ${problemDetail(body) ?? `activation failed (${res.status})`}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Re-bind the server to this tab's project. Two events can take the binding away without this tab
129
+ * hearing of it: another Studio tab activating ITS project, and a restarted `bun run dev`, which
130
+ * comes back bound to nothing. So a tab re-claims it when the author comes back to it (focus) and
131
+ * when the reload stream reconnects (a restart). Only a tab with a project claims anything.
132
+ */
133
+ function reclaimBinding(): void {
134
+ if (!_projectRoot) {
135
+ return;
136
+ }
137
+ _activation = postActivate(_projectRoot).catch((error: unknown) => {
138
+ console.error("Project re-activation failed:", error);
139
+ });
140
+ }
141
+ if (typeof window !== "undefined") {
142
+ window.addEventListener("focus", reclaimBinding);
143
+ }
144
+
101
145
  /**
102
146
  * Prefix a project-relative path with the active project root for server API calls.
103
147
  *
@@ -162,18 +206,7 @@ export function createDevServerPlatform() {
162
206
  * @param {string} [root]
163
207
  */
164
208
  async activate(root?: string) {
165
- const r = root ?? _projectRoot;
166
- const res = await fetch("/__studio/activate", {
167
- body: JSON.stringify({ root: r }),
168
- headers: { "Content-Type": "application/json" },
169
- method: "POST",
170
- });
171
- if (!res.ok) {
172
- const body = (await readJson<ErrorBody>(res).catch(() => ({}))) as ErrorBody;
173
- throw new Error(
174
- `Could not open ${r}: ${problemDetail(body) ?? `activation failed (${res.status})`}`,
175
- );
176
- }
209
+ await postActivate(root ?? _projectRoot);
177
210
  },
178
211
 
179
212
  // ─── Project opening ──────────────────────────────────────────────────
@@ -514,6 +547,8 @@ export function createDevServerPlatform() {
514
547
  es.addEventListener("open", () => {
515
548
  opens += 1;
516
549
  if (opens > 1) {
550
+ // A reconnect is usually a restarted server, which comes back bound to no project.
551
+ reclaimBinding();
517
552
  options?.onResync?.("reconnect");
518
553
  }
519
554
  });
@@ -36,6 +36,12 @@ export interface ConnectOptions {
36
36
  productionBranch: string;
37
37
  owner: string;
38
38
  repo: string;
39
+ /**
40
+ * The folder of the repository the project lives in, when it is not the root (a cloud project
41
+ * opened from a subfolder). Pages builds there, so `bunx jx build` finds that project's
42
+ * `project.json` and `dist` is that project's output.
43
+ */
44
+ rootDir?: string;
39
45
  }
40
46
 
41
47
  /** True when the active platform can reach the Cloudflare API. */
@@ -106,6 +112,7 @@ export async function createPagesProject(opts: ConnectOptions): Promise<PagesPro
106
112
  build_config: {
107
113
  build_command: "bunx jx build",
108
114
  destination_dir: "dist",
115
+ ...(opts.rootDir ? { root_dir: opts.rootDir } : {}),
109
116
  },
110
117
  source: {
111
118
  type: "github",
@@ -19,12 +19,12 @@
19
19
  * HTML, in the accessibility tree and in every DOM dump. It is gone, and the document is now the
20
20
  * thing that makes it impossible rather than the thing that remembers not to — the field binds no
21
21
  * `value` at all, and the draft lives in the adapter's closure until {@link saveToken} reads it. A
22
- * stored token is reported as *stored*, the field is only ever drawn for a REPLACEMENT the reader
22
+ * stored token is reported as _stored_, the field is only ever drawn for a REPLACEMENT the reader
23
23
  * asked for, and revoking lives where every other credential's does — Preferences › Accounts
24
24
  * (`studio.md` §15 rule 1: a surface never prints the secret it describes).
25
25
  *
26
- * @docs studio/publish/cloudflare
27
26
  * @license MIT
27
+ * @docs studio/publish/cloudflare
28
28
  */
29
29
 
30
30
  import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
@@ -36,6 +36,7 @@ import { resetModelCache } from "../services/ai-models";
36
36
  import { projectState } from "../store";
37
37
  import type { CfConnection } from "../types";
38
38
  import { layerHost } from "../ui/layers";
39
+ import { normalizeProjectDir } from "../utils/project-dir";
39
40
  import { openPublishSurface } from "../surfaces/publish";
40
41
  import type { PublishForm, PublishSurfaceHandle, PublishView } from "../surfaces/publish";
41
42
  import type { CfAccount, PagesDeploymentInfo } from "./pages-service";
@@ -82,6 +83,17 @@ function prefillRepo(): { owner: string; repo: string } {
82
83
  return match ? { owner: match[1]!, repo: match[2]! } : { owner: "", repo: "" };
83
84
  }
84
85
 
86
+ /**
87
+ * The repository folder a cloud project lives in ("owner/repo@branch:sites/blog"), or "" for one
88
+ * that is the whole repository and for every non-cloud root. Not a form field: the folder is the
89
+ * project's identity rather than a choice, and a Pages project that built anywhere else would build
90
+ * a different project, or none.
91
+ */
92
+ function projectFolder(): string {
93
+ const spec = /^[\w.-]+\/[\w.-]+@[^:]+:(.+)$/.exec(getPlatform().projectRoot)?.[1] ?? "";
94
+ return normalizeProjectDir(spec) ?? "";
95
+ }
96
+
85
97
  /** The connected Pages project, as the surface takes it: strings, never a config object. */
86
98
  function deployView(): { projectName: string; productionUrl: string } | null {
87
99
  const deploy: DeployConfig | undefined = currentDeploy();
@@ -257,12 +269,14 @@ async function submitConnect(): Promise<void> {
257
269
  _error = "";
258
270
  paint();
259
271
  try {
272
+ const rootDir = projectFolder();
260
273
  const deploy = await connectDeploy(config, {
261
274
  accountId: _form.accountId,
262
275
  owner: _form.owner,
263
276
  productionBranch: _form.branch || "main",
264
277
  projectName: _form.projectName,
265
278
  repo: _form.repo,
279
+ ...(rootDir ? { rootDir } : {}),
266
280
  });
267
281
  _deployment = await latestDeployment(deploy).catch(() => null);
268
282
  } catch (error) {
package/src/studio.ts CHANGED
@@ -200,7 +200,8 @@ import * as chatPanelMod from "./panels/chat-panel";
200
200
  import { setProjectAdopter } from "./services/project-adoption";
201
201
  import { setImportHandoff } from "./services/import-seed";
202
202
  import { watchConnection } from "./services/connection";
203
- import { tabBufferUnsaved } from "./services/monaco-buffer";
203
+ import { commitTabBuffers, tabBufferUnsaved } from "./services/monaco-buffer";
204
+ import { clearTyping } from "./tabs/typing";
204
205
  import * as leftPanelMod from "./panels/left-panel";
205
206
  import * as tabStrip from "./panels/tab-strip";
206
207
  import * as paneContext from "./panels/pane-context";
@@ -1492,7 +1493,18 @@ registerStudioCommands(
1492
1493
  // Whether the bytes landed, and a hook that neither supplies one nor reads the answer must not
1493
1494
  // Silently forward its own first argument as the tab to save.
1494
1495
  saveDocument: async () => {
1495
- await saveFile();
1496
+ const tab = activeTab.value;
1497
+ if (!tab) {
1498
+ return;
1499
+ }
1500
+ // What a source buffer holds goes into the document first, or ⌘S inside the commit's
1501
+ // Debounce would save the text from before the last keystrokes.
1502
+ await commitTabBuffers(tab);
1503
+ await saveFile(tab);
1504
+ // Text that would not parse is still only in the buffer, so it is still unsaved.
1505
+ if (!tabBufferUnsaved(tab)) {
1506
+ clearTyping(tab.id);
1507
+ }
1496
1508
  },
1497
1509
  },
1498
1510
  stageContext,