@jxsuite/studio 0.34.0 → 0.36.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 (41) hide show
  1. package/dist/iframe-entry.js +9 -1
  2. package/dist/iframe-entry.js.map +3 -3
  3. package/dist/studio.js +18382 -3686
  4. package/dist/studio.js.map +265 -26
  5. package/package.json +9 -6
  6. package/src/files/files.ts +3 -0
  7. package/src/new-project/design-fields.ts +260 -0
  8. package/src/new-project/import-tab.ts +322 -0
  9. package/src/new-project/new-project-modal.ts +472 -89
  10. package/src/new-project/templates.ts +61 -0
  11. package/src/packages/ensure-deps.ts +6 -0
  12. package/src/packages/pull-package-sync.ts +339 -0
  13. package/src/panels/ai-chat/attached-context.ts +49 -0
  14. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  15. package/src/panels/ai-chat/chat-view.ts +250 -0
  16. package/src/panels/ai-chat/composer.ts +315 -0
  17. package/src/panels/ai-chat/sessions-view.ts +98 -0
  18. package/src/panels/ai-panel.ts +214 -458
  19. package/src/panels/drag-ghost.ts +1 -1
  20. package/src/panels/git-panel.ts +16 -1
  21. package/src/panels/right-panel.ts +21 -5
  22. package/src/panels/toolbar.ts +2 -0
  23. package/src/panels/welcome-screen.ts +26 -0
  24. package/src/platforms/cloud.ts +774 -0
  25. package/src/platforms/devserver.ts +100 -1
  26. package/src/project-list.ts +38 -0
  27. package/src/publish/pages-service.ts +186 -0
  28. package/src/publish/publish-panel.ts +360 -0
  29. package/src/services/agent-seed.ts +91 -0
  30. package/src/services/ai-models.ts +93 -0
  31. package/src/services/ai-session-store.ts +250 -0
  32. package/src/services/ai-settings.ts +5 -0
  33. package/src/services/automation.ts +140 -0
  34. package/src/services/cf-settings.ts +58 -0
  35. package/src/services/document-assistant.ts +98 -38
  36. package/src/services/import-client.ts +134 -0
  37. package/src/services/settings-store.ts +99 -0
  38. package/src/studio.ts +33 -0
  39. package/src/types.ts +130 -133
  40. package/src/ui/ai-credentials-form.ts +205 -0
  41. package/src/ui/spectrum.ts +8 -0
@@ -8,8 +8,16 @@
8
8
  * See spec/desktop.md §8 for the full specification.
9
9
  */
10
10
 
11
+ import { streamImport } from "../services/import-client";
11
12
  import type { ProjectConfig } from "@jxsuite/schema/types";
12
- import type { DirEntry, FsEvent, RenameResult } from "../types";
13
+ import type {
14
+ DirEntry,
15
+ FsEvent,
16
+ ImportProgressEvent,
17
+ ImportSiteOptions,
18
+ RenameResult,
19
+ StarterInfo,
20
+ } from "../types";
13
21
 
14
22
  /** A directory entry from the server, tolerating extra wire fields. */
15
23
  type WireDirEntry = DirEntry & Record<string, unknown>;
@@ -215,6 +223,9 @@ export function createDevServerPlatform() {
215
223
  url?: string;
216
224
  adapter?: string;
217
225
  directory: string;
226
+ starter?: string;
227
+ template?: string;
228
+ design?: Record<string, unknown>;
218
229
  }) {
219
230
  const res = await fetch("/__studio/create-project", {
220
231
  body: JSON.stringify(opts),
@@ -228,6 +239,24 @@ export function createDevServerPlatform() {
228
239
  return await res.json();
229
240
  },
230
241
 
242
+ /** List starter templates from the dev server. */
243
+ async listStarters(): Promise<StarterInfo[]> {
244
+ const res = await fetch("/__studio/starters");
245
+ if (!res.ok) {
246
+ throw new Error("Failed to load starters");
247
+ }
248
+ return await readJson<StarterInfo[]>(res);
249
+ },
250
+
251
+ /** AI-guided site import: NDJSON progress stream from the dev server's import endpoint. */
252
+ async importSite(
253
+ opts: ImportSiteOptions,
254
+ onProgress: (evt: ImportProgressEvent) => void,
255
+ signal?: AbortSignal,
256
+ ) {
257
+ return await streamImport("/__studio/import-site", opts, onProgress, signal);
258
+ },
259
+
231
260
  // ─── File operations ──────────────────────────────────────────────────
232
261
 
233
262
  /** @param {string} dir */
@@ -808,6 +837,76 @@ export function createDevServerPlatform() {
808
837
  }
809
838
  },
810
839
 
840
+ // ─── Cloudflare publish surface (token-backed; see services/cf-settings) ─
841
+
842
+ /**
843
+ * Allowlisted Cloudflare API passthrough. The token comes from the client's cf-settings store
844
+ * and rides in a header to the same-origin proxy (api.cloudflare.com is not CORS-enabled).
845
+ */
846
+ async cfApi(apiPath: string, init?: { method?: string; body?: unknown }) {
847
+ const { getCfToken } = await import("../services/cf-settings");
848
+ const token = getCfToken();
849
+ if (!token) {
850
+ throw new Error("No Cloudflare API token configured");
851
+ }
852
+ const res = await fetch("/__studio/cf/proxy", {
853
+ method: "POST",
854
+ headers: { "Content-Type": "application/json", "X-CF-Token": token },
855
+ body: JSON.stringify({ path: apiPath, method: init?.method ?? "GET", body: init?.body }),
856
+ });
857
+ const envelope = (await res.json()) as {
858
+ success?: boolean;
859
+ result?: unknown;
860
+ errors?: { message: string }[];
861
+ error?: string;
862
+ };
863
+ if (!res.ok || envelope.success === false) {
864
+ const message =
865
+ envelope.errors?.map((e) => e.message).join("; ") ?? envelope.error ?? res.statusText;
866
+ throw new Error(`Cloudflare API: ${message}`);
867
+ }
868
+ return envelope.result ?? envelope;
869
+ },
870
+
871
+ /** Verify the stored token by listing accounts; null when none/invalid. */
872
+ async cfConnection() {
873
+ const { getCfAccountId, getCfToken, setCfAccountId } =
874
+ await import("../services/cf-settings");
875
+ if (!getCfToken()) {
876
+ return null;
877
+ }
878
+ try {
879
+ const accounts = (await this.cfApi?.("/accounts")) as { id: string; name: string }[];
880
+ if (!accounts?.length) {
881
+ return { connected: false };
882
+ }
883
+ const chosen = accounts.find((a) => a.id === getCfAccountId()) ?? accounts[0]!;
884
+ setCfAccountId(chosen.id);
885
+ return { connected: true, accountId: chosen.id, accountName: chosen.name };
886
+ } catch {
887
+ return { connected: false };
888
+ }
889
+ },
890
+
891
+ // ─── Project catalogue ──────────────────────────────────────────────────
892
+
893
+ /** Every site under the server root, from the /__studio/sites glob. */
894
+ async listProjects() {
895
+ const res = await fetch("/__studio/sites");
896
+ if (!res.ok) {
897
+ return [];
898
+ }
899
+ const sites = await readJson<SiteEntry[]>(res);
900
+ return sites.map((site) => {
901
+ const config = site.config as { name?: string } | null;
902
+ return {
903
+ name: config?.name || site.path.split("/").at(-1) || site.path,
904
+ root: site.path,
905
+ description: site.path,
906
+ };
907
+ });
908
+ },
909
+
811
910
  // ─── AI Assistant (Stack B: OpenAI-compatible SSE proxy) ───────────────────
812
911
 
813
912
  aiChatUrl() {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Project catalogue — a synchronous render cache over the optional `platform.listProjects` PAL
3
+ * member (dev server sites, cloud platforms). Hydrated once at boot (studio.ts) like the
4
+ * recent-projects cache; the welcome screen reads it synchronously.
5
+ */
6
+ import { getPlatform, hasPlatform } from "./platform";
7
+ import type { ProjectListEntry } from "./types";
8
+
9
+ let cache: ProjectListEntry[] = [];
10
+
11
+ /** True when the active platform exposes a project catalogue. */
12
+ export function platformListsProjects(): boolean {
13
+ return hasPlatform() && typeof getPlatform().listProjects === "function";
14
+ }
15
+
16
+ /** Refresh the cache from the platform; resolves to [] when unsupported or failing. */
17
+ export async function hydrateProjectList(): Promise<void> {
18
+ if (!platformListsProjects()) {
19
+ cache = [];
20
+ return;
21
+ }
22
+ try {
23
+ cache = (await getPlatform().listProjects?.()) ?? [];
24
+ } catch {
25
+ // Catalogue is progressive enhancement; a failed fetch leaves the section hidden.
26
+ cache = [];
27
+ }
28
+ }
29
+
30
+ /** Synchronous snapshot for render paths (never awaits). */
31
+ export function getProjectList(): ProjectListEntry[] {
32
+ return cache;
33
+ }
34
+
35
+ /** Reset seam for tests. */
36
+ export function resetProjectList(): void {
37
+ cache = [];
38
+ }
@@ -0,0 +1,186 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Pages-service — Cloudflare Pages publish domain logic over the PAL's
4
+ * `cfApi` passthrough. Works identically on every platform that provides it:
5
+ * the dev server / desktop (user API token via cf-settings → /__studio/cf/proxy)
6
+ * and the cloud platform (hosted OAuth). Publish state is written to
7
+ * project.json `build.deploy` — it travels with the repo, so any Studio can
8
+ * tell whether the publish workflow already exists.
9
+ *
10
+ * @license MIT
11
+ */
12
+
13
+ import { updateWranglerConfig } from "@jxsuite/create/scaffold";
14
+ import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
15
+ import { getPlatform } from "../platform";
16
+ import { updateSiteConfig } from "../site-context";
17
+
18
+ export interface CfAccount {
19
+ id: string;
20
+ name: string;
21
+ }
22
+
23
+ export interface PagesDeploymentInfo {
24
+ id: string;
25
+ url: string;
26
+ environment: string;
27
+ /** Latest stage name/status, e.g. "deploy: success" or "build: failure". */
28
+ stage: string;
29
+ status: string;
30
+ createdOn: string;
31
+ }
32
+
33
+ export interface ConnectOptions {
34
+ accountId: string;
35
+ projectName: string;
36
+ productionBranch: string;
37
+ owner: string;
38
+ repo: string;
39
+ }
40
+
41
+ /** True when the active platform can reach the Cloudflare API. */
42
+ export function platformSupportsPublish(): boolean {
43
+ return typeof getPlatform().cfApi === "function";
44
+ }
45
+
46
+ async function cfApi<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
47
+ const api = getPlatform().cfApi;
48
+ if (!api) {
49
+ throw new Error("This platform cannot reach the Cloudflare API");
50
+ }
51
+ return (await api(path, init)) as T;
52
+ }
53
+
54
+ export async function listAccounts(): Promise<CfAccount[]> {
55
+ return cfApi<CfAccount[]>("/accounts");
56
+ }
57
+
58
+ interface PagesProjectWire {
59
+ name: string;
60
+ subdomain?: string;
61
+ domains?: string[];
62
+ }
63
+
64
+ /** The Pages project, or null when it does not exist yet. */
65
+ export async function getPagesProject(
66
+ accountId: string,
67
+ projectName: string,
68
+ ): Promise<PagesProjectWire | null> {
69
+ try {
70
+ return await cfApi<PagesProjectWire>(`/accounts/${accountId}/pages/projects/${projectName}`);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create a GitHub-connected Pages project that builds `bunx jx build` on every push. Requires the
78
+ * Cloudflare Pages GitHub App on the repo — the characteristic failure is surfaced to the caller
79
+ * with an install hint.
80
+ */
81
+ export async function createPagesProject(opts: ConnectOptions): Promise<PagesProjectWire> {
82
+ return cfApi<PagesProjectWire>(`/accounts/${opts.accountId}/pages/projects`, {
83
+ method: "POST",
84
+ body: {
85
+ name: opts.projectName,
86
+ production_branch: opts.productionBranch,
87
+ build_config: {
88
+ build_command: "bunx jx build",
89
+ destination_dir: "dist",
90
+ },
91
+ source: {
92
+ type: "github",
93
+ config: {
94
+ owner: opts.owner,
95
+ repo_name: opts.repo,
96
+ production_branch: opts.productionBranch,
97
+ deployments_enabled: true,
98
+ },
99
+ },
100
+ },
101
+ });
102
+ }
103
+
104
+ interface DeploymentWire {
105
+ id: string;
106
+ url: string;
107
+ environment: string;
108
+ latest_stage?: { name?: string; status?: string };
109
+ created_on: string;
110
+ }
111
+
112
+ /** The most recent deployment of the connected project, or null when none. */
113
+ export async function latestDeployment(deploy: DeployConfig): Promise<PagesDeploymentInfo | null> {
114
+ const deployments = await cfApi<DeploymentWire[]>(
115
+ `/accounts/${deploy.accountId}/pages/projects/${deploy.projectName}/deployments`,
116
+ );
117
+ const [latest] = deployments;
118
+ if (!latest) {
119
+ return null;
120
+ }
121
+ return {
122
+ id: latest.id,
123
+ url: latest.url,
124
+ environment: latest.environment,
125
+ stage: latest.latest_stage?.name ?? "unknown",
126
+ status: latest.latest_stage?.status ?? "unknown",
127
+ createdOn: latest.created_on,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Write (or with null, remove) the `build.deploy` block, preserving the rest of `build`. On
133
+ * connect, wrangler.jsonc is patched so its `name` matches the connected Pages project
134
+ * (comment-bearing JSONC is left alone).
135
+ */
136
+ export async function writeDeployConfig(
137
+ config: ProjectConfig,
138
+ deploy: DeployConfig | null,
139
+ ): Promise<void> {
140
+ const build: NonNullable<ProjectConfig["build"]> = { ...config.build };
141
+ if (deploy === null) {
142
+ delete build.deploy;
143
+ } else {
144
+ build.deploy = deploy;
145
+ }
146
+ await updateSiteConfig({ build });
147
+
148
+ if (deploy !== null) {
149
+ const platform = getPlatform();
150
+ let existing: string | null = null;
151
+ try {
152
+ existing = await platform.readFile("wrangler.jsonc");
153
+ } catch {
154
+ existing = null;
155
+ }
156
+ const adapter =
157
+ build.adapter === "cloudflare-workers" ? "cloudflare-workers" : "cloudflare-pages";
158
+ const { content, patched } = updateWranglerConfig(existing, {
159
+ adapter,
160
+ slug: deploy.projectName,
161
+ });
162
+ if (patched) {
163
+ await platform.writeFile("wrangler.jsonc", content);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The whole connect flow: reuse the Pages project when it already exists, create it otherwise, then
170
+ * persist `build.deploy` (+ wrangler.jsonc sync).
171
+ */
172
+ export async function connectDeploy(
173
+ config: ProjectConfig,
174
+ opts: ConnectOptions,
175
+ ): Promise<DeployConfig> {
176
+ const existing = await getPagesProject(opts.accountId, opts.projectName);
177
+ const project = existing ?? (await createPagesProject(opts));
178
+ const deploy: DeployConfig = {
179
+ provider: "cloudflare-pages",
180
+ accountId: opts.accountId,
181
+ projectName: opts.projectName,
182
+ ...(project.subdomain ? { productionUrl: `https://${project.subdomain}` } : {}),
183
+ };
184
+ await writeDeployConfig(config, deploy);
185
+ return deploy;
186
+ }
@@ -0,0 +1,360 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Publish panel — the one-click Cloudflare Pages publish flow, driven by the
4
+ * PAL's cf* members. States: unsupported platform → info; no credential →
5
+ * connect (hosted OAuth via cfConnect, or an API-token form backed by
6
+ * cf-settings); connected without `build.deploy` → create-and-connect form;
7
+ * connected → deployment status (publishing rides every commit).
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import { html } from "lit-html";
13
+ import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
14
+ import { getPlatform } from "../platform";
15
+ import { getCfToken, setCfToken } from "../services/cf-settings";
16
+ import { projectState } from "../store";
17
+ import type { CfConnection } from "../types";
18
+ import { openModal } from "../ui/layers";
19
+ import type { CfAccount, PagesDeploymentInfo } from "./pages-service";
20
+ import {
21
+ connectDeploy,
22
+ latestDeployment,
23
+ listAccounts,
24
+ platformSupportsPublish,
25
+ writeDeployConfig,
26
+ } from "./pages-service";
27
+
28
+ const PAGES_APP_INSTALL_URL = "https://github.com/apps/cloudflare-pages/installations/new";
29
+
30
+ let _handle: ReturnType<typeof openModal> | null = null;
31
+ let _connection: CfConnection | null | "loading" = "loading";
32
+ let _accounts: CfAccount[] = [];
33
+ let _deployment: PagesDeploymentInfo | null = null;
34
+ let _error = "";
35
+ let _busy = false;
36
+ let _form = { accountId: "", branch: "main", owner: "", projectName: "", repo: "" };
37
+
38
+ function currentConfig(): ProjectConfig | null {
39
+ return (projectState?.projectConfig as ProjectConfig | undefined) ?? null;
40
+ }
41
+
42
+ function currentDeploy(): DeployConfig | undefined {
43
+ return currentConfig()?.build?.deploy;
44
+ }
45
+
46
+ function deriveSlug(name: string): string {
47
+ return (
48
+ name
49
+ .toLowerCase()
50
+ .replaceAll(/[^a-z0-9]+/g, "-")
51
+ .replaceAll(/^-+|-+$/g, "")
52
+ .slice(0, 58) || "site"
53
+ );
54
+ }
55
+
56
+ /** Prefill owner/repo from cloud roots ("owner/repo"); blank elsewhere. */
57
+ function prefillRepo(): { owner: string; repo: string } {
58
+ const root = getPlatform().projectRoot;
59
+ const match = /^([\w.-]+)\/([\w.-]+)$/.exec(root);
60
+ return match ? { owner: match[1]!, repo: match[2]! } : { owner: "", repo: "" };
61
+ }
62
+
63
+ async function loadConnection(): Promise<void> {
64
+ _connection = "loading";
65
+ render();
66
+ try {
67
+ _connection = (await getPlatform().cfConnection?.()) ?? null;
68
+ if (_connection?.connected) {
69
+ _accounts = await listAccounts();
70
+ _form.accountId = _connection.accountId ?? _accounts[0]?.id ?? "";
71
+ const deploy = currentDeploy();
72
+ if (deploy) {
73
+ _deployment = await latestDeployment(deploy);
74
+ }
75
+ }
76
+ } catch (error) {
77
+ _connection = null;
78
+ _error = error instanceof Error ? error.message : String(error);
79
+ }
80
+ render();
81
+ }
82
+
83
+ async function saveToken(host: HTMLElement): Promise<void> {
84
+ const input = host.querySelector<HTMLInputElement>("#cf-token-input");
85
+ setCfToken(input?.value ?? "");
86
+ _error = "";
87
+ await loadConnection();
88
+ }
89
+
90
+ async function hostedConnect(): Promise<void> {
91
+ _busy = true;
92
+ render();
93
+ try {
94
+ await getPlatform().cfConnect?.();
95
+ } catch (error) {
96
+ _error = error instanceof Error ? error.message : String(error);
97
+ }
98
+ _busy = false;
99
+ await loadConnection();
100
+ }
101
+
102
+ async function submitConnect(): Promise<void> {
103
+ const config = currentConfig();
104
+ if (!config) {
105
+ return;
106
+ }
107
+ if (!_form.projectName || !_form.owner || !_form.repo || !_form.accountId) {
108
+ _error = "Account, project name, and the GitHub owner/repo are all required.";
109
+ render();
110
+ return;
111
+ }
112
+ _busy = true;
113
+ _error = "";
114
+ render();
115
+ try {
116
+ const deploy = await connectDeploy(config, {
117
+ accountId: _form.accountId,
118
+ owner: _form.owner,
119
+ productionBranch: _form.branch || "main",
120
+ projectName: _form.projectName,
121
+ repo: _form.repo,
122
+ });
123
+ _deployment = await latestDeployment(deploy).catch(() => null);
124
+ } catch (error) {
125
+ _error = error instanceof Error ? error.message : String(error);
126
+ }
127
+ _busy = false;
128
+ render();
129
+ }
130
+
131
+ async function disconnect(): Promise<void> {
132
+ const config = currentConfig();
133
+ if (!config) {
134
+ return;
135
+ }
136
+ _busy = true;
137
+ render();
138
+ try {
139
+ await writeDeployConfig(config, null);
140
+ _deployment = null;
141
+ } catch (error) {
142
+ _error = error instanceof Error ? error.message : String(error);
143
+ }
144
+ _busy = false;
145
+ render();
146
+ }
147
+
148
+ function close(): void {
149
+ _handle?.close();
150
+ _handle = null;
151
+ }
152
+
153
+ function fieldRow(label: string, input: unknown) {
154
+ return html`
155
+ <label class="publish-field">
156
+ <span>${label}</span>
157
+ ${input}
158
+ </label>
159
+ `;
160
+ }
161
+
162
+ function credentialTpl() {
163
+ const platform = getPlatform();
164
+ if (platform.cfConnect) {
165
+ return html`
166
+ <p>Connect your Cloudflare account to publish this site.</p>
167
+ <sp-button ?disabled=${_busy} @click=${() => void hostedConnect()}>
168
+ Connect Cloudflare
169
+ </sp-button>
170
+ `;
171
+ }
172
+ return html`
173
+ <p>
174
+ Paste a Cloudflare API token (permissions: Account Settings Read, Pages Read/Write). It is
175
+ stored locally and only sent to the same-origin proxy.
176
+ </p>
177
+ ${fieldRow(
178
+ "API token",
179
+ html`<sp-textfield
180
+ id="cf-token-input"
181
+ type="password"
182
+ value=${getCfToken()}
183
+ placeholder="cf_..."
184
+ ></sp-textfield>`,
185
+ )}
186
+ <sp-button ?disabled=${_busy} @click=${(e: Event) => void saveToken(hostOf(e))}>
187
+ Verify & Connect
188
+ </sp-button>
189
+ `;
190
+ }
191
+
192
+ function hostOf(e: Event): HTMLElement {
193
+ return (e.target as HTMLElement).closest(".publish-modal") ?? document.body;
194
+ }
195
+
196
+ function connectFormTpl() {
197
+ return html`
198
+ <p>
199
+ Create a Cloudflare Pages project connected to this repository. Every commit then builds and
200
+ publishes automatically (<code>bunx jx build</code>).
201
+ </p>
202
+ ${fieldRow(
203
+ "Account",
204
+ html`
205
+ <sp-picker
206
+ value=${_form.accountId}
207
+ @change=${(e: Event) => {
208
+ _form.accountId = (e.target as HTMLInputElement).value;
209
+ }}
210
+ >
211
+ ${_accounts.map((a) => html`<sp-menu-item value=${a.id}>${a.name}</sp-menu-item>`)}
212
+ </sp-picker>
213
+ `,
214
+ )}
215
+ ${fieldRow(
216
+ "Pages project name",
217
+ html`<sp-textfield
218
+ value=${_form.projectName}
219
+ @input=${(e: Event) => {
220
+ _form.projectName = (e.target as HTMLInputElement).value;
221
+ }}
222
+ ></sp-textfield>`,
223
+ )}
224
+ ${fieldRow(
225
+ "GitHub owner",
226
+ html`<sp-textfield
227
+ value=${_form.owner}
228
+ @input=${(e: Event) => {
229
+ _form.owner = (e.target as HTMLInputElement).value;
230
+ }}
231
+ ></sp-textfield>`,
232
+ )}
233
+ ${fieldRow(
234
+ "GitHub repository",
235
+ html`<sp-textfield
236
+ value=${_form.repo}
237
+ @input=${(e: Event) => {
238
+ _form.repo = (e.target as HTMLInputElement).value;
239
+ }}
240
+ ></sp-textfield>`,
241
+ )}
242
+ ${fieldRow(
243
+ "Production branch",
244
+ html`<sp-textfield
245
+ value=${_form.branch}
246
+ @input=${(e: Event) => {
247
+ _form.branch = (e.target as HTMLInputElement).value;
248
+ }}
249
+ ></sp-textfield>`,
250
+ )}
251
+ <sp-button ?disabled=${_busy} @click=${() => void submitConnect()}>
252
+ ${_busy ? "Connecting…" : "Create & Connect"}
253
+ </sp-button>
254
+ `;
255
+ }
256
+
257
+ function statusTpl(deploy: DeployConfig) {
258
+ return html`
259
+ <p>
260
+ Connected to Pages project <strong>${deploy.projectName}</strong>
261
+ ${deploy.productionUrl
262
+ ? html` —
263
+ <a href=${deploy.productionUrl} target="_blank" rel="noreferrer">
264
+ ${deploy.productionUrl}
265
+ </a>`
266
+ : ""}
267
+ </p>
268
+ ${_deployment
269
+ ? html`
270
+ <p>
271
+ Latest deployment: <strong>${_deployment.stage}: ${_deployment.status}</strong>
272
+ (${_deployment.environment}) —
273
+ <a href=${_deployment.url} target="_blank" rel="noreferrer">preview</a>
274
+ </p>
275
+ `
276
+ : html`<p>No deployments yet — the first commit after connecting triggers one.</p>`}
277
+ <p class="publish-hint">Publishing happens automatically on every commit.</p>
278
+ <div class="publish-actions">
279
+ <sp-button variant="secondary" ?disabled=${_busy} @click=${() => void loadConnection()}>
280
+ Refresh
281
+ </sp-button>
282
+ <sp-button variant="negative" ?disabled=${_busy} @click=${() => void disconnect()}>
283
+ Disconnect
284
+ </sp-button>
285
+ </div>
286
+ `;
287
+ }
288
+
289
+ function bodyTpl() {
290
+ if (!platformSupportsPublish()) {
291
+ return html`
292
+ <p>
293
+ This platform cannot reach the Cloudflare API. Publish by committing and pushing — your host
294
+ builds <code>bunx jx build</code> and serves <code>dist/</code>.
295
+ </p>
296
+ `;
297
+ }
298
+ if (_connection === "loading") {
299
+ return html`<p>Checking Cloudflare connection…</p>`;
300
+ }
301
+ if (!_connection?.connected) {
302
+ return credentialTpl();
303
+ }
304
+ const deploy = currentDeploy();
305
+ return deploy ? statusTpl(deploy) : connectFormTpl();
306
+ }
307
+
308
+ function errorTpl() {
309
+ if (!_error) {
310
+ return "";
311
+ }
312
+ const needsPagesApp = /github/i.test(_error) && /app|install|source|repo/i.test(_error);
313
+ return html`
314
+ <p class="publish-error">
315
+ ${_error}
316
+ ${needsPagesApp
317
+ ? html` — if the Cloudflare Pages GitHub App is not installed on the repository,
318
+ <a href=${PAGES_APP_INSTALL_URL} target="_blank" rel="noreferrer">install it</a> and
319
+ retry.`
320
+ : ""}
321
+ </p>
322
+ `;
323
+ }
324
+
325
+ function render(): void {
326
+ const tpl = html`
327
+ <div class="new-project-modal publish-modal">
328
+ <div class="new-project-modal-header">
329
+ <h2 class="new-project-modal-title">Publish</h2>
330
+ <sp-action-button size="s" quiet @click=${close}>✕</sp-action-button>
331
+ </div>
332
+ <div class="new-project-modal-body">${bodyTpl()} ${errorTpl()}</div>
333
+ </div>
334
+ `;
335
+ if (_handle) {
336
+ _handle.update(tpl);
337
+ } else {
338
+ _handle = openModal(tpl);
339
+ }
340
+ }
341
+
342
+ /** Open the publish modal for the active project. */
343
+ export function openPublishPanel(): void {
344
+ const config = currentConfig();
345
+ const { owner, repo } = prefillRepo();
346
+ _connection = "loading";
347
+ _accounts = [];
348
+ _deployment = null;
349
+ _error = "";
350
+ _busy = false;
351
+ _form = {
352
+ accountId: "",
353
+ branch: "main",
354
+ owner,
355
+ projectName: currentDeploy()?.projectName ?? deriveSlug(config?.name ?? ""),
356
+ repo,
357
+ };
358
+ render();
359
+ void loadConnection();
360
+ }