diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index f4a9df5..1f98b85 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { createReadStream, existsSync, readFileSync } from "node:fs"; +import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { dirname, extname, join, normalize, resolve, sep } from "node:path"; @@ -49,6 +49,11 @@ export interface ServeOptions { export async function serve(opts: ServeOptions): Promise { const webDist = resolveWebDist(); const upstream = new URL(opts.connection.server); + // Layout sidecar: .layout.json, beside the notebook — + // written by PUT /layout ("Save layout" in the UI), read fresh per page load + // so external edits/git pulls apply on reload. Git-committable. + const sidecarPath = + opts.notebookPath.replace(/\.ya?ml$/i, "") + ".layout.json"; // Config injected into index.html as `window.__DASHBOOK__`, so the bare URL // (http://host:port/) loads the served notebook over the same-origin `/og` @@ -85,6 +90,19 @@ export async function serve(opts: ServeOptions): Promise { sendFile(res, opts.notebookPath, ".yaml"); return; } + // 2b. layout sidecar persistence (local file I/O — never proxied) + if (path === "/layout") { + if (req.method !== "PUT") { + res.writeHead(405, { + "content-type": "text/plain; charset=utf-8", + allow: "PUT", + }); + res.end("405 Method Not Allowed"); + return; + } + receiveLayout(req, res, sidecarPath); + return; + } // 3. real static files under web-dist (never index.html — that's injected) const staticPath = safeJoin(webDist, path); if ( @@ -96,8 +114,19 @@ export async function serve(opts: ServeOptions): Promise { sendFile(res, staticPath, extname(staticPath)); return; } - // 4. SPA fallback — index.html with the view config injected. - sendIndex(res, join(webDist, "index.html"), viewConfig); + // 4. SPA fallback — index.html with the view config injected. The sidecar + // rides the injection (read per request, so a reload picks up external + // edits) and canPersistLayout tells the SPA the Save-layout PUT exists. + const config: Record = { + ...viewConfig, + canPersistLayout: true, + }; + try { + config.layout = JSON.parse(readFileSync(sidecarPath, "utf8")) as unknown; + } catch { + /* no sidecar yet, or unreadable/garbage — the SPA validates anyway */ + } + sendIndex(res, join(webDist, "index.html"), config); }); await new Promise((resolvePromise, reject) => { @@ -156,6 +185,55 @@ function buildOpenUrl(port: number): string { return `http://127.0.0.1:${port}/`; } +/** Max accepted layout-sidecar body — far above any real notebook's overrides. */ +const LAYOUT_BODY_LIMIT = 256 * 1024; + +/** + * PUT /layout: collect the JSON body (size-capped), validate it parses, and + * pretty-print it to the sidecar so the file diffs cleanly under git. + */ +function receiveLayout( + req: http.IncomingMessage, + res: http.ServerResponse, + sidecarPath: string, +): void { + const chunks: Buffer[] = []; + let size = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + if (aborted) return; + size += chunk.length; + if (size > LAYOUT_BODY_LIMIT) { + aborted = true; + res.writeHead(413, { "content-type": "text/plain; charset=utf-8" }); + res.end("413 Payload Too Large"); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (aborted) return; + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); + res.end("400 Bad Request: body must be JSON"); + return; + } + try { + writeFileSync(sidecarPath, `${JSON.stringify(parsed, null, 2)}\n`); + } catch (err) { + res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + res.end(`500 could not write layout sidecar: ${String(err)}`); + return; + } + res.writeHead(204); + res.end(); + }); +} + function sendFile(res: http.ServerResponse, file: string, ext: string): void { const stream = createReadStream(file); // Commit the 200 only once the file is confirmed readable (`open`). On a read diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 64cbca1..92a135e 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -67,7 +67,9 @@ import { EMPTY_OVERRIDES, isHidden, loadOverrides, + migrateV2, notebookKey, + pruneOverrides, saveOverrides, withColor, withHidden, @@ -167,13 +169,30 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { >(null); const [cmdOpen, setCmdOpen] = useState(false); - // Layout edit mode: browser-local drag-reorder + width-resize, persisted to - // localStorage. An override layer over the declared layout; Reset clears it. + // Layout edit mode: drag-reorder + width-resize. Two override layers, both + // per-cell: the committed sidecar (`.layout.json`, injected by the + // view BFF as `config.initialLayout`) is the base; localStorage holds + // personal changes since the last save and wins wholesale when present. + // "Save layout" writes the effective set to the sidecar and clears the + // local layer; Reset clears only the local layer. const [editing, setEditing] = useState(false); const layoutKey = useMemo(() => notebookKey(config.notebook), [config.notebook]); - const [overrides, setOverrides] = useState(() => - loadOverrides(layoutKey), + const liveIds = useMemo( + () => new Set(config.notebook.cells.map((c) => c.id)), + [config.notebook], + ); + // The committed baseline (the sidecar's contents) — seeded from the page + // load and ADVANCED on every successful save, so Reset always falls back to + // the latest saved arrangement, not the page-load snapshot. + const [committedLayout, setCommittedLayout] = useState( + config.initialLayout, ); + const [overrides, setOverrides] = useState(() => { + const local = + loadOverrides(layoutKey) ?? migrateV2(config.notebook.title, liveIds); + if (local !== null) return pruneOverrides(local, liveIds); + return config.initialLayout ?? EMPTY_OVERRIDES; + }); const updateOverrides = useCallback( (next: LayoutOverrides) => { setOverrides(next); @@ -182,9 +201,44 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { [layoutKey], ); const resetLayout = useCallback(() => { - setOverrides(EMPTY_OVERRIDES); + // Drop the personal layer only — the committed sidecar (if any) remains + // the base. Removing a committed arrangement = delete the sidecar file. + setOverrides(committedLayout ?? EMPTY_OVERRIDES); clearOverrides(layoutKey); - }, [layoutKey]); + }, [layoutKey, committedLayout]); + const [savingLayout, setSavingLayout] = useState(false); + // Save-layout outcome — its own notice type (success OR error), announced + // by a dedicated bridge (the ToastProvider mounts inside this tree). + const [layoutNotice, setLayoutNotice] = useState(null); + const persistLayout = useCallback(async () => { + const effective = pruneOverrides(overrides, liveIds); + setSavingLayout(true); + try { + const res = await fetch("/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 1, ...effective }), + }); + if (!res.ok) throw new Error(`PUT /layout returned ${res.status}`); + // Disk is now the base — clear the personal layer, keep the canvas + // as-is, and advance the committed baseline Reset falls back to. + clearOverrides(layoutKey); + setOverrides(effective); + setCommittedLayout(effective); + setLayoutNotice((prev) => ({ + message: "Layout saved to the notebook's .layout.json", + seq: (prev?.seq ?? 0) + 1, + })); + } catch (err) { + setLayoutNotice((prev) => ({ + message: `Layout save failed: ${err instanceof Error ? err.message : String(err)}`, + tone: "error", + seq: (prev?.seq ?? 0) + 1, + })); + } finally { + setSavingLayout(false); + } + }, [overrides, liveIds, layoutKey]); // Tabs partition the one flat cell list into named pages (host-shell view // tier). Derived from the *declared* cells so the bar is stable across runtime @@ -320,6 +374,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { > + @@ -354,10 +409,23 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { size="sm" onClick={resetLayout} className="text-muted-foreground" + title="Discard personal layout changes (the saved .layout.json, if any, remains)" > Reset )} + {editing && config.canPersistLayout && ( + + )}