@jxsuite/studio 3.0.0 → 4.1.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 (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
@@ -0,0 +1,200 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Cf-account-picker.ts — "which Cloudflare account?", the question nothing in Studio asked.
4
+ *
5
+ * A Cloudflare grant can reach several accounts, and the broker stores exactly one id per
6
+ * connection. Until one is chosen every Cloudflare-backed call answers `cf_account_required`, so a
7
+ * multi-account user finished the hosted OAuth flow and landed in a state that reads as connected
8
+ * and behaves as broken: the assistant 401s, publishing 401s, and no surface says why. The broker
9
+ * has had `/cf/accounts` and `/cf/select-account` the whole time; this is the first caller.
10
+ *
11
+ * ONE picker, invoked wherever a connect resolves account-less — the assistant's managed-connect
12
+ * CTA, the publish panel, and a button on the Preferences row. A second picker per surface is how
13
+ * three surfaces end up disagreeing about what "connected" means.
14
+ *
15
+ * Cloud-only by construction: the PAL members it drives are the brokered ones, so a platform
16
+ * holding a pasted API token (desktop, dev server) keeps its own local account field and this
17
+ * resolves null without opening anything.
18
+ *
19
+ * @docs studio/ai
20
+ * @license MIT
21
+ */
22
+
23
+ import { html, render as litRender, nothing } from "lit-html";
24
+ import { ref } from "lit-html/directives/ref.js";
25
+ import type { TemplateResult } from "lit-html";
26
+ import { getPlatform, hasPlatform } from "../platform";
27
+ import { notifyCredentialsChanged } from "../settings/preferences-accounts";
28
+ import { showDialog } from "./layers";
29
+ import { overlayRegion } from "./regions";
30
+ import type { CfAccountSummary } from "../types";
31
+
32
+ /** Whether this platform brokers the connection, and so can be asked which accounts it reaches. */
33
+ function canPick(): boolean {
34
+ if (!hasPlatform()) {
35
+ return false;
36
+ }
37
+ const platform = getPlatform();
38
+ return Boolean(platform.cfAccounts && platform.cfSelectAccount);
39
+ }
40
+
41
+ /**
42
+ * Ask which Cloudflare account this connection should use, and store the answer.
43
+ *
44
+ * Resolves the chosen account, or null — dismissed, or a platform that does not broker connections
45
+ * at all. A caller treats null as "still unusable" rather than as a failure: nothing was changed,
46
+ * and the surface that opened this is the one that knows what to say about it.
47
+ *
48
+ * @returns {Promise<CfAccountSummary | null>}
49
+ */
50
+ export function openCfAccountPicker(): Promise<CfAccountSummary | null> {
51
+ if (!canPick()) {
52
+ return Promise.resolve(null);
53
+ }
54
+ return showDialog<CfAccountSummary | null>(
55
+ (done) => {
56
+ let accounts: CfAccountSummary[] = [];
57
+ let loading = true;
58
+ /** The last refusal — a listing that failed, or an account the broker would not accept. */
59
+ let failure = "";
60
+ /** The id being committed, so its own row can say so rather than the whole list going grey. */
61
+ let choosing = "";
62
+ let settled = false;
63
+ let wrapperEl: HTMLElement | null = null;
64
+
65
+ function finish(account: CfAccountSummary | null): void {
66
+ settled = true;
67
+ done(account);
68
+ }
69
+
70
+ function repaint(): void {
71
+ /* Resolved lazily, and only while the dialog is still up: `done` releases the slot, so a
72
+ late `load()` landing after a dismissal would otherwise paint into a detached host. */
73
+ const host = settled ? null : wrapperEl?.parentElement;
74
+ if (host) {
75
+ litRender(build(), host);
76
+ }
77
+ }
78
+
79
+ async function load(): Promise<void> {
80
+ loading = true;
81
+ failure = "";
82
+ repaint();
83
+ try {
84
+ accounts = (await getPlatform().cfAccounts?.()) ?? [];
85
+ } catch (error) {
86
+ failure = error instanceof Error ? error.message : String(error);
87
+ }
88
+ loading = false;
89
+ repaint();
90
+ }
91
+
92
+ async function choose(account: CfAccountSummary): Promise<void> {
93
+ choosing = account.id;
94
+ failure = "";
95
+ repaint();
96
+ try {
97
+ await getPlatform().cfSelectAccount?.({ id: account.id, name: account.name });
98
+ } catch (error) {
99
+ failure = error instanceof Error ? error.message : String(error);
100
+ choosing = "";
101
+ repaint();
102
+ return;
103
+ }
104
+ /* The stored account is a credential like any other: the assistant's gate, the publish
105
+ panel and the Preferences row all re-read from the same announcement. */
106
+ notifyCredentialsChanged();
107
+ finish(account);
108
+ }
109
+
110
+ function listTpl(): TemplateResult | typeof nothing {
111
+ if (loading) {
112
+ return html`<p class="cf-account-picker-empty">Reading your Cloudflare accounts…</p>`;
113
+ }
114
+ /* Nothing to show and a reason why. The retry is offered only here: a commit that was
115
+ refused (below) keeps the list, because listing again is not what would fix it. */
116
+ if (accounts.length === 0) {
117
+ return html`
118
+ <p class="cf-account-picker-empty">
119
+ ${
120
+ failure
121
+ ? `Cloudflare could not be reached: ${failure}`
122
+ : "This Cloudflare login reaches no accounts. Create one at dash.cloudflare.com, then try again."
123
+ }
124
+ </p>
125
+ ${
126
+ failure
127
+ ? html`<sp-button
128
+ size="s"
129
+ treatment="outline"
130
+ @click=${() => {
131
+ void load();
132
+ }}
133
+ >
134
+ Try again
135
+ </sp-button>`
136
+ : nothing
137
+ }
138
+ `;
139
+ }
140
+ /* The row shape is Preferences › Accounts', deliberately reused: this IS an account list,
141
+ and a second stylesheet entry for the same shape is how two lists drift apart. */
142
+ return html`
143
+ ${failure ? html`<p class="cf-account-picker-error">${failure}</p>` : nothing}
144
+ <div class="prefs-accounts">
145
+ ${accounts.map(
146
+ (account) => html`
147
+ <div class="prefs-account" data-account=${account.id}>
148
+ <div class="prefs-account-text">
149
+ <span class="prefs-account-label">${account.name}</span>
150
+ <span class="prefs-account-detail">${account.id}</span>
151
+ </div>
152
+ <sp-button
153
+ size="s"
154
+ variant="accent"
155
+ ?disabled=${choosing !== ""}
156
+ @click=${() => {
157
+ void choose(account);
158
+ }}
159
+ >
160
+ ${choosing === account.id ? "Selecting…" : "Use this account"}
161
+ </sp-button>
162
+ </div>
163
+ `,
164
+ )}
165
+ </div>
166
+ `;
167
+ }
168
+
169
+ function build(): TemplateResult {
170
+ return html`
171
+ <sp-dialog-wrapper
172
+ open
173
+ underlay
174
+ headline="Choose a Cloudflare account"
175
+ cancel-label="Cancel"
176
+ @cancel=${() => finish(null)}
177
+ @close=${() => finish(null)}
178
+ ${ref((el?: Element) => {
179
+ if (el) {
180
+ wrapperEl = el as HTMLElement;
181
+ }
182
+ })}
183
+ >
184
+ <div class="cf-account-picker">
185
+ <p class="cf-account-picker-lede">
186
+ Your Cloudflare login reaches more than one account. Publishing and the assistant
187
+ both run against the one you pick; you can change it in Preferences › Accounts.
188
+ </p>
189
+ ${listTpl()}
190
+ </div>
191
+ </sp-dialog-wrapper>
192
+ `;
193
+ }
194
+
195
+ void load();
196
+ return build();
197
+ },
198
+ { region: overlayRegion("dialog", "cf-accounts") },
199
+ );
200
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `choiceField` — one labelled choice, rendered the way every Spectrum picker in Studio is.
3
+ *
4
+ * It exists so the New File dialog's format picker and the Convert dialog's target picker are the
5
+ * SAME control rather than two that drift: same divider idiom for a sentinel row, same `live()`
6
+ * guard, same "one kind is not a choice" collapse to static text.
7
+ *
8
+ * **It imports lit only.** Both callers live in modules the other imports — `ui/layers.ts` is
9
+ * imported by `format/convert-file.ts`, which also wants this fragment — so a shared control that
10
+ * reached into either would be a cycle, and `import/no-cycle` is an error here.
11
+ *
12
+ * `.value=${live(...)}` is not stylistic. `sp-picker` writes its own `value` when the reader picks
13
+ * a row, so lit's dirty check sees the value it last committed and skips the re-commit that would
14
+ * put the control back where the model says it is. `scripts/check-lit-conventions.ts` fails on the
15
+ * unguarded form.
16
+ */
17
+
18
+ import { html, nothing } from "lit-html";
19
+ import { live } from "lit-html/directives/live.js";
20
+ import type { TemplateResult } from "lit-html";
21
+
22
+ /** One row of a {@link choiceField}. */
23
+ export interface ChoiceOption {
24
+ value: string;
25
+ label: string;
26
+ /** Draw an `sp-menu-divider` immediately before this row — how a sentinel row is set apart. */
27
+ dividerBefore?: boolean;
28
+ }
29
+
30
+ /** What a {@link choiceField} renders and reports. */
31
+ export interface ChoiceSpec {
32
+ label: string;
33
+ options: readonly ChoiceOption[];
34
+ value: string;
35
+ onChange: (next: string) => void;
36
+ }
37
+
38
+ /**
39
+ * A labelled picker, or static text when there is nothing to choose.
40
+ *
41
+ * The collapse is the rule `panels/pane-context.ts` already states about editor kinds: **one kind
42
+ * is not a choice**. A picker whose menu holds a single row is a control that cannot do anything,
43
+ * and drawing it invites the reader to open it and find out.
44
+ *
45
+ * @param spec - The label, the rows, the current value and the change sink.
46
+ * @returns The fragment, for a caller's own template.
47
+ */
48
+ export function choiceField(spec: ChoiceSpec): TemplateResult {
49
+ const current = spec.options.find((option) => option.value === spec.value);
50
+ if (spec.options.length < 2) {
51
+ return html`<div class="choice-row">
52
+ <sp-field-label size="s">${spec.label}</sp-field-label>
53
+ <span class="choice-static">${current?.label ?? spec.value}</span>
54
+ </div>`;
55
+ }
56
+ return html`<div class="choice-row">
57
+ <sp-field-label size="s">${spec.label}</sp-field-label>
58
+ <sp-picker
59
+ size="s"
60
+ label=${spec.label}
61
+ .value=${live(spec.value)}
62
+ @change=${(e: Event) => spec.onChange((e.target as HTMLInputElement).value)}
63
+ >
64
+ ${spec.options.map(
65
+ (option) =>
66
+ html`${
67
+ option.dividerBefore === true ? html`<sp-menu-divider></sp-menu-divider>` : nothing
68
+ }<sp-menu-item value=${option.value}>${option.label}</sp-menu-item>`,
69
+ )}
70
+ </sp-picker>
71
+ </div>`;
72
+ }
package/src/ui/layers.ts CHANGED
@@ -15,10 +15,12 @@
15
15
  import { html, render as litRender, nothing } from "lit-html";
16
16
  import { ref } from "lit-html/directives/ref.js";
17
17
  import { repeat } from "lit-html/directives/repeat.js";
18
+ import { choiceField } from "./choice-field";
18
19
  import { overlayRegion, REGION_ATTR } from "./regions";
19
20
  import { dismiss, toasts } from "../services/notify";
20
21
  import { activeRegistry } from "../commands/active-registry";
21
22
  import { effect, effectScope } from "../reactivity";
23
+ import type { ChoiceOption } from "./choice-field";
22
24
  import type { Notification, Severity } from "../services/notify";
23
25
  import type { EffectScope } from "@vue/reactivity";
24
26
  import type { TemplateResult } from "lit-html";
@@ -338,6 +340,31 @@ export function showSaveDiscardDialog(
338
340
  );
339
341
  }
340
342
 
343
+ /**
344
+ * A picker rendered above the field, whose selection the dialog OWNS.
345
+ *
346
+ * Owned, not merely displayed: the choice reaches `validate` as its second argument, and picking a
347
+ * row re-runs it. That is the whole reason this lives here rather than being smuggled in through
348
+ * `message` — `check` and `rerender` are private to {@link showPromptDialog}, so a caller-built
349
+ * control could change the selection but could never make the field's refusal catch up with it. The
350
+ * New File dialog's format picker is exactly that case: switching from Markdown to JSON changes
351
+ * whether the typed name is already taken, with no keystroke to notice.
352
+ */
353
+ export interface PromptChoice {
354
+ /** Label above the picker. */
355
+ label: string;
356
+ /**
357
+ * The rows, re-read on EVERY render rather than captured once. A snapshot taken when the options
358
+ * object is built freezes a list the project can still be loading — the same defect
359
+ * `content/entry-commands.ts`'s `derivedEnumProperty` exists to prevent one layer up.
360
+ */
361
+ options: () => readonly ChoiceOption[];
362
+ /** Which row starts selected. */
363
+ initial: string;
364
+ /** Told what was picked, for a caller keeping its own derived state. */
365
+ onChange?: (next: string) => void;
366
+ }
367
+
341
368
  /** Options accepted by {@link showPromptDialog}. */
342
369
  export interface PromptDialogOptions {
343
370
  /** Label on the confirming button. */
@@ -346,17 +373,21 @@ export interface PromptDialogOptions {
346
373
  cancelLabel?: string;
347
374
  /** Explanatory copy rendered above the text field. */
348
375
  message?: string | TemplateResult;
349
- /** Placeholder shown while the field is empty. */
350
- placeholder?: string;
376
+ /** Placeholder shown while the field is empty. Read on every render, so it may follow the choice. */
377
+ placeholder?: string | (() => string);
351
378
  /** How much of the pre-filled value to select once the field takes focus. */
352
379
  select?: "all" | "stem" | "none";
353
380
  /**
354
381
  * Validate the raw field value. Return an empty string when valid, or a message to show as
355
382
  * negative help text (which also blocks confirmation). Defaults to "must not be blank".
383
+ *
384
+ * `chosen` is the {@link PromptChoice} selection, or `""` when the dialog offers no choice.
356
385
  */
357
- validate?: (value: string) => string;
386
+ validate?: (value: string, chosen: string) => string;
358
387
  /** Pre-filled value. */
359
388
  value?: string;
389
+ /** A picker above the field whose selection this dialog owns. */
390
+ choice?: PromptChoice;
360
391
  }
361
392
 
362
393
  /**
@@ -375,6 +406,7 @@ export function showPromptDialog(
375
406
  ): Promise<string | null> {
376
407
  const {
377
408
  cancelLabel = "Cancel",
409
+ choice,
378
410
  confirmLabel = "OK",
379
411
  message,
380
412
  placeholder = "",
@@ -384,12 +416,22 @@ export function showPromptDialog(
384
416
  } = opts;
385
417
 
386
418
  const check = (candidate: string) =>
387
- validate ? validate(candidate) : candidate.trim() ? "" : "Enter a value.";
419
+ validate ? validate(candidate, chosen) : candidate.trim() ? "" : "Enter a value.";
388
420
 
389
421
  let value = initialValue;
422
+ let chosen = choice?.initial ?? "";
390
423
  let error = "";
391
424
  let wrapperEl: HTMLElement | null = null;
392
425
  let focusRequested = false;
426
+ /*
427
+ * Whether the reader has typed yet.
428
+ *
429
+ * A dialog opens with a valid prefill and NO error, and picking a format must not be the thing
430
+ * that first paints one under a field nobody has touched — "untitled" is not yet a mistake. So a
431
+ * pick refreshes an error that is already showing and never mints the first one; the confirm
432
+ * still refuses, because `confirm()` runs `check` unconditionally.
433
+ */
434
+ let touched = false;
393
435
 
394
436
  return showDialog<string | null>((done) => {
395
437
  function rerender() {
@@ -411,6 +453,7 @@ export function showPromptDialog(
411
453
  }
412
454
 
413
455
  function onInput(e: Event) {
456
+ touched = true;
414
457
  value = (e.target as HTMLInputElement).value || "";
415
458
  const next = check(value);
416
459
  if (next !== error) {
@@ -419,6 +462,23 @@ export function showPromptDialog(
419
462
  }
420
463
  }
421
464
 
465
+ /**
466
+ * Take a pick, and re-render UNCONDITIONALLY.
467
+ *
468
+ * `onInput` re-renders only when the error string changed, which is right for a keystroke: the
469
+ * template it would rebuild is identical. A pick is not that. It can change the placeholder,
470
+ * the picker's own selected row, and — for the New File dialog — whether the composed filename
471
+ * is already taken, all with the error text unchanged. Comparing error strings here would leave
472
+ * a dialog showing "about.md already exists" after the reader switched the format to JSON.
473
+ */
474
+ function onPick(next: string) {
475
+ chosen = next;
476
+ choice?.onChange?.(next);
477
+ const candidate = check(value);
478
+ error = touched || error ? candidate : "";
479
+ rerender();
480
+ }
481
+
422
482
  function onKeydown(e: KeyboardEvent) {
423
483
  if (e.key === "Enter") {
424
484
  confirm();
@@ -472,9 +532,27 @@ export function showPromptDialog(
472
532
  // Spectrum resets <p> margins to 0, so without this the copy sits flush on the field.
473
533
  message ? html`<p style="margin:0 0 8px">${message}</p>` : nothing
474
534
  }
535
+ ${
536
+ /*
537
+ * Above the field, and in ONE template position in every mode.
538
+ *
539
+ * The `sp-textfield` below must never move or be rebuilt by a conditional branch: lit
540
+ * would commit a new element, `onFieldRef` would fire again, the `focusRequested` latch
541
+ * would refuse to re-focus it, and the caret would be stranded mid-name. Only the
542
+ * placeholder, the validation rules and the help text vary between modes.
543
+ */
544
+ choice
545
+ ? choiceField({
546
+ label: choice.label,
547
+ onChange: onPick,
548
+ options: choice.options(),
549
+ value: chosen,
550
+ })
551
+ : nothing
552
+ }
475
553
  <sp-textfield
476
554
  style="width:100%"
477
- placeholder=${placeholder}
555
+ placeholder=${typeof placeholder === "function" ? placeholder() : placeholder}
478
556
  value=${value}
479
557
  ?invalid=${Boolean(error)}
480
558
  @input=${onInput}
@@ -4,8 +4,8 @@
4
4
  * These are all side-effect-free functions used by style/properties/events panels.
5
5
  */
6
6
 
7
- import { defaultContentFormat, formatByName } from "../format/format-host";
8
- import type { ContentSectionEntry } from "../types";
7
+ import { collectionForFile } from "../content/collection-match";
8
+ import { defaultContentFormat } from "../format/format-host";
9
9
  import type { ProjectConfig } from "@jxsuite/schema/types";
10
10
 
11
11
  /**
@@ -117,15 +117,6 @@ export function abbreviateValue(val: string) {
117
117
  return map[val] || val;
118
118
  }
119
119
 
120
- /**
121
- * The placeholder a content type's `source` may carry to say "one directory per locale".
122
- *
123
- * Held here rather than imported out of the parser extension's content loader ({@link
124
- * file://../../../../extensions/parser/src/content-loader.ts}): that module reads the filesystem
125
- * and this one runs in a browser. It is also an extension, and core may not import one.
126
- */
127
- const LOCALE_PLACEHOLDER = "{locale}";
128
-
129
120
  /**
130
121
  * True when a schema `format` names a MEDIA reference.
131
122
  *
@@ -184,8 +175,14 @@ export function inferInputType(entry: Record<string, unknown>) {
184
175
  }
185
176
 
186
177
  /**
187
- * Match a document path to a content type and return its schema. Compares the content type's
188
- * `source` directory against the document path prefix, using `format` for extension matching.
178
+ * Match a document path to a content type and return its schema.
179
+ *
180
+ * Delegates to `content/collection-match.ts`, which is the ONE matcher — the reason it takes a
181
+ * config rather than reading `projectState`. Two rules are added here and belong here, because they
182
+ * are about the FORM rather than about membership: a collection that declares no `schema` has no
183
+ * fields to draw, and a file whose extension is not the collection's is not one of its entries.
184
+ * Membership itself — the `{locale}` expansion, the recursive subdirectory, the single-file
185
+ * catalogue, longest-source-wins — is the matcher's, and is no longer restated here.
189
186
  *
190
187
  * @param {string | null} documentPath — project-relative path (e.g. "content/products/widget.md")
191
188
  * @param {ProjectConfig | null | undefined} projectConfig — parsed project.json
@@ -198,47 +195,20 @@ export function findContentTypeSchema(
198
195
  if (!documentPath || !projectConfig?.content) {
199
196
  return null;
200
197
  }
201
- // Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
202
- // OS-native backslash paths on Windows, so normalize before prefix matching.
203
- const docPath = documentPath.replaceAll("\\", "/");
204
- for (const [name, def] of Object.entries(
205
- projectConfig.content as Record<string, ContentSectionEntry>,
206
- )) {
207
- if (!def.source || !def.schema) {
208
- continue;
209
- }
210
- const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
211
- const hasExt = src.includes(".") && !src.endsWith("/") && !src.includes(LOCALE_PLACEHOLDER);
212
- if (hasExt) {
213
- if (docPath === src || docPath.endsWith(`/${src}`)) {
214
- return { name, schema: def.schema };
215
- }
216
- } else {
217
- const ext =
218
- def.format === "json"
219
- ? ".json"
220
- : (formatByName(def.format)?.extensions[0] ??
221
- defaultContentFormat()?.extensions[0] ??
222
- ".json");
223
- /* A localized source is N directories, one content type: `content/posts/{locale}` matches
224
- `content/posts/fr/hello.md` and carries the SAME schema, because a translation is the same
225
- post. Without this the placeholder was compared literally, matched no path anybody has, and
226
- a translated entry showed no frontmatter fields at all. */
227
- const prefix = src.includes(LOCALE_PLACEHOLDER)
228
- ? new RegExp(
229
- `^${src
230
- .split(LOCALE_PLACEHOLDER)
231
- .map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
232
- .join("[^/]+")}/`,
233
- )
234
- : null;
235
- const matches = prefix ? prefix.test(docPath) : docPath.startsWith(`${src}/`);
236
- if (matches && docPath.endsWith(ext)) {
237
- return { name, schema: def.schema };
238
- }
198
+ const match = collectionForFile(documentPath, projectConfig);
199
+ if (!match || !match.def.schema) {
200
+ return null;
201
+ }
202
+ /* A single-file source is matched AS that file, so its extension is settled by construction.
203
+ A directory-backed one is not: a `.json` sitting beside `.md` posts is somebody else's file,
204
+ and drawing the collection's fields over it would invite edits nothing will ever read. */
205
+ if (!match.fileBacked) {
206
+ const ext = match.ext ?? defaultContentFormat()?.extensions[0] ?? ".json";
207
+ if (!documentPath.toLowerCase().endsWith(ext.toLowerCase())) {
208
+ return null;
239
209
  }
240
210
  }
241
- return null;
211
+ return { name: match.name, schema: match.def.schema };
242
212
  }
243
213
 
244
214
  /**
@@ -48,6 +48,13 @@
48
48
  outline-offset: -2px;
49
49
  }
50
50
 
51
+ /* The rail's gear while its menu is open. That state is drawn with a `background`, which this
52
+ mode re-forces to the system canvas — so it says nothing. An outline says it instead. */
53
+ .rail-item--menu-open {
54
+ outline: 2px solid Highlight;
55
+ outline-offset: -2px;
56
+ }
57
+
51
58
  /* The drop target. A 3px translucent ring is invisible here twice over — shadow and alpha. */
52
59
  .drop-target,
53
60
  .is-drop-target {