fix(codex): move common-config TOML merge off smol-toml to backend toml_edit

updateTomlCommonConfigSnippet re-serialized the whole document through
smol-toml (parse -> deepMerge -> stringify): comments dropped, keys
reordered, and empty parent table headers synthesized -- the
long-standing "config.toml keeps getting reordered" symptom (audit C5,
introduced in 083e48bf).

Replace it with a backend command backed by the same
merge_toml_table_like / remove_toml_table_like used when writing live
configs, so the form preview and the live write share one merge
semantic and user formatting survives edit-time merges. The frontend
sync helper is deleted outright to keep the pattern from coming back.

Making the form operations async exposes them to interleaving, so a
result is discarded unless it is still current when it lands:

- a per-hook sequence number (last operation wins) covers rapid
  toggle/save races where an earlier merge resolves after a later
  removal and would flip the switch back;
- a config-baseline check covers the user hand-editing the TOML while
  a merge is in flight -- the stale result must not clobber their edit.
  The checkbox state self-heals via the existing inference effect.

Regression tests pin both by resolving a suspended merge after a newer
operation / an external edit, plus backend tests locking comment and
key-order preservation, scalar override, and value-matched removal.
This commit is contained in:
Jason
2026-07-08 22:21:57 +08:00
parent 94fc1cc064
commit 88d5ffba44
10 changed files with 381 additions and 97 deletions
@@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onSave: (value: string) => boolean;
onSave: (value: string) => boolean | Promise<boolean>;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
@@ -58,8 +58,8 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
onClose();
};
const handleSave = () => {
if (onSave(draftValue)) {
const handleSave = async () => {
if (await onSave(draftValue)) {
onClose();
}
};
@@ -22,11 +22,11 @@ interface CodexConfigEditorProps {
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onCommonConfigToggle: (checked: boolean) => void | Promise<void>;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => boolean;
onCommonConfigSnippetChange: (value: string) => boolean | Promise<boolean>;
onCommonConfigErrorClear: () => void;
@@ -1,13 +1,31 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { parse as parseToml } from "smol-toml";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
import { hasTomlCommonConfigSnippet } from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
import { normalizeTomlText } from "@/utils/textNormalization";
/**
* 合并/剥离通用配置片段(走后端 toml_edit,保注释、保键序)。
* 失败时返回原文本 + error,调用方据此决定是否回退 UI 状态。
*/
const applyTomlSnippet = async (
configToml: string,
snippetToml: string,
enabled: boolean,
): Promise<{ updatedConfig: string; error?: string }> => {
try {
const updatedConfig = await configApi.updateTomlCommonConfigSnippet(
configToml,
snippetToml,
enabled,
);
return { updatedConfig };
} catch (e) {
return { updatedConfig: configToml, error: String(e) };
}
};
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
@@ -48,6 +66,26 @@ export function useCodexCommonConfig({
const hasInitializedNewMode = useRef(false);
// 用于跟踪编辑模式是否已初始化显式开关/预览
const hasInitializedEditMode = useRef(false);
// 后端 TOML 合并是异步的:连续操作(快速点开关、连点保存)可能乱序
// 返回。每个写 config 的异步操作在发起时领取递增序号,结果落地前
// 校验自己仍是最新一次;过期结果直接丢弃,保证最后一次操作胜出。
const tomlOpSeqRef = useRef(0);
// 镜像最新的 codexConfig:用户在编辑器手动改动走父组件 onChange,
// 不经过本 hook(序号不变)。异步结果落地前还要校验发起时的 config
// 基线未被外部改写,否则会覆盖用户在请求在飞期间的手动编辑。
const latestCodexConfigRef = useRef(codexConfig);
useEffect(() => {
latestCodexConfigRef.current = codexConfig;
}, [codexConfig]);
// 结果落地前的过期判定:hook 内有更新的操作(序号变了),或发起时的
// config 基线已被外部改写(用户手动编辑为准)。任一成立即丢弃结果。
const isTomlOpStale = useCallback(
(seq: number, baseConfig: string) =>
seq !== tomlOpSeqRef.current ||
baseConfig !== latestCodexConfigRef.current,
[],
);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
@@ -168,25 +206,34 @@ export function useCodexCommonConfig({
// 如果应该启用通用配置但配置中还没有,则自动添加
if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) {
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (error) {
setCommonConfigError(error);
setUseCommonConfig(false);
return;
}
let cancelled = false;
const seq = ++tomlOpSeqRef.current;
(async () => {
const { updatedConfig, error } = await applyTomlSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (cancelled || isTomlOpStale(seq, codexConfig)) {
return;
}
if (error) {
setCommonConfigError(error);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
return;
setCommonConfigError("");
setUseCommonConfig(true);
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
})();
return () => {
cancelled = true;
};
}
setCommonConfigError("");
@@ -197,6 +244,7 @@ export function useCodexCommonConfig({
initialData,
initialEnabled,
isLoading,
isTomlOpStale,
onConfigChange,
parseCommonConfigSnippet,
]);
@@ -221,28 +269,39 @@ export function useCodexCommonConfig({
return;
}
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (error) {
setCommonConfigError(error);
setUseCommonConfig(false);
return;
}
let cancelled = false;
const seq = ++tomlOpSeqRef.current;
(async () => {
const { updatedConfig, error } = await applyTomlSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (cancelled || isTomlOpStale(seq, codexConfig)) {
return;
}
if (error) {
setCommonConfigError(error);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
setCommonConfigError("");
setUseCommonConfig(true);
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
})();
return () => {
cancelled = true;
};
}, [
initialData,
commonConfigSnippet,
isLoading,
isTomlOpStale,
codexConfig,
onConfigChange,
parseCommonConfigSnippet,
@@ -250,7 +309,10 @@ export function useCodexCommonConfig({
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
async (checked: boolean) => {
// 在同步校验之前领号:即使本次走同步早退分支,也要让更早发出、
// 仍在飞的异步结果作废,避免它晚到后把开关翻回去。
const seq = ++tomlOpSeqRef.current;
const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
if (parsedSnippet.error) {
setCommonConfigError(parsedSnippet.error);
@@ -267,12 +329,14 @@ export function useCodexCommonConfig({
return;
}
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
checked,
);
const { updatedConfig, error: snippetError } = await applyTomlSnippet(
codexConfig,
commonConfigSnippet,
checked,
);
if (isTomlOpStale(seq, codexConfig)) {
return;
}
if (snippetError) {
setCommonConfigError(snippetError);
@@ -293,6 +357,7 @@ export function useCodexCommonConfig({
[
codexConfig,
commonConfigSnippet,
isTomlOpStale,
onConfigChange,
parseCommonConfigSnippet,
t,
@@ -301,7 +366,10 @@ export function useCodexCommonConfig({
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string): boolean => {
async (value: string): Promise<boolean> => {
// 与 handleCommonConfigToggle 同一套序号:连续保存或保存与开关
// 交错时,只允许最后一次操作的结果落地。
const seq = ++tomlOpSeqRef.current;
const previousSnippet = commonConfigSnippet;
if (!value.trim()) {
@@ -312,11 +380,14 @@ export function useCodexCommonConfig({
let updatedConfig = codexConfig;
if (!previousParsed.error && previousParsed.hasContent) {
const removeResult = updateTomlCommonConfigSnippet(
const removeResult = await applyTomlSnippet(
codexConfig,
previousSnippet,
false,
);
if (isTomlOpStale(seq, codexConfig)) {
return false;
}
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return false;
@@ -352,11 +423,14 @@ export function useCodexCommonConfig({
const previousParsed = parseCommonConfigSnippet(previousSnippet);
if (!previousParsed.error && previousParsed.hasContent) {
const removeResult = updateTomlCommonConfigSnippet(
const removeResult = await applyTomlSnippet(
codexConfig,
previousSnippet,
false,
);
if (isTomlOpStale(seq, codexConfig)) {
return false;
}
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return false;
@@ -364,11 +438,11 @@ export function useCodexCommonConfig({
nextConfig = removeResult.updatedConfig;
}
const addResult = updateTomlCommonConfigSnippet(
nextConfig,
value,
true,
);
const addResult = await applyTomlSnippet(nextConfig, value, true);
// nextConfig 派生自发起时的 codexConfig,基线校验仍对 codexConfig 做
if (isTomlOpStale(seq, codexConfig)) {
return false;
}
if (addResult.error) {
setCommonConfigError(addResult.error);
@@ -400,6 +474,7 @@ export function useCodexCommonConfig({
[
commonConfigSnippet,
codexConfig,
isTomlOpStale,
onConfigChange,
parseCommonConfigSnippet,
t,
+23
View File
@@ -48,6 +48,29 @@ export async function setCommonConfigSnippet(
return invoke("set_common_config_snippet", { appType, snippet });
}
/**
* 对编辑器里的 config.toml 文本做通用配置片段的合并/剥离
*
* 合并/剥离在后端用 toml_edit 完成(保注释、保键序);前端 smol-toml
* 的整文档重序列化会破坏用户手写格式,禁止在前端做这类结构化改写。
*
* @param configToml - 编辑器当前的 config.toml 文本
* @param snippetToml - 通用配置片段(TOML 字符串)
* @param enabled - true 合并片段,false 按值匹配剥离片段
* @returns 更新后的 config.toml 文本
*/
export async function updateTomlCommonConfigSnippet(
configToml: string,
snippetToml: string,
enabled: boolean,
): Promise<string> {
return invoke<string>("update_toml_common_config_snippet", {
configToml,
snippetToml,
enabled,
});
}
/**
* 提取通用配置片段
*
+4 -35
View File
@@ -3,7 +3,7 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { deepClone } from "@/utils/deepClone";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { parse as parseToml } from "smol-toml";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
@@ -343,40 +343,9 @@ export const setApiKeyInConfig = (
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// Write/remove common config snippet to/from TOML config (structural merge)
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
return { updatedConfig: tomlString };
}
try {
const config = parseToml(normalizeTomlText(tomlString || ""));
const snippet = parseToml(normalizeTomlText(snippetString));
if (enabled) {
const merged = deepMerge(
deepClone(config) as Record<string, any>,
deepClone(snippet) as Record<string, any>,
);
return { updatedConfig: stringifyToml(merged) };
} else {
const result = deepClone(config) as Record<string, any>;
deepRemove(result, snippet as Record<string, any>);
return { updatedConfig: stringifyToml(result) };
}
} catch (e) {
return { updatedConfig: tomlString, error: String(e) };
}
};
// TOML 片段的合并/剥离必须走后端命令(configApi.updateTomlCommonConfigSnippet
// toml_edit 保注释保键序)。禁止在前端用 smol-toml parse→merge→stringify
// 整文档重序列化:注释全丢、键序重排、还会生成多余的空父表头。
// Check if TOML config already contains the common config snippet (structural subset check)
export const hasTomlCommonConfigSnippet = (