Accept a KiCad project drop so hierarchical sheets and the board upload in one step.

A single .kicad_sch was parsed in isolation, so child sheets looked like an empty netlist. Report 404s now surface the real API error instead of a generic fetch failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 07:26:27 +02:00
co-authored by Cursor
parent b3a47f0bec
commit a56dda2358
18 changed files with 965 additions and 116 deletions
+24 -1
View File
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel";
import { PcbUploadButton } from "@/components/project/pcb-upload";
export default function ProjectDetailPage({
params,
@@ -190,6 +191,11 @@ export default function ProjectDetailPage({
<h1 className="text-lg font-semibold">{project.name}</h1>
<p className="text-sm text-muted-foreground">
{new Date(project.created).toLocaleDateString()}
{project.hasPcb ? (
<span className="ml-2">· PCB uploaded</span>
) : (
<span className="ml-2">· no PCB</span>
)}
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
<span className="ml-2 font-mono tabular-nums text-foreground">
${project.totalCostUsd.toFixed(4)}
@@ -348,7 +354,11 @@ export default function ProjectDetailPage({
)}
{tab === "impedance" && (
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
<ImpedancePanel
projectId={id}
hasPcb={Boolean(project.hasPcb)}
onPcbUploaded={reload}
/>
)}
{tab === "logs" && (
@@ -357,6 +367,19 @@ export default function ProjectDetailPage({
{tab === "settings" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm">KiCad PCB</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{project.hasPcb
? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
: "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
</p>
<PcbUploadButton projectId={id} onUploaded={reload} />
</CardContent>
</Card>
<CollaboratorsSection projectId={id} />
<SkippedComponentsSection skipped={project.skippedComponents} />
<ReportVersionSection pinscopeVersion={project.pinscopeVersion} />
@@ -20,7 +20,7 @@ import {
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
import { cancelPipeline, fetchProject, fetchReport, resumePipeline, reprocessPipeline } from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types";
import {
AlertTriangle,
@@ -107,14 +107,24 @@ export default function ProgressPage({
}
};
// Auto-navigate to report when pipeline completes successfully
// Auto-navigate to report when pipeline completes and the file exists
useEffect(() => {
if (done && !error && !cancelled && !projectPaused) {
const timer = setTimeout(() => {
router.push(`/project/${id}/report`);
}, 1500);
return () => clearTimeout(timer);
}
if (!done || error || cancelled || projectPaused) return;
let stopped = false;
(async () => {
for (let i = 0; i < 8; i++) {
try {
await fetchReport(id);
if (!stopped) router.push(`/project/${id}/report`);
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
})();
return () => {
stopped = true;
};
}, [done, error, cancelled, projectPaused, id, router]);
// Auto-navigate to dashboard when pipeline is cancelled
@@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) {
if (error || !report || !graph) {
return (
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 text-sm text-muted-foreground">
{error ?? "Report not found."}
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 space-y-3">
<p className="text-sm text-muted-foreground">
{error ?? "Report not found."}
</p>
<p className="text-sm">
<a
href={`/project/${projectId}/progress`}
className="text-blue-600 dark:text-blue-500 hover:underline"
>
Open pipeline progress
</a>
</p>
</div>
);
}
@@ -352,6 +352,7 @@ function summarizeLcscModel(model: Record<string, unknown>): string {
type WizardStep =
| "details"
| "pcb"
| "columns"
| "subdesigns"
| "lcsc-passives"
@@ -361,6 +362,7 @@ type WizardStep =
const ALL_STEPS: { key: WizardStep; label: string }[] = [
{ key: "details", label: "Project Details" },
{ key: "pcb", label: "Board (optional)" },
{ key: "columns", label: "BOM Columns" },
{ key: "subdesigns", label: "Sub-designs" },
{ key: "lcsc-passives", label: "Resolving Passive Specs" },
@@ -374,6 +376,45 @@ const ALL_STEPS: { key: WizardStep; label: string }[] = [
const NULL_SUBDESIGN_KEY = "";
const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY;
const _NET_EXT = new Set([
".kicad_sch",
".kicad_net",
".kicad_pro",
".asc",
".net",
".edn",
".edif",
".edf",
".xml",
".zip",
]);
function _fileExt(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i).toLowerCase() : "";
}
function splitProjectFiles(incoming: File[]): {
netlist: File[];
pcb: File[];
bom: File[];
} {
const netlist: File[] = [];
const pcb: File[] = [];
const bom: File[] = [];
for (const f of incoming) {
const rel = (
(f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name
).toLowerCase();
if (rel.includes("-backups/") || rel.includes(".pretty/")) continue;
const ext = _fileExt(f.name);
if (ext === ".kicad_pcb") pcb.push(f);
else if (ext === ".csv" || ext === ".xlsx") bom.push(f);
else if (_NET_EXT.has(ext)) netlist.push(f);
}
return { netlist, pcb, bom };
}
// Concurrency cap for the per-row LCSC passive resolve. Backend charges one
// credit-billing API call per request; small batch keeps latency reasonable
// without overloading the resolve endpoint.
@@ -533,7 +574,7 @@ export function CreateProjectDialog({
// Rerun mode: operate on an existing project rather than creating new.
const [existingProjectId, setExistingProjectId] = useState<string | null>(null);
const [initialBomFile, setInitialBomFile] = useState<File | null>(null);
const [initialNetlistFile, setInitialNetlistFile] = useState<File | null>(null);
const [initialNetlistFiles, setInitialNetlistFiles] = useState<File[]>([]);
const [existingDatasheetStems, setExistingDatasheetStems] = useState<Set<string>>(
new Set(),
);
@@ -548,8 +589,12 @@ export function CreateProjectDialog({
// Step 1
const [name, setName] = useState("");
const [bomFile, setBomFile] = useState<File | null>(null);
const [netlistFile, setNetlistFile] = useState<File | null>(null);
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
const netlistFile = netlistFiles[0] ?? null;
const [pcbFile, setPcbFile] = useState<File | null>(null);
const skipPcbStep =
Boolean(pcbFile) ||
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".zip"));
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
const [netlistError, setNetlistError] = useState<string | null>(null);
@@ -840,6 +885,7 @@ export function CreateProjectDialog({
// lcsc-passives drains to zero before the auto-advance effect
// moves us off the step on the next tick).
if (s.key === step) return true;
if (s.key === "pcb") return !skipPcbStep;
if (s.key === "subdesigns") return hasSubdesignChoice;
if (s.key === "lcsc-passives") return hasLcscPassives;
if (s.key === "datasheets") return hasIcs;
@@ -847,14 +893,15 @@ export function CreateProjectDialog({
if (s.key === "passives") return hasPassives;
return true;
});
}, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
}, [step, skipPcbStep, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
const stepIndex = activeSteps.findIndex((s) => s.key === step);
// ---- Handlers ----
const handleBomChange = useCallback((files: File[]) => {
const file = files[0] || null;
const file =
files.find((f) => /\.(csv|xlsx)$/i.test(f.name)) || files[0] || null;
setBomFile(file);
if (!file) {
setCsvData(null);
@@ -880,9 +927,13 @@ export function CreateProjectDialog({
}
}, []);
const handleNetlistChange = useCallback((files: File[]) => {
const handleNetlistChange = useCallback((incoming: File[]) => {
const { netlist, pcb, bom } = splitProjectFiles(incoming);
if (pcb[0]) setPcbFile(pcb[0]);
if (bom[0]) handleBomChange([bom[0]]);
const files = netlist;
const file = files[0] || null;
setNetlistFile(file);
setNetlistFiles(files);
setNetlistNetCount(null);
setNetlistError(null);
// Invalidate any cached netlist preview, since it was relative to the
@@ -895,6 +946,11 @@ export function CreateProjectDialog({
setNetlistUploadedEarly(false);
setNetlistIsEdif(false);
if (!file) return;
const lower = file.name.toLowerCase();
if (lower.endsWith(".zip") || files.length > 1) {
setNetlistPreview([]);
return;
}
file.text().then((text) => {
// EDIF: skip browser-side preview — the net-count badge will populate
// after the server upload parses the file.
@@ -920,14 +976,15 @@ export function CreateProjectDialog({
);
}
});
}, []);
}, [handleBomChange]);
const resetAndClose = useCallback(() => {
setOpen(false);
setStep("details");
setName("");
setBomFile(null);
setNetlistFile(null);
setNetlistFiles([]);
setPcbFile(null);
setNetlistNetCount(null);
setNetlistError(null);
setCsvData(null);
@@ -954,7 +1011,7 @@ export function CreateProjectDialog({
setError(null);
setExistingProjectId(null);
setInitialBomFile(null);
setInitialNetlistFile(null);
setInitialNetlistFiles([]);
setExistingDatasheetStems(new Set());
setPrefillLoading(false);
setPrefillError(null);
@@ -1049,8 +1106,8 @@ export function CreateProjectDialog({
if (netlist) {
const netlistText = await netlist.text();
if (cancelled) return;
setNetlistFile(netlist);
setInitialNetlistFile(netlist);
setNetlistFiles([netlist]);
setInitialNetlistFiles([netlist]);
if (isEdifNetlist(netlistText)) {
setNetlistIsEdif(true);
// EDIF: PADS-shape browser preview doesn't apply.
@@ -1135,7 +1192,7 @@ export function CreateProjectDialog({
if (netlist) {
const netlistText = await netlist.text();
if (cancelled) return;
setNetlistFile(netlist);
setNetlistFiles([netlist]);
if (isEdifNetlist(netlistText)) {
setNetlistIsEdif(true);
setNetlistNetCount(null);
@@ -1488,7 +1545,7 @@ export function CreateProjectDialog({
setBomUploadedEarly(true);
setInitialBomFile(bomFile);
// Treat the netlist as fresh so it still uploads in handleCreate.
setInitialNetlistFile(null);
setInitialNetlistFiles([]);
const map = uploadRes.lcsc_to_mpn ?? {};
if (Object.keys(map).length > 0) setLcscToMpn(map);
if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) {
@@ -1547,13 +1604,13 @@ export function CreateProjectDialog({
createdProjectIdHere = proj.id;
setExistingProjectId(proj.id);
}
const result = await uploadNetlist(projectId, netlistFile);
const result = await uploadNetlist(projectId, netlistFiles);
setEdifSubDesigns(result.sub_designs);
if (result.designator_pins.length > 0) {
setNetlistPreview(result.designator_pins);
}
setNetlistUploadedEarly(true);
setInitialNetlistFile(netlistFile);
setInitialNetlistFiles(netlistFiles);
return { ok: true, subDesigns: result.sub_designs };
} catch (e) {
// Only delete the project if we created it just now — leave LCSC's
@@ -1573,7 +1630,7 @@ export function CreateProjectDialog({
} finally {
setEarlyUploading(false);
}
}, [netlistFile, existingProjectId, name]);
}, [netlistFile, netlistFiles, existingProjectId, name]);
// ---- LCSC per-row passive resolve ----
//
@@ -1689,6 +1746,7 @@ export function CreateProjectDialog({
const canAdvance = (): boolean => {
if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError);
if (step === "pcb") return true;
if (step === "columns") return !!(refCol && mpnCol);
if (step === "subdesigns")
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
@@ -1729,6 +1787,9 @@ export function CreateProjectDialog({
const res = await runEarlyNetlistUpload();
if (!res.ok) return;
}
setStep(skipPcbStep ? "columns" : "pcb");
}
else if (step === "pcb") {
setStep("columns");
}
else if (step === "columns") {
@@ -1806,7 +1867,8 @@ export function CreateProjectDialog({
const goBack = () => {
setError(null);
if (step === "columns") setStep("details");
if (step === "pcb") setStep("details");
else if (step === "columns") setStep(skipPcbStep ? "details" : "pcb");
else if (step === "subdesigns") setStep("columns");
else if (step === "lcsc-passives") {
if (hasSubdesignChoice) setStep("subdesigns");
@@ -1877,9 +1939,9 @@ export function CreateProjectDialog({
);
}
if (!projectAlreadyExists || netlistFile !== initialNetlistFile) {
if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) {
setProgress("Uploading netlist...");
await uploadNetlist(project.id, netlistFile!);
await uploadNetlist(project.id, netlistFiles);
}
if (pcbFile) {
setProgress("Uploading PCB...");
@@ -2065,14 +2127,13 @@ export function CreateProjectDialog({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<FileUploadZone
label="BOM (.csv, .xlsx)"
label="BOM"
accept=".csv,.xlsx"
files={bomFile ? [bomFile] : []}
onFilesChange={handleBomChange}
/>
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Requires columns: Designator, Manufacturer Part Number, and
Comment (for passive values / mismatch detection).{" "}
CSV or Excel.{" "}
<a
href="/file-guide#the-bom"
target="_blank"
@@ -2085,9 +2146,10 @@ export function CreateProjectDialog({
</div>
<div className="space-y-1.5">
<FileUploadZone
label="Netlist (.asc / .net / .edn / .kicad_sch)"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
files={netlistFile ? [netlistFile] : []}
label="Schematic"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
multiple
files={netlistFiles}
onFilesChange={handleNetlistChange}
/>
{netlistError ? (
@@ -2099,13 +2161,17 @@ export function CreateProjectDialog({
<p className="text-[11px] text-muted-foreground leading-tight px-1">
{netlistNetCount} nets detected
</p>
) : netlistFile ? (
) : netlistFiles.length > 0 ? (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Net count will appear after upload.
{pcbFile
? "Board included."
: netlistFiles.length > 1
? `${netlistFiles.length} schematic files.`
: "Ready."}
</p>
) : (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
Drop the KiCad folder, or one netlist.{" "}
<a
href="/file-guide#the-netlist"
target="_blank"
@@ -2118,19 +2184,20 @@ export function CreateProjectDialog({
)}
</div>
</div>
<div className="space-y-1.5">
<FileUploadZone
label="PCB (.kicad_pcb, optional)"
accept=".kicad_pcb"
files={pcbFile ? [pcbFile] : []}
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
/>
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Optional. Layout checks (trace width, 3W, placement mm) stay
off until a board is present. Schema review still runs
without it.
</p>
</div>
</div>
)}
{step === "pcb" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Optional. Skip if you only want schematic review.
</p>
<FileUploadZone
label="KiCad board (.kicad_pcb)"
accept=".kicad_pcb"
files={pcbFile ? [pcbFile] : []}
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
/>
</div>
)}
@@ -10,6 +10,7 @@ import {
computeImpedance,
fetchImpedanceNets,
} from "@/lib/api";
import { PcbUploadButton } from "@/components/project/pcb-upload";
import type {
ImpedanceKind,
ImpedanceNetsReport,
@@ -29,9 +30,11 @@ function fmt(n: number | null | undefined, digits = 2): string {
export function ImpedancePanel({
projectId,
hasPcb,
onPcbUploaded,
}: {
projectId: string;
hasPcb: boolean;
onPcbUploaded?: () => void;
}) {
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
const [h, setH] = useState("0.20");
@@ -225,10 +228,13 @@ export function ImpedancePanel({
</CardHeader>
<CardContent className="space-y-3">
{!hasPcb && (
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb` and run analysis. Power/ground nets are
skipped; signal traces with stackup εr/h are sampled.
</p>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground
nets are skipped; signal traces with stackup εr/h are sampled.
</p>
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
</div>
)}
{hasPcb && boardNets?.skipped && (
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { uploadPcb } from "@/lib/api";
export function PcbUploadButton({
projectId,
onUploaded,
}: {
projectId: string;
onUploaded?: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="space-y-1">
<label className="inline-flex">
<Button
size="sm"
variant="outline"
disabled={busy}
nativeButton={false}
render={<span />}
>
{busy ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-1" />
)}
{busy ? "Uploading…" : "Upload .kicad_pcb"}
</Button>
<input
type="file"
accept=".kicad_pcb"
className="hidden"
disabled={busy}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setBusy(true);
setError(null);
try {
await uploadPcb(projectId, file);
onUploaded?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setBusy(false);
}
}}
/>
</label>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
@@ -13,6 +13,80 @@ interface FileUploadZoneProps {
preloaded?: string[];
}
function withRelativePath(file: File, rel: string): File {
if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) {
return file;
}
try {
Object.defineProperty(file, "webkitRelativePath", {
value: rel,
configurable: true,
});
} catch {
/* ignore — some File objects are sealed */
}
return file;
}
function readDirEntries(
reader: FileSystemDirectoryReader,
): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
const acc: FileSystemEntry[] = [];
const pump = () => {
reader.readEntries((chunk) => {
if (chunk.length === 0) resolve(acc);
else {
acc.push(...chunk);
pump();
}
}, reject);
};
pump();
});
}
async function filesFromEntry(
entry: FileSystemEntry,
prefix: string,
): Promise<File[]> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
return [withRelativePath(file, prefix + file.name)];
}
if (entry.isDirectory) {
const skip = /(-backups|\.pretty|3dmodels|__macosx)$/i.test(entry.name);
if (skip) return [];
const reader = (entry as FileSystemDirectoryEntry).createReader();
const children = await readDirEntries(reader);
const nested: File[] = [];
for (const child of children) {
nested.push(...(await filesFromEntry(child, prefix + entry.name + "/")));
}
return nested;
}
return [];
}
async function filesFromDrop(e: React.DragEvent): Promise<File[]> {
const items = e.dataTransfer?.items;
if (items && items.length > 0) {
const out: File[] = [];
for (const item of Array.from(items)) {
const entry = item.webkitGetAsEntry?.();
if (entry) out.push(...(await filesFromEntry(entry, "")));
else {
const f = item.getAsFile();
if (f) out.push(f);
}
}
if (out.length > 0) return out;
}
return Array.from(e.dataTransfer?.files ?? []);
}
export function FileUploadZone({
label,
accept,
@@ -27,10 +101,11 @@ export function FileUploadZone({
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const dropped = Array.from(e.dataTransfer.files);
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
void filesFromDrop(e).then((dropped) => {
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
});
},
[files, multiple, onFilesChange]
[files, multiple, onFilesChange],
);
const handleChange = useCallback(
@@ -38,7 +113,7 @@ export function FileUploadZone({
const selected = Array.from(e.target.files ?? []);
onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1));
},
[files, multiple, onFilesChange]
[files, multiple, onFilesChange],
);
const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0);
@@ -48,9 +123,12 @@ export function FileUploadZone({
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
hasFiles && "border-emerald-500/40 bg-emerald-500/5"
hasFiles && "border-emerald-500/40 bg-emerald-500/5",
)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
@@ -69,12 +147,27 @@ export function FileUploadZone({
)}
{files.length > 0 && (
<div className="text-xs text-muted-foreground">
{files.map((f) => (
<span key={f.name} className="font-mono block">{f.name}</span>
))}
{files.length <= 2 ? (
files.map((f) => {
const rel =
(f as File & { webkitRelativePath?: string }).webkitRelativePath ||
f.name;
return (
<span key={rel} className="font-mono block">{rel.split("/").pop()}</span>
);
})
) : (
<span>{files.length} files</span>
)}
</div>
)}
<input type="file" accept={accept} multiple={multiple} className="hidden" onChange={handleChange} />
<input
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={handleChange}
/>
</label>
);
}
+39 -7
View File
@@ -4,6 +4,13 @@ import { useState, useEffect } from "react";
import type { ValidationReport, DesignGraph } from "@/lib/types";
import { fetchReport, fetchGraph } from "@/lib/api";
function describeLoadError(e: unknown): string {
if (e instanceof TypeError) {
return "Could not reach the Pinscope API. Check that the backend is running and that /api is proxied to it.";
}
return e instanceof Error ? e.message : "Failed to load report";
}
export function useReport(projectId: string) {
const [report, setReport] = useState<ValidationReport | null>(null);
const [graph, setGraph] = useState<DesignGraph | null>(null);
@@ -11,14 +18,39 @@ export function useReport(projectId: string) {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([fetchReport(projectId), fetchGraph(projectId)])
.then(([r, g]) => {
setReport(r);
setGraph(g);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
setError(null);
(async () => {
let lastErr: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const [r, g] = await Promise.all([
fetchReport(projectId),
fetchGraph(projectId),
]);
if (!cancelled) {
setReport(r);
setGraph(g);
}
return;
} catch (e) {
lastErr = e;
const msg = e instanceof Error ? e.message : "";
const retryable = msg.includes("not found") || e instanceof TypeError;
if (!retryable || attempt === 3) break;
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
}
}
if (!cancelled) setError(describeLoadError(lastErr));
})().finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId]);
return { report, graph, loading, error };
+40 -4
View File
@@ -56,6 +56,19 @@ async function authFetch(url: string, init?: RequestInit): Promise<Response> {
return fetch(url, { ...init, headers });
}
async function throwHttpError(res: Response, fallback: string): Promise<never> {
let detail: unknown;
try {
const body = await res.json();
detail = (body as { detail?: unknown; error?: unknown }).detail
?? (body as { error?: unknown }).error;
} catch {
detail = undefined;
}
const msg = typeof detail === "string" && detail.trim() ? detail : fallback;
throw new Error(msg);
}
// --- Projects ---
function mapProject(p: Record<string, unknown>): Project {
@@ -301,10 +314,19 @@ export async function uploadPcb(
}
export async function uploadNetlist(
projectId: string, file: File,
projectId: string, files: File | File[],
): Promise<UploadNetlistResult> {
const list = Array.isArray(files) ? files : [files];
const form = new FormData();
form.append("file", file);
const rels = list.map((f) => {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
return rel && rel.length > 0 ? rel : f.name;
});
if (list[0]) form.append("file", list[0], list[0].name);
if (list.length > 1) {
for (const f of list) form.append("files", f, f.name);
form.append("paths", JSON.stringify(rels));
}
const res = await authFetch(
`${BASE}/api/projects/${projectId}/upload/netlist`,
{ method: "POST", body: form },
@@ -651,7 +673,14 @@ export async function fetchReport(
projectId: string,
): Promise<ValidationReport> {
const res = await authFetch(`${BASE}/api/report/${projectId}`);
if (!res.ok) throw new Error("Failed to fetch report");
if (!res.ok) {
await throwHttpError(
res,
res.status === 404
? "Report not found — the pipeline has not finished, or it failed before writing a report."
: "Failed to fetch report",
);
}
return res.json();
}
@@ -723,7 +752,14 @@ export async function deleteComment(
export async function fetchGraph(projectId: string): Promise<DesignGraph> {
const res = await authFetch(`${BASE}/api/graph/${projectId}`);
if (!res.ok) throw new Error("Failed to fetch graph");
if (!res.ok) {
await throwHttpError(
res,
res.status === 404
? "Design graph not found — the pipeline has not finished, or it failed during graph build."
: "Failed to fetch graph",
);
}
return res.json();
}
+1 -1
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
export const APP_VERSION = "2.26.0";
export const APP_VERSION = "2.26.1";
export const APP_VERSION_DATE = "2026-09-11";