From b5df45e8b2af72d87c835cd2608c9ffef9e133d4 Mon Sep 17 00:00:00 2001 From: andrew Date: Sun, 5 Jul 2026 00:51:32 +0300 Subject: [PATCH 1/3] Durable canvas layout: stable per-cell overrides + committable sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout (drag/resize boxes, hidden cells, card tints) previously lived in one browser's localStorage under a title+cell-fingerprint key: ANY notebook edit changed the key and silently reset the whole arranged canvas, and the arrangement never left the browser. Stable key + per-cell continuity - notebookKey is now v3, keyed by title alone. Overrides were always per-cell maps — only the storage key discarded that. Surviving cells keep their boxes; pruneOverrides drops orphans at load/save; new cells flow in below (buildTabLayout already handled partial maps). - migrateV2 adopts an existing v2 arrangement once (newest entry, pruned, re-homed under v3; old keys removed). - loadOverrides returns null on a key miss (miss vs empty now distinguishable); normalizeOverrides extracted as the shared validator for localStorage, migration, and sidecar payloads. Committable sidecar via the view BFF - PUT /layout on the view server writes .layout.json beside the YAML (pretty-printed; size-capped; 400 on non-JSON, 405 on other methods). Read fresh per page load and injected pre-paint through window.__DASHBOOK__ (layout + canPersistLayout) — no fetch, no flash. - Layering: personal localStorage ?? committed sidecar ?? YAML defaults (wholesale, so hide/unhide never half-merges). "Save layout" (edit mode, BFF only) PUTs the pruned effective set, clears the personal layer, and toasts; Reset drops only personal changes. Verified live on the dev-graph view: hide → save → sidecar on disk → localStorage.clear() + reload → arrangement restored from the sidecar alone; GET /layout 405, garbage 400. Web tests 54 → 59. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --- packages/cli/src/serve.ts | 84 ++++++++++++++- packages/web/src/App.tsx | 75 +++++++++++-- packages/web/src/config.ts | 26 +++++ packages/web/src/layout-overrides.test.ts | 126 ++++++++++++++++++---- packages/web/src/layout-overrides.ts | 124 ++++++++++++++++----- 5 files changed, 380 insertions(+), 55 deletions(-) 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..67ce4e2 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,24 @@ 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], ); + 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 +195,45 @@ 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(config.initialLayout ?? EMPTY_OVERRIDES); clearOverrides(layoutKey); - }, [layoutKey]); + }, [layoutKey, config.initialLayout]); + const [savingLayout, setSavingLayout] = useState(false); + // Save-layout outcome, announced through the same toast bridge as mutation + // feedback (the ToastProvider mounts inside this component's 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. + clearOverrides(layoutKey); + setOverrides(effective); + setLayoutNotice((prev) => ({ + kind: "success", + message: "Layout saved to the notebook's .layout.json", + seq: (prev?.seq ?? 0) + 1, + })); + } catch (err) { + setLayoutNotice((prev) => ({ + kind: "success", + message: `Layout save failed: ${err instanceof Error ? err.message : String(err)}`, + 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 +369,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { > + @@ -354,10 +404,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 && ( + + )}