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 d691029..44a0d1a 100644 Binary files a/web/standalone/src/lib/idb-project-store.ts and b/web/standalone/src/lib/idb-project-store.ts differ diff --git a/web/standalone/src/lib/new-file.ts b/web/standalone/src/lib/new-file.ts new file mode 100644 index 0000000..c5c42ce --- /dev/null +++ b/web/standalone/src/lib/new-file.ts @@ -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>; + +/** 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) }} + /> )}