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:
@@ -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 `<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";
|
||||
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<string | null>(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 ? (
|
||||
<p className="text-sm text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1">
|
||||
|
||||
@@ -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<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[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const collected: FileSystemEntry[] = [];
|
||||
@@ -53,21 +73,24 @@ function fileFromEntry(entry: FileSystemFileEntry): Promise<File> {
|
||||
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[]> {
|
||||
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<File[]> {
|
||||
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<File[]> {
|
||||
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<File[]> {
|
||||
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<File[]> {
|
||||
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 ? (
|
||||
<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" />
|
||||
) : (
|
||||
<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 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user