feat(standalone): browser-local (IndexedDB) virtual projects

Loading a folder now imports a COPY into a browser-local IndexedDB project with
its own /p/:slug URL (original disk files untouched): edits persist to IDB across
visits, and the project exports via Download .zip or per-file. Gated by
VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access
write-back flow), so it's configurable per deployment.

Modular, swappable project sources behind the shared ProjectSource interface,
each self-describing via a SourceDescriptor whose kind is shown verbatim in the
UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the
home page, the project view, and inside the editor, so the user always knows
whether/how Save persists:
  - remote      → REST backend (remote-rw)
  - static      → CDN gallery (remote-ro), saves download
  - local (new) → idbProjectStore, writable IDB store
A composite layers the local store over the configured remote/gallery source,
routing per slug so imported/saved projects and the gallery share one namespace.

New, dependency-free (matching sync-client's raw-IDB ethos):
  - lib/idb-project-store.ts  raw IndexedDB store + create/delete/rename/export
  - lib/zip.ts                store-only ZIP writer (verified via system unzip)
  - lib/import-folder.ts      FSA/webkitdirectory folder → in-memory bytes
  - lib/project-source-shared.ts  source descriptors + deterministic uuid
  - components/SourceChip, components/LocalProjectsSection
Home lists local projects (open/export/rename/delete). Verified: typecheck,
project-source tests, prod build, and IDB key-range project isolation in-browser
(prefix-colliding slugs don't leak).

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

View file

@ -71,6 +71,9 @@ function main() {
// Read-only example gallery, saves download to local.
VITE_PROJECT_SOURCE: "static",
VITE_PROJECT_MANIFEST_URL: `${a.cdn}/content/${a.tag}/manifest.json`,
// Loaded folders import into a browser-local (IndexedDB) project — editable,
// persistent, exported via Download .zip — layered over the gallery.
VITE_LOCAL_PROJECTS: "idb",
// Built-in offline symbols (no backend); cross-tab collab only.
VITE_LIBS_SOURCE: "static",
VITE_YJS_PROVIDER: "broadcastchannel",

View file

@ -0,0 +1,120 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
import { useLocalProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
import { zipFiles } from "@/lib/zip";
import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip";
/**
* The home-page list of browser-local (IndexedDB) projects folders imported
* via "Open a local folder" plus anything saved in the editor. Each row opens
* the project (its own /p/:slug URL), exports it as a .zip, renames, or deletes.
* Only rendered when the local store is enabled (LOCAL_PROJECTS_ENABLED).
*/
export function LocalProjectsSection() {
const { data: projects, isLoading } = useLocalProjects();
const qc = useQueryClient();
const store = localProjectStore();
const [busy, setBusy] = React.useState<string | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ["local-projects"] });
const exportZip = async (slug: string) => {
if (!store) return;
setBusy(slug);
try {
const files = await store.readFiles(slug);
downloadBytes(`${slug}.zip`, zipFiles(files));
} finally {
setBusy(null);
}
};
const rename = async (slug: string, current: string) => {
if (!store) return;
const name = window.prompt("Rename project", current)?.trim();
if (!name || name === current) return;
await store.renameProject(slug, name);
void refresh();
};
const remove = async (slug: string, name: string) => {
if (!store) return;
if (!window.confirm(`Delete "${name}" from this browser? This can't be undone.`))
return;
await store.deleteProject(slug);
void refresh();
};
return (
<section className="mb-10">
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
Your projects
<SourceChip descriptor={SOURCE_DESCRIPTORS.local} />
</h2>
{isLoading ? (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading
</p>
) : projects && projects.length > 0 ? (
<div className="divide-y rounded-lg border">
{projects.map((p) => (
<div
key={p.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
<Button
variant="ghost"
size="sm"
title="Download .zip"
disabled={busy === p.slug}
onClick={() => void exportZip(p.slug)}
>
{busy === p.slug ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Download size={15} />
)}
</Button>
<Button
variant="ghost"
size="sm"
title="Rename"
onClick={() => void rename(p.slug, p.name)}
>
<Pencil size={15} />
</Button>
<Button
variant="ghost"
size="sm"
title="Delete"
onClick={() => void remove(p.slug, p.name)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="rounded-lg border px-4 py-6 text-sm text-muted-foreground">
No saved projects yet open a local folder above to start. Files stay
in this browser; export anytime with Download .zip.
</p>
)}
</section>
);
}

View file

@ -0,0 +1,40 @@
import { Cloud, CloudOff, HardDrive } from "lucide-react";
import type { SourceKind, SourceDescriptor } from "@/lib/project-source-shared";
/**
* A small chip that says explicitly where a project lives and whether saves
* persist there: "Local (this browser)", "Remote · read-only", or "Remote ·
* editable". Shown on the home page sections, the project view, and inside the
* editor so the user always knows what Save does. `title` carries the longer
* description for hover.
*/
const ICONS: Record<SourceKind, typeof HardDrive> = {
local: HardDrive,
"remote-ro": CloudOff,
"remote-rw": Cloud,
};
const TONES: Record<SourceKind, string> = {
local: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
"remote-ro": "border-amber-500/40 bg-amber-500/10 text-amber-300",
"remote-rw": "border-sky-500/40 bg-sky-500/10 text-sky-300",
};
export function SourceChip({
descriptor,
className = "",
}: {
descriptor: SourceDescriptor;
className?: string;
}) {
const Icon = ICONS[descriptor.kind];
return (
<span
title={descriptor.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${TONES[descriptor.kind]} ${className}`}
>
<Icon size={13} />
{descriptor.label}
</span>
);
}

View file

@ -46,6 +46,8 @@ import { clog, cwarn } from "@/wasm/collab/debug";
import type * as Y from "yjs";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
import type { SourceDescriptor } from "@/lib/project-source-shared";
import { SourceChip } from "@/components/SourceChip";
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
@ -508,6 +510,7 @@ export function WasmTool({
docSource,
assetBaseUrl,
libsSource,
sourceDescriptor,
}: {
tool: Tool;
slug: string;
@ -515,6 +518,9 @@ export function WasmTool({
projectId: string;
files: ToolFile[];
targetPath?: string;
/** Where this project lives (local / remote-ro / remote-rw) shown as a chip
* so the user knows whether/how Save persists. Omitted no chip. */
sourceDescriptor?: SourceDescriptor;
/**
* Override the library source the editor browses. Omitted the configured
* default (`libsSourceConfig`). Used to open a single library scoped to itself
@ -833,6 +839,13 @@ export function WasmTool({
</div>
)}
{/* Where this project lives + whether Save persists (top-right). */}
{ready && sourceDescriptor && (
<div className="absolute right-3 top-3 z-20">
<SourceChip descriptor={sourceDescriptor} />
</div>
)}
{/* A library item is being fetched (open/save). */}
{ready && libBusy && (
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">

View file

@ -1,21 +1,49 @@
import type { DriftReportBody, Lib } from "@pcbjam/shared";
import type { DriftReportBody, Lib, Project } from "@pcbjam/shared";
import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
import { client } from "./contract-client";
import { downloadBytes } from "./download";
import { projectSource } from "./project-source";
import {
ReadOnlyProjectError,
descriptorForSlug,
listPrimaryProjects,
localProjectStore,
projectSource,
} from "./project-source";
import type { SourceDescriptor } from "./project-source-shared";
/**
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
* the @pcbjam/shared REST backend, or the read-only static gallery (demo mode).
* Libraries + collab drift reporting are backend-only and stay on the contract
* client here.
* the @pcbjam/shared REST backend, the read-only static gallery (demo mode), or
* the browser-local IndexedDB store composited per slug. Libraries + collab
* drift reporting are backend-only and stay on the contract client here.
*/
/** Remote/gallery projects (excludes browser-local ones those have their own
* hook so the home page can list + manage them as a distinct section). */
export function useProjects() {
return useQuery({
queryKey: ["projects"],
queryFn: () => projectSource().listProjects(),
queryFn: () => listPrimaryProjects(),
});
}
/** Browser-local (IndexedDB) projects; empty when the local store is disabled. */
export function useLocalProjects() {
return useQuery({
queryKey: ["local-projects"],
queryFn: (): Promise<Project[]> =>
localProjectStore()?.listProjects() ?? Promise.resolve([]),
});
}
/** The source kind that owns `slug` (local / remote-ro / remote-rw) for the
* "where your edits go" chip on the project + editor views. */
export function useSourceDescriptor(slug: string) {
return useQuery({
queryKey: ["source-descriptor", slug],
queryFn: (): Promise<SourceDescriptor> => descriptorForSlug(slug),
enabled: !!slug,
});
}
@ -57,17 +85,26 @@ export function fetchFileBytes(
* to the user's machine instead. The remote-vs-static choice is config-driven
* (the active project source), so callers just call this.
*/
export function uploadFileBytes(
export async function uploadFileBytes(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void> {
const source = projectSource();
if (source.uploadFileBytes) {
return source.uploadFileBytes(slug, relPath, bytes);
if (!source.uploadFileBytes) {
downloadBytes(relPath, bytes);
return;
}
try {
await source.uploadFileBytes(slug, relPath, bytes);
} catch (e) {
// Composite write to a read-only (gallery) project → fall back to download.
if (e instanceof ReadOnlyProjectError) {
downloadBytes(relPath, bytes);
return;
}
throw e;
}
downloadBytes(relPath, bytes);
return Promise.resolve();
}
// --- collaboration drift reporting (ysync; backend-only) ---

View file

@ -60,6 +60,17 @@ export const PROJECT_SOURCE_KIND: ProjectSourceKind =
* "https://cdn.pcbjam.com/content/2.7.7/manifest.json". Required for "static". */
export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || null;
/**
* When "idb", loaded folders import into a browser-local (IndexedDB) project
* with its own /p/:slug URL editable, persistent across visits, exported via
* Download .zip / per-file instead of the in-page File System Access flow.
* The local store is layered (composite) alongside the configured remote/gallery
* source. Off by default (plain dev keeps disk write-back); build-demo.mjs turns
* it on for the demo. See lib/idb-project-store.ts + lib/project-source.ts.
*/
export const LOCAL_PROJECTS_ENABLED =
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import { scopedLibsSource } from "@/wasm/libs/scoped-source";

Binary file not shown.

View file

@ -0,0 +1,55 @@
import type { NewFile } from "./idb-project-store";
/**
* Read a picked folder's bytes into memory so it can be COPIED into a
* browser-local (IndexedDB) project. Unlike the File System Access write-back
* flow, this never holds disk handles the import is a snapshot; the user's
* original files are untouched and edits live only in IDB (export to get them
* back out). Two pickers, same result: FSA directory handle (Chromium) or a
* webkitdirectory FileList (Firefox/Safari).
*/
export interface ImportedFolder {
name: string;
files: NewFile[];
}
/** Walk a File System Access directory handle, reading every file's bytes. */
export async function importFsaFolder(
root: FileSystemDirectoryHandle,
): Promise<ImportedFolder> {
const files: NewFile[] = [];
async function walk(
dir: FileSystemDirectoryHandle,
prefix: string,
): Promise<void> {
for await (const [name, handle] of dir.entries()) {
if (handle.kind === "file") {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
path: prefix + name,
bytes: new Uint8Array(await file.arrayBuffer()),
});
} else {
await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
}
}
}
await walk(root, "");
return { name: root.name, files };
}
/** Read a webkitdirectory FileList, stripping the leading top-folder segment. */
export async function importFileList(fileList: FileList): Promise<ImportedFolder> {
const list = Array.from(fileList);
const first = list[0];
const topPrefix = first?.webkitRelativePath?.includes("/")
? first.webkitRelativePath.split("/")[0] + "/"
: "";
const files: NewFile[] = [];
for (const f of list) {
const rel = f.webkitRelativePath || f.name;
const path = rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel;
files.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
}
return { name: topPrefix ? topPrefix.slice(0, -1) : "local", files };
}

View file

@ -0,0 +1,73 @@
/**
* Shared vocabulary for the pluggable PROJECT SOURCES (lib/project-source.ts) and
* the local IndexedDB store (lib/idb-project-store.ts). Kept in its own module so
* both can import the descriptor types + the deterministic-uuid helper without a
* runtime import cycle.
*
* A source has one of three KINDS, surfaced verbatim in the UI so the user always
* knows where their edits go:
* - "remote-rw" a backend the editor reads AND writes (saves upload).
* - "remote-ro" a read-only remote (the demo's CDN gallery); saves download.
* - "local" this browser's IndexedDB; saves persist locally, export by zip.
*/
export type SourceKind = "remote-rw" | "remote-ro" | "local";
export interface SourceDescriptor {
kind: SourceKind;
/** Whether editor saves persist back to this source. */
writable: boolean;
/** Short chip label, e.g. "Local (this browser)". */
label: string;
/** One-line explanation of what saving does, for tooltips/help text. */
description: string;
}
export const SOURCE_DESCRIPTORS: Record<SourceKind, SourceDescriptor> = {
"remote-rw": {
kind: "remote-rw",
writable: true,
label: "Remote · editable",
description: "Stored on the backend — your saves are uploaded there.",
},
"remote-ro": {
kind: "remote-ro",
writable: false,
label: "Remote · read-only",
description:
"A read-only example from the server — edits aren't saved back; Save downloads the file to your machine.",
},
local: {
kind: "local",
writable: true,
label: "Local (this browser)",
description:
"Stored in this browser only — saves persist here across visits. Export with Download .zip (nothing is uploaded).",
},
};
/**
* Stable v4-format UUID from a seed (cyrb128). The contract ids are UUIDs and the
* editor uses project.id for the (broadcast-only here) collab room name, so a
* deterministic id keeps that stable across reloads/tabs for the same slug.
*/
export function deterministicUuid(seed: string): string {
let h1 = 1779033703,
h2 = 3144134277,
h3 = 1013904242,
h4 = 2773480762;
for (let i = 0; i < seed.length; i++) {
const k = seed.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
}

View file

@ -25,6 +25,9 @@ async function loadStatic() {
API_BASE_URL: "http://localhost:3050",
PROJECT_SOURCE_KIND: "static",
PROJECT_MANIFEST_URL: MANIFEST_URL,
// Local IDB store off ⇒ the active source is the plain static gallery
// (no composite), which is what these read-only assertions cover.
LOCAL_PROJECTS_ENABLED: false,
}));
return (await import("./project-source")).projectSource;
}

View file

@ -1,20 +1,33 @@
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
import {
API_BASE_URL,
LOCAL_PROJECTS_ENABLED,
PROJECT_MANIFEST_URL,
PROJECT_SOURCE_KIND,
} from "./config";
import { client } from "./contract-client";
import { idbProjectStore, type LocalProjectStore } from "./idb-project-store";
import {
SOURCE_DESCRIPTORS,
type SourceDescriptor,
deterministicUuid,
} from "./project-source-shared";
/**
* Where the standalone gets its PROJECTS. The default `remote` source talks the
* @pcbjam/shared REST contract; the `static` source serves a read-only example
* gallery (manifest + file bytes) from a CDN with no backend the
* demo.pcbjam.com mode, where Save downloads to local (`uploadFileBytes` absent
* the caller downloads). One source is active per deployment, selected by
* PROJECT_SOURCE_KIND. See docs/features/demo-deploy/.
* Where the standalone gets its PROJECTS. Every source implements this one
* interface (so they're swappable) and self-describes via `descriptor`:
* - remote REST backend over the @pcbjam/shared contract (remote-rw).
* - static read-only example gallery from a CDN, no backend (remote-ro);
* Save downloads to local (`uploadFileBytes` absent).
* - local this browser's IndexedDB (idb-project-store.ts), writable.
* The configured PROJECT_SOURCE_KIND picks the remote/gallery source; when
* LOCAL_PROJECTS_ENABLED, the local IDB store is layered on top (a composite
* that routes per slug) so loaded folders + saved work coexist with the gallery.
* See docs/features/demo-deploy/.
*/
export interface ProjectSource {
/** What this source is + whether saves persist (surfaced in the UI). */
readonly descriptor: SourceDescriptor;
/** No write-back target — the editor should download saves to local. */
readonly readOnly: boolean;
listProjects(): Promise<Project[]>;
@ -41,6 +54,7 @@ function remoteProjectSource(): ProjectSource {
const fileUrl = (slug: string, relPath: string) =>
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
return {
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
readOnly: false,
async listProjects() {
const res = await client.listProjects();
@ -92,31 +106,6 @@ interface StaticManifest {
projects: StaticManifestProject[];
}
/** Stable v4-format UUID from a seed (cyrb128) the contract ids are UUIDs and
* the editor uses project.id for the (broadcast-only here) collab room name, so
* a deterministic id keeps that stable across reloads/tabs. */
function deterministicUuid(seed: string): string {
let h1 = 1779033703,
h2 = 3144134277,
h3 = 1013904242,
h4 = 2773480762;
for (let i = 0; i < seed.length; i++) {
const k = seed.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
function contentTypeFor(path: string): string {
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
return "text/plain; charset=utf-8";
@ -163,6 +152,7 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
};
return {
descriptor: SOURCE_DESCRIPTORS["remote-ro"],
readOnly: true,
async listProjects() {
const m = await load();
@ -184,16 +174,98 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
};
}
// --- composite (local IDB layered over a remote/gallery source) ---------------
/**
* Routes each call to the LOCAL store or the REMOTE/gallery `primary` by which
* one owns the slug, so browser-saved projects and the read-only gallery share
* one `/p/:slug` namespace. A locally-stored slug always wins (local list is
* deduped). `descriptorFor(slug)` answers which kind a given project is, for the
* UI; reads/writes fall through to `primary` for anything not in IDB.
*/
function compositeProjectSource(
local: LocalProjectStore,
primary: ProjectSource,
): ProjectSource {
const route = async (slug: string): Promise<ProjectSource> =>
(await local.hasProject(slug)) ? local : primary;
return {
descriptor: local.descriptor,
readOnly: false,
async listProjects() {
const [a, b] = await Promise.all([
local.listProjects(),
primary.listProjects().catch(() => [] as Project[]),
]);
const localSlugs = new Set(a.map((p) => p.slug));
return [...a, ...b.filter((p) => !localSlugs.has(p.slug))];
},
getProject: (slug) => route(slug).then((s) => s.getProject(slug)),
fetchFileBytes: (slug, p) => route(slug).then((s) => s.fetchFileBytes(slug, p)),
uploadFileBytes: async (slug, p, bytes) => {
const s = await route(slug);
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes);
// Read-only gallery project being edited → download (api.ts also guards).
throw new ReadOnlyProjectError(slug);
},
};
}
/** Thrown by a composite write to a read-only project; api.ts maps it to a
* browser download (the gallery's save-to-local behavior). */
export class ReadOnlyProjectError extends Error {
constructor(slug: string) {
super(`project is read-only: ${slug}`);
this.name = "ReadOnlyProjectError";
}
}
// --- selection ----------------------------------------------------------------
let cached: ProjectSource | null = null;
let cachedPrimary: ProjectSource | null = null;
let cachedLocal: LocalProjectStore | null = null;
let cachedActive: ProjectSource | null = null;
/** The active project source for this deployment (memoized). */
export function projectSource(): ProjectSource {
if (cached) return cached;
cached =
function primarySource(): ProjectSource {
if (cachedPrimary) return cachedPrimary;
cachedPrimary =
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
? staticProjectSource(PROJECT_MANIFEST_URL)
: remoteProjectSource();
return cached;
return cachedPrimary;
}
/**
* The browser-local IDB project store, when enabled for this deployment
* (LOCAL_PROJECTS_ENABLED) used by the home page to import folders + manage
* saved projects. `null` when the feature is off (then loaded folders use the
* in-page File System Access flow instead).
*/
export function localProjectStore(): LocalProjectStore | null {
if (!LOCAL_PROJECTS_ENABLED) return null;
return (cachedLocal ??= idbProjectStore());
}
/** The active project source for this deployment (memoized). When the local IDB
* store is enabled it's a composite over the configured remote/gallery source. */
export function projectSource(): ProjectSource {
if (cachedActive) return cachedActive;
const local = localProjectStore();
cachedActive = local
? compositeProjectSource(local, primarySource())
: primarySource();
return cachedActive;
}
/** The configured remote/gallery list only (excludes browser-local projects)
* the home page shows local + gallery as separate, clearly-labeled sections. */
export function listPrimaryProjects(): Promise<Project[]> {
return primarySource().listProjects();
}
/** Which source kind owns `slug` — for showing/describing it in the UI. */
export async function descriptorForSlug(slug: string): Promise<SourceDescriptor> {
const local = localProjectStore();
if (local && (await local.hasProject(slug))) return local.descriptor;
return primarySource().descriptor;
}

View file

@ -0,0 +1,121 @@
/**
* Minimal store-only (no compression) ZIP writer enough to bundle a project's
* files for a single "Download .zip" export. Hand-rolled to keep the GPL bundle
* dependency-free (same ethos as sync-client's raw IndexedDB store). KiCad
* projects are a handful of text files, so skipping DEFLATE is a fine trade for
* zero deps. Entries are stored with the UTF-8 name flag set; timestamps are
* zeroed (we don't track per-file mtimes here).
*/
export interface ZipEntry {
/** Forward-slash relative path inside the archive. */
path: string;
bytes: Uint8Array;
}
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
/** Build a store-only ZIP archive from the given entries. */
export function zipFiles(entries: ZipEntry[]): Uint8Array {
const enc = new TextEncoder();
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
const u16 = (n: number) => new Uint8Array([n & 0xff, (n >>> 8) & 0xff]);
const u32 = (n: number) =>
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
for (const e of entries) {
const name = enc.encode(e.path);
const crc = crc32(e.bytes);
const size = e.bytes.length;
const flags = 0x0800; // bit 11: filename is UTF-8
// Local file header + name + data.
const local = concat([
u32(0x04034b50),
u16(20), // version needed
u16(flags),
u16(0), // method 0 = store
u16(0), // mod time
u16(0), // mod date
u32(crc),
u32(size), // compressed size (== uncompressed for store)
u32(size), // uncompressed size
u16(name.length),
u16(0), // extra len
name,
e.bytes,
]);
chunks.push(local);
// Central directory header for this entry.
central.push(
concat([
u32(0x02014b50),
u16(20), // version made by
u16(20), // version needed
u16(flags),
u16(0), // method
u16(0), // mod time
u16(0), // mod date
u32(crc),
u32(size),
u32(size),
u16(name.length),
u16(0), // extra len
u16(0), // comment len
u16(0), // disk number start
u16(0), // internal attrs
u32(0), // external attrs
u32(offset), // local header offset
name,
]),
);
offset += local.length;
}
const centralBlob = concat(central);
const eocd = concat([
u32(0x06054b50),
u16(0), // this disk
u16(0), // disk with central dir
u16(entries.length),
u16(entries.length),
u32(centralBlob.length),
u32(offset), // central dir offset
u16(0), // comment len
]);
return concat([...chunks, centralBlob, eocd]);
}
function concat(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
return out;
}

View file

@ -1,14 +1,20 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Lib, Tool } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs, useProjects } from "@/lib/api";
import { PROJECT_SOURCE_KIND } from "@/lib/config";
import { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
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 { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
import { Button } from "@/components/ui/button";
import { ToolGrid } from "@/components/ToolGrid";
import { SourceChip } from "@/components/SourceChip";
import { LocalProjectsSection } from "@/components/LocalProjectsSection";
import type { SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
import { WasmTool } from "@/components/WasmTool";
@ -105,6 +111,11 @@ export function HomePage() {
// Static (no-backend) demo mode: projects come from a read-only CDN gallery,
// there are no backend libraries, and editor saves download to local.
const staticMode = PROJECT_SOURCE_KIND === "static";
// When on, loaded folders import into a browser-local (IDB) project with its
// own URL instead of the in-page File System Access write-back flow.
const localEnabled = LOCAL_PROJECTS_ENABLED;
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: projects, isLoading, error } = useProjects();
const symbolLibs = useLibs("symbol");
const footprintLibs = useLibs("footprint");
@ -128,6 +139,17 @@ export function HomePage() {
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
}, []);
// Import a picked folder into the browser-local store as a new editable
// project, then open it at its own /p/:slug URL. The original disk files are
// untouched (this is a copy); edits persist to IDB, export via Download .zip.
const importToLocal = async (imported: { name: string; files: { path: string; bytes: Uint8Array }[] }) => {
const store = localProjectStore();
if (!store) return;
const project = await store.createProject(imported.name, imported.files);
await queryClient.invalidateQueries({ queryKey: ["local-projects"] });
navigate(`/p/${project.slug}`);
};
// Tool launched from the home page: no project, no files. File-less editors
// read libraries from the backend (libsSourceConfig) and persist through the
// lib write bridge; document editors open a blank document. Either way there's
@ -205,8 +227,9 @@ export function HomePage() {
<FolderOpen size={18} /> Open a local folder
</h2>
<p className="mb-4 text-sm text-muted-foreground">
No upload files stay in your browser. Pick a folder containing a
KiCad project.
{localEnabled
? "No upload — a copy is imported into this browser as an editable project (your original files aren't touched). Save persists here; export anytime with Download .zip."
: "No upload — files stay in your browser. Pick a folder containing a KiCad project."}
</p>
{window.showDirectoryPicker ? (
<Button
@ -215,19 +238,24 @@ export function HomePage() {
void (async () => {
let root: FileSystemDirectoryHandle;
try {
root = await window.showDirectoryPicker!({ mode: "readwrite" });
// Import only needs read; the write-back flow needs readwrite.
root = await window.showDirectoryPicker!({
mode: localEnabled ? "read" : "readwrite",
});
} catch {
return; // user cancelled the picker / denied write access
return; // user cancelled the picker / denied access
}
setLocal(await buildFsaProject(root));
if (localEnabled) await importToLocal(await importFsaFolder(root));
else setLocal(await buildFsaProject(root));
})();
}}
>
<FolderOpen size={16} /> Choose folder
</Button>
) : (
// No File System Access API (Firefox/Safari): read-only folder input;
// editor saves arrive as browser downloads instead of disk writes.
// No File System Access API (Firefox/Safari): folder input. With the
// local store on, the files are imported into IDB; otherwise it's a
// read-only session where editor saves arrive as browser downloads.
<input
ref={inputRef}
type="file"
@ -236,12 +264,19 @@ export function HomePage() {
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
setLocal(buildLocalProject(fl));
if (localEnabled) {
void (async () => importToLocal(await importFileList(fl)))();
} else {
setLocal(buildLocalProject(fl));
}
}}
/>
)}
</section>
{/* --- Your browser-local projects (imported folders + saved work) --- */}
{localEnabled && <LocalProjectsSection />}
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Tools</h2>
@ -250,8 +285,15 @@ export function HomePage() {
{/* --- Projects (backend, or the static example gallery) --- */}
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
{staticMode ? "Example projects" : "Projects from the backend"}
<SourceChip
descriptor={
staticMode
? SOURCE_DESCRIPTORS["remote-ro"]
: SOURCE_DESCRIPTORS["remote-rw"]
}
/>
</h2>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">

View file

@ -1,14 +1,20 @@
import { Link, useParams } from "react-router-dom";
import * as React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
type Tool,
} from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
import { useProject } from "@/lib/api";
import { ArrowLeft, Download, ExternalLink, Loader2, Trash2 } from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip";
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
@ -17,13 +23,45 @@ function toolForPath(path: string): Tool | null {
}
/**
* Read-only view of a backend project: list its files and open them in a tool.
* The editor is GPL and intentionally has no create/delete/upload those live
* in the closed application that hosts this editor.
* 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.
*/
export function ProjectView() {
const { project: slug = "" } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { data, isLoading, error } = useProject(slug);
const { data: descriptor } = useSourceDescriptor(slug);
const isLocal = descriptor?.kind === "local";
const [busy, setBusy] = React.useState(false);
const exportZip = async () => {
const store = localProjectStore();
if (!store) return;
setBusy(true);
try {
downloadBytes(`${slug}.zip`, zipFiles(await store.readFiles(slug)));
} finally {
setBusy(false);
}
};
const downloadOne = async (path: string) => {
downloadBytes(path, await fetchFileBytes(slug, path));
};
const remove = async () => {
const store = localProjectStore();
if (!store || !data) return;
if (!window.confirm(`Delete "${data.project.name}" from this browser? This can't be undone.`))
return;
await store.deleteProject(slug);
await qc.invalidateQueries({ queryKey: ["local-projects"] });
navigate("/");
};
return (
<div className="container py-10">
@ -43,14 +81,30 @@ export function ProjectView() {
{data && (
<>
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-semibold tracking-tight">
{data.project.name}
{descriptor && <SourceChip descriptor={descriptor} />}
</h1>
<p className="text-sm text-muted-foreground">
/p/{data.project.slug}
</p>
<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>
</div>
)}
<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. */}
@ -65,9 +119,7 @@ export function ProjectView() {
))}
</div>
<h2 className="mb-3 text-lg font-medium">
Files ({data.files.length})
</h2>
<h2 className="mb-3 text-lg font-medium">Files ({data.files.length})</h2>
<div className="divide-y rounded-lg border">
{data.files.map((f) => {
const tool = toolForPath(f.path);
@ -82,14 +134,25 @@ export function ProjectView() {
{formatBytes(f.size)}
</p>
</div>
{tool && (
<a
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
href={`/p/${slug}/${tool}/${f.path}`}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</a>
)}
<div className="flex shrink-0 items-center gap-1">
{isLocal && (
<button
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-sm hover:bg-accent"
title="Download this file"
onClick={() => void downloadOne(f.path)}
>
<Download size={14} />
</button>
)}
{tool && (
<a
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
href={`/p/${slug}/${tool}/${f.path}`}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</a>
)}
</div>
</div>
);
})}

View file

@ -1,6 +1,11 @@
import { useParams } from "react-router-dom";
import { toolSchema } from "@pcbjam/shared";
import { fetchFileBytes, uploadFileBytes, useProject } from "@/lib/api";
import {
fetchFileBytes,
uploadFileBytes,
useProject,
useSourceDescriptor,
} from "@/lib/api";
import { docSourceConfig } from "@/lib/config";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
@ -12,6 +17,7 @@ export function ToolPage() {
const parsedTool = toolSchema.safeParse(params.tool);
const { data, isLoading, error } = useProject(slug);
const { data: sourceDescriptor } = useSourceDescriptor(slug);
if (!parsedTool.success) {
return (
@ -56,6 +62,7 @@ export function ToolPage() {
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
docSource={docSource}
sourceDescriptor={sourceDescriptor}
/>
</PreflightGate>
);

View file

@ -18,6 +18,8 @@ interface ImportMetaEnv {
readonly VITE_PROJECT_SOURCE?: string;
/** Static gallery manifest URL (required when VITE_PROJECT_SOURCE=static), e.g. https://cdn.pcbjam.com/content/2.7.7/manifest.json. */
readonly VITE_PROJECT_MANIFEST_URL?: string;
/** "idb" ⇒ loaded folders import into a browser-local (IndexedDB) project with its own URL; otherwise the in-page File System Access flow. */
readonly VITE_LOCAL_PROJECTS?: string;
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
readonly VITE_YJS_PROVIDER?: string;
/** Host/URL for network collab providers (partykit, hocuspocus). */