Open a real folder picker for Apri progetto KiCad.

The Mac Carica dialog was a file input (accept empty, no working
webkitdirectory). Use showDirectoryPicker or a hidden input with
webkitdirectory=true and no accept. Ignore empty/cancel picks.
This commit is contained in:
2026-09-21 07:59:06 +02:00
parent fbf2fc87a6
commit 278f176859
4 changed files with 130 additions and 39 deletions
@@ -2,6 +2,12 @@
What's new in Periscope.
## 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.
- [Fixed] One folder gesture; `.kicad_pro` from name or webkitRelativePath.
## 2.60.7 — 2026-09-21 — Detect HubAudio.kicad_pro after folder pick
Apri progetto was rejecting the folder that actually contains `HubAudio.kicad_pro`. Safari/macOS folder pick often puts the real name in `webkitRelativePath` while `File.name` is the stem without `.kicad_pro`, and React was not setting `webkitdirectory` on the input. We match name **or** relative path, sniff JSON if the suffix is missing, and set the folder attribute on the DOM.
@@ -884,6 +884,7 @@ export function CreateProjectDialog({
}, []);
const onKicadProjectFolder = useCallback((incoming: File[]) => {
if (incoming.length === 0) return;
setBomFromBundle(true);
setPcbFromBundle(true);
setPcbFile(null);
@@ -2122,9 +2123,7 @@ export function CreateProjectDialog({
<div className="space-y-1.5">
<FileUploadZone
label="Apri progetto KiCad"
accept=""
directory
multiple
files={netlistFiles}
onFilesChange={onKicadProjectFolder}
/>
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useState, type ChangeEvent, type DragEvent } from "react";
import { useCallback, useState, type ChangeEvent, type DragEvent, type MouseEvent } from "react";
import { FileCheck, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
export type FileUploadZoneProps = {
label: string;
accept: string;
accept?: string;
multiple?: boolean;
directory?: boolean;
files: File[];
@@ -94,6 +94,64 @@ function mergeSelection(current: File[], incoming: File[], multiple: boolean, di
return [...current, ...incoming];
}
async function filesFromDirectoryHandle(
handle: FileSystemDirectoryHandle,
prefix = "",
): Promise<File[]> {
const out: File[] = [];
for await (const [name, child] of handle.entries()) {
if (SKIP_DIR.test(name)) continue;
if (child.kind === "file") {
const file = await child.getFile();
out.push(stampRelativePath(file, prefix + name));
} else if (child.kind === "directory") {
out.push(
...(await filesFromDirectoryHandle(
child as FileSystemDirectoryHandle,
prefix + name + "/",
)),
);
}
}
return out;
}
function pickFolderViaHiddenInput(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.setAttribute("webkitdirectory", "true");
input.setAttribute("directory", "true");
input.style.display = "none";
const finish = (files: File[]) => {
input.remove();
resolve(files);
};
input.addEventListener("change", () => finish(Array.from(input.files ?? [])));
input.addEventListener("cancel", () => finish([]));
document.body.appendChild(input);
input.click();
});
}
async function pickProjectFolder(): Promise<File[]> {
const picker = (
window as Window & {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}
).showDirectoryPicker;
if (typeof picker === "function") {
try {
const handle = await picker.call(window);
return await filesFromDirectoryHandle(handle, `${handle.name}/`);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return [];
}
}
return pickFolderViaHiddenInput();
}
export function FileUploadZone({
label,
accept,
@@ -111,6 +169,7 @@ export function FileUploadZone({
event.preventDefault();
setHover(false);
void filesFromDropEvent(event).then((dropped) => {
if (dropped.length === 0) return;
onFilesChange(mergeSelection(files, dropped, multiple, directory));
});
},
@@ -120,25 +179,32 @@ export function FileUploadZone({
const onPick = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(event.target.files ?? []);
if (picked.length === 0) return;
onFilesChange(mergeSelection(files, picked, multiple, directory));
},
[files, multiple, directory, onFilesChange],
);
return (
<label
className={cn(
const onOpenFolder = useCallback(
(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
void pickProjectFolder().then((picked) => {
if (picked.length === 0) return;
onFilesChange(picked);
});
},
[onFilesChange],
);
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",
)}
onDragOver={(event) => {
event.preventDefault();
setHover(true);
}}
onDragLeave={() => setHover(false)}
onDrop={onDrop}
>
);
const body = (
<>
{filled ? (
<FileCheck className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
) : (
@@ -167,7 +233,7 @@ export function FileUploadZone({
})
) : directory ? (
<span>
{files.find((f) => f.name.toLowerCase().includes("kicad_pro") || (f as File & { webkitRelativePath?: string }).webkitRelativePath?.toLowerCase().includes("kicad_pro"))?.name
{files.find((f) => relativePath(f).toLowerCase().includes("kicad_pro"))?.name
?? `${files.length} file`}
</span>
) : (
@@ -175,23 +241,44 @@ export function FileUploadZone({
)}
</div>
) : null}
</>
);
if (directory) {
return (
<button
type="button"
className={cn(boxClass, "w-full")}
onClick={onOpenFolder}
onDragOver={(event) => {
event.preventDefault();
setHover(true);
}}
onDragLeave={() => setHover(false)}
onDrop={onDrop}
>
{body}
</button>
);
}
return (
<label
className={boxClass}
onDragOver={(event) => {
event.preventDefault();
setHover(true);
}}
onDragLeave={() => setHover(false)}
onDrop={onDrop}
>
{body}
<input
type="file"
accept={directory ? undefined : accept}
multiple={multiple || directory}
accept={accept || undefined}
multiple={multiple}
className="hidden"
onChange={onPick}
ref={(el) => {
if (!el) return;
if (directory) {
el.setAttribute("webkitdirectory", "");
el.setAttribute("directory", "");
el.multiple = true;
} else {
el.removeAttribute("webkitdirectory");
el.removeAttribute("directory");
}
}}
/>
</label>
);
@@ -1,9 +1,8 @@
"""Pick KiCad project files from a folder picker FileList (Chrome + Safari).
/** Pick KiCad project files from a folder picker FileList (Chrome + Safari).
Safari/macOS often puts the real name in ``webkitRelativePath`` while
``File.name`` is the stem without ``.kicad_pro``. Matching only ``file.name``
misses ``HubAudio.kicad_pro`` after Apri progetto.
"""
Safari/macOS often puts the real name in webkitRelativePath while File.name
is the stem without .kicad_pro. Matching only file.name misses HubAudio.kicad_pro.
*/
export type NamedUpload = {
name: string;
@@ -62,8 +61,8 @@ function isKicadKeep(file: NamedUpload): boolean {
base.endsWith(".kicad_sch") ||
base.endsWith(".kicad_pcb") ||
looksLikeKicadSch(file) ||
(file.name.toLowerCase().includes(".kicad_pcb") ||
uploadRel(file).toLowerCase().includes(".kicad_pcb"))
file.name.toLowerCase().includes(".kicad_pcb") ||
uploadRel(file).toLowerCase().includes(".kicad_pcb")
);
}