(null);
+ const [folderBusy, setFolderBusy] = useState(false);
// EDIF sub-design state, populated from the early netlist upload response.
// `selectedSubdesignIds = null` means "include every sub-design" (default
@@ -1715,10 +1717,7 @@ export function CreateProjectDialog({
const stepReady = (): boolean => {
if (step === "details") {
- const kicadReady =
- netlistFiles.some((f) => looksLikeKicadPro(f)) &&
- netlistFiles.some((f) => looksLikeKicadSch(f));
- return !!(name.trim() && kicadReady && !netlistError);
+ return detailsStepReady(name, netlistFiles) && !folderBusy;
}
if (step === "columns") return !!(refCol && mpnCol);
if (step === "subdesigns")
@@ -1748,6 +1747,12 @@ export function CreateProjectDialog({
const advanceStep = async () => {
if (step === "details") {
+ if (!netlistFiles.some((f) => looksLikeKicadSch(f))) {
+ setNetlistError(
+ "Manca lo schematico nella stessa cartella del .kicad_pro.",
+ );
+ return;
+ }
// EDIF: eagerly upload so we can show the sub-design picker before
// the user reaches Run. Skip in rerun mode (netlist is already on
// the server) and when already done.
@@ -2126,6 +2131,10 @@ export function CreateProjectDialog({
directory
files={netlistFiles}
onFilesChange={onKicadProjectFolder}
+ onPickState={({ busy, error }) => {
+ setFolderBusy(busy);
+ if (error) setNetlistError(error);
+ }}
/>
{netlistError ? (
diff --git a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx
index d404800..f348fe9 100644
--- a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx
+++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useState, type ChangeEvent, type DragEvent, type MouseEvent } from "react";
-import { FileCheck, Upload } from "lucide-react";
+import { FileCheck, Loader2, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
+import { isKicadProjectFileName, skipWalkDirName } from "@/lib/kicad-project-files";
export type FileUploadZoneProps = {
label: string;
@@ -12,9 +13,12 @@ export type FileUploadZoneProps = {
files: File[];
onFilesChange: (files: File[]) => void;
preloaded?: string[];
+ onPickState?: (state: { busy: boolean; error: string | null }) => void;
};
-const SKIP_DIR = /(-backups|\.pretty|3dmodels|__macosx)$/i;
+const WALK_MS = 8_000;
+const WALK_TIMEOUT_IT =
+ "La cartella è troppo grande o il browser non finisce di leggerla. Scegli solo la cartella del .kicad_pro, non il repo intero.";
function relativePath(file: File): string {
return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
@@ -32,6 +36,22 @@ function stampRelativePath(file: File, rel: string): File {
return file;
}
+function withTimeout(promise: Promise, ms: number, message: string): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = window.setTimeout(() => reject(new Error(message)), ms);
+ promise.then(
+ (value) => {
+ window.clearTimeout(timer);
+ resolve(value);
+ },
+ (err) => {
+ window.clearTimeout(timer);
+ reject(err);
+ },
+ );
+ });
+}
+
function readAllEntries(reader: FileSystemDirectoryReader): Promise {
return new Promise((resolve, reject) => {
const collected: FileSystemEntry[] = [];
@@ -53,21 +73,24 @@ function fileFromEntry(entry: FileSystemFileEntry): Promise {
return new Promise((resolve, reject) => entry.file(resolve, reject));
}
+/** Project folder only: kicad_pro / sch / pcb. Do not recurse (HubAudio .history is ~2000 files). */
async function flattenEntry(root: FileSystemEntry): Promise {
const out: File[] = [];
- const stack: { entry: FileSystemEntry; prefix: string }[] = [{ entry: root, prefix: "" }];
- while (stack.length > 0) {
- const { entry, prefix } = stack.pop()!;
- if (entry.isFile) {
- const file = await fileFromEntry(entry as FileSystemFileEntry);
- out.push(stampRelativePath(file, prefix + file.name));
- continue;
- }
- if (!entry.isDirectory) continue;
- if (SKIP_DIR.test(entry.name)) continue;
- const kids = await readAllEntries((entry as FileSystemDirectoryEntry).createReader());
- const nestedPrefix = prefix + entry.name + "/";
- for (const child of kids) stack.push({ entry: child, prefix: nestedPrefix });
+ if (root.isFile) {
+ if (!isKicadProjectFileName(root.name)) return out;
+ const file = await fileFromEntry(root as FileSystemFileEntry);
+ out.push(stampRelativePath(file, file.name));
+ return out;
+ }
+ if (!root.isDirectory) return out;
+ if (skipWalkDirName(root.name)) return out;
+ const kids = await readAllEntries((root as FileSystemDirectoryEntry).createReader());
+ const nestedPrefix = root.name + "/";
+ for (const child of kids) {
+ if (child.isDirectory) continue;
+ if (!isKicadProjectFileName(child.name)) continue;
+ const file = await fileFromEntry(child as FileSystemFileEntry);
+ out.push(stampRelativePath(file, nestedPrefix + child.name));
}
return out;
}
@@ -81,12 +104,14 @@ async function filesFromDropEvent(event: DragEvent): Promise {
if (entry) fromEntries.push(...(await flattenEntry(entry)));
else {
const fallback = item.getAsFile();
- if (fallback) fromEntries.push(fallback);
+ if (fallback && isKicadProjectFileName(fallback.name)) fromEntries.push(fallback);
}
}
if (fromEntries.length > 0) return fromEntries;
}
- return Array.from(event.dataTransfer?.files ?? []);
+ return Array.from(event.dataTransfer?.files ?? []).filter((f) =>
+ isKicadProjectFileName(relativePath(f)),
+ );
}
function mergeSelection(current: File[], incoming: File[], multiple: boolean, directory: boolean): File[] {
@@ -102,13 +127,11 @@ type DirHandle = {
async function filesFromDirectoryHandle(handle: DirHandle, prefix = ""): Promise {
const out: File[] = [];
for await (const [name, child] of handle.entries()) {
- if (SKIP_DIR.test(name)) continue;
- if (child.kind === "file" && child.getFile) {
- const file = await child.getFile();
- out.push(stampRelativePath(file, prefix + name));
- } else if (child.kind === "directory") {
- out.push(...(await filesFromDirectoryHandle(child, prefix + name + "/")));
- }
+ if (child.kind === "directory") continue;
+ if (!isKicadProjectFileName(name)) continue;
+ if (!child.getFile) continue;
+ const file = await child.getFile();
+ out.push(stampRelativePath(file, prefix + name));
}
return out;
}
@@ -123,7 +146,7 @@ function pickFolderViaHiddenInput(): Promise {
input.style.display = "none";
const finish = (files: File[]) => {
input.remove();
- resolve(files);
+ resolve(files.filter((f) => isKicadProjectFileName(relativePath(f))));
};
input.addEventListener("change", () => finish(Array.from(input.files ?? [])));
input.addEventListener("cancel", () => finish([]));
@@ -139,12 +162,17 @@ async function pickProjectFolder(): Promise {
if (typeof picker === "function") {
try {
const handle = await picker.call(window);
- return await filesFromDirectoryHandle(handle, `${handle.name}/`);
+ return await withTimeout(
+ filesFromDirectoryHandle(handle, `${handle.name}/`),
+ WALK_MS,
+ WALK_TIMEOUT_IT,
+ );
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return [];
+ throw err;
}
}
- return pickFolderViaHiddenInput();
+ return withTimeout(pickFolderViaHiddenInput(), WALK_MS, WALK_TIMEOUT_IT);
}
export function FileUploadZone({
@@ -155,20 +183,37 @@ export function FileUploadZone({
files,
onFilesChange,
preloaded,
+ onPickState,
}: FileUploadZoneProps) {
const [hover, setHover] = useState(false);
+ const [busy, setBusy] = useState(false);
const filled = files.length > 0 || Boolean(preloaded && preloaded.length > 0);
+ const report = useCallback(
+ (nextBusy: boolean, error: string | null) => {
+ setBusy(nextBusy);
+ onPickState?.({ busy: nextBusy, error });
+ },
+ [onPickState],
+ );
+
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
setHover(false);
- void filesFromDropEvent(event).then((dropped) => {
- if (dropped.length === 0) return;
- onFilesChange(mergeSelection(files, dropped, multiple, directory));
- });
+ report(true, null);
+ void withTimeout(filesFromDropEvent(event), WALK_MS, WALK_TIMEOUT_IT)
+ .then((dropped) => {
+ report(false, null);
+ if (dropped.length === 0) return;
+ onFilesChange(mergeSelection(files, dropped, multiple, directory));
+ })
+ .catch((err) => {
+ const message = err instanceof Error ? err.message : WALK_TIMEOUT_IT;
+ report(false, message);
+ });
},
- [files, multiple, directory, onFilesChange],
+ [files, multiple, directory, onFilesChange, report],
);
const onPick = useCallback(
@@ -184,28 +229,39 @@ export function FileUploadZone({
(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
- void pickProjectFolder().then((picked) => {
- if (picked.length === 0) return;
- onFilesChange(picked);
- });
+ if (busy) return;
+ report(true, null);
+ void pickProjectFolder()
+ .then((picked) => {
+ report(false, null);
+ if (picked.length === 0) return;
+ onFilesChange(picked);
+ })
+ .catch((err) => {
+ const message = err instanceof Error ? err.message : WALK_TIMEOUT_IT;
+ report(false, message);
+ });
},
- [onFilesChange],
+ [busy, onFilesChange, report],
);
const boxClass = cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
hover ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
filled && "border-emerald-500/40 bg-emerald-500/5",
+ busy && "pointer-events-none opacity-80",
);
const body = (
<>
- {filled ? (
+ {busy ? (
+
+ ) : filled ? (
) : (
)}
- {label}
+ {busy ? "Lettura cartella…" : label}
{preloaded && preloaded.length > 0 ? (
{preloaded.map((name) => (
@@ -245,6 +301,7 @@ export function FileUploadZone({
type="button"
className={cn(boxClass, "w-full")}
onClick={onOpenFolder}
+ disabled={busy}
onDragOver={(event) => {
event.preventDefault();
setHover(true);
diff --git a/periscope/src/frontend/src/lib/kicad-project-files.ts b/periscope/src/frontend/src/lib/kicad-project-files.ts
index 001e0e4..d6269f2 100644
--- a/periscope/src/frontend/src/lib/kicad-project-files.ts
+++ b/periscope/src/frontend/src/lib/kicad-project-files.ts
@@ -36,11 +36,31 @@ function parentDir(rel: string): string {
return slash >= 0 ? rel.slice(0, slash) : "";
}
-function skipRel(rel: string): boolean {
- const low = rel.toLowerCase();
+export function skipWalkDirName(name: string): boolean {
+ const n = name.replace(/\/+$/, "").toLowerCase();
+ if (!n) return true;
+ if (n.startsWith(".")) return true;
+ return /(-backups|\.pretty|3dmodels|__macosx|node_modules)$/i.test(n);
+}
+
+export function isKicadProjectFileName(name: string): boolean {
+ const base = name.replaceAll("\\", "/").split("/").pop() || name;
+ const n = base.toLowerCase();
+ return n.endsWith(".kicad_pro") || n.endsWith(".kicad_sch") || n.endsWith(".kicad_pcb");
+}
+
+export function skipRel(rel: string): boolean {
+ const low = rel.replaceAll("\\", "/").toLowerCase();
+ const parts = low.split("/").filter(Boolean);
+ if (parts.some((p) => skipWalkDirName(p))) return true;
return low.includes("-backups/") || low.includes("/__macosx/") || low.includes(".pretty/");
}
+/** Step 1 Next: project name + a .kicad_pro already in the pick (not waiting on the whole tree). */
+export function detailsStepReady(projectName: string, files: NamedUpload[]): boolean {
+ return Boolean(projectName.trim() && files.some((f) => looksLikeKicadPro(f)));
+}
+
function depth(rel: string): number {
const p = parentDir(rel);
if (!p) return 0;
diff --git a/tests/test_kicad_project.py b/tests/test_kicad_project.py
index 52a0b9e..325238e 100644
--- a/tests/test_kicad_project.py
+++ b/tests/test_kicad_project.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+import re
from pathlib import Path
import pytest
@@ -321,3 +322,87 @@ def test_real_hubaudio_kicad_pro_filename_and_folder_ingest(tmp_path: Path):
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "U13" not in parts
+
+
+# Mirrors periscope/src/frontend/src/lib/kicad-project-files.ts (folder walk + Next).
+def _skip_walk_dir_name(name: str) -> bool:
+ n = name.rstrip("/").lower()
+ if not n or n.startswith("."):
+ return True
+ return bool(re.search(r"(-backups|\.pretty|3dmodels|__macosx|node_modules)$", n))
+
+
+def _is_kicad_project_file_name(name: str) -> bool:
+ base = name.replace("\\", "/").rsplit("/", 1)[-1].lower()
+ return base.endswith((".kicad_pro", ".kicad_sch", ".kicad_pcb"))
+
+
+def _skip_rel(rel: str) -> bool:
+ parts = [p for p in rel.replace("\\", "/").lower().split("/") if p]
+ return any(_skip_walk_dir_name(p) for p in parts)
+
+
+def _details_step_ready(project_name: str, files: list[dict[str, str]]) -> bool:
+ if not project_name.strip():
+ return False
+ return any(_looks_like_kicad_pro(f.get("name", ""), f.get("webkitRelativePath", "")) for f in files)
+
+
+def test_folder_walk_skips_history_and_keeps_project_dir_only():
+ """Live 2.60.8 recursed handle.entries() into HubAudio/.history (~2000 files) so Next never enabled."""
+ names = [
+ "HubAudio.kicad_pro",
+ "HubAudio.kicad_sch",
+ "Codec.kicad_sch",
+ "HubAudio.kicad_pcb",
+ "HubAudio.kicad_prl",
+ "~HubAudio.kicad_pcb.lck",
+ ".history",
+ ".git",
+ "fp-lib-table",
+ ]
+ kept = [n for n in names if _is_kicad_project_file_name(n)]
+ skipped_dirs = [n for n in names if _skip_walk_dir_name(n)]
+ assert kept == [
+ "HubAudio.kicad_pro",
+ "HubAudio.kicad_sch",
+ "Codec.kicad_sch",
+ "HubAudio.kicad_pcb",
+ ]
+ assert ".history" in skipped_dirs and ".git" in skipped_dirs
+ assert _skip_rel("HubAudio/.history/objects/aa")
+ assert not _skip_rel("HubAudio/HubAudio.kicad_pro")
+
+
+def test_details_next_enables_when_kicad_pro_is_in():
+ empty: list[dict[str, str]] = []
+ assert not _details_step_ready("HubAudio", empty)
+ assert not _details_step_ready(
+ "",
+ [{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
+ )
+ assert _details_step_ready(
+ "HubAudio",
+ [{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
+ )
+ assert _details_step_ready(
+ "HubAudio",
+ [{"name": "HubAudio", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
+ )
+
+
+@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
+def test_real_hubaudio_recursive_tree_is_huge_project_dir_is_small():
+ tree = list(HUBAUDIO.rglob("*"))
+ project_dir = [
+ p
+ for p in HUBAUDIO.iterdir()
+ if p.is_file() and _is_kicad_project_file_name(p.name)
+ ]
+ assert len(tree) > 100
+ assert (HUBAUDIO / ".history").is_dir()
+ assert any(p.name == "HubAudio.kicad_pro" for p in project_dir)
+ assert any(p.name.endswith(".kicad_sch") for p in project_dir)
+ assert any(p.name.endswith(".kicad_pcb") for p in project_dir)
+ assert all(".history" not in str(p) for p in project_dir)
+ assert len(project_dir) < 40