diff --git a/cloudflare/links.ts b/cloudflare/links.ts index 409e0b0..1d50344 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -44,6 +44,7 @@ export class ArtifactLinks extends DurableObject { catalog(input: Parameters[0]) { return this.ownership.catalog(input); } fetch(request: Request) { return this.subscriptions.accept(request); } changed(target: ArtifactTarget) { this.subscriptions.changed(this.ownership.owner(target), target.workspace); } + nativeAppChanged(selection: { libraryKey: string; workspace: string }) { this.subscriptions.changed(selection.libraryKey, selection.workspace); } webSocketMessage(socket: WebSocket) { socket.close(1008, "Receive-only subscription"); } webSocketClose(socket: WebSocket, code: number, reason: string) { socket.close(code, reason); } webSocketError(socket: WebSocket) { socket.close(1011, "Subscription disconnected"); } diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index 4886516..c475668 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -113,6 +113,10 @@ export class ManagedArtifactService { default: throw new Error("Unknown app tool"); } const payload = result as Record; + if (["app_write", "app_restore", "app_reconcile", "app_move"].includes(tool) || (tool === "app_secrets" && args.secrets)) { + await this.links.nativeAppChanged({ libraryKey: this.libraryKey, workspace: this.workspace }); + if (tool === "app_move") await this.links.nativeAppChanged({ libraryKey: args.library === "team" ? "team" : this.privateKey, workspace: this.workspace }); + } const failed = payload.ok === false || (payload.deployment as { ok?: boolean } | undefined)?.ok === false; return { ...text(result), structuredContent: payload, isError: failed }; } @@ -169,7 +173,12 @@ export class ManagedArtifactService { : await this.env.LIBRARIES.getByName(target.libraryKey).draftRevision({ workspace: item.workspace, name: item.name }); } } + const workers = this.env.NATIVE_APPS + ? await this.nativeController().list({ owner: this.libraryKey, workspace: all ? undefined : this.workspace, offset }) + : null; + hasMore ||= workers?.next_offset != null; return { capabilities: { scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true }, workspace: this.workspace, + ...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}), artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null }; } } diff --git a/cloudflare/native-gallery-test-worker.ts b/cloudflare/native-gallery-test-worker.ts new file mode 100644 index 0000000..cd99389 --- /dev/null +++ b/cloudflare/native-gallery-test-worker.ts @@ -0,0 +1,24 @@ +export { ArtifactLibrary } from "./library"; +export { ScriptLibrary } from "./scripts"; +export { ArtifactLinks } from "./links"; +export { NativeApps } from "./native-worker/controller"; +import { ManagedArtifactService } from "./managed-service"; +import type { Env } from "./worker"; + +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + const owner = url.searchParams.get("owner") ?? "alice"; + const workspace = url.searchParams.get("workspace") ?? "default"; + const service = new ManagedArtifactService(env, workspace, owner, "alice", url.origin, url.origin, { user: { subject: owner, authority: "test" }, env }); + try { + if (url.pathname === "/subscribe") return service.subscribeGallery(url.searchParams.get("all") === "1"); + if (url.pathname === "/gallery") return Response.json(await service.gallery(url.searchParams.get("all") === "1", Number(url.searchParams.get("offset") ?? 0))); + if (url.pathname === "/seed-artifact") return Response.json(await env.LIBRARIES.getByName(owner).writeDraft({ workspace, name: "report", source: "source" })); + const input = await request.json() as { name: string; arguments: Record }; + return Response.json(await service.callTool(input.name, input.arguments)); + } catch (error) { + return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }); + } + }, +}; diff --git a/cloudflare/native-gallery.test.ts b/cloudflare/native-gallery.test.ts new file mode 100644 index 0000000..a3d70c1 --- /dev/null +++ b/cloudflare/native-gallery.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { Miniflare } from "miniflare"; +import type { GalleryData } from "../src/gallery/types"; +import { openGallerySubscription } from "../src/test/gallery-subscription"; + +test("gallery lists Worker drafts across workspaces with owner isolation, pagination and live updates", async () => { + const build = await Bun.build({ + entrypoints: [new URL("./native-gallery-test-worker.ts", import.meta.url).pathname], target: "browser", format: "esm", external: ["cloudflare:workers", "node:*", "fs", "fs/promises"], + plugins: [{ name: "draft-only-compiler", setup(build) { + // Persist real controller drafts without contacting a deployment provider. + build.onLoad({ filter: /\/cloudflare\/compiler\.ts$/ }, () => ({ loader: "ts", contents: ` + export function compileNativeWorker() { return { ok: false, diagnostics: [{ severity: "error", message: "Fixture draft" }] }; } + function unexpected() { throw new Error("Gallery reads must not compile or execute projects"); } + export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource, + unexpected as typecheckArtifactSource, unexpected as typecheckArtifactServerSource }; + ` })); + } }], + }); + if (!build.success) throw new Error(build.logs.join("\n")); + const bindings = { LIBRARIES: "ArtifactLibrary", SCRIPTS: "ScriptLibrary", LINKS: "ArtifactLinks", NATIVE_APPS: "NativeApps" }; + const runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: { + name: "native-gallery-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + manifest: { mainModule: "worker.js", modulesRoot: import.meta.dir, modules: { "worker.js": { type: "esm", contents: await build.outputs[0]!.text() } } }, + env: { + ...Object.fromEntries(Object.entries(bindings).map(([name, exportName]) => [name, { type: "durable-object", worker: "native-gallery-test", exportName }])), + NATIVE_CF_ACCOUNT_ID: { type: "text", value: "a".repeat(32) }, NATIVE_CF_API_TOKEN: { type: "text", value: "fixture-provider-secret" }, + }, + exports: Object.fromEntries(Object.values(bindings).map(name => [name, { type: "durable-object", storage: "sqlite" }])), + }, dev: {} }] } as ConstructorParameters[0]); + const origin = (await runtime.ready).origin; + async function request(path: string, input?: unknown) { + const response = await fetch(`${origin}${path}`, input === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }); + const result = await response.json() as any; + if (!response.ok) throw new Error(result.error); + return result; + } + const gallery = (params = ""): Promise => request(`/gallery?${params}`); + const write = (name: string, params = "") => request(`/tools?${params}`, { name: "app_write", arguments: { + name, source: "private draft source", manifest: { main: "worker.ts", compatibility_date: "2026-09-06" }, expected_revision: null, + } }); + const subscriptions = await Promise.all([ + openGallerySubscription(`${origin}/subscribe?all=1`), + openGallerySubscription(`${origin}/subscribe?workspace=other`), + openGallerySubscription(`${origin}/subscribe?owner=bob&all=1`), + openGallerySubscription(`${origin}/subscribe?owner=team&all=1`), + ]); + const [alice, otherWorkspace, bob, team] = subscriptions; + try { + await request("/seed-artifact", {}); + await write("example"); + expect(await alice!.changes()).toEqual(["changed"]); + expect(await otherWorkspace!.changes()).toEqual([]); + expect(await bob!.changes()).toEqual([]); + expect(await team!.changes()).toEqual([]); + await write("example", "workspace=other"); + await write("bob-only", "owner=bob"); + const all = await gallery("all=1"); + expect(all.artifacts.map(item => item.name)).toEqual(["report"]); + expect(all.workerApps?.map(item => [item.name, item.workspace, item.kind])).toEqual([["example", "default", "worker"], ["example", "other", "worker"]]); + expect(new Set(all.workerApps?.map(item => item.key)).size).toBe(2); + expect(all.nativeAppProviders).toEqual(["cloudflare"]); + expect(JSON.stringify(all)).not.toContain("private draft source"); + expect(JSON.stringify(all)).not.toContain("fixture-provider-secret"); + expect((await gallery()).workerApps?.map(item => item.workspace)).toEqual(["default"]); + expect((await gallery("owner=bob&all=1")).workerApps?.map(item => item.name)).toEqual(["bob-only"]); + for (let index = 0; index < 100; index++) await write(`worker-${String(index).padStart(3, "0")}`, "workspace=other"); + const first = await gallery("all=1"), second = await gallery("all=1&offset=100"); + expect(first.workerApps).toHaveLength(100); + expect(first.nextOffset).toBe(100); + expect(second.workerApps).toHaveLength(2); + expect(second.nextOffset).toBeNull(); + expect(new Set([...first.workerApps!, ...second.workerApps!].map(item => item.key)).size).toBe(102); + // Moving an app invalidates both libraries and removes the old owner's row. + await Promise.all(subscriptions.map(subscription => subscription.changes())); + await request("/tools", { name: "app_move", arguments: { name: "example", library: "team" } }); + expect(await alice!.changes()).toEqual(["changed"]); + expect(await team!.changes()).toEqual(["changed"]); + expect(await bob!.changes()).toEqual([]); + expect(await otherWorkspace!.changes()).toEqual([]); + expect((await gallery()).workerApps).toEqual([]); + expect((await gallery("owner=team&all=1")).workerApps?.map(item => item.name)).toEqual(["example"]); + } finally { + subscriptions.forEach(subscription => subscription.close()); + await runtime.dispose(); + } +}, 30000); diff --git a/cloudflare/native-worker/controller.ts b/cloudflare/native-worker/controller.ts index dd16c4d..b60f95f 100644 --- a/cloudflare/native-worker/controller.ts +++ b/cloudflare/native-worker/controller.ts @@ -68,10 +68,12 @@ export class NativeApps extends DurableObject { return { name: row.name, workspace: row.workspace, provider: row.provider, desired_revision: row.desired, active_revision: row.active, revision_token: createHash("sha256").update(row.draft).digest("hex"), status: row.status, stage: row.stage, error: row.error, updated_at: row.updated_at }; } - list(input: { owner: string; workspace: string; offset?: number }) { + list(input: { owner: string; workspace?: string; offset?: number }) { const offset = input.offset ?? 0; if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid app pagination"); - const rows = this.sql.exec("select * from native_apps where owner=? and workspace=? order by name limit 100 offset ?", input.owner, input.workspace, offset).toArray(); + const rows = input.workspace === undefined + ? this.sql.exec("select * from native_apps where owner=? order by name,workspace limit 100 offset ?", input.owner, offset).toArray() + : this.sql.exec("select * from native_apps where owner=? and workspace=? order by name limit 100 offset ?", input.owner, input.workspace, offset).toArray(); return { apps: rows.map(row => this.summary(row)), next_offset: rows.length === 100 ? offset + 100 : null, providers: nativeProviders(this.env) }; } read(input: Selection & { revision_id?: string }) { diff --git a/docs/native-workers.md b/docs/native-workers.md index a6b94ad..748a3b8 100644 --- a/docs/native-workers.md +++ b/docs/native-workers.md @@ -6,11 +6,11 @@ with [app backends](app-backends.md); for an endpoint or scheduled job with SQLi start with [scripts](scripts.md). Your administrator must configure a [deployment provider](#providers) before you -can deploy native apps. Then use **Worker apps** in the gallery, or ask your agent +can deploy native apps. Then choose **New Worker app** from the **+** menu beside Artifacts, or ask your agent to read `app_guide`. Native app URLs are private; they do not currently support public links. -Native apps deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the script SDK's `env.sql` wrapper. The gallery's **Worker apps** button and hosted MCP `app_*` tools use the same authenticated deployment controller. +Native apps deploy ordinary Cloudflare Worker modules with their default handler and named Durable Object exports. They use native KV, R2, D1, queue and DO bindings rather than the script SDK's `env.sql` wrapper. Worker apps appear alongside artifacts and scripts in the sidebar, with the same folders and search. Select one to edit its source, resources, triggers, deployments, and settings in the detail pane. The gallery and hosted MCP `app_*` tools use the same authenticated deployment controller. An app belongs to a private or team library and a workspace. Its UUID permanently owns provider resources; source revisions, export names, logical library moves and binding names do not change those identities. App URLs are private: diff --git a/e2e/gallery-creation.test.ts b/e2e/gallery-creation.test.ts new file mode 100644 index 0000000..8e55639 --- /dev/null +++ b/e2e/gallery-creation.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { chromium, type Page } from "playwright"; +import { createArtifactServer } from "../src/serve"; +import type { GalleryArtifact } from "../src/gallery/types"; + +async function screenshot(page: Page, name: string) { + const directory = process.env.GALLERY_SCREENSHOT_DIR; + if (!directory) return; + await mkdir(directory, { recursive: true }); + await page.screenshot({ path: join(directory, `${name}.png`), fullPage: true }); +} + +async function newArtifact(page: Page) { + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "New artifact", exact: true }).click(); +} + +test("local creation validates before saving, preserves existing names, and runs the starter", async () => { + const directory = await mkdtemp(join(tmpdir(), "gallery-creation-")); + const server = await createArtifactServer({ artifactsDir: directory, historyPath: join(directory, "history.sqlite"), gallery: true }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + page.setDefaultTimeout(10000); + await page.goto(server.url); + await newArtifact(page); + await page.getByLabel("Artifact name", { exact: true }).fill("hello"); + const editor = page.getByRole("textbox", { name: "hello.artifact.tsx", exact: true }); + const starter = await editor.innerText(); + await editor.fill("export default function Artifact() { return
{missing}
; }"); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Fix the source errors" }).waitFor(); + expect(await Bun.file(join(directory, "hello.artifact.tsx")).exists()).toBe(false); + expect(await editor.innerText()).toContain("{missing}"); + await editor.fill(starter); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("heading", { name: "hello", exact: true }).waitFor(); + const preview = page.frameLocator('iframe[title="Preview of hello"]'); + await preview.getByRole("button", { name: "Count: 0", exact: true }).click(); + await preview.getByRole("button", { name: "Count: 1", exact: true }).waitFor(); + expect(await page.getByTitle(`${directory}/hello`, { exact: true }).getAttribute("aria-current")).toBe("true"); + expect(await Bun.file(join(directory, "hello.artifact.tsx")).text()).toBe(starter); + await screenshot(page, "artifact-created"); + + await newArtifact(page); + await page.getByLabel("Artifact name", { exact: true }).fill("hello"); + await editor.fill(starter.replace("Hello", "Replacement")); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "already exists" }).waitFor(); + expect(await Bun.file(join(directory, "hello.artifact.tsx")).text()).toBe(starter); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Back to library", exact: true }).click(); + const create = page.getByRole("button", { name: "Create or import", exact: true }); + await create.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("menu").waitFor(); + expect(await page.getByRole("menuitem").allTextContents()).toEqual(["New artifact", "Import project"]); + expect((await create.boundingBox())!.width).toBeGreaterThanOrEqual(44); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await screenshot(page, "create-menu-mobile"); + await page.keyboard.press("Escape"); + await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label") === "Create or import"); + expect(await create.evaluate(element => element === document.activeElement)).toBe(true); + } finally { await browser.close(); server.stop(); await rm(directory, { recursive: true, force: true }); } +}, 60000); + +test("one creation menu preserves artifact drafts and retries saved invalid hosted revisions safely", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + if (!build.success) throw new Error(build.logs.join("\n")); + const js = await build.outputs[0]!.text(); + const artifacts: GalleryArtifact[] = [{ key: "existing", name: "existing", workspace: "default", working: true, versions: [] }]; + const calls: { name: string; arguments: Record }[] = []; + const token = "a".repeat(64); + let invalid = true; + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") return Response.json({ workspace: "default", artifacts, capabilities: { links: true, scripts: true, nativeApps: true } }); + if (url.pathname === "/api/source") return Response.json({ source: "export default function Artifact() { return
Hello
; }", project: { files: {}, dependencies: {}, lock: {} }, revision_token: token }); + if (url.pathname === "/gallery/preview") return new Response("

Preview

", { headers: { "content-type": "text/html" } }); + if (url.pathname === "/api/tools") { + const call = await request.json() as typeof calls[number]; + calls.push(call); + if (call.arguments.name === "existing") return Response.json({ error: "Project already exists; choose another name." }, { status: 409 }); + if (invalid) { + invalid = false; + return Response.json({ isError: true, structuredContent: { applied: true, ok: false, revision_token: token, diagnostics: [{ severity: "error", file: "artifact.artifact.tsx", message: "Invalid source" }] } }); + } + artifacts.push({ key: "created", name: String(call.arguments.name), workspace: "default", working: true, versions: [], access: "private" }); + return Response.json({ structuredContent: { ok: true, applied: true, revision_token: "b".repeat(64) } }); + } + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + page.setDefaultTimeout(5000); + await page.goto(server.url.href); + const create = page.getByRole("button", { name: "Create or import", exact: true }); + await create.click(); + await page.getByRole("menu").waitFor(); + expect(await page.getByRole("menuitem").allTextContents()).toEqual(["New artifact", "New script", "New Worker app", "Import project"]); + const heading = await page.locator(".library-heading").boundingBox(); + const search = await page.getByRole("searchbox").boundingBox(); + const plus = await create.boundingBox(); + expect(heading!.y + heading!.height).toBeLessThanOrEqual(search!.y); + expect(plus!.x).toBeGreaterThan(search!.x); + expect(plus!.x + plus!.width).toBeLessThanOrEqual(search!.x + search!.width + 4); + expect(await page.getByRole("button", { name: "New Worker app", exact: true }).count()).toBe(0); + expect(await page.getByText("Artifacts and scripts", { exact: true }).count()).toBe(0); + await screenshot(page, "create-menu-desktop"); + await page.keyboard.press("Escape"); + await page.setViewportSize({ width: 1000, height: 800 }); + await create.click(); + const tabletSearch = await page.getByRole("searchbox").boundingBox(); + const tabletPlus = await create.boundingBox(); + expect(tabletPlus!.x + tabletPlus!.width).toBeLessThanOrEqual(tabletSearch!.x + tabletSearch!.width + 4); + await screenshot(page, "create-menu-tablet"); + await page.getByRole("menuitem", { name: "New artifact", exact: true }).click(); + await page.getByLabel("Artifact name", { exact: true }).fill("unfinished"); + await page.getByRole("textbox", { name: "unfinished.artifact.tsx", exact: true }).fill("unfinished source"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await newArtifact(page); + expect(await page.getByLabel("Artifact name", { exact: true }).inputValue()).toBe("unfinished"); + expect(await page.getByRole("textbox", { name: "unfinished.artifact.tsx", exact: true }).innerText()).toBe("unfinished source"); + await page.getByLabel("Artifact name", { exact: true }).fill("existing"); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "already exists" }).waitFor(); + expect(calls[0]).toMatchObject({ name: "artifact_write", arguments: { name: "existing", expected_revision: null, access: "private" } }); + await page.getByLabel("Artifact name", { exact: true }).fill("new-artifact"); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Invalid draft saved" }).waitFor(); + expect(await page.getByLabel("Artifact name", { exact: true }).isDisabled()).toBe(true); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await newArtifact(page); + await page.getByRole("textbox", { name: "new-artifact.artifact.tsx", exact: true }).fill("corrected source"); + await page.getByRole("button", { name: "Create artifact", exact: true }).click(); + await page.getByRole("heading", { name: "new-artifact", exact: true }).waitFor(); + expect(calls[2]).toMatchObject({ name: "artifact_write", arguments: { name: "new-artifact", expected_revision: token, contents: "corrected source", access: "private" } }); + expect(await page.getByTitle("default/new-artifact", { exact: true }).getAttribute("aria-current")).toBe("true"); + await newArtifact(page); + expect(await page.getByLabel("Artifact name", { exact: true }).inputValue()).toBe(""); + } finally { await browser.close(); server.stop(true); } +}, 30000); diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts index 3b072e6..60b739f 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -47,12 +47,14 @@ test("gallery retains complete unsaved projects and new scripts and offers confl await page.getByRole("textbox", { name: "Project dependencies" }).fill('{"example":"latest"}'); await page.getByTitle("default/handler", { exact: true }).click(); await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unsaved script"); - await page.getByRole("button", { name: "New script", exact: true }).click(); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "New script", exact: true }).click(); await page.getByRole("textbox", { name: "Script name", exact: true }).fill("unfinished-handler"); await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unfinished new script"); await page.getByRole("button", { name: "Cancel", exact: true }).click(); expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unsaved script"); - await page.getByRole("button", { name: "New script", exact: true }).click(); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "New script", exact: true }).click(); expect(await page.getByRole("textbox", { name: "Script name", exact: true }).inputValue()).toBe("unfinished-handler"); expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unfinished new script"); await page.getByTitle("default/report", { exact: true }).click(); diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts index 90bf238..c3c4d28 100644 --- a/e2e/gallery-native-apps.test.ts +++ b/e2e/gallery-native-apps.test.ts @@ -2,6 +2,116 @@ import { expect, test } from "bun:test"; import { chromium } from "playwright"; import { galleryHtml } from "../src/gallery/server"; +test("Workers share the library folders, search, selection and mobile navigation with artifacts and scripts", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + if (!build.success) throw new Error(build.logs.join("\n")); + const js = await build.outputs[0]!.text(); + const calls: { workspace: string; name: string; arguments: Record }[] = []; + const workers = ["default", "Jobs"].map(workspace => ({ + key: JSON.stringify(["worker", workspace, "example"]), workspace, kind: "worker", name: "example", provider: "cloudflare", status: "active", revision_token: "a".repeat(64), + })); + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/gallery.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") return Response.json({ + workspace: "default", capabilities: { scripts: true, links: true, nativeApps: true }, nativeAppProviders: ["cloudflare"], + artifacts: url.searchParams.has("offset") ? [] : [ + { key: "report", name: "Report", workspace: "default", working: true, versions: [] }, + { key: "sync", kind: "script", name: "sync", workspace: "default", working: true, versions: [] }, + ], + workerApps: [workers[url.searchParams.has("offset") ? 1 : 0]], nextOffset: url.searchParams.has("offset") ? null : 100, + }); + if (url.pathname === "/api/source") return Response.json({ source: "export default function Report() { return

Report

; }", project: { files: {}, dependencies: {}, lock: {} } }); + if (url.pathname === "/gallery/preview") return new Response("

Report

", { headers: { "content-type": "text/html" } }); + if (url.pathname === "/api/tools") { + const call = await request.json() as { name: string; arguments: Record }; + const workspace = url.searchParams.get("workspace")!; + calls.push({ workspace, ...call }); + if (call.name === "app_read") return Response.json({ structuredContent: { + ...workers.find(worker => worker.workspace === workspace), source: `export default { fetch() { return new Response("${workspace}"); } };`, + manifest: { main: "worker.ts", compatibility_date: "2026-09-06" }, project: { files: {}, dependencies: {}, lock: {} }, + } }); + if (call.name === "app_history") return Response.json({ structuredContent: { revisions: [], next_offset: null } }); + throw new Error(`Unexpected tool ${call.name}`); + } + return new Response(galleryHtml(), { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.setDefaultTimeout(5000); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(server.url.href); + const library = page.getByRole("complementary"); + const worker = library.getByTitle("Jobs/example", { exact: true }); + const otherWorker = library.getByTitle("default/example", { exact: true }); + const report = library.getByTitle("default/Report", { exact: true }); + await worker.waitFor(); + expect(await otherWorker.isVisible()).toBe(true); + expect(await library.getByTitle("default/sync", { exact: true }).isVisible()).toBe(true); + const search = library.getByRole("searchbox", { name: "Search library" }); + await search.fill("Jobs"); + await otherWorker.waitFor({ state: "hidden" }); + expect(await worker.isVisible()).toBe(true); + expect(await report.count()).toBe(0); + await search.fill(""); + const filters = library.getByRole("group", { name: "Filter library" }); + await filters.getByRole("button", { name: "Workers", exact: true }).click(); + expect(await report.count()).toBe(0); + expect(await library.locator(".artifact-row").count()).toBe(2); + await filters.getByRole("button", { name: "All", exact: true }).click(); + // An artifact draft survives opening and closing a Worker in the same detail pane. + await report.click(); + await page.getByRole("tab", { name: "Source", exact: true }).click(); + const artifactSource = page.getByRole("textbox", { name: "Report.artifact.tsx", exact: true }); + await artifactSource.fill("export default function Report() { return

Unsaved report

; }"); + await worker.click(); + const panel = page.getByRole("region", { name: "Worker apps", exact: true }); + const source = panel.getByRole("textbox", { name: "worker.ts", exact: true }); + await source.waitFor(); + expect(await source.innerText()).toContain('"Jobs"'); + expect(await library.isVisible()).toBe(true); + expect(await worker.getAttribute("aria-current")).toBe("true"); + expect(await otherWorker.getAttribute("aria-current")).toBeNull(); + expect(await report.getAttribute("aria-current")).toBeNull(); + expect(await page.locator(".gallery-layout > .artifact-detail").count()).toBe(1); + expect(calls.filter(call => call.name === "app_read").at(-1)?.workspace).toBe("Jobs"); + if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/worker-shared-library.png`, fullPage: true }); + await source.fill('export default { fetch() { return new Response("unsaved worker"); } };'); + page.once("dialog", dialog => dialog.dismiss()); + await report.click(); + expect(await source.innerText()).toContain("unsaved worker"); + expect(new URL(page.url()).searchParams.get("app")).toBe("example"); + page.once("dialog", dialog => dialog.accept()); + await report.click(); + await artifactSource.waitFor(); + expect(await artifactSource.innerText()).toContain("Unsaved report"); + await page.goBack(); + await source.waitFor(); + expect(await source.innerText()).toContain('"Jobs"'); + await otherWorker.click(); + await page.waitForFunction(() => document.querySelector('.worker-workspace .cm-content')?.textContent?.includes('"default"')); + expect(new URL(page.url()).searchParams.get("workspace")).toBe("default"); + await page.reload(); + await source.waitFor(); + expect(await otherWorker.getAttribute("aria-current")).toBe("true"); + await page.setViewportSize({ width: 390, height: 844 }); + await source.fill('export default { fetch() { return new Response("mobile draft"); } };'); + await panel.getByRole("button", { name: "Back to library", exact: true }).click(); + await library.waitFor(); + expect(await otherWorker.evaluate(element => element === document.activeElement)).toBe(true); + await otherWorker.click(); + await panel.waitFor(); + expect(await source.innerText()).toContain("mobile draft"); + expect(await panel.getByRole("link", { name: "Open private app" }).isVisible()).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(calls.every(call => ["app_read", "app_history"].includes(call.name))).toBe(true); + expect(errors).toEqual([]); + } finally { await browser.close(); server.stop(true); } +}, 30000); + test("Worker gallery deploys drafts, manages revisions/secrets and preserves edit base across status refresh", async () => { const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], @@ -29,6 +139,8 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi return Response.json({ workspace: "test", artifacts: [], + workerApps: saved ? [{ ...saved, key: "worker-example", kind: "worker", workspace: "test" }] : [], + nativeAppProviders: ["cloudflare"], libraryScope: "private", capabilities: { links: true, nativeApps: true }, }); @@ -93,6 +205,9 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi return result({ ...saved, ok: true }); case "app_secrets": return result({ names: ["TOKEN"] }); + case "app_move": + saved = null; + return result({ ok: true }); case "app_restore": { if (failRestore) { failRestore = false; @@ -128,10 +243,12 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto(server.url.href); - await page.getByRole("button", { name: "Worker apps", exact: true }).click(); + const library = page.getByRole("complementary"); + expect(await page.locator(".header-actions").getByRole("button", { name: "Worker apps", exact: true }).count()).toBe(0); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "New Worker app", exact: true }).click(); const panel = page.getByRole("region", { name: "Worker apps", exact: true }); - await panel.getByText("No Worker apps saved.").waitFor(); - await panel.getByRole("button", { name: "New Worker app", exact: true }).click(); + expect(await library.isVisible()).toBe(true); await panel.getByLabel("Worker app name").fill("example"); await panel.getByRole("button", { name: "Continue to editor" }).click(); await panel.getByRole("tab", { name: "Settings", exact: true }).click(); @@ -167,6 +284,8 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi }); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); await panel.getByText("Worker app deployed", { exact: true }).waitFor(); + await library.getByTitle("test/example", { exact: true }).waitFor(); + expect(await library.getByTitle("test/example", { exact: true }).getAttribute("aria-current")).toBe("true"); expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); expect(await panel.getByRole("link", { name: "Open private app" }).getAttribute("href")).toBe( "/apps/example/?workspace=test", @@ -240,8 +359,17 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/native-apps.png`, fullPage: true }); - await panel.getByRole("button", { name: "Back to gallery", exact: true }).click(); + await panel.getByRole("button", { name: "Back to library", exact: true }).click(); + await library.waitFor(); + expect(await panel.isVisible()).toBe(false); + await library.getByTitle("test/example", { exact: true }).click(); + await panel.waitFor(); + await panel.getByRole("tab", { name: "Settings", exact: true }).click(); + await panel.getByRole("button", { name: "Move to team library", exact: true }).click(); + await library.waitFor(); + await library.getByTitle("test/example", { exact: true }).waitFor({ state: "hidden" }); expect(await panel.count()).toBe(0); + expect(new URL(page.url()).searchParams.get("view")).toBeNull(); expect(errors).toEqual([]); } finally { await browser.close(); @@ -267,6 +395,8 @@ test("Worker provider empty state has documentation and no creation editor", asy return Response.json({ workspace: "test", artifacts: [], + workerApps: [{ key: "unavailable-app", kind: "worker", workspace: "test", name: "unavailable-app", provider: "cloudflare", status: "active", revision_token: "a".repeat(64) }], + nativeAppProviders: [], libraryScope: "private", capabilities: { nativeApps: true }, }); @@ -298,7 +428,6 @@ test("Worker provider empty state has documentation and no creation editor", asy expect(await panel.getByRole("link", { name: "Worker setup documentation" }).getAttribute("href")).toBe( "https://github.com/sidequery/artifacts/blob/main/docs/native-workers.md", ); - expect(await panel.getByRole("button", { name: "New Worker app", exact: true }).isDisabled()).toBe(true); expect(await panel.getByLabel("Worker app name").count()).toBe(0); expect(await panel.locator(".cm-content").count()).toBe(0); await page.goto(`${server.url.href}?workspace=test&library=team&view=workers&app=unavailable-app&appTab=source`); diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts index b27673e..f5c600c 100644 --- a/e2e/gallery-scripts.test.ts +++ b/e2e/gallery-scripts.test.ts @@ -63,7 +63,8 @@ test("gallery edits scripts without execution and runs requests only on demand", await page.getByRole("button", { name: "Share", exact: true }).click(); await page.getByRole("button", { name: "Save link", exact: true }).click(); await page.getByRole("status").filter({ hasText: "Slug is already in use" }).waitFor(); - await page.getByRole("button", { name: "New script" }).click(); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "New script", exact: true }).click(); await page.getByRole("textbox", { name: "Script name", exact: true }).fill("new-handler"); await page.getByRole("textbox", { name: "Script slug" }).fill("new-handler"); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); @@ -82,7 +83,8 @@ test("gallery edits scripts without execution and runs requests only on demand", await page.reload(); await page.getByRole("tab", { name: "Preview", exact: true }).click(); await page.getByTitle("Preview of local").waitFor(); - expect(await page.getByRole("button", { name: "New script" }).count()).toBe(0); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + expect(await page.getByRole("menuitem", { name: "New script", exact: true }).count()).toBe(0); expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0); } finally { await browser.close(); server.stop(true); } }, 30000); diff --git a/e2e/gallery-transfer.test.ts b/e2e/gallery-transfer.test.ts index 168b135..1a622e7 100644 --- a/e2e/gallery-transfer.test.ts +++ b/e2e/gallery-transfer.test.ts @@ -16,7 +16,8 @@ test("gallery imports a fresh complete project and exports its saved snapshot th const page = await browser.newPage({ acceptDownloads: true, viewport: { width: 1440, height: 960 } }); await page.route("**/api/session", route => route.fulfill({ json: { authMode: "better-auth", user: { id: "test", name: "Test owner", email: "test@example.test" } } })); await page.goto(server.url); - await page.getByRole("button", { name: "Import project", exact: true }).click(); + await page.getByRole("button", { name: "Create or import", exact: true }).click(); + await page.getByRole("menuitem", { name: "Import project", exact: true }).click(); await page.getByLabel("Project archive", { exact: true }).setInputFiles({ name: "portable.artifact-project.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(archive)) }); await page.waitForFunction(() => (document.querySelector('[aria-label="Imported project name"]') as HTMLInputElement)?.value === "portable-import"); expect(await page.getByText("Includes backend source", { exact: false }).innerText()).toContain("1 helper files"); diff --git a/package.json b/package.json index bd7e1f8..8e58900 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "test:native-worker": "tsc -p scripts/native-worker/tsconfig.json --noEmit && bun test scripts/native-worker --timeout 30000", "native-worker:operator": "bun run scripts/native-worker/operator.ts", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/mcp-file-editor.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-creation.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/mcp-file-editor.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", "typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index be6b7b7..84ff665 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -4,7 +4,7 @@ import { ArtifactFolders } from "./folders"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; -import type { GalleryArtifact, GalleryData } from "./types"; +import type { GalleryArtifact, GalleryData, GalleryWorker } from "./types"; import { ExecutionControls } from "./execution-controls"; import { SecretControls } from "./secrets"; import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; @@ -15,14 +15,19 @@ import { MovePanel } from "./move"; import { SourceEditor } from "./source-editor"; import { DraftProvider, confirmLeavingDrafts, useDrafts } from "./drafts"; import { ProjectImportPanel } from "./project-transfer"; -import { NativeAppsPanel } from "./native-apps"; +import { NativeAppEditor, type NativeAppEditorHandle } from "./native-apps"; import { fontFaces } from "./brand"; import { themeStyles } from "./theme"; import { subscribeGallery } from "./subscription"; type Scope = "current" | "all"; type DetailTab = "preview" | "source" | "schedule" | "requests" | "settings"; -type KindFilter = "all" | "artifact" | "script"; +type KindFilter = "all" | "artifact" | "script" | "worker"; +type WorkerSelection = { workspace: string; name: string | null }; +function workerSelection(): WorkerSelection | null { + const params = new URLSearchParams(window.location.search); + return params.get("view") === "workers" ? { workspace: params.get("workspace") ?? "default", name: params.get("app") } : null; +} type SourceState = | { status: "idle"; text: ""; error: "" } | { status: "loading"; text: ""; error: "" } @@ -64,7 +69,10 @@ const styles = ` } .gallery-app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; } .app-header { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 8px 16px; flex-shrink: 0; } - .wordmark { display: flex; align-items: center; gap: 10px; width: 224px; flex-shrink: 0; font-size: 14px; font-weight: 600; letter-spacing: -.3px; } + .library-heading { display: flex; align-items: center; justify-content: space-between; width: 224px; flex-shrink: 0; } + .wordmark { font-size: 14px; font-weight: 600; letter-spacing: -.3px; } + .create-item { width: 32px; padding: 0; color: var(--muted); font-size: 22px; font-weight: 400; } + .create-item[data-popup-open] { background: var(--selected); color: var(--text); } .theme-toggle { color: var(--muted); white-space: nowrap; } .theme-icon { display: block; width: 15px; height: 15px; border-radius: 50%; } .theme-icon.moon { box-shadow: inset 5px -3px 0 0 currentColor; transform: rotate(-15deg); } @@ -74,8 +82,6 @@ const styles = ` .header-actions { display: flex; gap: 8px; align-items: center; margin-left: auto; } .header-actions .select-control { max-width: 180px; } .header-actions select { border-color: transparent; background: transparent; } - .new-script { border-color: var(--line); white-space: nowrap; } - .new-script span { color: inherit; } .account { display: flex; align-items: center; gap: 8px; margin-left: 8px; } .account-name { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } .gallery-layout { display: grid; grid-template-columns: 256px minmax(0, 1fr); flex: 1; min-height: 0; } @@ -105,11 +111,7 @@ const styles = ` .artifact-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; } .row-kind { flex-shrink: 0; color: var(--subtle); font-size: 11px; } .workspace-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtle); font-size: 11px; } - .library-footer:empty { display: none; } - .library-footer { padding: 8px 12px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; } - .library-footer details { margin-top: 4px; } - .library-footer summary { color: var(--muted); min-height: 24px; padding: 2px 0; } - .library-footer p { margin: 6px 0; font-size: 12px; } + .library-count { padding: 8px 12px; color: var(--subtle); font-size: 11px; } .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; margin: 0 6px 6px 0; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } .detail-header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 10px 16px; flex-shrink: 0; flex-wrap: nowrap; border-bottom: 1px solid var(--line); } .detail-title { min-width: 0; flex: 1; } @@ -144,7 +146,7 @@ const styles = ` .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } .source-actions p { margin: 0; color: var(--muted); } - .gallery-app button.primary-action, .gallery-app .new-script { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } + .gallery-app button.primary-action { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } button.primary-action:disabled { opacity: .45; } .deployment-status { margin: 6px 0 0; color: var(--muted); } .save-feedback { padding: 8px 16px; flex-shrink: 0; max-height: 30vh; overflow: auto; } @@ -216,12 +218,12 @@ const styles = ` button:hover:not(:disabled), .download-link:hover { background: var(--raised); } .gallery-app button.primary-action:hover:not(:disabled) { background: var(--primary-hover); } .artifact-row[aria-current="true"]:hover { background: var(--selected); } - .gallery-app .new-script:hover { background: var(--primary-hover); } + .create-item:hover { background: var(--raised); color: var(--text); } .view-control button:hover:not([data-active]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } } @media (max-width: 1100px) { .gallery-layout { grid-template-columns: 232px minmax(0, 1fr); } - .wordmark { width: 200px; } + .library-heading { width: 200px; } .detail-header { gap: 8px; padding-inline: 12px; } .detail-actions { max-width: 300px; } .revision-control > span:not(.select-control) { display: none; } @@ -229,7 +231,8 @@ const styles = ` } @media (max-width: 760px) { .app-header { min-height: 52px; padding: 6px 12px; gap: 10px; } - .wordmark { font-size: 14px; width: auto; } + .library-heading { width: auto; flex: 1; gap: 16px; } + .create-item { width: 44px; height: 44px; } .app-header { flex-wrap: wrap; } .header-actions { flex-wrap: wrap; } .artifact-detail { margin: 0 4px 4px; } @@ -237,7 +240,6 @@ const styles = ` .header-actions { gap: 4px; } .header-actions .select-control { max-width: 130px; } .account { margin-left: 0; } - .new-script { font-size: 12px; } .gallery-layout { display: flex; flex: 1; } .library-panel, .artifact-detail { width: 100%; flex: 1; } .library-panel { border-right: 0; } @@ -357,8 +359,13 @@ function App() { try { localStorage.setItem("artifacts-theme", theme); } catch { /* The theme still works when storage is unavailable. */ } }, [theme]); const [remixing, setRemixing] = useState(false); - const [creatingScript, setCreatingScript] = useState(false); - const [showNativeApps, setShowNativeApps] = useState(() => new URLSearchParams(window.location.search).get("view") === "workers"); + const [creatingKind, setCreatingKind] = useState<"artifact" | "script" | null>(null); + const [selectedWorker, setSelectedWorker] = useState(workerSelection); + const showNativeApps = selectedWorker !== null; + const workerEditor = useRef(null); + // Selecting another item resets its editor; saving a new app keeps its feedback and draft state. + const workerEditorKey = useRef(0); + const navigationUrl = useRef(window.location.href); const scope: Scope = "all"; const [query, setQuery] = useState(""); const [kindFilter, setKindFilter] = useState("all"); @@ -366,7 +373,7 @@ function App() { const [showMove, setShowMove] = useState(false); const [showImport, setShowImport] = useState(false); const [sourceVisited, setSourceVisited] = useState(null); - const [mobileDetail, setMobileDetail] = useState(false); + const [mobileDetail, setMobileDetail] = useState(() => workerSelection() !== null); const [narrowLayout, setNarrowLayout] = useState(() => window.matchMedia("(max-width: 760px)").matches); const detailHeading = useRef(null); const libraryPanel = useRef(null); @@ -390,7 +397,6 @@ function App() { const [signingOut, setSigningOut] = useState(false); const [accountError, setAccountError] = useState(""); const createdRemix = useRef<{name:string;workspace:string;kind:string} | null>(null); - const createdScriptName = useRef(null); const galleryController = useRef(null); const galleryRequest = useRef(0); const sourceRequest = useRef(0); @@ -421,6 +427,7 @@ function App() { let offset = 0; let payload: GalleryData; const artifacts = new Map(); + const workerApps = new Map(); do { const response = await fetch(galleryUrl(scope, offset), { signal: controller.signal, @@ -435,7 +442,8 @@ function App() { const versions = new Map([...(previous?.versions ?? []), ...artifact.versions].map(version => [version.id, version])); artifacts.set(artifact.key, { ...artifact, working: artifact.working || !!previous?.working, versions: [...versions.values()] }); } - payload = { ...page, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)) }; + for (const worker of page.workerApps ?? []) workerApps.set(worker.key, worker); + payload = { ...page, workerApps: [...workerApps.values()], artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)) }; if (page.nextOffset === undefined || page.nextOffset === null) break; if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) throw new Error("Invalid gallery pagination."); offset = page.nextOffset; @@ -449,13 +457,10 @@ function App() { return before && JSON.stringify(before) === JSON.stringify(item) ? before : item; }) }; }); - const created = createdScriptName.current; - createdScriptName.current = null; const remix = createdRemix.current; createdRemix.current = null; setSelectedKey((current) => { if (remix) return payload.artifacts.find(artifact => (artifact.kind ?? "artifact") === remix.kind && artifact.name === remix.name && artifact.workspace === remix.workspace)?.key ?? current; - if (created) return payload.artifacts.find(artifact => artifact.kind === "script" && artifact.name === created)?.key ?? current; if (current && payload.artifacts.some((artifact) => artifact.key === current)) return current; const selection = new URLSearchParams(window.location.search); return payload.artifacts.find(item => item.name === selection.get("name") && item.workspace === (selection.get("workspace") ?? payload.workspace) && (item.kind ?? "artifact") === (selection.get("kind") ?? "artifact"))?.key ?? payload.artifacts[0]?.key ?? null; @@ -496,13 +501,15 @@ function App() { return () => controller.abort(); }, []); + const libraryItems = useMemo(() => [...(gallery?.artifacts ?? []), ...(gallery?.workerApps ?? [])] + .sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), [gallery]); const filteredArtifacts = useMemo(() => { const normalized = query.trim().toLocaleLowerCase(); - return (gallery?.artifacts ?? []).filter((artifact) => + return libraryItems.filter((artifact) => (kindFilter === "all" || (artifact.kind ?? "artifact") === kindFilter) && (!normalized || `${artifact.name}\n${artifact.workspace}`.toLocaleLowerCase().includes(normalized)), ); - }, [gallery, query, kindFilter]); + }, [libraryItems, query, kindFilter]); const selectedArtifact = useMemo( () => gallery?.artifacts.find((artifact) => artifact.key === selectedKey) ?? null, @@ -636,18 +643,27 @@ function App() { }, [previewUrl, activeTab]); const selectArtifact = (artifact: GalleryArtifact) => { + const leavingWorker = showNativeApps; + if (!leaveWorker()) return; if (narrowLayout && !mobileDetail) setPreviewLoading(true); setMobileDetail(true); setShowLinks(false); setShowMove(false); - setCreatingScript(false); + setCreatingKind(null); setRemixing(false); setSelectedKey(artifact.key); setSelectedVersion(artifact.working ? WORKING_VERSION : [...artifact.versions].sort((a, b) => b.revision - a.revision)[0]?.id ?? null); + const url = new URL(window.location.href); + url.searchParams.set("name", artifact.name); + url.searchParams.set("workspace", artifact.workspace); + url.searchParams.set("kind", artifact.kind ?? "artifact"); + if (leavingWorker) window.history.replaceState(null, "", url); + else window.history.pushState(null, "", url); + navigationUrl.current = url.href; }; const signOut = async () => { - if (!confirmLeavingDrafts(drafts)) return; + if (!workerCanLeave() || !confirmLeavingDrafts(drafts)) return; setSigningOut(true); setAccountError(""); try { @@ -660,25 +676,83 @@ function App() { } }; - function workerDestination(open: boolean) { - if (open === showNativeApps) return; + function workerCanLeave() { return workerEditor.current?.canLeave() ?? true; } + function startCreating(kind: "artifact" | "script") { + if (!leaveWorker()) return; + setCreatingKind(kind); + setTab("source"); + setShowImport(false); + setRemixing(false); + setShowLinks(false); + setShowMove(false); + setMobileDetail(true); + } + async function createdItem(name: string, kind: "artifact" | "script") { + createdRemix.current = { name, kind, workspace: gallery?.workspace ?? "default" }; + await loadGallery(); + setCreatingKind(null); + setSelectedVersion("working"); + setTab(kind === "artifact" ? "preview" : "source"); + setQuery(""); + setKindFilter("all"); + } + function leaveWorker() { + if (!showNativeApps) return true; + if (!workerCanLeave()) return false; + const url = new URL(window.location.href); + url.searchParams.delete("view"); url.searchParams.delete("app"); url.searchParams.delete("appTab"); + window.history.pushState(null, "", url); + navigationUrl.current = url.href; + setSelectedWorker(null); + return true; + } + function selectWorker(selection: WorkerSelection) { + if (selection.name === selectedWorker?.name && selection.workspace === selectedWorker?.workspace) { + setMobileDetail(true); + return; + } + if (!workerCanLeave()) return; + workerEditorKey.current++; const url = new URL(window.location.href); - if (open) url.searchParams.set("view", "workers"); - else { url.searchParams.delete("view"); url.searchParams.delete("app"); url.searchParams.delete("appTab"); } + url.searchParams.set("view", "workers"); + url.searchParams.set("workspace", selection.workspace); + url.searchParams.delete("name"); url.searchParams.delete("kind"); + if (selection.name) url.searchParams.set("app", selection.name); else url.searchParams.delete("app"); + url.searchParams.set("appTab", "source"); window.history.pushState(null, "", url); - setShowNativeApps(open); + navigationUrl.current = url.href; + setSelectedWorker(selection); + setCreatingKind(null); + setShowImport(false); + setMobileDetail(true); } useEffect(() => { - // Worker apps can restore the URL when browser navigation would discard a draft. - const navigate = () => window.setTimeout(() => setShowNativeApps(new URLSearchParams(window.location.search).get("view") === "workers"), 0); + const navigate = () => { + const next = workerSelection(); + const changingItem = next?.name !== selectedWorker?.name || next?.workspace !== selectedWorker?.workspace; + if (changingItem && !workerCanLeave()) { + window.history.pushState(null, "", navigationUrl.current); + return; + } + if (changingItem) workerEditorKey.current++; + navigationUrl.current = window.location.href; + setSelectedWorker(next); + if (next) setMobileDetail(true); + else { + const params = new URLSearchParams(window.location.search); + const item = gallery?.artifacts.find(item => item.name === params.get("name") && item.workspace === params.get("workspace") && (item.kind ?? "artifact") === (params.get("kind") ?? "artifact")); + if (item) setSelectedKey(item.key); + } + }; window.addEventListener("popstate", navigate); return () => window.removeEventListener("popstate", navigate); - }, []); + }, [selectedWorker, gallery]); useEffect(() => { if (showNativeApps) return; const url = new URL(window.location.href); url.searchParams.set("tab", activeTab); window.history.replaceState(null, "", url); + navigationUrl.current = url.href; }, [activeTab, showNativeApps]); const downloadSource = async (event: React.MouseEvent) => { @@ -705,42 +779,49 @@ function App() {
- Artifacts +
+ Artifacts + + + + startCreating("artifact")}>New artifact + {gallery?.capabilities?.scripts ? startCreating("script")}>New script : null} + {gallery?.capabilities?.nativeApps ? selectWorker({ workspace: gallery.workspace, name: null })}>New Worker app : null} + { if (!leaveWorker()) return; setShowImport(true); setCreatingKind(null); setMobileDetail(true); }}>Import project + + +
{gallery?.workspace ? {gallery.workspace.split(/[\\/]/).filter(Boolean).at(-1) ?? gallery.workspace} : null}
- {gallery?.capabilities?.nativeApps ? : null} - {gallery?.libraryScope ? : null} - {gallery?.capabilities?.scripts ? : null} {user ?
{user.name}
: null}
- {showNativeApps && gallery ? workerDestination(false)} /> : null} {galleryError && gallery ?

Refresh failed: {galleryError}. Showing the last loaded files.

: null} {accountError ?

{accountError}

: null} -
diff --git a/src/gallery/folders.tsx b/src/gallery/folders.tsx index d551000..f9b6d87 100644 --- a/src/gallery/folders.tsx +++ b/src/gallery/folders.tsx @@ -1,16 +1,16 @@ import { useState, type ReactNode } from "react"; -import type { GalleryArtifact } from "./types"; +import type { GalleryItem } from "./types"; -type Folder = { path: string; name: string; folders: Map; artifacts: GalleryArtifact[] }; +type Folder = { path: string; name: string; folders: Map>; artifacts: T[] }; /** Trim a shared local parent, retaining the actual containing folder names. */ -export function artifactFolders(artifacts: GalleryArtifact[], workspaces: string[]) { +export function artifactFolders(artifacts: T[], workspaces: string[]) { const paths = workspaces.map(path => path.replaceAll("\\", "/").split("/").filter(Boolean)); let common = 0; if (workspaces.every(path => path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path))) { while (paths.length && paths.every(parts => common < parts.length - 1 && parts[common] === paths[0]![common])) common++; } - const root: Folder = { path: "", name: "", folders: new Map(), artifacts: [] }; + const root: Folder = { path: "", name: "", folders: new Map(), artifacts: [] }; for (const artifact of artifacts) { const parts = artifact.workspace === "default" ? [] : artifact.workspace.replaceAll("\\", "/").split("/").filter(Boolean).slice(common); let folder = root; @@ -24,13 +24,13 @@ export function artifactFolders(artifacts: GalleryArtifact[], workspaces: string return root; } -export function ArtifactFolders({ artifacts, workspaces, searching, renderArtifact }: { - artifacts: GalleryArtifact[]; workspaces: string[]; searching: boolean; - renderArtifact: (artifact: GalleryArtifact, depth: number) => ReactNode; +export function ArtifactFolders({ artifacts, workspaces, searching, renderArtifact }: { + artifacts: T[]; workspaces: string[]; searching: boolean; + renderArtifact: (artifact: T, depth: number) => ReactNode; }) { const [collapsed, setCollapsed] = useState>(() => new Set()); const root = artifactFolders(artifacts, workspaces); - function contents(folder: Folder, depth: number): ReactNode { + function contents(folder: Folder, depth: number): ReactNode { return <> {[...folder.folders.values()].sort((a, b) => a.name.localeCompare(b.name)).map(child => { const expanded = searching || !collapsed.has(child.path); diff --git a/src/gallery/hosted.tsx b/src/gallery/hosted.tsx index 1b1ff72..14fbb77 100644 --- a/src/gallery/hosted.tsx +++ b/src/gallery/hosted.tsx @@ -7,6 +7,7 @@ import type { GalleryArtifact } from "./types"; import { Select } from "./select"; import type { Diagnostic } from "../diagnostics"; import type { SourceLocation } from "./source-editor"; +import { PROJECT_ARCHIVE_FORMAT } from "../project-archive-contract"; export type ScriptView = "source" | "requests" | "schedule" | "settings"; type ToolResult = { error?: string; isError?: boolean; content?: { type: string; text?: string }[]; structuredContent?: unknown }; @@ -107,6 +108,19 @@ function ConflictActions({ sourceUrl, dirty, onReload }: { sourceUrl: string; di ; } const initialSource = `export default {\n async fetch(request, env, ctx) {\n return Response.json({ message: "Hello" });\n },\n} satisfies ExportedHandler;\n`; +const initialArtifactSource = `import { Button, H1, Stack, Text, useArtifactState } from "sidequery/artifacts"; + +export default function Artifact() { + const [count, setCount] = useArtifactState("count", 0); + return ( + +

Hello

+ Your artifact is ready to edit. + +
+ ); +} +`; export function AgentOnboarding({ workspace }: { workspace: string }) { const [status, setStatus] = useState(""); const url = new URL("/mcp", window.location.origin); @@ -120,7 +134,7 @@ export function AgentOnboarding({ workspace }: { workspace: string }) { } return

Create your first artifact

-

Connect your agent to this deployment’s MCP URL, then send the starter prompt. You can also create an HTTP handler with New script.

+

Choose New artifact from the + menu beside Artifacts, or connect your agent using the MCP URL below.

Connect agent @@ -231,9 +245,14 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved,
; } -export function ArtifactSourcePanel({ artifact, version, sourceUrl, onSaved }: { artifact: GalleryArtifact; version: string; sourceUrl: string; onSaved: () => Promise }) { - const buffer = useProjectDraft(`${artifact.key}:${version}`); +export function ArtifactSourcePanel({ artifact, workspace = artifact?.workspace ?? "default", hosted = true, version = "working", sourceUrl, onSaved, onCancel }: { + artifact?: GalleryArtifact; workspace?: string; hosted?: boolean; version?: string; sourceUrl?: string; + onSaved: (name: string) => Promise; onCancel?: () => void; +}) { + const buffer = useProjectDraft(artifact ? `${artifact.key}:${version}` : `new-artifact:${workspace}`); const snapshot = buffer.draft?.content; + const name = artifact?.name ?? snapshot?.name ?? ""; + const filename = name.trim() || "artifact"; const [projectLoad, setProjectLoad] = useState(0); const [location, setLocation] = useState(); const [projectValid, setProjectValid] = useState(true); @@ -241,34 +260,41 @@ export function ArtifactSourcePanel({ artifact, version, sourceUrl, onSaved }: { const [error, setError] = useState(""); const [conflict, setConflict] = useState(false); const [status, setStatus] = useState(""); - const historical = version !== "working"; + const historical = !!artifact && version !== "working"; useEffect(() => { - if (snapshot && (historical || buffer.draft?.dirty || !artifact.draftRevision || snapshot.revision_token === artifact.draftRevision)) return; + if (snapshot && (!artifact || historical || buffer.draft?.dirty || !artifact.draftRevision || snapshot.revision_token === artifact.draftRevision)) return; + if (!artifact) { buffer.reset({ source: initialArtifactSource, project: emptyEditableProject(), name: "" }); return; } + if (!sourceUrl) return; const controller = new AbortController(); setError(""); setStatus(""); setProjectValid(true); void loadSourceSnapshot(sourceUrl, controller.signal).then(value => { if (!controller.signal.aborted) buffer.reset(value, true); }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); return () => controller.abort(); - }, [sourceUrl, artifact.draftRevision, buffer.reset]); + }, [sourceUrl, artifact?.draftRevision, buffer.reset]); const loaded = !!snapshot; async function save() { - if (!loaded || !snapshot) return; + if (!loaded || !snapshot || !name.trim()) return; setBusy(true); setError(""); setStatus(""); try { - const result = await galleryTool(artifact.workspace, historical ? "artifact_restore" : "artifact_write", historical ? { version_id: version } : { name: artifact.name, contents: snapshot.source, server: snapshot.server_source ?? null, project: snapshot.project, expected_revision: snapshot.revision_token }) as MutationResult; + const response = !hosted && !artifact + ? await galleryTool(workspace, "artifact_import", { new_name: name.trim(), validate: true, archive: { format: PROJECT_ARCHIVE_FORMAT, version: 1, kind: "artifact", name: name.trim(), source: snapshot.source, server_source: snapshot.server_source ?? null, project: { ...snapshot.project, lock: {} } } }) + : await galleryTool(workspace, historical ? "artifact_restore" : "artifact_write", historical ? { version_id: version } : { name: name.trim(), contents: snapshot.source, server: snapshot.server_source ?? null, project: snapshot.project, expected_revision: snapshot.revision_token ?? (artifact ? undefined : null), ...(!artifact ? { access: "private" } : {}) }); + const result = (typeof response === "string" ? JSON.parse(response) : response) as MutationResult; buffer.saved(result.revision_token); setConflict(false); - if (historical) buffer.invalidateClean(`${artifact.key}:working`); - await onSaved(); setStatus(historical ? "Revision restored and deployed" : "Artifact saved and deployed"); + if (historical) buffer.invalidateClean(`${artifact!.key}:working`); + if (!artifact) buffer.clear(); + await onSaved(name.trim()); setStatus(historical ? "Revision restored and deployed" : "Artifact saved and deployed"); } catch (error) { if (error instanceof GalleryToolError) { setConflict(error.status === 409); - if (error.result?.applied) { buffer.saved(error.result.revision_token); await onSaved(); } + if (error.result?.applied) { buffer.saved(error.result.revision_token); if (artifact) await onSaved(name.trim()); } } setError(error); } finally { setBusy(false); } } return
{ event.preventDefault(); if (!busy && loaded && projectValid) void save(); }}> - setLocation({ ...diagnostic, file: diagnostic.file!, request: (location?.request ?? 0) + 1 })} /> - {conflict ? { buffer.reset(snapshot); setConflict(false); setError(""); setProjectLoad(value => value + 1); }} /> : null} - {!snapshot ?

Loading source…

: buffer.update({project})} onEntryChange={(id, source) => buffer.update(id === "server" ? { server_source: source } : { source })} dependencyText={buffer.draft!.dependencyText} onDependencyTextChange={text => buffer.update({}, text)} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} />} -
{historical ? Read-only revision : null}{buffer.draft?.dirty ? Unsaved changes : null}{status ?

{status}

: null}
+ setLocation({ ...diagnostic, file: diagnostic.file!, request: (location?.request ?? 0) + 1 })} /> + {conflict && sourceUrl ? { buffer.reset(snapshot); setConflict(false); setError(""); setProjectLoad(value => value + 1); }} /> : null} + {!artifact ?
: null} + {!snapshot ?

Loading source…

: buffer.update({project})} onEntryChange={(id, source) => buffer.update(id === "server" ? { server_source: source } : { source })} dependencyText={buffer.draft!.dependencyText} onDependencyTextChange={text => buffer.update({}, text)} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} />} +
{historical ? Read-only revision : null}{buffer.draft?.dirty ? Unsaved changes : null}{onCancel ? : null}{status ?

{status}

: null}
; } diff --git a/src/gallery/native-apps.tsx b/src/gallery/native-apps.tsx index c6ee5af..fa77cff 100644 --- a/src/gallery/native-apps.tsx +++ b/src/gallery/native-apps.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; import { GalleryToolError, galleryTool } from "./hosted"; import { ProjectEditor, editableProject, emptyEditableProject, type EditableProject } from "./project-editor"; import { SecretControls } from "./secrets"; @@ -28,33 +28,38 @@ const initialManifest = { const initialSource = 'export default { fetch(request: Request) { return new Response("Hello from your Worker"); } };'; const decode = (value: unknown): T => (typeof value === "string" ? JSON.parse(value) : value) as T; -export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onClose: () => void }) { - const currentUrl = useRef(window.location.href); +export type NativeAppEditorHandle = { canLeave: () => boolean }; + +export function NativeAppEditor({ workspace, appName, providers, onNavigate, onSaved, onBack, hidden, ref }: { + workspace: string; appName: string | null; providers: string[]; + onNavigate: (name: string | null) => void; onSaved: () => Promise; onBack: () => void; + hidden: boolean; ref: Ref; +}) { + const heading = useRef(null); const [section, setSection] = useState(workerSection()); - const [creating, setCreating] = useState(false), + const [creating, setCreating] = useState(!appName), [editingNew, setEditingNew] = useState(false); - const [loaded, setLoaded] = useState(false), - [template, setTemplate] = useState("http"); + const [template, setTemplate] = useState("http"); function navigate(app: string | null, tab = section) { const url = new URL(window.location.href); url.searchParams.set("view", "workers"); + url.searchParams.set("workspace", workspace); if (app) { url.searchParams.set("app", app); url.searchParams.set("appTab", tab); } else { + url.searchParams.delete("view"); url.searchParams.delete("app"); url.searchParams.delete("appTab"); } window.history.pushState(null, "", url); - currentUrl.current = url.href; setSection(tab); + onNavigate(app); } - const [apps, setApps] = useState([]), - [providers, setProviders] = useState([]); const [selected, setSelected] = useState(null), [name, setName] = useState(""); const [baseRevision, setBaseRevision] = useState(null); - const [provider, setProvider] = useState(""), + const [provider, setProvider] = useState(providers[0] ?? ""), [source, setSource] = useState(initialSource); const [manifest, setManifest] = useState(JSON.stringify(initialManifest, null, 2)); const [project, setProject] = useState(emptyEditableProject), @@ -69,30 +74,18 @@ export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onC const [editorKey, setEditorKey] = useState(0); const library = new URLSearchParams(window.location.search).get("library") === "team" ? "team" : "private"; const allowLeave = () => !dirty || window.confirm("Discard unsaved Worker app changes?"); - async function loadList() { - let offset = 0; - const rows: App[] = []; - for (;;) { - const page = decode<{ apps: App[]; providers: string[]; next_offset: number | null }>( - await galleryTool(workspace, "app_list", { offset }), - ); - rows.push(...page.apps); - setProviders(page.providers); - setProvider((value) => value || page.providers[0] || ""); - if (page.next_offset === null) break; - if (page.next_offset <= offset) throw new Error("Invalid app pagination"); - offset = page.next_offset; + useImperativeHandle(ref, () => ({ canLeave: () => !busy && allowLeave() })); + useEffect(() => { + setSection(workerSection()); + if (appName && appName !== selected?.name) { + setSelected(null); + setDirty(false); + void perform(() => loadApp(appName)); } - setApps(rows); - } + }, [workspace, appName]); useEffect(() => { - void perform(async () => { - await loadList(); - setLoaded(true); - const app = new URLSearchParams(window.location.search).get("app"); - if (app) await loadApp(app); - }); - }, [workspace]); + if (!hidden) heading.current?.focus({ preventScroll: true }); + }, [hidden, appName]); useEffect(() => { if (!dirty) return; const listener = (event: BeforeUnloadEvent) => { @@ -103,33 +96,10 @@ export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onC return () => window.removeEventListener("beforeunload", listener); }, [dirty]); useEffect(() => { - const listener = () => { - const params = new URLSearchParams(window.location.search); - const app = params.get("app"); - const leavingWorkspace = params.get("view") !== "workers"; - if (!leavingWorkspace && app === selected?.name) { - currentUrl.current = window.location.href; - setSection(workerSection()); - return; - } - if (busy || !allowLeave()) { - window.history.pushState(null, "", currentUrl.current); - return; - } - currentUrl.current = window.location.href; - if (leavingWorkspace) return; - setCreating(false); - setEditingNew(false); - setSection(workerSection()); - if (app) void perform(() => loadApp(app)); - else { - setSelected(null); - setDirty(false); - } - }; - window.addEventListener("popstate", listener, true); - return () => window.removeEventListener("popstate", listener, true); - }, [selected, dirty, busy]); + const listener = () => setSection(workerSection()); + window.addEventListener("popstate", listener); + return () => window.removeEventListener("popstate", listener); + }, []); async function perform(action: () => Promise, editorMatchesDraft = false, preserveDraft = false) { setBusy(true); setError(""); @@ -145,7 +115,7 @@ export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onC navigate(name); } else if (preserveDraft) setSelected(error.result as App); else await loadApp(name); - await loadList(); + await onSaved(); } const diagnostics = error instanceof GalleryToolError ? error.result?.diagnostics : undefined; setError( @@ -187,25 +157,6 @@ export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onC setRevisions(revisions); setRestoreId(revisions[0]?.id ?? ""); } - function newApp() { - if (!allowLeave()) return; - navigate(null, "source"); - setCreating(true); - setEditingNew(false); - setSelected(null); - setBaseRevision(null); - setName(""); - setSource(initialSource); - setManifest(JSON.stringify(initialManifest, null, 2)); - setProject(emptyEditableProject()); - setDependencies("{}"); - setRevisions([]); - setProvider(providers[0] ?? ""); - setDirty(false); - setError(""); - setStatus(""); - setEditorKey((value) => value + 1); - } const appUrl = selected?.status === "active" ? (() => { @@ -229,237 +180,172 @@ export function NativeAppsPanel({ workspace, onClose }: { workspace: string; onC const providerAvailable = providers.includes(provider); const detail = (!!selected || editingNew) && providerAvailable; return ( -
} aria-label="Worker apps" - className="worker-workspace" - style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 24, minWidth: 0 }} + className="artifact-detail worker-workspace" + hidden={hidden} + value={section} + onValueChange={(value) => { + const tab = value as WorkerSection; + setSection(tab); + if (selected) navigate(selected.name, tab); + }} > -
-
-

{detail ? name : "Worker apps"}

-

Native Workers with persistent resources, queues, and scheduled triggers.

+
+ +
+

{appName || name || "New Worker app"}

- + {detail ? + {workerSections.map(tab => {tab[0]!.toUpperCase() + tab.slice(1)})} + : null} + {appUrl ? : null}
- {error ? ( -

- {error} -

- ) : null} - {status ?

{status}

: null} - {!loaded ?

Loading Worker apps…

: null} - {selected && !providerAvailable ? ( -

- {selected.name} uses {selected.provider}, which is not available on this deployment. Configure that provider - to edit or deploy this app. -

- ) : null} - {loaded && !providers.length ? ( -
-

Worker apps are unavailable

-

- No Worker app provider is available on this deployment. Configure a provider to create and deploy Workers. +

+ {error ? ( +

+ {error}

- - Worker setup documentation - -
- ) : null} - {!detail && !creating ? ( - <> -
- - + ) : null} + {status ?

{status}

: null} + {appName && !selected && busy ?

Loading Worker app…

: null} + {selected && !providerAvailable ? ( +

+ {selected.name} uses {selected.provider}, which is not available on this deployment. Configure that provider + to edit or deploy this app. +

+ ) : null} + {!providers.length ? ( +
+

Worker apps are unavailable

+

+ No Worker app provider is available on this deployment. Configure a provider to create and deploy Workers. +

+ + Worker setup documentation +
- {apps.length ? ( -
    - {apps.map((app) => ( -
  • - - -
- - ) : null} - {detail ? ( - <> -
-
+
+ + +
+ + ) : null} + {detail ? ( + <> +
+ {provider} + {selected ? ( + + Deployment: {selected.status.replaceAll("-", " ")} + {` · saved ${selected.revision_token.slice(0, 12)}`} + {selected.desired_revision ? ` · desired ${selected.desired_revision.slice(0, 12)}` : ""} + {selected.active_revision ? ` · active ${selected.active_revision.slice(0, 12)}` : ""} + + ) : ( + New draft + )} +
+ {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 ? "Worker app deployed" : "Worker app needs reconciliation"); + }, true); }} > - All Worker apps - - {provider} - {selected ? ( - - Deployment: {selected.status.replaceAll("-", " ")} - {` · saved ${selected.revision_token.slice(0, 12)}`} - {selected.desired_revision ? ` · desired ${selected.desired_revision.slice(0, 12)}` : ""} - {selected.active_revision ? ` · active ${selected.active_revision.slice(0, 12)}` : ""} - - ) : ( - New draft - )} - {appUrl ? ( - - Open private app - - ) : null} -
- {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 loadList(); - await loadApp(name); - navigate(name); - setStatus(result.ok ? "Worker app deployed" : "Worker app needs reconciliation"); - }, true); - }} - > - { - const tab = value as WorkerSection; - setSection(tab); - if (selected) navigate(selected.name, tab); - }} - > - - {workerSections.map((tab) => ( - - {tab[0]!.toUpperCase() + tab.slice(1)} - - ))} - - -
- - {dirty ? Unsaved changes : null} -
- - {section === "settings" && selected ? ( - <> -

Secret values

- { - await loadList(); - const saved = decode(await galleryTool(workspace, "app_read", { name })); - setSelected(saved); - }} - /> -

Library

- - - ) : null} - - ) : null} -
+
+ + {dirty ? Unsaved changes : null} +
+ + {section === "settings" && selected ? ( + <> +

Secret values

+ { + await onSaved(); + const saved = decode(await galleryTool(workspace, "app_read", { name })); + setSelected(saved); + }} + /> +

Library

+ + + ) : null} + + ) : null} + + ); } diff --git a/src/gallery/types.ts b/src/gallery/types.ts index d45f754..c2bd6c5 100644 --- a/src/gallery/types.ts +++ b/src/gallery/types.ts @@ -1,3 +1,5 @@ export type GalleryVersion = { id: string; revision: number; createdAt: string; reason: string; serveCount: number }; export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; -export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean } }; +export type GalleryWorker = { key: string; kind: "worker"; name: string; workspace: string; provider: string; status: string; revision_token: string }; +export type GalleryItem = GalleryArtifact | GalleryWorker; +export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean } }; diff --git a/src/serve.ts b/src/serve.ts index db5c22f..24f6d09 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -114,7 +114,9 @@ export async function createArtifactServer(opts: CreateArtifactServerOptions): P const service = new ArtifactService({ artifactsDir, env: { ...env, ARTIFACTS_HISTORY_DB: history.path } }); if (body.name === "artifact_import") { const archive = parseProjectArchive(body.arguments.archive, "artifact"); - const result = service.importProject(body.arguments.new_name as string, archive); + const result = body.arguments.validate === true + ? await service.createProject(body.arguments.new_name as string, archive) + : service.importProject(body.arguments.new_name as string, archive); return json({ structuredContent: result, isError: !result.ok }); } return json(service.remix(body.arguments as Parameters[0])); diff --git a/src/service.ts b/src/service.ts index 5fa0d5b..e97de26 100644 --- a/src/service.ts +++ b/src/service.ts @@ -23,7 +23,7 @@ import { compileArtifact, type CompileResult } from "./compile"; import { formatArtifactCheck, type Diagnostic } from "./diagnostics"; import { createHerdrClient, type HerdrClient, type PanePlacement } from "./herdr"; import { openArtifact, type OpenResult } from "./open"; -import { typecheckArtifact } from "./typecheck"; +import { typecheckArtifact, typecheckArtifactSource } from "./typecheck"; import { ArtifactHistory, historyPath, runtimeIdentity } from "./history"; export type ArtifactInfo = { @@ -214,10 +214,24 @@ export class ArtifactService { return join(this.artifactsDir, filename); } - importProject(newName: string, input: unknown) { + async createProject(newName: string, input: unknown) { + const archive = parseProjectArchive(input, "artifact"); + const require = createRequire(join(PLUGIN_ROOT, "package.json")); + const hostPackages = Object.fromEntries(["react", "react-dom", "react-router"].map(name => [name, require(`${name}/package.json`).version as string])); + const project = await resolveProject(archive.project, undefined, fetch, hostPackages); + return this.importProject(newName, { ...archive, project }, { validate: true }); + } + + importProject(newName: string, input: unknown, options: { validate?: boolean } = {}): WriteResult & { + applied: boolean; imported: boolean; name: string; kind: ProjectArchive["kind"]; error?: string; runtime_notice?: string; + } { const archive = parseProjectArchive(input); const path = archive.kind === "artifact" ? join(this.artifactsDir, ensureArtifactFileName(newName)) : this.scriptPath(newName); const name = archive.kind === "artifact" ? artifactIdFromFile(path) : basename(path, ".script.ts"); + // Creation validates before reserving a name so users can correct the draft + // and retry. Ordinary imports still preserve invalid source snapshots. + const checked = options.validate && archive.kind === "artifact" ? typecheckArtifactSource(path, archive.source, archive.project) : undefined; + if (checked?.length) return { ok: false, applied: false, imported: false, name, kind: archive.kind, path, check: formatArtifactCheck(checked), error: "Fix the source errors before creating the artifact.", diagnostics: checked.map(item => ({ ...item, file: item.file === path ? basename(path) : item.file })) }; const history = new ArtifactHistory(historyPath(this.env)); try { history.db.transaction(() => { @@ -236,7 +250,7 @@ export class ArtifactService { if (archive.kind === "artifact") history.capture({ workspace: this.artifactsDir, name, sourcePath: path, source: archive.source, server_source: archive.server_source, project: archive.project, runtime: runtimeIdentity(), reason: "import" }); } catch (error) { for (const file of created.reverse()) unlinkSync(file); throw error; } }).immediate(); - const diagnostics = archive.kind === "artifact" ? typecheckArtifact(path) : []; + const diagnostics = checked ?? (archive.kind === "artifact" ? typecheckArtifact(path) : []); return { ok: diagnostics.length === 0, applied: true, imported: true, name, kind: archive.kind, path, check: archive.kind === "artifact" ? formatArtifactCheck(diagnostics) : "Script source imported; a hosted runtime is required to validate and execute it.", diagnostics, ...(archive.kind === "script" || archive.server_source !== null ? { runtime_notice: "Filesystem previews do not execute server code or scripts. Deploy this archive in a hosted runtime to validate and run them." } : {}) }; } finally { history.close(); } diff --git a/src/typecheck.ts b/src/typecheck.ts index aaae590..2382e27 100644 --- a/src/typecheck.ts +++ b/src/typecheck.ts @@ -7,10 +7,15 @@ import { PLUGIN_ROOT, SDK_ENTRY } from "./paths"; import { relative, resolve } from "node:path"; import { loadBrowserPlugins } from "./plugins/browser"; import type { BrowserPlugins } from "./plugins/types"; +import type { ArtifactProject } from "../cloudflare/project"; export function typecheckArtifact(artifactPath: string, plugins: BrowserPlugins = loadBrowserPlugins()): Diagnostic[] { const source = readFileSync(artifactPath, "utf8"); const project = readLocalProject(artifactPath); + return typecheckArtifactSource(artifactPath, source, project, plugins); +} + +export function typecheckArtifactSource(artifactPath: string, source: string, project: ArtifactProject, plugins: BrowserPlugins = loadBrowserPlugins()): Diagnostic[] { const violations = projectDiagnostics(artifactPath, source, project, Object.keys(plugins.paths)); if (violations.length > 0) { return violations;