diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md
index 5541cfe..4cfec26 100644
--- a/periscope/src/frontend/content/changelog.md
+++ b/periscope/src/frontend/content/changelog.md
@@ -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 ` ` 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.
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 ecae69c..a6212fb 100644
--- a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx
+++ b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx
@@ -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({
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 0908f22..90e1da6 100644
--- a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx
+++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx
@@ -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 {
+ 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 {
+ 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 {
+ const picker = (
+ window as Window & {
+ showDirectoryPicker?: () => Promise;
+ }
+ ).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) => {
const picked = Array.from(event.target.files ?? []);
+ if (picked.length === 0) return;
onFilesChange(mergeSelection(files, picked, multiple, directory));
},
[files, multiple, directory, onFilesChange],
);
- return (
- {
- event.preventDefault();
- setHover(true);
- }}
- onDragLeave={() => setHover(false)}
- onDrop={onDrop}
- >
+ 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",
+ );
+
+ const body = (
+ <>
{filled ? (
) : (
@@ -167,7 +233,7 @@ export function FileUploadZone({
})
) : directory ? (
- {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`}
) : (
@@ -175,23 +241,44 @@ export function FileUploadZone({
)}
) : null}
+ >
+ );
+
+ if (directory) {
+ return (
+ {
+ event.preventDefault();
+ setHover(true);
+ }}
+ onDragLeave={() => setHover(false)}
+ onDrop={onDrop}
+ >
+ {body}
+
+ );
+ }
+
+ return (
+ {
+ event.preventDefault();
+ setHover(true);
+ }}
+ onDragLeave={() => setHover(false)}
+ onDrop={onDrop}
+ >
+ {body}
{
- if (!el) return;
- if (directory) {
- el.setAttribute("webkitdirectory", "");
- el.setAttribute("directory", "");
- el.multiple = true;
- } else {
- el.removeAttribute("webkitdirectory");
- el.removeAttribute("directory");
- }
- }}
/>
);
diff --git a/periscope/src/frontend/src/lib/kicad-project-files.ts b/periscope/src/frontend/src/lib/kicad-project-files.ts
index 4a7ce64..001e0e4 100644
--- a/periscope/src/frontend/src/lib/kicad-project-files.ts
+++ b/periscope/src/frontend/src/lib/kicad-project-files.ts
@@ -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")
);
}