feat(ui): show raw config pending changes

Adds a raw config pending-changes diff panel in Control UI raw mode, with JSON5 parsing, sensitive-value redaction until explicit reveal, bounded diff work, and tests for redaction/reveal and stale reveal-state reset.

Also aligns provider manifest contract coverage for google-vertex and Qwen aliases to unblock the rebased CI matrix.

Supersedes stale PRs #48621 and #46654. PR #48621 had gone stale without maintainer follow-up, so this maintainer-authored PR carries the implementation forward transparently while preserving changelog credit for the original contributor and @BunsDev.
This commit is contained in:
Val Alexander
2026-04-27 04:42:10 -05:00
committed by GitHub
parent 531a0ddfe4
commit 14a27e11f7
7 changed files with 662 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
- Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata. Thanks @codex.
- Plugins/providers: move pre-runtime model-id normalization, provider endpoint host metadata, and OpenAI-compatible request-family hints into plugin manifests so core no longer carries bundled-provider routing tables. Thanks @codex.
- Plugins/install: allow `OPENCLAW_PLUGIN_STAGE_DIR` to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.
- Control UI: add a raw config pending-changes diff panel that parses JSON5, redacts sensitive values until reveal, and avoids fake raw-edit callbacks when opening the panel. Refs #39831; supersedes #48621 and #46654. Thanks @JiajunBernoulli and @BunsDev.
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.

View File

@@ -28,6 +28,22 @@ export function createGoogleProvider(): ProviderPlugin {
};
}
export function createGoogleVertexProvider(): ProviderPlugin {
return {
id: "google-vertex",
label: "Google Vertex AI",
docsPath: "/providers/models",
envVars: [
"GOOGLE_CLOUD_API_KEY",
"GOOGLE_CLOUD_PROJECT",
"GCLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_APPLICATION_CREDENTIALS",
],
auth: [],
};
}
export function createGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: "google-gemini-cli",

3
pnpm-lock.yaml generated
View File

@@ -1537,6 +1537,9 @@ importers:
dompurify:
specifier: ^3.4.1
version: 3.4.1
json5:
specifier: ^2.2.3
version: 2.2.3
lit:
specifier: ^3.3.2
version: 3.3.2

View File

@@ -12,6 +12,7 @@
"@create-markdown/preview": "^2.0.3",
"@noble/ed25519": "3.1.0",
"dompurify": "^3.4.1",
"json5": "^2.2.3",
"lit": "^3.3.2",
"markdown-it": "^14.1.1",
"markdown-it-task-lists": "^2.1.1",

View File

@@ -64,7 +64,7 @@ export function hintForPath(path: Array<string | number>, hints: ConfigUiHints)
if (direct) {
return direct;
}
const segments = key.split(".");
const segments = path.map(String);
for (const [hintKey, hint] of Object.entries(hints)) {
if (!hintKey.includes("*")) {
continue;
@@ -120,6 +120,28 @@ const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
const MAX_SENSITIVE_SCAN_DEPTH = 64;
const MAX_SENSITIVE_SCAN_NODES = 20_000;
type SensitiveScanState = {
visited: number;
};
function createSensitiveScanState(): SensitiveScanState {
return { visited: 0 };
}
function enterSensitiveScanNode(state: SensitiveScanState, depth: number): boolean {
if (depth > MAX_SENSITIVE_SCAN_DEPTH) {
return false;
}
state.visited += 1;
if (state.visited > MAX_SENSITIVE_SCAN_NODES) {
return false;
}
return true;
}
function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -146,6 +168,20 @@ export function hasSensitiveConfigData(
path: Array<string | number>,
hints: ConfigUiHints,
): boolean {
return hasSensitiveConfigDataInner(value, path, hints, createSensitiveScanState(), 0);
}
function hasSensitiveConfigDataInner(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
scan: SensitiveScanState,
depth: number,
): boolean {
if (!enterSensitiveScanNode(scan, depth)) {
return true;
}
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
@@ -155,12 +191,14 @@ export function hasSensitiveConfigData(
}
if (Array.isArray(value)) {
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
return value.some((item, index) =>
hasSensitiveConfigDataInner(item, [...path, index], hints, scan, depth + 1),
);
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
hasSensitiveConfigData(childValue, [...path, childKey], hints),
hasSensitiveConfigDataInner(childValue, [...path, childKey], hints, scan, depth + 1),
);
}
@@ -172,6 +210,20 @@ export function countSensitiveConfigValues(
path: Array<string | number>,
hints: ConfigUiHints,
): number {
return countSensitiveConfigValuesInner(value, path, hints, createSensitiveScanState(), 0);
}
function countSensitiveConfigValuesInner(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
scan: SensitiveScanState,
depth: number,
): number {
if (!enterSensitiveScanNode(scan, depth)) {
return 1;
}
if (value == null) {
return 0;
}
@@ -186,7 +238,8 @@ export function countSensitiveConfigValues(
if (Array.isArray(value)) {
return value.reduce(
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
(count, item, index) =>
count + countSensitiveConfigValuesInner(item, [...path, index], hints, scan, depth + 1),
0,
);
}
@@ -194,7 +247,8 @@ export function countSensitiveConfigValues(
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).reduce(
(count, [childKey, childValue]) =>
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
count +
countSensitiveConfigValuesInner(childValue, [...path, childKey], hints, scan, depth + 1),
0,
);
}

View File

@@ -464,6 +464,283 @@ describe("config view", () => {
expect(onRawChange).toHaveBeenCalledWith(textarea.value);
});
it("opens raw pending changes without sending a fake raw edit", () => {
const container = document.createElement("div");
const onRawChange = vi.fn();
let updateCount = 0;
const props: ConfigProps = {
...baseProps(),
formMode: "raw",
raw: '{\n gateway: { mode: "remote" }\n}\n',
originalRaw: '{\n gateway: { mode: "local" }\n}\n',
formValue: {
gateway: {
mode: "remote",
},
},
originalValue: {
gateway: {
mode: "local",
},
},
onRawChange,
};
const rerender = () =>
render(
renderConfig({
...props,
onRequestUpdate: () => {
updateCount += 1;
rerender();
},
}),
container,
);
rerender();
expect(normalizedText(container)).toContain("View pending changes");
expect(normalizedText(container)).not.toContain("gateway.mode");
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
expect(details).not.toBeNull();
details!.open = true;
details!.dispatchEvent(new Event("toggle"));
const text = normalizedText(container);
expect(updateCount).toBe(1);
expect(onRawChange).not.toHaveBeenCalled();
expect(text).toContain("gateway.mode");
expect(text).toContain('"local"');
expect(text).toContain('"remote"');
});
it("renders array diff summaries without serializing array values", () => {
const poison = {
value: "TOKEN_AFTER",
toJSON: () => {
throw new Error("array value should not be serialized");
},
};
const { container } = renderConfigView({
formValue: {
items: [poison],
},
originalValue: {
items: [],
},
});
const text = normalizedText(container);
expect(text).toContain("View 1 pending change");
expect(text).toContain("items");
expect(text).toContain("[0 items]");
expect(text).toContain("[1 item]");
});
it("redacts sensitive values in raw pending changes until raw values are revealed", () => {
const container = document.createElement("div");
const props: ConfigProps = {
...baseProps(),
formMode: "raw",
raw: '{\n channels: { discord: { token: { id: "TOKEN_AFTER" } } }\n}\n',
originalRaw: '{\n channels: { discord: { token: { id: "TOKEN_BEFORE" } } }\n}\n',
uiHints: {
"channels.discord.token": { sensitive: true },
},
formValue: {
channels: {
discord: {
token: {
id: "TOKEN_AFTER",
},
},
},
},
originalValue: {
channels: {
discord: {
token: {
id: "TOKEN_BEFORE",
},
},
},
},
};
const rerender = () =>
render(
renderConfig({
...props,
onRequestUpdate: rerender,
}),
container,
);
rerender();
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
expect(details).not.toBeNull();
details!.open = true;
details!.dispatchEvent(new Event("toggle"));
const text = normalizedText(container);
expect(text).toContain("channels.discord.token.id");
expect(text).toContain("[redacted - click reveal to view]");
expect(text).not.toContain("TOKEN_BEFORE");
expect(text).not.toContain("TOKEN_AFTER");
const revealButton = container.querySelector<HTMLButtonElement>(".config-raw-toggle");
expect(revealButton).not.toBeNull();
revealButton!.click();
const revealedText = normalizedText(container);
expect(revealedText).toContain("TOKEN_BEFORE");
expect(revealedText).toContain("TOKEN_AFTER");
});
it("resets raw reveal state when the config context changes", () => {
const container = document.createElement("div");
const props: ConfigProps = {
...baseProps(),
configPath: "/tmp/openclaw-a.json5",
formMode: "raw",
raw: '{\n token: "TOKEN_A_AFTER"\n}\n',
originalRaw: '{\n token: "TOKEN_A_BEFORE"\n}\n',
uiHints: {
token: { sensitive: true },
},
formValue: {
token: "TOKEN_A_AFTER",
},
originalValue: {
token: "TOKEN_A_BEFORE",
},
};
const rerender = () =>
render(
renderConfig({
...props,
onRequestUpdate: rerender,
}),
container,
);
rerender();
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
expect(details).not.toBeNull();
details!.open = true;
details!.dispatchEvent(new Event("toggle"));
const revealButton = container.querySelector<HTMLButtonElement>(".config-raw-toggle");
expect(revealButton).not.toBeNull();
revealButton!.click();
expect(normalizedText(container)).toContain("TOKEN_A_AFTER");
props.configPath = "/tmp/openclaw-b.json5";
props.raw = '{\n token: "TOKEN_B_AFTER"\n}\n';
props.originalRaw = '{\n token: "TOKEN_B_BEFORE"\n}\n';
props.formValue = {
token: "TOKEN_B_AFTER",
};
props.originalValue = {
token: "TOKEN_B_BEFORE",
};
rerender();
const text = normalizedText(container);
expect(text).toContain("1 secret redacted");
expect(text).not.toContain("TOKEN_A_AFTER");
expect(text).not.toContain("TOKEN_B_AFTER");
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector<HTMLDetailsElement>(".config-diff")?.open).toBe(false);
});
it("redacts raw diff values under leaf wildcard sensitive hints when keys contain dots", () => {
const container = document.createElement("div");
const props: ConfigProps = {
...baseProps(),
formMode: "raw",
raw: '{\n integrations: { "foo.bar": { credential: "TOKEN_AFTER" } }\n}\n',
originalRaw: '{\n integrations: { "foo.bar": { credential: "TOKEN_BEFORE" } }\n}\n',
uiHints: {
"integrations.*.credential": { sensitive: true },
},
formValue: {
integrations: {
"foo.bar": {
credential: "TOKEN_AFTER",
},
},
},
originalValue: {
integrations: {
"foo.bar": {
credential: "TOKEN_BEFORE",
},
},
},
};
const rerender = () =>
render(
renderConfig({
...props,
onRequestUpdate: rerender,
}),
container,
);
rerender();
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
expect(details).not.toBeNull();
details!.open = true;
details!.dispatchEvent(new Event("toggle"));
const text = normalizedText(container);
expect(text).toContain("integrations.foo.bar.credential");
expect(text).toContain("[redacted - click reveal to view]");
expect(text).not.toContain("TOKEN_BEFORE");
expect(text).not.toContain("TOKEN_AFTER");
});
it("removes the raw pending changes panel after raw changes clear", () => {
const container = document.createElement("div");
const props: ConfigProps = {
...baseProps(),
formMode: "raw",
raw: '{\n gateway: { mode: "remote" }\n}\n',
originalRaw: '{\n gateway: { mode: "local" }\n}\n',
formValue: {
gateway: {
mode: "remote",
},
},
originalValue: {
gateway: {
mode: "local",
},
},
};
const rerender = () =>
render(
renderConfig({
...props,
onRequestUpdate: rerender,
}),
container,
);
rerender();
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
expect(details).not.toBeNull();
details!.open = true;
details!.dispatchEvent(new Event("toggle"));
expect(normalizedText(container)).toContain("gateway.mode");
props.raw = props.originalRaw;
props.formValue = props.originalValue;
rerender();
expect(container.querySelector(".config-diff")).toBeNull();
expect(normalizedText(container)).toContain("No changes");
});
it("renders structured SecretRef values without stringifying", () => {
const onFormPatch = vi.fn();
const secretRefSchema = {

View File

@@ -1,3 +1,4 @@
import JSON5 from "json5";
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
@@ -7,6 +8,7 @@ import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
import {
countSensitiveConfigValues,
hintForPath,
humanize,
isSensitiveConfigPath,
pathKey,
@@ -529,32 +531,132 @@ function resolveSectionMeta(
};
}
const MAX_CONFIG_DIFF_DEPTH = 64;
const MAX_CONFIG_DIFF_NODES = 20_000;
const MAX_CONFIG_DIFF_CHANGES = 1_000;
const MAX_CONFIG_DIFF_ARRAY_COMPARE_ITEMS = 2_000;
const MAX_RAW_DIFF_CHARS = 200_000;
type ConfigDiffPath = string[];
type ConfigDiffEntry = { path: ConfigDiffPath; from: unknown; to: unknown };
let rawDiffCache:
| {
original: string;
current: string;
diff: ConfigDiffEntry[];
}
| undefined;
function formatConfigDiffPath(path: ConfigDiffPath): string {
return path.length > 0 ? path.join(".") : "<root>";
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
): ConfigDiffEntry[] {
if (!original || !current) {
return [];
}
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
const changes: ConfigDiffEntry[] = [];
let visited = 0;
function compare(orig: unknown, curr: unknown, path: string) {
function pushChange(path: ConfigDiffPath, from: unknown, to: unknown) {
if (changes.length < MAX_CONFIG_DIFF_CHANGES) {
changes.push({ path, from, to });
}
}
function arrayValuesDiffer(orig: unknown[], curr: unknown[], depth: number): boolean {
if (orig.length !== curr.length) {
return true;
}
if (orig.length > MAX_CONFIG_DIFF_ARRAY_COMPARE_ITEMS) {
return true;
}
for (let index = 0; index < orig.length; index += 1) {
if (valuesDiffer(orig[index], curr[index], depth + 1)) {
return true;
}
}
return false;
}
function objectValuesDiffer(
orig: Record<string, unknown>,
curr: Record<string, unknown>,
depth: number,
): boolean {
const origKeys = Object.keys(orig);
const currKeys = Object.keys(curr);
if (origKeys.length !== currKeys.length) {
return true;
}
for (const key of origKeys) {
if (
!Object.prototype.hasOwnProperty.call(curr, key) ||
valuesDiffer(orig[key], curr[key], depth + 1)
) {
return true;
}
}
return false;
}
function valuesDiffer(orig: unknown, curr: unknown, depth: number): boolean {
visited += 1;
if (visited > MAX_CONFIG_DIFF_NODES || depth > MAX_CONFIG_DIFF_DEPTH) {
return true;
}
if (orig === curr) {
return false;
}
if (typeof orig !== typeof curr) {
return true;
}
if (typeof orig !== "object" || orig === null || curr === null) {
return orig !== curr;
}
if (Array.isArray(orig) || Array.isArray(curr)) {
return Array.isArray(orig) && Array.isArray(curr)
? arrayValuesDiffer(orig, curr, depth + 1)
: true;
}
return objectValuesDiffer(
orig as Record<string, unknown>,
curr as Record<string, unknown>,
depth + 1,
);
}
function compare(orig: unknown, curr: unknown, path: ConfigDiffPath, depth: number) {
visited += 1;
if (
visited > MAX_CONFIG_DIFF_NODES ||
depth > MAX_CONFIG_DIFF_DEPTH ||
changes.length >= MAX_CONFIG_DIFF_CHANGES
) {
return;
}
if (orig === curr) {
return;
}
if (typeof orig !== typeof curr) {
changes.push({ path, from: orig, to: curr });
pushChange(path, orig, curr);
return;
}
if (typeof orig !== "object" || orig === null || curr === null) {
if (orig !== curr) {
changes.push({ path, from: orig, to: curr });
pushChange(path, orig, curr);
}
return;
}
if (Array.isArray(orig) && Array.isArray(curr)) {
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
changes.push({ path, from: orig, to: curr });
if (Array.isArray(orig) || Array.isArray(curr)) {
if (Array.isArray(orig) && Array.isArray(curr) && arrayValuesDiffer(orig, curr, depth + 1)) {
pushChange(path, orig, curr);
} else if (!Array.isArray(orig) || !Array.isArray(curr)) {
pushChange(path, orig, curr);
}
return;
}
@@ -562,15 +664,52 @@ function computeDiff(
const currObj = curr as Record<string, unknown>;
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
for (const key of allKeys) {
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
compare(origObj[key], currObj[key], [...path, key], depth + 1);
}
}
compare(original, current, "");
compare(original, current, [], 0);
return changes;
}
function computeRawDiff(original: string, current: string): ConfigDiffEntry[] {
if (rawDiffCache?.original === original && rawDiffCache.current === current) {
return rawDiffCache.diff;
}
if (original.length > MAX_RAW_DIFF_CHARS || current.length > MAX_RAW_DIFF_CHARS) {
rawDiffCache = { original, current, diff: [] };
return rawDiffCache.diff;
}
try {
const originalValue = JSON5.parse(original) as unknown;
const currentValue = JSON5.parse(current) as unknown;
if (
!originalValue ||
!currentValue ||
typeof originalValue !== "object" ||
typeof currentValue !== "object" ||
Array.isArray(originalValue) ||
Array.isArray(currentValue)
) {
rawDiffCache = { original, current, diff: [] };
return [];
}
const diff = computeDiff(
originalValue as Record<string, unknown>,
currentValue as Record<string, unknown>,
);
rawDiffCache = { original, current, diff };
return diff;
} catch {
rawDiffCache = { original, current, diff: [] };
return [];
}
}
function truncateValue(value: unknown, maxLen = 40): string {
if (Array.isArray(value)) {
return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
}
let str: string;
try {
const json = JSON.stringify(value);
@@ -584,8 +723,54 @@ function truncateValue(value: unknown, maxLen = 40): string {
return str.slice(0, maxLen - 3) + "...";
}
function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string {
if (isSensitiveConfigPath(path) && value != null && truncateValue(value).trim() !== "") {
function renderDiffValue(path: ConfigDiffPath, value: unknown, _uiHints: ConfigUiHints): string {
if (
isSensitiveConfigPath(formatConfigDiffPath(path)) &&
value != null &&
truncateValue(value).trim() !== ""
) {
return REDACTED_PLACEHOLDER;
}
return truncateValue(value);
}
function hintKeyMatchesPathPrefix(hintKey: string, path: ConfigDiffPath): boolean {
const hintSegments = hintKey.split(".");
if (hintSegments.length !== path.length) {
return false;
}
return hintSegments.every((segment, index) => segment === "*" || segment === path[index]);
}
function hasSensitiveHintForPathPrefix(path: ConfigDiffPath, uiHints: ConfigUiHints): boolean {
return Object.entries(uiHints).some(
([hintKey, hint]) => Boolean(hint.sensitive) && hintKeyMatchesPathPrefix(hintKey, path),
);
}
function isSensitiveDiffPath(path: ConfigDiffPath, uiHints: ConfigUiHints): boolean {
for (let index = 1; index <= path.length; index += 1) {
const prefix = path.slice(0, index);
const key = formatConfigDiffPath(prefix);
if (
(hintForPath(prefix, uiHints)?.sensitive ?? false) ||
hasSensitiveHintForPathPrefix(prefix, uiHints) ||
isSensitiveConfigPath(key)
) {
return true;
}
}
return false;
}
function renderRawDiffValue(
path: ConfigDiffPath,
value: unknown,
uiHints: ConfigUiHints,
rawRevealed: boolean,
): string {
const hasSensitiveValue = countSensitiveConfigValues(value, path, uiHints) > 0;
if (!rawRevealed && value != null && (isSensitiveDiffPath(path, uiHints) || hasSensitiveValue)) {
return REDACTED_PLACEHOLDER;
}
return truncateValue(value);
@@ -912,6 +1097,7 @@ function renderAppearanceSection(props: ConfigProps) {
interface ConfigEphemeralState {
rawRevealed: boolean;
rawDiffOpen: boolean;
envRevealed: boolean;
validityDismissed: boolean;
revealedSensitivePaths: Set<string>;
@@ -921,6 +1107,7 @@ interface ConfigEphemeralState {
function createConfigEphemeralState(): ConfigEphemeralState {
return {
rawRevealed: false,
rawDiffOpen: false,
envRevealed: false,
validityDismissed: false,
revealedSensitivePaths: new Set(),
@@ -929,6 +1116,24 @@ function createConfigEphemeralState(): ConfigEphemeralState {
}
const cvs = createConfigEphemeralState();
let lastConfigContextKey: string | null = null;
function resetConfigEphemeralState() {
Object.assign(cvs, createConfigEphemeralState());
rawDiffCache = undefined;
}
function configContextKey(props: ConfigProps): string {
const include = props.includeSections?.join("\u001f") ?? "";
const exclude = props.excludeSections?.join("\u001f") ?? "";
return [
props.configPath ?? "",
props.gatewayUrl,
props.navRootLabel ?? "",
include,
exclude,
].join("\u001e");
}
function isSensitivePathRevealed(path: Array<string | number>): boolean {
const key = pathKey(path);
@@ -948,7 +1153,8 @@ function toggleSensitivePathReveal(path: Array<string | number>) {
}
export function resetConfigViewStateForTests() {
Object.assign(cvs, createConfigEphemeralState());
resetConfigEphemeralState();
lastConfigContextKey = null;
}
export function renderConfig(props: ConfigProps) {
@@ -965,8 +1171,13 @@ export function renderConfig(props: ConfigProps) {
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
const rawAvailable = props.rawAvailable ?? true;
const formMode = showModeToggle && rawAvailable ? props.formMode : "form";
const requestUpdate = props.onRequestUpdate ?? (() => {});
const currentContextKey = configContextKey(props);
if (lastConfigContextKey !== currentContextKey) {
resetConfigEphemeralState();
lastConfigContextKey = currentContextKey;
}
const envSensitiveVisible = cvs.envRevealed;
const requestUpdate = props.onRequestUpdate ?? (() => props.onRawChange(props.raw));
// Build categorised nav from schema - only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
@@ -1128,6 +1339,16 @@ export function renderConfig(props: ConfigProps) {
// Compute diff for showing changes (works for both form and raw modes)
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
if ((!hasRawChanges || formMode !== "raw") && cvs.rawDiffOpen) {
cvs.rawDiffOpen = false;
}
if (!hasRawChanges || formMode !== "raw" || !cvs.rawDiffOpen) {
rawDiffCache = undefined;
}
const rawDiff =
formMode === "raw" && hasRawChanges && cvs.rawDiffOpen
? computeRawDiff(props.originalRaw, props.raw)
: [];
const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
@@ -1332,7 +1553,7 @@ export function renderConfig(props: ConfigProps) {
`
: nothing}
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
<!-- Diff panel -->
${hasChanges && formMode === "form"
? html`
<details class="config-diff">
@@ -1352,7 +1573,7 @@ export function renderConfig(props: ConfigProps) {
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__path">${formatConfigDiffPath(change.path)}</div>
<div class="config-diff__values">
<span class="config-diff__from"
>${renderDiffValue(change.path, change.from, props.uiHints)}</span
@@ -1369,6 +1590,74 @@ export function renderConfig(props: ConfigProps) {
</details>
`
: nothing}
${hasRawChanges && formMode === "raw"
? html`
<details
class="config-diff"
?open=${cvs.rawDiffOpen}
@toggle=${(e: Event) => {
const details = e.target as HTMLDetailsElement;
if (cvs.rawDiffOpen === details.open) {
return;
}
cvs.rawDiffOpen = details.open;
if (!details.open) {
rawDiffCache = undefined;
}
requestUpdate();
}}
>
<summary class="config-diff__summary">
<span>View pending changes</span>
<svg
class="config-diff__chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="config-diff__content">
${rawDiff.length > 0
? rawDiff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">
${formatConfigDiffPath(change.path)}
</div>
<div class="config-diff__values">
<span class="config-diff__from"
>${renderRawDiffValue(
change.path,
change.from,
props.uiHints,
cvs.rawRevealed,
)}</span
>
<span class="config-diff__arrow">→</span>
<span class="config-diff__to"
>${renderRawDiffValue(
change.path,
change.to,
props.uiHints,
cvs.rawRevealed,
)}</span
>
</div>
</div>
`,
)
: html`
<div class="config-diff__item">
Changes detected (JSON diff not available)
</div>
`}
</div>
</details>
`
: nothing}
${activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">