feat: local-folder project view + suppress browser save dialog on Cmd/Ctrl+S

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-12 11:29:17 +02:00
commit 5830f18393
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 152 additions and 67 deletions

View file

@ -0,0 +1,103 @@
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
type Tool,
} from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, FolderOpen } from "lucide-react";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export interface LocalFile {
path: string;
size?: number;
}
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
/**
* The local-folder twin of ProjectView: list the picked folder's files and open
* one in its tool. Unlike backend projects this CANNOT navigate (the folder
* handles / File objects live in this page's JS memory and don't survive a
* reload), so opening is a callback that swaps this view for the editor
* in-place and "Back" only works until a tool has launched (the WASM runtime
* is one-shot per page load).
*/
export function LocalProjectView({
name,
files,
onOpen,
onBack,
}: {
name: string;
files: LocalFile[];
/** Launch a tool; `path` is undefined for file-less tools. */
onOpen: (tool: Tool, path?: string) => void;
onBack: () => void;
}) {
return (
<div className="container py-10">
<Button variant="ghost" size="sm" className="mb-4" onClick={onBack}>
<ArrowLeft /> Back
</Button>
<div className="mb-6">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<FolderOpen size={22} /> {name}
</h1>
<p className="text-sm text-muted-foreground">
local folder files stay in your browser
</p>
</div>
<div className="mb-6 flex flex-wrap gap-3">
{[...FILELESS_TOOLS].map((tool) => (
<button
key={tool}
className="text-sm underline underline-offset-4"
onClick={() => onOpen(tool)}
>
Open {TOOL_LABELS[tool]}
</button>
))}
</div>
<h2 className="mb-3 text-lg font-medium">Files ({files.length})</h2>
<div className="divide-y rounded-lg border">
{files.map((f) => {
const tool = toolForPath(f.path);
return (
<div
key={f.path}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{f.path}</p>
{f.size !== undefined && (
<p className="text-xs text-muted-foreground">{formatBytes(f.size)}</p>
)}
</div>
{tool && (
<button
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpen(tool, f.path)}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</button>
)}
</div>
);
})}
{files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files in this folder.
</div>
)}
</div>
</div>
);
}

View file

@ -418,6 +418,16 @@ export function WasmTool({
const { proceed } = oom.start();
if (!proceed) return;
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
// browser's "save page" dialog (observed in Firefox) — the keydown still
// propagates to the wx canvas handler, which performs the actual save.
const swallowBrowserSave = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "s") {
e.preventDefault();
}
};
win.addEventListener("keydown", swallowBrowserSave, true);
void (async () => {
try {
await bootKicadTool({
@ -469,7 +479,10 @@ export function WasmTool({
}
})();
return () => oom.stop();
return () => {
win.removeEventListener("keydown", swallowBrowserSave, true);
oom.stop();
};
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
// they don't retrigger a (rejected) second boot.
// eslint-disable-next-line react-hooks/exhaustive-deps

View file

@ -1,24 +1,18 @@
import * as React from "react";
import { Link } from "react-router-dom";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
TOOLS,
type Tool,
} from "@pcbjam/shared";
import type { Tool } from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { Button } from "@/components/ui/button";
import type { ToolFile } from "@/wasm/kicad-runner";
import type { SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
import { WasmTool } from "@/components/WasmTool";
/** A KiCad project picked from the local filesystem (no backend involved). */
interface LocalProject {
name: string;
files: ToolFile[];
files: LocalFile[];
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
* Where editor saves land: write-back through File System Access handles
@ -26,22 +20,6 @@ interface LocalProject {
* (webkitdirectory fallback its FileList grants no write access).
*/
saveBytes: SaveBytes;
defaultTool?: Tool;
defaultTarget?: string;
}
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
function defaultOpenTarget(files: ToolFile[]): { defaultTool?: Tool; defaultTarget?: string } {
for (const { path } of files) {
const tool = toolForPath(path);
if (tool) return { defaultTool: tool, defaultTarget: path };
}
return {};
}
/**
@ -51,18 +29,22 @@ function defaultOpenTarget(files: ToolFile[]): { defaultTool?: Tool; defaultTarg
*/
async function buildFsaProject(root: FileSystemDirectoryHandle): Promise<LocalProject> {
const handles = new Map<string, FileSystemFileHandle>();
const files: LocalFile[] = [];
async function walk(dir: FileSystemDirectoryHandle, prefix: string): Promise<void> {
for await (const [name, handle] of dir.entries()) {
if (handle.kind === "file") handles.set(prefix + name, handle as FileSystemFileHandle);
else await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
if (handle.kind === "file") {
const fh = handle as FileSystemFileHandle;
handles.set(prefix + name, fh);
files.push({ path: prefix + name, size: (await fh.getFile()).size });
} else {
await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
}
}
}
await walk(root, "");
const files: ToolFile[] = [...handles.keys()].map((path) => ({ path }));
return {
name: root.name,
files,
...defaultOpenTarget(files),
fetchBytes: async (relPath) => {
const handle = handles.get(relPath);
if (!handle) throw new Error(`local file not found: ${relPath}`);
@ -98,11 +80,13 @@ function buildLocalProject(fileList: FileList): LocalProject {
const rel = f.webkitRelativePath || f.name;
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
}
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
const files: LocalFile[] = [...map.entries()].map(([path, f]) => ({
path,
size: f.size,
}));
return {
name: topPrefix ? topPrefix.slice(0, -1) : "local",
files,
...defaultOpenTarget(files),
fetchBytes: async (relPath) => {
const f = map.get(relPath);
if (!f) throw new Error(`local file not found: ${relPath}`);
@ -117,8 +101,9 @@ export function HomePage() {
const { data: projects, isLoading, error } = useProjects();
const inputRef = React.useRef<HTMLInputElement>(null);
const [local, setLocal] = React.useState<LocalProject | null>(null);
const [tool, setTool] = React.useState<Tool | "">("");
const [launched, setLaunched] = React.useState(false);
const [launched, setLaunched] = React.useState<{ tool: Tool; target?: string } | null>(
null,
);
// <input webkitdirectory> is non-standard; set it imperatively.
React.useEffect(() => {
@ -127,21 +112,32 @@ export function HomePage() {
// Once launched, the WASM runtime is process-global and one-shot for this page
// load — render the editor full-screen and nothing else (no going back).
if (launched && local && tool) {
const target = tool === local.defaultTool ? local.defaultTarget : undefined;
if (local && launched) {
return (
<WasmTool
tool={tool}
tool={launched.tool}
slug="local"
projectId="local"
files={local.files}
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
targetPath={launched.target}
fetchBytes={local.fetchBytes}
saveBytes={local.saveBytes}
/>
);
}
// Folder picked but no tool launched yet: the local twin of ProjectView.
if (local) {
return (
<LocalProjectView
name={local.name}
files={local.files}
onOpen={(tool, path) => setLaunched({ tool, target: path })}
onBack={() => setLocal(null)}
/>
);
}
return (
<div className="container max-w-3xl py-10">
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
@ -170,9 +166,7 @@ export function HomePage() {
} catch {
return; // user cancelled the picker / denied write access
}
const proj = await buildFsaProject(root);
setLocal(proj);
setTool(proj.defaultTool ?? "");
setLocal(await buildFsaProject(root));
})();
}}
>
@ -189,35 +183,10 @@ export function HomePage() {
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
const proj = buildLocalProject(fl);
setLocal(proj);
setTool(proj.defaultTool ?? "");
setLocal(buildLocalProject(fl));
}}
/>
)}
{local && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{local.files.length} files
</span>
<select
className="rounded-md border px-2 py-1.5 text-sm"
value={tool}
onChange={(e) => setTool(e.target.value as Tool)}
>
<option value="">Select a tool</option>
{TOOLS.map((t) => (
<option key={t} value={t}>
{TOOL_LABELS[t]}
</option>
))}
</select>
<Button disabled={!tool} onClick={() => setLaunched(true)}>
Open editor
</Button>
</div>
)}
</section>
{/* --- Backend projects --- */}