From 58c834c241b50ca6d7c1bb53972a5f6e13f5f842 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 2 Oct 2026 15:49:40 -0700 Subject: [PATCH] Clarify live revisions and navigate source diagnostics --- cloudflare/artifact-service.ts | 2 +- cloudflare/links.ts | 3 ++- cloudflare/managed-service.ts | 13 +++++++-- cloudflare/script-service.ts | 2 +- cloudflare/scripts.ts | 3 ++- cloudflare/service.ts | 6 ++--- cloudflare/worker.test.ts | 2 ++ e2e/gallery-drafts.test.ts | 8 +++--- e2e/gallery-navigation.test.ts | 4 +-- e2e/gallery-scripts.test.ts | 41 ++++++++++++++++++++++++++-- src/gallery/client.tsx | 13 ++++++++- src/gallery/hosted.tsx | 49 ++++++++++++++++++++++++---------- src/gallery/project-editor.tsx | 15 ++++++++--- src/gallery/source-editor.tsx | 12 ++++++++- src/gallery/types.ts | 2 +- 15 files changed, 138 insertions(+), 37 deletions(-) diff --git a/cloudflare/artifact-service.ts b/cloudflare/artifact-service.ts index de7ead3..e474e58 100644 --- a/cloudflare/artifact-service.ts +++ b/cloudflare/artifact-service.ts @@ -26,7 +26,7 @@ export class ArtifactService { async linkDetails(target: ArtifactTarget) { const link = await this.hosted?.links.find(target); - if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.publicOrigin ?? this.hosted!.origin}/${link.slug}` }; + if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.publicOrigin ?? this.hosted!.origin}/${link.slug}`, live: link.live, liveId: link.version_id ?? link.script_hash }; const pending = await this.hosted?.links.draft(target); return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {}; } diff --git a/cloudflare/links.ts b/cloudflare/links.ts index 6c3dca6..0b5768f 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -2,7 +2,8 @@ import { DurableObject } from "cloudflare:workers"; import { LibraryOwnership, type LibrarySelection, type OwnershipEnvironment } from "./ownership"; export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string }; -export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string }; +export type LiveRevision = { id: string; revision: number; revision_token: string }; +export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string; live?: LiveRevision }; export type ArtifactLink = ArtifactTarget & LinkUpdate & { slug: string; access: "private" | "public"; generation: number }; const reserved = new Set(["api", "mcp", "gallery", "health", "sign-in", "consent"]); export function validateSlug(value: unknown): string { diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index 375e7b6..05dd893 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -76,7 +76,7 @@ export class ManagedArtifactService { private async linkDetails(kind: "artifact" | "script", workspace: string, name: string) { const target = await this.links.admit({ libraryKey: this.libraryKey, workspace, kind, name }); const link = await this.links.find(target); - if (link) return { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}` }; + if (link) return { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}`, live: link.live, liveId: link.version_id ?? link.script_hash }; const pending = await this.links.draft(target); return pending.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {}; } @@ -98,7 +98,16 @@ export class ManagedArtifactService { } } } - for (const item of artifacts.values()) Object.assign(item, await this.linkDetails(item.kind!, item.workspace, item.name)); + for (const item of artifacts.values()) { + Object.assign(item, await this.linkDetails(item.kind!, item.workspace, item.name)); + if (item.working) { + const target = await this.links.admit({ libraryKey: this.libraryKey, workspace: item.workspace, kind: item.kind!, name: item.name }); + const snapshot = item.kind === "script" + ? await this.env.SCRIPTS.getByName(target.libraryKey).readRange({ workspace: item.workspace, name: item.name, end_line: 1 }) + : await this.env.LIBRARIES.getByName(target.libraryKey).preview({ workspace: item.workspace, name: item.name }); + item.draftRevision = snapshot.revision_token; + } + } return { capabilities: { scripts: true, links: true, moves: true }, workspace: this.workspace, 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/script-service.ts b/cloudflare/script-service.ts index a0dbbec..a61dbbc 100644 --- a/cloudflare/script-service.ts +++ b/cloudflare/script-service.ts @@ -114,7 +114,7 @@ export class CloudScriptService { if (validation.ok) { try { const activated = await scripts.activate({ ...destination, source_hash: mutation.source_hash, code: compiled.js }); - const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash }); + const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash, live: activated.live }); if (!link) { const { source, project: snapshotProject, ...summary } = mutation; const superseded = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index 51720ac..1a1aa46 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -129,7 +129,8 @@ export class ScriptLibrary extends DurableObject { this.sql.exec("insert into compiled_scripts(workspace,name,hash,code) values(?,?,?,?) on conflict(workspace,name,hash) do nothing",row.workspace,row.name,hash,input.code); this.sql.exec("update scripts set active_code=?,active_hash=? where workspace=? and name=?",input.code,hash,row.workspace,row.name); }); - return {ok:true,source_hash:row.source_hash,hash}; + const version = this.sql.exec<{id: string; revision: number}>("select id,revision from script_versions where workspace=? and name=? and source_hash=? order by revision desc limit 1",row.workspace,row.name,row.source_hash).toArray()[0]!; + return {ok:true,source_hash:row.source_hash,hash,live:{...version,revision_token:projectRevision(row.source,null,this.projectStorage.read(row.project))}}; } /** Internal execution lookup. Never return this response through management APIs. */ active(input: {workspace: string; name: string; hash?: string}) { diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 4002cd7..5caa2e3 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -1,5 +1,5 @@ import browserRuntime from "../dist/cloudflare/browser-runtime.json"; -import { assertProjectRevision, resolveProject, type ArtifactProject } from "./project"; +import { assertProjectRevision, emptyProject, projectRevision, resolveProject, type ArtifactProject } from "./project"; import type { DurableObjectStub, DurableObjectNamespace } from "@cloudflare/workers-types"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ArtifactLibrary, ArtifactEdit, CompiledArtifact } from "./library"; @@ -198,7 +198,7 @@ export class CloudArtifactService { const { _meta, ...details } = await this.preview(snapshot, compiled); const target = this.artifacts.target("artifact", mutation.name); if (generation != null && details.ok && "artifact" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { - const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId }); + const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId, live: { id: details.artifact.versionId, revision: details.artifact.revision, revision_token: projectRevision(mutation.source, mutation.server_source, mutation.project ?? emptyProject()) } }); if (!link) { const superseded = { ...payload, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; return { ...text(superseded, true), structuredContent: superseded }; @@ -302,7 +302,7 @@ export class CloudArtifactService { if (!compiled.ok) return text({ ok: false, diagnostics: compiled.diagnostics, check: formatArtifactCheck(compiled.diagnostics) }, true); const { _meta, ...preview } = await this.preview(snapshot, compiled); if (preview.ok && "artifact" in preview) { - const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.artifact.versionId }); + const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.artifact.versionId, live: { id: preview.artifact.versionId, revision: preview.artifact.revision, revision_token: projectRevision(snapshot.source, snapshot.server_source, snapshot.project ?? emptyProject()) } }); if (!link) return text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); } return { ...text({ ...preview, ...await this.artifacts.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 04d7f01..31f29ab 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -81,6 +81,8 @@ test("hosted conditional saves return conflicts and invalid drafts can be correc expect(stale.response.status).toBe(409); expect(stale.result.error).toContain("Project changed since"); expect(await (await fetch(`${origin}/${name}`)).text()).toBe("working"); + const gallery = await (await fetch(`${origin}/api/gallery`)).json() as { artifacts: { name: string; live: { revision: number; revision_token: string }; draftRevision: string }[] }; + expect(gallery.artifacts.find(item => item.name === name)).toMatchObject({ live: { revision: 1, revision_token: token }, draftRevision: newerToken }); const corrected = await write(good, newerToken); expect(corrected.result.isError).not.toBe(true); expect((await write(good, null)).response.status).toBe(409); diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts index 5698267..7024fa7 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -63,14 +63,14 @@ test("gallery retains complete unsaved projects and new scripts and offers confl expect(await page.getByRole("textbox", { name: "lib/value.ts", exact: true }).innerText()).toBe("unsaved helper"); await page.getByText("Dependencies (0)", { exact: true }).click(); expect(await page.getByRole("textbox", { name: "Project dependencies" }).inputValue()).toBe('{"example":"latest"}'); - expect(await page.getByRole("button", { name: "Save artifact", exact: true }).isEnabled()).toBe(false); + expect(await page.getByRole("button", { name: "Save and deploy", exact: true }).isEnabled()).toBe(false); await page.getByRole("textbox", { name: "Project dependencies" }).fill("{}"); await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("old"); - await page.getByRole("button", { name: "Restore revision", exact: true }).waitFor(); + await page.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor(); expect(await client.innerText()).toBe("historical client"); await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("working"); expect(await client.innerText()).toBe("unsaved client"); - await page.getByRole("button", { name: "Save artifact", exact: true }).click(); + await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("button", { name: "Compare saved project" }).waitFor(); expect(calls[0]!.arguments).toMatchObject({ contents: "unsaved client", server: "unsaved server", expected_revision: token, project: { files: { "lib/value.ts": "unsaved helper" }, dependencies: {} } }); await page.getByRole("button", { name: "Compare saved project" }).click(); @@ -80,7 +80,7 @@ test("gallery retains complete unsaved projects and new scripts and offers confl await page.getByRole("button", { name: "Reload saved project" }).click(); await page.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="report.artifact.tsx"]')?.textContent === "agent client"); await client.fill("merged client"); - await page.getByRole("button", { name: "Save artifact", exact: true }).click(); + await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("status").filter({ hasText: "Artifact saved" }).waitFor(); expect(calls.at(-1)!.arguments.expected_revision).toBe(latestToken); } finally { await browser.close(); server.stop(true); } diff --git a/e2e/gallery-navigation.test.ts b/e2e/gallery-navigation.test.ts index 4aa5797..e7d4c4c 100644 --- a/e2e/gallery-navigation.test.ts +++ b/e2e/gallery-navigation.test.ts @@ -123,7 +123,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati await noHorizontalOverflow(desktop); await screenshot(desktop, "gallery-desktop-source"); await desktop.getByRole("combobox", { name: "Version", exact: true }).selectOption("campaign-revision-1"); - await desktop.getByRole("button", { name: "Restore revision", exact: true }).waitFor(); + await desktop.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor(); await desktop.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="Campaign overview.artifact.tsx"]')?.textContent?.includes("Campaign revision 1")); expect(await source.getAttribute("aria-readonly")).toBe("true"); await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Preview", exact: true }).click(); @@ -165,7 +165,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati await mobileSource.waitFor(); await mobileSource.fill(sourceFor(false).replace("Current campaign", "Mobile unsaved campaign")); await mobileSource.press("ControlOrMeta+Home"); - for (const control of [mobile.getByRole("combobox", { name: "Version", exact: true }), mobile.getByRole("button", { name: "Save artifact", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) { + for (const control of [mobile.getByRole("combobox", { name: "Version", exact: true }), mobile.getByRole("button", { name: "Save and deploy", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) { await control.scrollIntoViewIfNeeded(); const bounds = (await control.boundingBox())!; expect(bounds.x).toBeGreaterThanOrEqual(0); diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts index a8aca2a..493d4b1 100644 --- a/e2e/gallery-scripts.test.ts +++ b/e2e/gallery-scripts.test.ts @@ -48,7 +48,7 @@ test("gallery edits scripts without execution and runs requests only on demand", await views.getByRole("button", { name: "Requests", exact: true }).click(); await views.getByRole("button", { name: "Source", exact: true }).click(); expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toContain('new Response("changed")'); - await page.getByRole("button", { name: "Save script", exact: true }).click(); + await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("status").filter({ hasText: "Script saved" }).waitFor(); expect(calls.map(call => call.name)).toEqual(["script_write"]); await views.getByRole("button", { name: "Requests", exact: true }).click(); @@ -66,7 +66,7 @@ test("gallery edits scripts without execution and runs requests only on demand", await page.getByRole("button", { name: "New script" }).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 script", exact: true }).click(); + await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("button", { name: "new-handler Script", exact: true }).waitFor(); expect(await page.getByRole("button", { name: "new-handler Script", exact: true }).getAttribute("aria-current")).toBe("true"); expect(calls.filter(call => call.name === "script_run")).toHaveLength(1); @@ -84,3 +84,40 @@ test("gallery edits scripts without execution and runs requests only on demand", expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0); } finally { await browser.close(); server.stop(true); } }, 30000); + +test("invalid saved drafts show live targets and clickable helper diagnostics", 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 artifact = { key: "handler", kind: "script", name: "handler", workspace: "default", working: true, versions: [], live: { id: "good", revision: 1, revision_token: "good" }, draftRevision: "good" }; + 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: [artifact], capabilities: { links: true, scripts: true } }); + if (url.pathname === "/api/source") return Response.json({ source: "export default {};", revision_token: "good", project: { files: { "lib/value.ts": "export const value = 1;\nexport const broken = missing;\n" }, dependencies: {}, lock: {} } }); + if (url.pathname === "/api/tools") { + artifact.draftRevision = "invalid"; + return Response.json({ isError: true, structuredContent: { applied: true, ok: false, revision_token: "invalid", diagnostics: [{ severity: "error", file: "lib/value.ts", line: 2, column: 23, message: "Cannot find name 'missing'." }] } }); + } + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(server.url.href); + await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("invalid source"); + await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Invalid draft saved; live revision unchanged." }).waitFor(); + expect(await page.locator(".deployment-status").innerText()).toBe("Live revision 1 · Saved draft; live revision unchanged"); + await page.getByRole("button", { name: "lib/value.ts:2:23", exact: false }).click(); + const helper = page.getByRole("textbox", { name: "lib/value.ts", exact: true }); + await helper.waitFor(); + expect(await helper.evaluate(el => el === document.activeElement)).toBe(true); + expect(await helper.evaluate(() => window.getSelection()?.anchorNode?.parentElement?.textContent)).toContain("export const broken"); + await page.getByText("Raw details", { exact: true }).click(); + expect(await page.locator(".save-feedback pre").innerText()).toContain('"revision_token": "invalid"'); + await page.getByRole("group", { name: "Script view" }).getByRole("button", { name: "Requests", exact: true }).click(); + expect(await page.getByText("Run target:", { exact: false }).innerText()).toContain("Live revision 1"); + } finally { await browser.close(); server.stop(true); } +}, 30000); diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index fe4363c..3828ff9 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; import { ExecutionControls } from "./execution-controls"; -import { ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; +import { ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted"; import { artifactFileTransferUrl } from "../sdk/files"; import { RemixPanel } from "./remix"; import { Select } from "./select"; @@ -123,6 +123,13 @@ 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); } + button.primary-action { background: #d8e3ec; color: #17202a; border-color: #d8e3ec; 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; } + .save-feedback p { margin: 0 0 6px; } + .save-feedback button { height: auto; text-align: left; text-decoration: underline; } + .save-feedback pre { white-space: pre-wrap; overflow-wrap: anywhere; } .source-actions .source-readonly { margin-right: auto; } .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } .save-conflict button { border-color: var(--line); margin-right: 8px; } @@ -660,6 +667,10 @@ function App() {

{creatingScript ? "Create" : selectedArtifact?.kind === "script" ? "HTTP script" : selectedArtifact ? "Interactive artifact" : "Library"}

{creatingScript ? "New script" : selectedArtifact?.name ?? "Your artifacts"}

+ {!creatingScript && selectedArtifact && gallery?.capabilities?.links ?

+ {liveRevisionLabel(selectedArtifact)} + {drafts.get(`${selectedArtifact.key}:working`)?.dirty ? " · Unsaved changes" : selectedArtifact.live && (drafts.get(`${selectedArtifact.key}:working`)?.content.revision_token ?? selectedArtifact.draftRevision) !== selectedArtifact.live.revision_token ? " · Saved draft; live revision unchanged" : ""} +

: null}
{!creatingScript && selectedArtifact && resolvedVersion ?
{gallery?.capabilities?.links ? : null} diff --git a/src/gallery/hosted.tsx b/src/gallery/hosted.tsx index 29bf5b0..9401361 100644 --- a/src/gallery/hosted.tsx +++ b/src/gallery/hosted.tsx @@ -4,10 +4,12 @@ import { ProjectEditor, emptyEditableProject } from "./project-editor"; import { loadSourceSnapshot, useProjectDraft, type SourceSnapshot } from "./drafts"; import type { GalleryArtifact } from "./types"; import { Select } from "./select"; +import type { Diagnostic } from "../diagnostics"; +import type { SourceLocation } from "./source-editor"; export type ScriptView = "source" | "requests" | "activity" | "secrets"; type ToolResult = { error?: string; isError?: boolean; content?: { type: string; text?: string }[]; structuredContent?: unknown }; -export type MutationResult = { applied?: boolean; ok?: boolean; revision_token?: string; error?: string }; +export type MutationResult = { applied?: boolean; ok?: boolean; revision_token?: string; error?: string; check?: string; diagnostics?: Diagnostic[] }; export class GalleryToolError extends Error { constructor(message: string, readonly status: number, readonly result?: MutationResult) { super(message); } } @@ -27,6 +29,23 @@ export async function galleryTool(workspace: string, name: string, args: Record< } const message = (error: unknown) => error instanceof Error ? error.message : String(error); const show = (value: unknown) => typeof value === "string" ? value : JSON.stringify(value, null, 2); +export function liveRevisionLabel(artifact: GalleryArtifact): string { + return artifact.live ? `Live revision ${artifact.live.revision}` : artifact.liveId ? `Live revision ${artifact.liveId.slice(0, 12)}` : artifact.url ? "Live revision unavailable" : "No live revision"; +} +function ErrorFeedback({ error, files, onLocate }: { error: unknown; files: string[]; onLocate: (diagnostic: Diagnostic) => void }) { + if (!error) return null; + const result = error instanceof GalleryToolError ? error.result : undefined; + return
+

{result?.applied ? (result.diagnostics?.some(item => item.severity === "error") ? "Invalid draft saved; live revision unchanged." : "Draft saved; live revision unchanged.") : result?.error ?? message(error)}

+ {result?.diagnostics?.length ?
    {result.diagnostics.map((diagnostic, index) => { + const file = diagnostic.file?.replace(/^\//, ""); + const canOpen = !!file && files.includes(file); + const text = `${file ? `${file}${diagnostic.line ? `:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ""}` : ""} — ` : ""}${diagnostic.message}`; + return
  • {canOpen ? : text}
  • ; + })}
: null} +
Raw details
{result ? show(result) : message(error)}
+
; +} export function formatScriptResponse(value: unknown): string { if (!value || typeof value !== "object" || !("response" in value)) return show(value); const response = (value as { response: { status: number; statusText?: string; headers: [string, string][]; body?: string } }).response; @@ -89,8 +108,9 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, const project = draft?.content.project ?? emptyEditableProject(); const [projectValid, setProjectValid] = useState(true); const [projectLoad, setProjectLoad] = useState(0); + const [location, setLocation] = useState(); const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); + const [error, setError] = useState(""); const [conflict, setConflict] = useState(false); const [status, setStatus] = useState(""); const [output, setOutput] = useState(""); @@ -113,10 +133,10 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, const sourceReady = !!draft; async function perform(action: () => Promise) { setBusy(true); setError(""); setStatus(""); - try { await action(); } catch (error) { setError(message(error)); } finally { setBusy(false); } + try { await action(); } catch (error) { setError(error); } finally { setBusy(false); } } return
- {error ?

{error}

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