From 027546b0bd780cb5c3db03f99ae5bf7ae25d1cf2 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 21 Sep 2026 08:18:01 +0200 Subject: [PATCH] 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. --- periscope/src/frontend/content/changelog.md | 7 + .../dashboard/create-project-dialog.tsx | 17 ++- .../components/upload/file-upload-zone.tsx | 135 +++++++++++++----- .../frontend/src/lib/kicad-project-files.ts | 24 +++- tests/test_kicad_project.py | 85 +++++++++++ 5 files changed, 223 insertions(+), 45 deletions(-) diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 4cfec26..c51df68 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ 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 Apri progetto opened the Mac file list (**Carica**, no row selected) because the control was a React `` 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. diff --git a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx index a6212fb..8f201ac 100644 --- a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx @@ -76,6 +76,7 @@ import type { } from "@/lib/types"; import { cn } from "@/lib/utils"; import { + detailsStepReady, filesBesideKicadPro, looksLikeKicadPro, looksLikeKicadSch, @@ -635,6 +636,7 @@ export function CreateProjectDialog({ const [netlistUploadedEarly, setNetlistUploadedEarly] = useState(false); const [earlyUploading, setEarlyUploading] = useState(false); const [earlyUploadError, setEarlyUploadError] = useState(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