From 341d2413df4bed787e411ad8d29f1211c885789f Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 6 Nov 2026 10:19:44 -0800 Subject: [PATCH] Rebuild the Worker settings tab as one aligned column Render Settings outside the source form so Secrets, Sharing and Library can sit inside the panel instead of below its Save bar, and give each section its own clear save. Show which declared secrets need a value and which saved values never reach the Worker. --- e2e/gallery-native-apps.test.ts | 19 +- src/gallery/native-apps.tsx | 361 +++++++++++++++++++------------- src/gallery/secrets.tsx | 30 ++- src/gallery/styles.ts | 3 + 4 files changed, 257 insertions(+), 156 deletions(-) diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts index fe605d3..437ed48 100644 --- a/e2e/gallery-native-apps.test.ts +++ b/e2e/gallery-native-apps.test.ts @@ -128,6 +128,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi let failFirst = true; let failRestore = true; let failReconcile = true; + let secretNames: string[] = []; const server = Bun.serve({ port: 0, hostname: "127.0.0.1", @@ -204,7 +205,8 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi saved!.status = "active"; return result({ ...saved, ok: true }); case "app_secrets": - return result({ names: ["TOKEN"] }); + if (call.arguments.secrets) secretNames = ["TOKEN", "UNDECLARED"]; + return result({ names: secretNames }); case "app_move": saved = null; return result({ ok: true }); @@ -316,12 +318,25 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi expect(new URL(page.url()).searchParams.get("app")).toBe("example"); expect(new URL(page.url()).searchParams.get("appTab")).toBe("deployments"); await panel.getByRole("tab", { name: "Settings", exact: true }).click(); - await panel.getByLabel("Secret name", { exact: true }).fill("TOKEN"); + const settings = panel.getByRole("tabpanel", { name: "Settings", exact: true }); + const secretRows = settings.getByRole("list", { name: "Secret names", exact: true }).getByRole("listitem"); + await secretRows.filter({ hasText: "TOKEN" }).getByText("Needs a value", { exact: true }).waitFor(); + await secretRows.filter({ hasText: "TOKEN" }).getByRole("button", { name: "Set value", exact: true }).click(); + expect(await panel.getByLabel("Secret name", { exact: true }).inputValue()).toBe("TOKEN"); + expect(await panel.getByLabel("Secret value", { exact: true }).evaluate((input) => input === document.activeElement)).toBe(true); + // Sections that save on their own sit inside the panel, below the deploy action that does not cover them. + const deployBox = await settings.getByRole("button", { name: "Save and deploy Worker", exact: true }).boundingBox(); + for (const heading of ["Secrets", "Sharing", "Library"]) { + const headingBox = await settings.getByRole("heading", { name: heading, exact: true }).boundingBox(); + expect(headingBox!.y).toBeGreaterThan(deployBox!.y); + } await panel.getByLabel("Secret value", { exact: true }).fill("secret-fixture-value"); const writesBeforeSecret = calls.filter((call) => call.name === "app_write").length; await panel.getByRole("button", { name: "Save secret", exact: true }).click(); await panel.getByText("Secret saved", { exact: true }).waitFor(); expect(await panel.getByLabel("Secret value", { exact: true }).inputValue()).toBe(""); + expect(await settings.getByText("Needs a value", { exact: true }).count()).toBe(0); + await secretRows.filter({ hasText: "UNDECLARED" }).getByText("Not passed to the Worker", { exact: true }).waitFor(); expect(calls.filter((call) => call.name === "app_write").length).toBe(writesBeforeSecret); expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); diff --git a/src/gallery/native-apps.tsx b/src/gallery/native-apps.tsx index 372afab..8ea323f 100644 --- a/src/gallery/native-apps.tsx +++ b/src/gallery/native-apps.tsx @@ -1,4 +1,4 @@ -import { useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; +import { useEffect, useImperativeHandle, useRef, useState, type FormEvent, type ReactNode, type Ref } from "react"; import { GalleryToolError, LinkSettings, galleryTool } from "./hosted"; import { ProjectEditor, editableProject, emptyEditableProject, type EditableProject } from "./project-editor"; import { SecretControls } from "./secrets"; @@ -186,6 +186,34 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS }; const providerAvailable = providers.includes(provider); const detail = (!!selected || editingNew) && providerAvailable; + function deploy(event: FormEvent) { + event.preventDefault(); + void perform(async () => { + const result = decode( + await galleryTool(workspace, "app_write", { + name, + source, + manifest: JSON.parse(manifest), + project, + provider, + expected_revision: baseRevision, + }), + ); + setDirty(false); + await onSaved(); + await loadApp(name); + navigate(name); + setStatus(result.ok ? "Native Worker deployed" : "Native Worker needs reconciliation"); + }, true); + } + const deployControls = ( + <> + + {dirty ? Unsaved changes : null} + + ); return ( } @@ -206,7 +234,7 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS .worker-workspace [role="tabpanel"][hidden] { display: none; } .worker-workspace .worker-source-panel { min-height: 300px; padding: 0; } .worker-workspace .worker-source-panel > .project-editor { height: 100%; } - .worker-workspace .worker-content > :is(p, .worker-empty, .script-fields, h3, button) { margin: 12px 16px; } + .worker-workspace .worker-content > :is(p, .worker-empty, .script-fields) { margin: 12px 16px; } .worker-workspace .source-actions { flex-wrap: wrap; } .worker-workspace input, .worker-workspace textarea { max-width: 100%; box-sizing: border-box; } .worker-workspace fieldset { min-width: 0; } @@ -219,13 +247,38 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS .worker-workspace [role="tabpanel"]:not(.worker-source-panel) { max-width: 760px; } .worker-workspace [role="tabpanel"] > h2 { margin: 0 0 6px; font-size: 14px; font-weight: 600; } .worker-workspace [role="tabpanel"] > h3 { margin: 24px 0 6px; font-size: 13px; font-weight: 600; } - .worker-workspace .worker-content > h3 { font-size: 13px; font-weight: 600; } .worker-workspace legend { padding: 0; margin-bottom: 8px; font-size: 12px; font-weight: 600; } + .worker-workspace [role="tabpanel"].worker-settings { padding-block: 0; } + .settings-section { padding-block: 24px; } + .settings-section + .settings-section { border-top: 1px solid var(--line); } + .settings-section > h2 { margin: 0 0 4px; font-size: 14px; font-weight: 600; } + .worker-settings .settings-description { margin-bottom: 16px; } + .worker-settings .secret-controls { max-width: none; padding: 0; } + .worker-settings .settings-feedback { margin-top: 8px; } + .worker-settings .settings-feedback:empty { min-height: 0; margin: 0; } + .worker-settings .link-settings { padding: 0; margin-top: 12px; } + .worker-settings .link-settings > label:has(input) { flex: 1; } + .worker-settings .link-settings > label input { width: 100%; } + .worker-settings :is(.settings-stack, .settings-section) > .settings-actions { margin-top: 0; } + .settings-stack { display: flex; flex-direction: column; gap: 20px; } + .settings-list { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; margin: 0; padding: 0; border: 0; } + .worker-workspace .settings-list legend { margin-bottom: 4px; } + .settings-hint { margin: 0; color: var(--muted); font-size: 12px; } + .settings-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-self: stretch; gap: 8px; } + .settings-row.variable-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; } + .settings-row input { width: 100%; font-family: var(--font-mono); } + .settings-row :is(input, button), .settings-list > button { height: 36px; } .worker-manifest { display: block; width: 100%; min-height: 240px; margin-top: 6px; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: 12px/1.6 var(--font-mono); } @media (max-width: 1100px) { .worker-workspace .detail-header { flex-wrap: wrap; gap: 8px; } .worker-workspace .view-control { order: 3; width: 100%; } } + @media (max-width: 760px) { + .settings-row.variable-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .settings-row.variable-row button { grid-column: 1 / -1; justify-self: end; } + .settings-row :is(input, button), .settings-list > button { height: 44px; } + .settings-row input { font-size: 16px; } + } `}
@@ -342,29 +395,8 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS {detail ? ( <> {selected?.error ?

{selected.error}

: null} -
{ - event.preventDefault(); - void perform(async () => { - const result = decode( - await galleryTool(workspace, "app_write", { - name, - source, - manifest: JSON.parse(manifest), - project, - provider, - expected_revision: baseRevision, - }), - ); - setDirty(false); - await onSaved(); - await loadApp(name); - navigate(name); - setStatus(result.ok ? "Native Worker deployed" : "Native Worker needs reconciliation"); - }, true); - }} - > + {/* Settings holds its own forms, so it cannot render inside this one. */} + - -

Settings

- {parsed ? ( - <> -
- - -
- updateManifest({ compatibility_flags })} - /> - updateManifest({ vars })} - /> - updateManifest({ secrets })} + {section !== "settings" ?
{deployControls}
: null} + + + +
+ {parsed ? ( + <> +
+ + +
+ updateManifest({ compatibility_flags })} + /> + updateManifest({ vars })} + /> + Names passed to the Worker on env. Deploying fails until each one has a value under Secrets.} + placeholder="API_KEY" + values={parsed.secrets ?? []} + disabled={busy} + onChange={(secrets) => updateManifest({ secrets })} + /> + + ) : ( +

Enter a valid manifest object below to use structured settings.

+ )} +
+ Advanced manifest (JSON) +