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:
parent
8131bf9bac
commit
5830f18393
3 changed files with 152 additions and 67 deletions
103
web/standalone/src/components/LocalProjectView.tsx
Normal file
103
web/standalone/src/components/LocalProjectView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 --- */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue