Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions packages/cli/src/serve.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,6 +49,11 @@ export interface ServeOptions {
export async function serve(opts: ServeOptions): Promise<void> {
const webDist = resolveWebDist();
const upstream = new URL(opts.connection.server);
// Layout sidecar: <notebook minus .yaml>.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`
Expand Down Expand Up @@ -85,6 +90,19 @@ export async function serve(opts: ServeOptions): Promise<void> {
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 (
Expand All @@ -96,8 +114,19 @@ export async function serve(opts: ServeOptions): Promise<void> {
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<string, unknown> = {
...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<void>((resolvePromise, reject) => {
Expand Down Expand Up @@ -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
Expand Down
106 changes: 100 additions & 6 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ import {
EMPTY_OVERRIDES,
isHidden,
loadOverrides,
migrateV2,
notebookKey,
pruneOverrides,
saveOverrides,
withColor,
withHidden,
Expand Down Expand Up @@ -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 (`<notebook>.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<LayoutOverrides>(() =>
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<LayoutOverrides | null>(
config.initialLayout,
);
const [overrides, setOverrides] = useState<LayoutOverrides>(() => {
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);
Expand All @@ -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<LayoutNotice | null>(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,
}));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} 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
Expand Down Expand Up @@ -320,6 +374,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {
>
<ToastProvider>
<SuccessToastBridge feedback={snapshot.mutationFeedback} />
<LayoutToastBridge notice={layoutNotice} />
<Shell
header={
<div className="sticky top-0 z-30 border-b border-border bg-background">
Expand Down Expand Up @@ -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
</Button>
)}
{editing && config.canPersistLayout && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void persistLayout()}
disabled={savingLayout}
title="Write the current arrangement to the notebook's .layout.json (git-committable)"
>
{savingLayout ? "Saving…" : "Save layout"}
</Button>
)}
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -522,6 +590,32 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {
* pre-mount success is never replayed. Errors/no-ops never reach
* `mutationFeedback`, so this can only announce real successes.
*/
interface LayoutNotice {
message: string;
tone?: "error";
seq: number;
}

/** Announces Save-layout outcomes — success or error tone — once per seq. */
function LayoutToastBridge({
notice,
}: {
notice: LayoutNotice | null;
}): null {
const manager = useToastManager();
const lastSeq = React.useRef(notice?.seq ?? 0);
useEffect(() => {
if (notice !== null && notice.seq !== lastSeq.current) {
lastSeq.current = notice.seq;
manager.add({
title: notice.message,
...(notice.tone !== undefined ? { tone: notice.tone } : {}),
});
}
}, [notice, manager]);
return null;
}

export function SuccessToastBridge({
feedback,
}: {
Expand Down
18 changes: 13 additions & 5 deletions packages/web/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ const LIMIT = 3;
interface ToastItem {
id: number;
title: string;
tone?: "error";
}

interface ToastManager {
add: (options: { title: string }) => void;
add: (options: { title: string; tone?: "error" }) => void;
}

const ToastContext = createContext<ToastManager | null>(null);
Expand Down Expand Up @@ -54,9 +55,12 @@ export function ToastProvider({
}, []);

const add = useCallback(
({ title }: { title: string }) => {
({ title, tone }: { title: string; tone?: "error" }) => {
const id = nextId.current++;
setToasts((prev) => [...prev.slice(-(LIMIT - 1)), { id, title }]);
setToasts((prev) => [
...prev.slice(-(LIMIT - 1)),
{ id, title, ...(tone !== undefined ? { tone } : {}) },
]);
timers.current.set(
id,
setTimeout(() => dismiss(id), TIMEOUT_MS),
Expand Down Expand Up @@ -84,9 +88,13 @@ export function ToastProvider({
<div
key={toast.id}
role="status"
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg"
className={
toast.tone === "error"
? "flex items-start justify-between gap-3 rounded-lg border border-destructive/50 bg-card px-4 py-3 shadow-lg"
: "flex items-start justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg"
}
>
<p className="text-sm text-foreground">{toast.title}</p>
<p className={toast.tone === "error" ? "text-sm text-destructive" : "text-sm text-foreground"}>{toast.title}</p>
<button
type="button"
aria-label="dismiss"
Expand Down
26 changes: 26 additions & 0 deletions packages/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ import { parseNotebook } from "@modernrelay/notebook-core";
import { Client, ServerSource } from "@modernrelay/notebook-client";
import type { Source } from "@modernrelay/notebook-core";

import {
normalizeOverrides,
pruneOverrides,
type LayoutOverrides,
} from "./layout-overrides.js";

import defaultServerNotebookYaml from "../../../examples/company-server.notebook.yaml?raw";

export interface AppConfig {
notebook: ReturnType<typeof parseNotebook>;
source: Source;
label: string;
/**
* The committed layout sidecar (`<notebook>.layout.json`), injected by the
* `view` BFF — the base layer under any personal localStorage tweaks. Null
* without a sidecar (or outside the BFF, e.g. Vite dev).
*/
initialLayout: LayoutOverrides | null;
/** True when the serving BFF accepts PUT /layout ("Save layout" enabled). */
canPersistLayout: boolean;
}

/**
Expand All @@ -21,6 +35,8 @@ interface InjectedConfig {
graph?: string;
branch?: string;
allowRawGq?: boolean;
layout?: unknown;
canPersistLayout?: boolean;
}
declare global {
interface Window {
Expand Down Expand Up @@ -94,13 +110,23 @@ export async function buildConfig(): Promise<AppConfig> {
token: readToken(server),
graphId: graph,
});
// Layout sidecar contents ride the injected config (read per page load by
// the BFF); validate + prune against this notebook's cells before use.
const liveIds = new Set(notebook.cells.map((c) => c.id));
const initialLayout =
injected.layout !== undefined && injected.layout !== null
? pruneOverrides(normalizeOverrides(injected.layout), liveIds)
: null;

return {
notebook,
source: new ServerSource(client, {
...(branch ? { branch } : {}),
...(allowRawGq ? { allowRawGq: true } : {}),
}),
label: `server: ${server} · graph: ${graph}${branch ? ` · ${branch}` : ""}`,
initialLayout,
canPersistLayout: injected.canPersistLayout === true,
};
}

Expand Down
Loading
Loading