diff --git a/CHANGELOG.md b/CHANGELOG.md index 31571ac574b..ad88d277664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/google/provider-contract-api.ts b/extensions/google/provider-contract-api.ts index 50150a7d1c8..c90b1fe92ab 100644 --- a/extensions/google/provider-contract-api.ts +++ b/extensions/google/provider-contract-api.ts @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2f9ca31983..2489a300f0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ui/package.json b/ui/package.json index d3b25cc0ac1..0617fc15beb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index 04fc651e5ec..f98bf963f4a 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -64,7 +64,7 @@ export function hintForPath(path: Array, 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, hints: ConfigUiHints, ): boolean { + return hasSensitiveConfigDataInner(value, path, hints, createSensitiveScanState(), 0); +} + +function hasSensitiveConfigDataInner( + value: unknown, + path: Array, + 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).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, hints: ConfigUiHints, ): number { + return countSensitiveConfigValuesInner(value, path, hints, createSensitiveScanState(), 0); +} + +function countSensitiveConfigValuesInner( + value: unknown, + path: Array, + 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).reduce( (count, [childKey, childValue]) => - count + countSensitiveConfigValues(childValue, [...path, childKey], hints), + count + + countSensitiveConfigValuesInner(childValue, [...path, childKey], hints, scan, depth + 1), 0, ); } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 6c46caaa120..5846b9b0314 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -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(".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(".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(".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(".config-diff"); + expect(details).not.toBeNull(); + details!.open = true; + details!.dispatchEvent(new Event("toggle")); + const revealButton = container.querySelector(".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(".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(".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(".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 = { diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 044010a7791..42a06afaa86 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -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(".") : ""; +} + function computeDiff( original: Record | null, current: Record | 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, + curr: Record, + 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, + curr as Record, + 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; 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, + currentValue as Record, + ); + 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; @@ -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): boolean { const key = pathKey(path); @@ -948,7 +1153,8 @@ function toggleSensitivePathReveal(path: Array) { } 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} - + ${hasChanges && formMode === "form" ? html`
@@ -1352,7 +1573,7 @@ export function renderConfig(props: ConfigProps) { ${diff.map( (change) => html`
-
${change.path}
+
${formatConfigDiffPath(change.path)}
${renderDiffValue(change.path, change.from, props.uiHints)} ` : nothing} + ${hasRawChanges && formMode === "raw" + ? html` +
{ + const details = e.target as HTMLDetailsElement; + if (cvs.rawDiffOpen === details.open) { + return; + } + cvs.rawDiffOpen = details.open; + if (!details.open) { + rawDiffCache = undefined; + } + requestUpdate(); + }} + > + + View pending changes + + + + +
+ ${rawDiff.length > 0 + ? rawDiff.map( + (change) => html` +
+
+ ${formatConfigDiffPath(change.path)} +
+
+ ${renderRawDiffValue( + change.path, + change.from, + props.uiHints, + cvs.rawRevealed, + )} + + ${renderRawDiffValue( + change.path, + change.to, + props.uiHints, + cvs.rawRevealed, + )} +
+
+ `, + ) + : html` +
+ Changes detected (JSON diff not available) +
+ `} +
+
+ ` + : nothing} ${activeSectionMeta && formMode === "form" ? html`