forked from molecule-ai/molecule-core
Independent code review surfaced two required documentation fixes and one growth-correctness gap. All addressed here. Auto-fit gate (useCanvasViewport): The previous "subtree-grew-by-count" check missed the delete-then-add case: subtree of 6 → delete one → 5 → a different child arrives → 6 again. A length-only comparison reads no growth and the fit is skipped, leaving the new node off-screen. Switched to an id-set membership snapshot so any brand-new id forces the fit even when the count is unchanged. The gate logic is now extracted as a pure exported function `shouldFitGrowing(currentIds, prevIds, userPannedAt, lastAutoFitAt)` so the regression-prone decision can be unit-tested in isolation without standing up React Flow + DOM event refs. 8 cases cover: first-fit, empty-prior, brand-new id, status-update with user pan, no-pan-ever, pan-before-last-fit, delete-then-add same length, and shrink-only with user pan. Parser parity (dotenv.go + next.config.ts): Existing-env semantics were undocumented in both parsers. Both now explicitly note that an explicitly-set empty string (`KEY=` from the parent shell) counts as "set" — the file value does NOT backfill — matching the Go (os.LookupEnv) and Node (`process.env[k] !== undefined`) primitives. `export ` prefix uses a literal space; `export\tFOO=bar` is intentionally rejected. Added the same comment in both parsers to lock in this parity invariant since the commit message claims "if one parser changes, the other has to." Skipped (per analysis): - Drag-pan respect for left-click drag-pan during deploy. The growth-check safety net means any pan gets overridden on the next arrival anyway, which is the desired behavior for the "watch the org deploy" use case. After deploy completes, no more fit-deploying-org events fire so drag-pan works freely. - Map cleanup for lastFitSubtreeIdsRef. Per-tab session, UUID keys, tiny entries — not worth the cleanup hook. 993 canvas tests pass (8 new); Go dotenv tests pass; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
import type { NextConfig } from "next";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { dirname, join } from "node:path";
|
||
|
||
// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh
|
||
// `pnpm dev` works without a per-developer canvas/.env.local. Next.js
|
||
// only auto-loads .env from the project root by default — but our
|
||
// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL,
|
||
// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared
|
||
// by the Go platform binary. Without this, the canvas falls back to
|
||
// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays
|
||
// "Reconnecting" forever because Next.js dev doesn't serve /ws.
|
||
//
|
||
// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env
|
||
// loader. Both processes look for the SAME marker (`workspace-server/
|
||
// go.mod`) so a developer renaming or relocating the repo only has to
|
||
// update one heuristic. Production is unaffected: `output: "standalone"`
|
||
// bakes resolved env into the build, and the marker file isn't shipped.
|
||
loadMonorepoEnv();
|
||
|
||
const nextConfig: NextConfig = {
|
||
output: "standalone",
|
||
};
|
||
|
||
export default nextConfig;
|
||
|
||
function loadMonorepoEnv() {
|
||
const root = findMonorepoRoot(__dirname);
|
||
if (!root) return;
|
||
const envPath = join(root, ".env");
|
||
if (!existsSync(envPath)) return;
|
||
const body = readFileSync(envPath, "utf8");
|
||
let loaded = 0;
|
||
let skipped = 0;
|
||
for (const line of body.split(/\r?\n/)) {
|
||
const kv = parseLine(line);
|
||
if (!kv) continue;
|
||
const [k, v] = kv;
|
||
// Existing env wins. NOTE: an explicitly-set empty string
|
||
// (`KEY=` exported from a parent shell, where Node represents it
|
||
// as `""` not `undefined`) counts as "set" — we keep the empty
|
||
// value rather than backfilling from the file. Matches Go's
|
||
// os.LookupEnv check in workspace-server/cmd/server/dotenv.go so
|
||
// both processes treat the same input identically. Operators who
|
||
// want the file value to win must `unset KEY` in the launching
|
||
// shell.
|
||
if (process.env[k] !== undefined) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
process.env[k] = v;
|
||
loaded++;
|
||
}
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
`[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`,
|
||
);
|
||
}
|
||
|
||
function findMonorepoRoot(start: string): string | null {
|
||
let dir = start;
|
||
for (let i = 0; i < 6; i++) {
|
||
if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir;
|
||
const parent = dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine
|
||
// — same rules so the two loaders agree on every line in the shared
|
||
// .env. If you change one parser, change the other.
|
||
function parseLine(raw: string): [string, string] | null {
|
||
let line = raw.replace(/^/, "").trim();
|
||
if (line === "" || line.startsWith("#")) return null;
|
||
// `export ` prefix uses a literal space — `export\tFOO=bar` with a
|
||
// tab is intentionally rejected, matching the Go mirror in
|
||
// workspace-server/cmd/server/dotenv.go. Shells emit the prefix
|
||
// with a space; tabs would only appear in hand-mangled files.
|
||
if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
|
||
const eq = line.indexOf("=");
|
||
if (eq <= 0) return null;
|
||
const k = line.slice(0, eq).trim();
|
||
let v = line.slice(eq + 1).replace(/^[ \t]+/, "");
|
||
if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) {
|
||
const quote = v[0];
|
||
const end = v.indexOf(quote, 1);
|
||
if (end >= 0) return [k, v.slice(1, end)];
|
||
// unterminated — fall through to bare-value handling
|
||
}
|
||
for (let i = 0; i < v.length; i++) {
|
||
if (v[i] !== "#") continue;
|
||
if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") {
|
||
v = v.slice(0, i);
|
||
break;
|
||
}
|
||
}
|
||
return [k, v.trim()];
|
||
}
|