Stop hanging after KiCad folder permission.

Apri progetto walked every nested file (HubAudio .history ~2000) so
onFilesChange never ran and Next stayed disabled. Read only kicad_pro,
sch, and pcb in the project dir; timeout to an error; enable Next when
.kicad_pro is in.
This commit is contained in:
2026-09-21 08:18:01 +02:00
parent a0b89365c2
commit 027546b0bd
5 changed files with 223 additions and 45 deletions
@@ -2,6 +2,13 @@
What's new in Periscope. What's new in Periscope.
## 2.60.9 — 2026-09-21 — Apri progetto does not walk forever
After folder permission, 2.60.8 kept `Next` disabled because `showDirectoryPicker` then recursed `dirHandle.entries()` / `getFile()` through the whole tree (HubAudio `.history` is ~2000 files). We only read the project directory: `.kicad_pro`, `.kicad_sch`, `.kicad_pcb`. Timeout shows an error instead of a spinner forever. Next enables when a `.kicad_pro` is in.
- [Fixed] Folder pick no longer hangs on nested dirs / permission / giant trees.
- [Fixed] Next on step 1 when `.kicad_pro` is present.
## 2.60.8 — 2026-09-21 — Folder picker, not Carica file dialog ## 2.60.8 — 2026-09-21 — Folder picker, not Carica file dialog
Apri progetto opened the Mac file list (**Carica**, no row selected) because the control was a React `<input type="file" accept="">` without a working `webkitdirectory`. Empty Carica then looked like “no .kicad_pro”. It now uses `showDirectoryPicker` (Chrome) or a hidden input created with `webkitdirectory=true` and **no accept**. Cancel/empty pick is ignored. Apri progetto opened the Mac file list (**Carica**, no row selected) because the control was a React `<input type="file" accept="">` without a working `webkitdirectory`. Empty Carica then looked like “no .kicad_pro”. It now uses `showDirectoryPicker` (Chrome) or a hidden input created with `webkitdirectory=true` and **no accept**. Cancel/empty pick is ignored.
@@ -76,6 +76,7 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
detailsStepReady,
filesBesideKicadPro, filesBesideKicadPro,
looksLikeKicadPro, looksLikeKicadPro,
looksLikeKicadSch, looksLikeKicadSch,
@@ -635,6 +636,7 @@ export function CreateProjectDialog({
const [netlistUploadedEarly, setNetlistUploadedEarly] = useState(false); const [netlistUploadedEarly, setNetlistUploadedEarly] = useState(false);
const [earlyUploading, setEarlyUploading] = useState(false); const [earlyUploading, setEarlyUploading] = useState(false);
const [earlyUploadError, setEarlyUploadError] = useState<string | null>(null); const [earlyUploadError, setEarlyUploadError] = useState<string | null>(null);
const [folderBusy, setFolderBusy] = useState(false);
// EDIF sub-design state, populated from the early netlist upload response. // EDIF sub-design state, populated from the early netlist upload response.
// `selectedSubdesignIds = null` means "include every sub-design" (default // `selectedSubdesignIds = null` means "include every sub-design" (default
@@ -1715,10 +1717,7 @@ export function CreateProjectDialog({
const stepReady = (): boolean => { const stepReady = (): boolean => {
if (step === "details") { if (step === "details") {
const kicadReady = return detailsStepReady(name, netlistFiles) && !folderBusy;
netlistFiles.some((f) => looksLikeKicadPro(f)) &&
netlistFiles.some((f) => looksLikeKicadSch(f));
return !!(name.trim() && kicadReady && !netlistError);
} }
if (step === "columns") return !!(refCol && mpnCol); if (step === "columns") return !!(refCol && mpnCol);
if (step === "subdesigns") if (step === "subdesigns")
@@ -1748,6 +1747,12 @@ export function CreateProjectDialog({
const advanceStep = async () => { const advanceStep = async () => {
if (step === "details") { 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 // 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 user reaches Run. Skip in rerun mode (netlist is already on
// the server) and when already done. // the server) and when already done.
@@ -2126,6 +2131,10 @@ export function CreateProjectDialog({
directory directory
files={netlistFiles} files={netlistFiles}
onFilesChange={onKicadProjectFolder} onFilesChange={onKicadProjectFolder}
onPickState={({ busy, error }) => {
setFolderBusy(busy);
if (error) setNetlistError(error);
}}
/> />
{netlistError ? ( {netlistError ? (
<p className="text-sm text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1"> <p className="text-sm text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1">
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useCallback, useState, type ChangeEvent, type DragEvent, type MouseEvent } from "react"; 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 { cn } from "@/lib/utils";
import { isKicadProjectFileName, skipWalkDirName } from "@/lib/kicad-project-files";
export type FileUploadZoneProps = { export type FileUploadZoneProps = {
label: string; label: string;
@@ -12,9 +13,12 @@ export type FileUploadZoneProps = {
files: File[]; files: File[];
onFilesChange: (files: File[]) => void; onFilesChange: (files: File[]) => void;
preloaded?: string[]; 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 { function relativePath(file: File): string {
return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
@@ -32,6 +36,22 @@ function stampRelativePath(file: File, rel: string): File {
return file; return file;
} }
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
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<FileSystemEntry[]> { function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const collected: FileSystemEntry[] = []; const collected: FileSystemEntry[] = [];
@@ -53,21 +73,24 @@ function fileFromEntry(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => entry.file(resolve, reject)); 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<File[]> { async function flattenEntry(root: FileSystemEntry): Promise<File[]> {
const out: File[] = []; const out: File[] = [];
const stack: { entry: FileSystemEntry; prefix: string }[] = [{ entry: root, prefix: "" }]; if (root.isFile) {
while (stack.length > 0) { if (!isKicadProjectFileName(root.name)) return out;
const { entry, prefix } = stack.pop()!; const file = await fileFromEntry(root as FileSystemFileEntry);
if (entry.isFile) { out.push(stampRelativePath(file, file.name));
const file = await fileFromEntry(entry as FileSystemFileEntry); return out;
out.push(stampRelativePath(file, prefix + file.name)); }
continue; if (!root.isDirectory) return out;
} if (skipWalkDirName(root.name)) return out;
if (!entry.isDirectory) continue; const kids = await readAllEntries((root as FileSystemDirectoryEntry).createReader());
if (SKIP_DIR.test(entry.name)) continue; const nestedPrefix = root.name + "/";
const kids = await readAllEntries((entry as FileSystemDirectoryEntry).createReader()); for (const child of kids) {
const nestedPrefix = prefix + entry.name + "/"; if (child.isDirectory) continue;
for (const child of kids) stack.push({ entry: child, prefix: nestedPrefix }); if (!isKicadProjectFileName(child.name)) continue;
const file = await fileFromEntry(child as FileSystemFileEntry);
out.push(stampRelativePath(file, nestedPrefix + child.name));
} }
return out; return out;
} }
@@ -81,12 +104,14 @@ async function filesFromDropEvent(event: DragEvent): Promise<File[]> {
if (entry) fromEntries.push(...(await flattenEntry(entry))); if (entry) fromEntries.push(...(await flattenEntry(entry)));
else { else {
const fallback = item.getAsFile(); const fallback = item.getAsFile();
if (fallback) fromEntries.push(fallback); if (fallback && isKicadProjectFileName(fallback.name)) fromEntries.push(fallback);
} }
} }
if (fromEntries.length > 0) return fromEntries; 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[] { function mergeSelection(current: File[], incoming: File[], multiple: boolean, directory: boolean): File[] {
@@ -102,13 +127,11 @@ type DirHandle = {
async function filesFromDirectoryHandle(handle: DirHandle, prefix = ""): Promise<File[]> { async function filesFromDirectoryHandle(handle: DirHandle, prefix = ""): Promise<File[]> {
const out: File[] = []; const out: File[] = [];
for await (const [name, child] of handle.entries()) { for await (const [name, child] of handle.entries()) {
if (SKIP_DIR.test(name)) continue; if (child.kind === "directory") continue;
if (child.kind === "file" && child.getFile) { if (!isKicadProjectFileName(name)) continue;
const file = await child.getFile(); if (!child.getFile) continue;
out.push(stampRelativePath(file, prefix + name)); const file = await child.getFile();
} else if (child.kind === "directory") { out.push(stampRelativePath(file, prefix + name));
out.push(...(await filesFromDirectoryHandle(child, prefix + name + "/")));
}
} }
return out; return out;
} }
@@ -123,7 +146,7 @@ function pickFolderViaHiddenInput(): Promise<File[]> {
input.style.display = "none"; input.style.display = "none";
const finish = (files: File[]) => { const finish = (files: File[]) => {
input.remove(); input.remove();
resolve(files); resolve(files.filter((f) => isKicadProjectFileName(relativePath(f))));
}; };
input.addEventListener("change", () => finish(Array.from(input.files ?? []))); input.addEventListener("change", () => finish(Array.from(input.files ?? [])));
input.addEventListener("cancel", () => finish([])); input.addEventListener("cancel", () => finish([]));
@@ -139,12 +162,17 @@ async function pickProjectFolder(): Promise<File[]> {
if (typeof picker === "function") { if (typeof picker === "function") {
try { try {
const handle = await picker.call(window); 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) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return []; if (err instanceof DOMException && err.name === "AbortError") return [];
throw err;
} }
} }
return pickFolderViaHiddenInput(); return withTimeout(pickFolderViaHiddenInput(), WALK_MS, WALK_TIMEOUT_IT);
} }
export function FileUploadZone({ export function FileUploadZone({
@@ -155,20 +183,37 @@ export function FileUploadZone({
files, files,
onFilesChange, onFilesChange,
preloaded, preloaded,
onPickState,
}: FileUploadZoneProps) { }: FileUploadZoneProps) {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const [busy, setBusy] = useState(false);
const filled = files.length > 0 || Boolean(preloaded && preloaded.length > 0); 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( const onDrop = useCallback(
(event: DragEvent) => { (event: DragEvent) => {
event.preventDefault(); event.preventDefault();
setHover(false); setHover(false);
void filesFromDropEvent(event).then((dropped) => { report(true, null);
if (dropped.length === 0) return; void withTimeout(filesFromDropEvent(event), WALK_MS, WALK_TIMEOUT_IT)
onFilesChange(mergeSelection(files, dropped, multiple, directory)); .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( const onPick = useCallback(
@@ -184,28 +229,39 @@ export function FileUploadZone({
(event: MouseEvent) => { (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
void pickProjectFolder().then((picked) => { if (busy) return;
if (picked.length === 0) return; report(true, null);
onFilesChange(picked); 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( const boxClass = cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors", "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", hover ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
filled && "border-emerald-500/40 bg-emerald-500/5", filled && "border-emerald-500/40 bg-emerald-500/5",
busy && "pointer-events-none opacity-80",
); );
const body = ( const body = (
<> <>
{filled ? ( {busy ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : filled ? (
<FileCheck className="h-6 w-6 text-emerald-600 dark:text-emerald-400" /> <FileCheck className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
) : ( ) : (
<Upload className="h-6 w-6 text-muted-foreground" /> <Upload className="h-6 w-6 text-muted-foreground" />
)} )}
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{busy ? "Lettura cartella…" : label}</span>
{preloaded && preloaded.length > 0 ? ( {preloaded && preloaded.length > 0 ? (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{preloaded.map((name) => ( {preloaded.map((name) => (
@@ -245,6 +301,7 @@ export function FileUploadZone({
type="button" type="button"
className={cn(boxClass, "w-full")} className={cn(boxClass, "w-full")}
onClick={onOpenFolder} onClick={onOpenFolder}
disabled={busy}
onDragOver={(event) => { onDragOver={(event) => {
event.preventDefault(); event.preventDefault();
setHover(true); setHover(true);
@@ -36,11 +36,31 @@ function parentDir(rel: string): string {
return slash >= 0 ? rel.slice(0, slash) : ""; return slash >= 0 ? rel.slice(0, slash) : "";
} }
function skipRel(rel: string): boolean { export function skipWalkDirName(name: string): boolean {
const low = rel.toLowerCase(); 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/"); 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 { function depth(rel: string): number {
const p = parentDir(rel); const p = parentDir(rel);
if (!p) return 0; if (!p) return 0;
+85
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
import pytest 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) parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch" assert fmt == "kicad_sch"
assert "U13" not in parts 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