diff --git a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx new file mode 100644 index 0000000000..c57ac34c7d --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ConfigGeneratorYamlPreview } from "@/components/site/app-panels/config-generator-yaml-preview"; +import type { GeneratorFormState } from "@/lib/config-generator-yaml"; + +describe("ConfigGeneratorYamlPreview", () => { + it("renders the serialized YAML, the filename label, and a copy button for a populated form state", () => { + const state: GeneratorFormState = { + gate: { aiReview: { combine: "consensus", provider: "anthropic" } }, + }; + render(); + + expect(screen.getByText("Preview")).toBeTruthy(); + // The filename appears both in the descriptive copy and the CodeBlock's own filename label. + expect(screen.getAllByText(".gittensory.yml").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText(/combine: consensus/)).toBeTruthy(); + expect(screen.getByText(/provider: anthropic/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Copy code" })).toBeTruthy(); + }); + + it("still renders a valid (header-only) preview for an empty form state", () => { + render(); + expect(screen.getByText(/generated by the config generator/)).toBeTruthy(); + expect(screen.queryByText(/aiReview/)).toBeNull(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx new file mode 100644 index 0000000000..5968e88cd3 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx @@ -0,0 +1,23 @@ +import { CodeBlock } from "@/components/site/primitives"; +import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml"; + +/** + * Read-only `.gittensory.yml` preview for the config generator (#2210, part of #1683): renders the + * current GeneratorFormState as text via CodeBlock (built-in copy-to-clipboard) so the output is + * explicit and reviewable before a self-hoster saves or copies it. Purely presentational — field-group + * panels own collecting the form state. + */ +export function ConfigGeneratorYamlPreview({ formState }: { formState: GeneratorFormState }) { + return ( +
+

Preview

+

+ The exact .gittensory.yml this configuration would + produce. Nothing is saved until you copy it into your repo. +

+
+ +
+
+ ); +} diff --git a/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts b/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts new file mode 100644 index 0000000000..5e3583d314 --- /dev/null +++ b/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml"; + +const HEADER = "# .gittensory.yml — generated by the config generator"; + +describe("formStateToYaml", () => { + it("serializes a full gate.aiReview config with every field set", () => { + const state: GeneratorFormState = { + gate: { + aiReview: { + combine: "consensus", + provider: "anthropic", + model: "claude-3-5-sonnet-latest", + }, + }, + }; + expect(formStateToYaml(state)).toBe( + [ + HEADER, + "gate:", + " aiReview:", + " combine: consensus", + " provider: anthropic", + " model: claude-3-5-sonnet-latest", + ].join("\n"), + ); + }); + + it("omits unset keys for a partial config (only provider set)", () => { + const state: GeneratorFormState = { gate: { aiReview: { provider: "openai" } } }; + expect(formStateToYaml(state)).toBe( + [HEADER, "gate:", " aiReview:", " provider: openai"].join("\n"), + ); + }); + + it("omits unset keys for a partial config (only combine and model set)", () => { + const state: GeneratorFormState = { gate: { aiReview: { combine: "single", model: "gpt-5" } } }; + expect(formStateToYaml(state)).toBe( + [HEADER, "gate:", " aiReview:", " combine: single", " model: gpt-5"].join("\n"), + ); + }); + + it("emits only the header for a fully empty state", () => { + expect(formStateToYaml({})).toBe(HEADER); + }); + + it("emits only the header when gate is present but aiReview is unset", () => { + expect(formStateToYaml({ gate: {} })).toBe(HEADER); + }); + + it("treats null fields the same as unset (omitted, not emitted as `null`)", () => { + const state: GeneratorFormState = { + gate: { aiReview: { combine: null, provider: null, model: null } }, + }; + expect(formStateToYaml(state)).toBe(HEADER); + }); + + it("treats an empty-string model as unset rather than emitting a blank value", () => { + const state: GeneratorFormState = { gate: { aiReview: { provider: "anthropic", model: "" } } }; + expect(formStateToYaml(state)).toBe( + [HEADER, "gate:", " aiReview:", " provider: anthropic"].join("\n"), + ); + }); + + it("quotes a model value containing YAML-special characters", () => { + const state: GeneratorFormState = { gate: { aiReview: { model: "claude: sonnet" } } }; + expect(formStateToYaml(state)).toBe( + [HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify("claude: sonnet")}`].join("\n"), + ); + }); + + it("quotes a model value with leading/trailing whitespace", () => { + const state: GeneratorFormState = { gate: { aiReview: { model: " claude " } } }; + expect(formStateToYaml(state)).toBe( + [HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify(" claude ")}`].join("\n"), + ); + }); +}); diff --git a/apps/gittensory-ui/src/lib/config-generator-yaml.ts b/apps/gittensory-ui/src/lib/config-generator-yaml.ts new file mode 100644 index 0000000000..a8269ef85f --- /dev/null +++ b/apps/gittensory-ui/src/lib/config-generator-yaml.ts @@ -0,0 +1,52 @@ +// Config-generator YAML preview serializer (#2210, part of #1683). Pure form-state -> `.gittensory.yml` +// text builder — the reviewable-output half of the config generator; field-group panels (#2208 and +// siblings) own collecting GeneratorFormState, this module only turns it into text. Key names and +// nesting match the real manifest schema (packages/gittensory-engine/src/focus-manifest.ts's +// gateConfigToJson `gate.aiReview` shape, the same shape documented in this repo's own root +// .gittensory.yml under the commented-out `aiReview:` example) — no parallel schema. Every field is +// optional so a fresh/partial form never produces invalid output; unset keys are omitted entirely. + +export type AiCombineStrategy = "single" | "consensus" | "synthesis"; +export type AiProvider = "anthropic" | "openai"; + +export type GeneratorGateAiReviewState = { + combine?: AiCombineStrategy | null; + provider?: AiProvider | null; + model?: string | null; +}; + +export type GeneratorFormState = { + gate?: { + aiReview?: GeneratorGateAiReviewState; + }; +}; + +const YAML_HEADER = "# .gittensory.yml — generated by the config generator"; + +/** Plain-scalar YAML value needs quoting when it would otherwise be ambiguous (a YAML special + * character, leading/trailing whitespace, or the empty string). Model names are free text, so this + * is a real correctness need, not defensive-for-its-own-sake. */ +function yamlScalar(value: string): string { + const needsQuote = value === "" || value.trim() !== value || /[:#[\]{}&*!|>'"%@`,]/.test(value); + return needsQuote ? JSON.stringify(value) : value; +} + +function yamlLine(indent: number, key: string, value: string): string { + return `${" ".repeat(indent)}${key}: ${yamlScalar(value)}`; +} + +/** Serializes the `gate.aiReview` slice of GeneratorFormState into `.gittensory.yml` text, omitting + * every unset field/block so the preview is always valid YAML (including the empty-state case). */ +export function formStateToYaml(state: GeneratorFormState): string { + const aiReview = state.gate?.aiReview; + const aiReviewLines: string[] = []; + if (aiReview?.combine) aiReviewLines.push(yamlLine(2, "combine", aiReview.combine)); + if (aiReview?.provider) aiReviewLines.push(yamlLine(2, "provider", aiReview.provider)); + if (aiReview?.model) aiReviewLines.push(yamlLine(2, "model", aiReview.model)); + + const lines = [YAML_HEADER]; + if (aiReviewLines.length > 0) { + lines.push("gate:", " aiReview:", ...aiReviewLines); + } + return lines.join("\n"); +}