From 142e2d4e5ef7e697c101e3fb1e715ee958260d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sat, 20 Jun 2026 10:02:36 +0200 Subject: [PATCH] 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) --- .../src/components/NewFileDialog.tsx | 168 ++++++++++++++++++ web/standalone/src/lib/idb-project-store.ts | Bin 8865 -> 9093 bytes web/standalone/src/lib/new-file.ts | 76 ++++++++ web/standalone/src/pages/HomePage.tsx | 15 +- web/standalone/src/pages/ProjectView.tsx | 114 +++++++++--- 5 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 web/standalone/src/components/NewFileDialog.tsx create mode 100644 web/standalone/src/lib/new-file.ts diff --git a/web/standalone/src/components/NewFileDialog.tsx b/web/standalone/src/components/NewFileDialog.tsx new file mode 100644 index 0000000..ba8a5a0 --- /dev/null +++ b/web/standalone/src/components/NewFileDialog.tsx @@ -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(NEW); + const [projectName, setProjectName] = React.useState("Untitled"); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(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 ( + !o && onClose()}> + + + New {TOOL_LABELS[tool]} file + +
+ {homeMode && ( +
+ + +
+ )} + {homeMode && target === NEW && ( +
+ + setProjectName(e.target.value)} + /> +
+ )} +
+ + setFileName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit(); + }} + /> +

+ Created in the project, then opened in the editor — saves persist + back to it. +

+
+ {error &&

{error}

} +
+ + + +
+
+ ); +} diff --git a/web/standalone/src/lib/idb-project-store.ts b/web/standalone/src/lib/idb-project-store.ts index d691029b2c857e697401dbd28b5bf556fe855587..44a0d1a8c43e829179e801a8d7adf270f8af17f5 100644 GIT binary patch delta 499 zcmaKo&r8EF6vx>n2%8{N5b^Yofle3aQ4|ODx1b^$f`TAz`r5|Tq$H_YL_B#E#Qp{T z0iNbx=3OvNy$Qa(Gh@D^nT7EdErx>Td=n1sb9<=6|VBo-v< za^S@$b8-@$N(7e+9icQSLJA&-L}I}12^De7e8xk|R}ixyLLdZmi%vXO=1wiCl3xB< zTY37U2&4ylEXF=kA^o~>RN6Xi!u(~rYHEH1y4^x<{XZvIw1X%nI59>dC~Z{x7kTWQ zUu_>WojzD_V9J8?kH*}rnZaz7*o{J(Bu3m5DfdUyV=v?RS7WWwdME)-|8Ybn^vn9q_h)dh(q8MO%t#rcy1IfP{r zi&7Qx^HLR(^YcnF(@XP9ixsj{D;0_o^U_l%-)Amhe$+N;av+PIA*KmH83nh@oYbJy z [tool, ext]), +) as Partial>; + +/** 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 ""; + } +} diff --git a/web/standalone/src/pages/HomePage.tsx b/web/standalone/src/pages/HomePage.tsx index d8bade4..8de0002 100644 --- a/web/standalone/src/pages/HomePage.tsx +++ b/web/standalone/src/pages/HomePage.tsx @@ -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(null); // is non-standard; set it imperatively. React.useEffect(() => { @@ -278,9 +283,17 @@ export function HomePage() { {/* --- Tools (KiCad-style launcher for the standalone tools) --- */}

Tools

- setLaunchedTool({ tool })} /> + + localEnabled && isDocumentTool(tool) + ? setNewFileTool(tool) + : setLaunchedTool({ tool }) + } + />
+ setNewFileTool(null)} /> + {/* --- Backend libraries (hidden in the no-backend static demo) --- */} {!staticMode && (
diff --git a/web/standalone/src/pages/ProjectView.tsx b/web/standalone/src/pages/ProjectView.tsx index 019c41f..c349dbf 100644 --- a/web/standalone/src/pages/ProjectView.tsx +++ b/web/standalone/src/pages/ProjectView.tsx @@ -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(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 (
- {isLocal && ( -
- - + {/* New file (writable projects): a templated doc, created then opened. */} + {descriptor?.writable && ( +
+

+ New file +

+
+ {DOCUMENT_TOOLS.map((tool) => ( + + ))} +
)} + {/* Project-level actions. */} +
+ {canFork && ( + + )} + {isLocal && ( + <> + + + + )} +
+ + {/* File-less tools (gerber viewer, calculator, lib editors) — still + project-scoped, opened without a target file. Full reload. */}
- {/* File-less tools — launched without a target file. Full reload - (anchor) so Emscripten boots into a clean page. */} {[...FILELESS_TOOLS].map((tool) => ( - No files in this project. + No files yet — use “New file” above to create one.
)}
+ + setNewFileTool(null)} + project={{ slug, existingPaths: data.files.map((f) => f.path) }} + /> )}