feat(standalone): project-centric new-file flow + fork gallery; clean idb key-sep source

Mirrors the closed app: create a templated file then open it, so you always edit a real file that saves back.

- lib/new-file.ts: minimal valid KiCad templates (pcbnew/eeschema/pl_editor, from the e2e fixtures) + document-tool helpers.

- NewFileDialog: in-project mode (file name) and home mode (pick/create a browser-local project + file name); writes via the active source uploadFileBytes (local IDB or remote-rw backend), then opens by path.

- HomePage: a document tool from the grid (local store on) opens the dialog instead of booting a throwaway blank doc.

- ProjectView: project-centric New-file row (writable projects) + Save-a-copy-to-browser fork for read-only gallery projects (copies into editable IDB), alongside files/export.

Also replaces a stray NUL byte that had been written into idb-project-store.ts in place of a space (made the file parse as binary) with an explicit unicode escape for the key separator (NUL, sorts below every slug char so project key ranges cannot collide) and the range upper bound. Runtime behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-20 10:02:36 +02:00
commit 142e2d4e5e
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 349 additions and 24 deletions

View file

@ -0,0 +1,168 @@
import * as React from "react";
import { TOOL_LABELS, type Tool } from "@pcbjam/shared";
import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api";
import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
import {
defaultFileName,
newFileTemplate,
withExtension,
} from "@/lib/new-file";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Create-a-new-file dialog for a document editor (mirrors the closed app's
* NewFileDialog). Writes a minimal valid template into the target project, then
* navigates to open it by path — so every Save persists straight back, with none
* of the "save into the right folder" footgun of a blank editor.
*
* Two modes:
* - `project` set → create into that existing project (file name only).
* - `project` unset → home mode: pick an existing browser-local project or
* create a new one (name + file name).
*/
const NEW = "__new__";
export function NewFileDialog({
tool,
onClose,
project,
}: {
tool: Tool | null;
onClose: () => void;
project?: { slug: string; existingPaths: string[] };
}) {
const localQ = useLocalProjects();
const store = localProjectStore();
const [fileName, setFileName] = React.useState("");
const [target, setTarget] = React.useState<string>(NEW);
const [projectName, setProjectName] = React.useState("Untitled");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (tool) {
setFileName(defaultFileName(tool));
setProjectName("Untitled");
setTarget(project ? project.slug : NEW);
setError(null);
setBusy(false);
}
}, [tool, project]);
if (!tool) return null;
const homeMode = !project;
const submit = async () => {
const trimmed = fileName.trim();
if (!trimmed) {
setError("Enter a file name.");
return;
}
const finalName = withExtension(tool, trimmed);
if (project && project.existingPaths.includes(finalName)) {
setError(`"${finalName}" already exists in this project.`);
return;
}
setBusy(true);
setError(null);
try {
const bytes = new TextEncoder().encode(
newFileTemplate(tool, crypto.randomUUID()),
);
let slug: string;
if (homeMode && target === NEW) {
if (!store) throw new Error("local project store unavailable");
const created = await store.createProject(projectName.trim() || "Untitled", [
{ path: finalName, bytes },
]);
slug = created.slug;
} else {
slug = project ? project.slug : target;
await uploadFileBytes(slug, finalName, bytes);
}
// Full navigation so Emscripten boots into a clean page opening the file.
window.location.assign(
`/p/${encodeURIComponent(slug)}/${tool}/${finalName}`,
);
} catch (e) {
setBusy(false);
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>New {TOOL_LABELS[tool]} file</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{homeMode && (
<div>
<Label htmlFor="newfile-project">Project</Label>
<select
id="newfile-project"
className="mt-1 h-9 w-full rounded-md border bg-transparent px-3 text-sm"
value={target}
disabled={busy}
onChange={(e) => setTarget(e.target.value)}
>
<option value={NEW}>New project…</option>
{(localQ.data ?? []).map((p) => (
<option key={p.slug} value={p.slug}>
{p.name}
</option>
))}
</select>
</div>
)}
{homeMode && target === NEW && (
<div>
<Label htmlFor="newfile-projectname">Project name</Label>
<Input
id="newfile-projectname"
value={projectName}
disabled={busy}
onChange={(e) => setProjectName(e.target.value)}
/>
</div>
)}
<div>
<Label htmlFor="newfile-name">File name</Label>
<Input
id="newfile-name"
value={fileName}
autoFocus
disabled={busy}
onChange={(e) => setFileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
Created in the project, then opened in the editor — saves persist
back to it.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button onClick={() => void submit()} disabled={busy}>
{busy && <Loader2 className="mr-1 animate-spin" />} Create &amp; open
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}