From ce64132771c24626e937a9c56fdf32aa14ea089a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 9 Sep 2026 08:44:57 +0700 Subject: [PATCH 01/10] feat(cc-config): edit settings.json values from the dashboard Replace the batch multi-key settings.json editor with a recursive per-leaf editor: each scalar value gets its own inline edit control, the dotted path is rebuilt from the node's ancestry, and a redacted value renders as a static non-editable chip. Saves go straight through the existing PATCH /api/cc-config/settings per-key merge. --- client/src/i18n/locales/en/ccConfig.json | 10 +- client/src/i18n/locales/vi/ccConfig.json | 10 +- client/src/pages/CcConfig.tsx | 431 +++++++++++------- .../pages/__tests__/CcConfigSettings.test.tsx | 190 ++++++++ 4 files changed, 474 insertions(+), 167 deletions(-) create mode 100644 client/src/pages/__tests__/CcConfigSettings.test.tsx diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index 912ecc42..e16de71f 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -331,12 +331,12 @@ "settingsKeyPlaceholder": "key or dotted.path", "settingsValue": "Value (JSON)", "settingsAddKey": "Add key", - "settingsRemoveKey": "Remove key", "settingsEmptyKey": "A key name cannot be empty.", - "settingsDupKey": "Key {{key}} is listed twice.", "settingsBadJson": "Value of {{key}} is not valid JSON.", - "settingsSaved": "Settings saved to {{file}}.", - "settingsHelp": "Values are JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Only the keys you change are written; the rest of the file is left as-is.", - "settingsRedactedKeys": "Not editable here because their values are redacted: {{keys}}" + "editKey": "Edit {{key}}", + "settingsKeySaved": "Saved {{key}} · backup at {{backup}}", + "settingsLastBackup": "Saved {{key}} · backup at {{backup}}", + "settingsRedactedTooltip": "The dashboard never received this value.", + "settingsRedactedChip": "redacted" } } diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index a310b3a7..7f4d1903 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -326,12 +326,12 @@ "settingsKeyPlaceholder": "key hoặc dotted.path", "settingsValue": "Giá trị (JSON)", "settingsAddKey": "Thêm khóa", - "settingsRemoveKey": "Xóa khóa", "settingsEmptyKey": "Tên khóa không được để trống.", - "settingsDupKey": "Khóa {{key}} bị lặp hai lần.", "settingsBadJson": "Giá trị của {{key}} không phải JSON hợp lệ.", - "settingsSaved": "Đã lưu settings vào {{file}}.", - "settingsHelp": "Giá trị là JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Chỉ những khóa bạn sửa mới được ghi; phần còn lại của file giữ nguyên.", - "settingsRedactedKeys": "Không sửa được ở đây vì giá trị đã bị che: {{keys}}" + "editKey": "Sửa {{key}}", + "settingsKeySaved": "Đã lưu {{key}} · bản sao lưu tại {{backup}}", + "settingsLastBackup": "Đã lưu {{key}} · bản sao lưu tại {{backup}}", + "settingsRedactedTooltip": "Dashboard chưa từng nhận được giá trị này.", + "settingsRedactedChip": "đã che" } } diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index 935e21d6..61fe7d90 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -5,9 +5,10 @@ * file, marketplace, keybinding, and statusline script Claude Code knows * about. Read access for all surfaces; create / edit / delete for the * low-risk text-file surfaces (skills, agents, commands, output styles, - * CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP, - * hooks-in-settings, and settings.json files stay read-only - those have - * concurrent-write races with the live CLI. + * CLAUDE.md memory, and per-project file-based memory files), plus a + * per-key merge editor for settings.json (`PATCH /cc-config/settings` - + * never a whole-file overwrite, so a concurrent CLI write loses nothing + * else). Plugins, MCP, and hooks-in-settings stay read-only here. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -134,6 +135,7 @@ import type { CcProfile, CcAccountIdentity, CcScope, + CcSettingsPatchResult, CcSettingsSource, CcStatusline, } from "../lib/api"; @@ -2590,7 +2592,12 @@ function isRedacted(value: unknown): boolean { } } -type SettingsDraftRow = { key: string; text: string; original: string; isNew: boolean }; +/** A JSON object (not an array, not null) — the only value kind the tree + * recurses into. Everything else (string/number/boolean/null/array) is a + * leaf, edited as one unit. */ +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} function SettingsBlock({ source, @@ -2604,68 +2611,61 @@ function SettingsBlock({ onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); - const { busy, run } = useConfigAction(onToast, onChanged); const [showRaw, setShowRaw] = useState(false); - const [draft, setDraft] = useState(null); - const [err, setErr] = useState(null); + // Last successful write in this block — shown inline so the backup it took + // is visible without hunting through the (auto-dismissing) toast. + const [lastSaved, setLastSaved] = useState<{ key: string; backupPath: string | null } | null>( + null + ); + const [addOpen, setAddOpen] = useState(false); + const [addKey, setAddKey] = useState(""); + const [addValue, setAddValue] = useState(""); + const [addErr, setAddErr] = useState(null); + const [addBusy, setAddBusy] = useState(false); const data = (source.data as Record | null | undefined) || {}; - // Redacted keys stay out of the draft entirely, so a save can never mention - // them; they keep rendering read-only below the editor. - const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v)); - const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v)); - const startEdit = () => { - setErr(null); - setDraft( - editableKeys.map(([key, value]) => { - const text = JSON.stringify(value, null, 2); - return { key, text, original: text, isNew: false }; - }) - ); - }; + const handleSaved = useCallback( + (dottedKey: string, result: CcSettingsPatchResult) => { + setLastSaved({ key: dottedKey, backupPath: result.backupPath }); + onToast({ + kind: "success", + message: t("manage.settingsKeySaved", { key: dottedKey, backup: result.backupPath || "-" }), + }); + onChanged(); + }, + [onChanged, onToast, t] + ); - const save = async () => { - if (!draft) return; - const set: Record = {}; - const seen = new Set(); - for (const row of draft) { - const key = row.key.trim(); - if (!key) { - setErr(t("manage.settingsEmptyKey")); - return; - } - if (seen.has(key)) { - setErr(t("manage.settingsDupKey", { key })); - return; - } - seen.add(key); - // Only send what actually changed — an untouched key is left alone. - if (!row.isNew && row.text === row.original) continue; - try { - set[key] = JSON.parse(row.text); - } catch { - setErr(t("manage.settingsBadJson", { key })); - return; - } - } - const unset = editableKeys.map(([key]) => key).filter((key) => !seen.has(key)); - if (Object.keys(set).length === 0 && unset.length === 0) { - setDraft(null); + const submitAdd = async () => { + const key = addKey.trim(); + if (!key) { + setAddErr(t("manage.settingsEmptyKey")); return; } - setErr(null); - const ok = await run( - "save", - () => - api.ccConfig.patchSettings({ - scope: source.scope, - ...(Object.keys(set).length ? { set } : {}), - ...(unset.length ? { unset } : {}), - }), - t("manage.settingsSaved", { file: source.file }) - ); - if (ok) setDraft(null); + let value: unknown; + try { + value = JSON.parse(addValue); + } catch { + setAddErr(t("manage.settingsBadJson", { key })); + return; + } + setAddBusy(true); + setAddErr(null); + try { + const result = await api.ccConfig.patchSettings({ + scope: source.scope, + set: { [key]: value }, + }); + handleSaved(key, result); + setAddOpen(false); + setAddKey(""); + setAddValue(""); + } catch (err: unknown) { + setAddErr(err instanceof Error ? err.message : "unknown error"); + } finally { + setAddBusy(false); + } }; return ( @@ -2694,128 +2694,245 @@ function SettingsBlock({ ) : ( {t("settings.fileMissing")} )} - {draft ? ( -
- setDraft(null)} disabled={busy === "save"}> - {t("edit.cancel")} - - void save()} disabled={busy === "save"}> - {busy === "save" ? t("edit.saving") : t("edit.save")} - -
- ) : ( - - {t("edit.editButton")} - - )} - {err && ( -
- - {err} + {lastSaved && ( +
+ {t("manage.settingsLastBackup", { + key: lastSaved.key, + backup: lastSaved.backupPath || "-", + })}
)} - {draft ? ( -
- {draft.map((row, i) => ( -
-
- - setDraft((d) => - d ? d.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) : d - ) - } - placeholder={t("manage.settingsKeyPlaceholder")} - aria-label={t("manage.settingsKey")} - className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" - /> - -
-