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:
parent
4839a9d73a
commit
142e2d4e5e
5 changed files with 349 additions and 24 deletions
168
web/standalone/src/components/NewFileDialog.tsx
Normal file
168
web/standalone/src/components/NewFileDialog.tsx
Normal 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 & open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
76
web/standalone/src/lib/new-file.ts
Normal file
76
web/standalone/src/lib/new-file.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { EXTENSION_TOOL, type Tool } from "@pcbjam/shared";
|
||||
|
||||
/**
|
||||
* Minimal valid empty documents per tool — the shapes the WASM KiCad build loads
|
||||
* cleanly (taken from the e2e fixtures; mirrors the closed app's NewFileDialog).
|
||||
* Creating a file from a template (rather than booting the editor blank) means it
|
||||
* already lives at a known path under the project, so every Save persists back
|
||||
* with none of the "save into the right folder" footgun. eeschema needs a unique
|
||||
* root uuid per new file; the others are uuid-free at the top level.
|
||||
*/
|
||||
|
||||
/** Document tools (have a file extension) → that extension. Reverse EXTENSION_TOOL. */
|
||||
export const TOOL_EXT = Object.fromEntries(
|
||||
Object.entries(EXTENSION_TOOL).map(([ext, tool]) => [tool, ext]),
|
||||
) as Partial<Record<Tool, string>>;
|
||||
|
||||
/** The document tools that create a file (pcbnew / eeschema / pl_editor). */
|
||||
export const DOCUMENT_TOOLS: Tool[] = [...new Set(Object.values(EXTENSION_TOOL))];
|
||||
|
||||
export function isDocumentTool(tool: Tool): boolean {
|
||||
return TOOL_EXT[tool] !== undefined;
|
||||
}
|
||||
|
||||
/** Default file name for a new document of this tool, e.g. "main.kicad_pcb". */
|
||||
export function defaultFileName(tool: Tool): string {
|
||||
return `main${TOOL_EXT[tool] ?? ""}`;
|
||||
}
|
||||
|
||||
/** Ensure `name` carries the tool's extension (append if missing). */
|
||||
export function withExtension(tool: Tool, name: string): string {
|
||||
const ext = TOOL_EXT[tool] ?? "";
|
||||
return ext && !name.toLowerCase().endsWith(ext) ? `${name}${ext}` : name;
|
||||
}
|
||||
|
||||
export function newFileTemplate(tool: Tool, uuid: string): string {
|
||||
switch (tool) {
|
||||
case "eeschema":
|
||||
return `(kicad_sch
|
||||
\t(version 20250114)
|
||||
\t(generator "eeschema")
|
||||
\t(generator_version "9.0")
|
||||
\t(uuid "${uuid}")
|
||||
\t(paper "A4")
|
||||
\t(lib_symbols)
|
||||
\t(sheet_instances
|
||||
\t\t(path "/" (page "1"))
|
||||
\t)
|
||||
)
|
||||
`;
|
||||
case "pcbnew":
|
||||
return `(kicad_pcb
|
||||
\t(version 20241229)
|
||||
\t(generator "pcbnew")
|
||||
\t(generator_version "9.0")
|
||||
\t(general (thickness 1.6))
|
||||
\t(paper "A4")
|
||||
\t(layers
|
||||
\t\t(0 "F.Cu" signal)
|
||||
\t\t(2 "B.Cu" signal)
|
||||
\t\t(37 "F.SilkS" user)
|
||||
\t\t(25 "Edge.Cuts" user)
|
||||
\t)
|
||||
\t(setup)
|
||||
\t(net 0 "")
|
||||
)
|
||||
`;
|
||||
case "pl_editor":
|
||||
return `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0")
|
||||
\t(setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)
|
||||
\t\t(left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10))
|
||||
)
|
||||
`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ import type { LibsSource } from "@/wasm/libs/source";
|
|||
import { downloadBytes } from "@/lib/download";
|
||||
import { importFileList, importFsaFolder } from "@/lib/import-folder";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { isDocumentTool } from "@/lib/new-file";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolGrid } from "@/components/ToolGrid";
|
||||
import { ProjectsSection } from "@/components/ProjectsSection";
|
||||
import { NewFileDialog } from "@/components/NewFileDialog";
|
||||
import type { SaveBytes } from "@/wasm/save-flow";
|
||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
|
|
@ -130,6 +132,9 @@ export function HomePage() {
|
|||
tool: Tool;
|
||||
libsSource?: LibsSource | null;
|
||||
} | null>(null);
|
||||
// A document tool launched from the grid with the local store on → ask for a
|
||||
// name + project (new-file dialog) instead of booting a throwaway blank doc.
|
||||
const [newFileTool, setNewFileTool] = React.useState<Tool | null>(null);
|
||||
|
||||
// <input webkitdirectory> is non-standard; set it imperatively.
|
||||
React.useEffect(() => {
|
||||
|
|
@ -278,9 +283,17 @@ export function HomePage() {
|
|||
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 text-lg font-medium">Tools</h2>
|
||||
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
|
||||
<ToolGrid
|
||||
onLaunch={(tool) =>
|
||||
localEnabled && isDocumentTool(tool)
|
||||
? setNewFileTool(tool)
|
||||
: setLaunchedTool({ tool })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NewFileDialog tool={newFileTool} onClose={() => setNewFileTool(null)} />
|
||||
|
||||
{/* --- Backend libraries (hidden in the no-backend static demo) --- */}
|
||||
{!staticMode && (
|
||||
<section>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,24 @@ import {
|
|||
TOOL_LABELS,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ArrowLeft, Download, ExternalLink, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { DOCUMENT_TOOLS } from "@/lib/new-file";
|
||||
import { zipFiles } from "@/lib/zip";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
import { NewFileDialog } from "@/components/NewFileDialog";
|
||||
|
||||
function toolForPath(path: string): Tool | null {
|
||||
const dot = path.lastIndexOf(".");
|
||||
|
|
@ -23,11 +33,11 @@ function toolForPath(path: string): Tool | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* View of a project's files (open them in a tool). For a browser-local project
|
||||
* it also exports — Download .zip for the whole project, or per file — and can
|
||||
* delete it; the source chip says where edits go. Backend projects stay
|
||||
* read-mostly (create/delete live in the closed app that hosts this editor); the
|
||||
* read-only gallery downloads on save.
|
||||
* Project-centric view (mirrors the closed app): Files + a "New file" row whose
|
||||
* document-tool buttons create a templated file in THIS project and open it, so
|
||||
* you always edit a real file that saves back. Writable projects (local IDB or a
|
||||
* remote-rw backend) get New / export; a read-only gallery project gets "Save a
|
||||
* copy to browser" (fork into editable IDB). The source chip says where edits go.
|
||||
*/
|
||||
export function ProjectView() {
|
||||
const { project: slug = "" } = useParams();
|
||||
|
|
@ -36,7 +46,9 @@ export function ProjectView() {
|
|||
const { data, isLoading, error } = useProject(slug);
|
||||
const { data: descriptor } = useSourceDescriptor(slug);
|
||||
const isLocal = descriptor?.kind === "local";
|
||||
const canFork = descriptor?.kind === "remote-ro" && !!localProjectStore();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [newFileTool, setNewFileTool] = React.useState<Tool | null>(null);
|
||||
|
||||
const exportZip = async () => {
|
||||
const store = localProjectStore();
|
||||
|
|
@ -63,6 +75,26 @@ export function ProjectView() {
|
|||
navigate("/");
|
||||
};
|
||||
|
||||
// Copy a read-only gallery project into an editable browser-local project.
|
||||
const fork = async () => {
|
||||
const store = localProjectStore();
|
||||
if (!store || !data) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
data.files.map(async (f) => ({
|
||||
path: f.path,
|
||||
bytes: await fetchFileBytes(slug, f.path),
|
||||
})),
|
||||
);
|
||||
const created = await store.createProject(data.project.name, files);
|
||||
await qc.invalidateQueries({ queryKey: ["local-projects"] });
|
||||
window.location.assign(`/p/${created.slug}`);
|
||||
} catch {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-10">
|
||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||
|
|
@ -88,26 +120,56 @@ export function ProjectView() {
|
|||
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
|
||||
</div>
|
||||
|
||||
{isLocal && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void exportZip()}>
|
||||
{busy ? <Loader2 className="animate-spin" size={15} /> : <Download size={15} />}
|
||||
Download .zip
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 size={15} /> Delete
|
||||
</Button>
|
||||
{/* New file (writable projects): a templated doc, created then opened. */}
|
||||
{descriptor?.writable && (
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
New file
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DOCUMENT_TOOLS.map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setNewFileTool(tool)}
|
||||
>
|
||||
<FilePlus size={14} /> {TOOL_LABELS[tool]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project-level actions. */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
{canFork && (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void fork()}>
|
||||
{busy ? <Loader2 className="animate-spin" size={15} /> : <Copy size={15} />}
|
||||
Save a copy to browser
|
||||
</Button>
|
||||
)}
|
||||
{isLocal && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void exportZip()}>
|
||||
{busy ? <Loader2 className="animate-spin" size={15} /> : <Download size={15} />}
|
||||
Download .zip
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 size={15} /> Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File-less tools (gerber viewer, calculator, lib editors) — still
|
||||
project-scoped, opened without a target file. Full reload. */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
{/* File-less tools — launched without a target file. Full reload
|
||||
(anchor) so Emscripten boots into a clean page. */}
|
||||
{[...FILELESS_TOOLS].map((tool) => (
|
||||
<a
|
||||
key={tool}
|
||||
|
|
@ -158,10 +220,16 @@ export function ProjectView() {
|
|||
})}
|
||||
{data.files.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||
No files in this project.
|
||||
No files yet — use “New file” above to create one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewFileDialog
|
||||
tool={newFileTool}
|
||||
onClose={() => setNewFileTool(null)}
|
||||
project={{ slug, existingPaths: data.files.map((f) => f.path) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue