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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+274
-157
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -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<string, unknown> {
|
||||
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<SettingsDraftRow[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(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<string | null>(null);
|
||||
const [addBusy, setAddBusy] = useState(false);
|
||||
|
||||
const data = (source.data as Record<string, unknown> | 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<string, unknown> = {};
|
||||
const seen = new Set<string>();
|
||||
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({
|
||||
) : (
|
||||
<span className="text-[11px] text-fg-muted">{t("settings.fileMissing")}</span>
|
||||
)}
|
||||
{draft ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<ActionButton icon={X} onClick={() => setDraft(null)} disabled={busy === "save"}>
|
||||
{t("edit.cancel")}
|
||||
</ActionButton>
|
||||
<ActionButton icon={Save} onClick={() => void save()} disabled={busy === "save"}>
|
||||
{busy === "save" ? t("edit.saving") : t("edit.save")}
|
||||
</ActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<ActionButton icon={Pencil} onClick={startEdit}>
|
||||
{t("edit.editButton")}
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="mx-3 mt-3 rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{err}</span>
|
||||
{lastSaved && (
|
||||
<div className="px-4 py-1.5 border-b border-border text-[10px] text-fg-muted">
|
||||
{t("manage.settingsLastBackup", {
|
||||
key: lastSaved.key,
|
||||
backup: lastSaved.backupPath || "-",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<div className="p-3 space-y-2">
|
||||
{draft.map((row, i) => (
|
||||
<div key={i} className="rounded-md border border-border bg-surface-1 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => (d ? d.filter((_, j) => j !== i) : d))}
|
||||
title={t("manage.settingsRemoveKey")}
|
||||
aria-label={t("manage.settingsRemoveKey")}
|
||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={row.text}
|
||||
onChange={(e) =>
|
||||
setDraft((d) =>
|
||||
d ? d.map((r, j) => (j === i ? { ...r, text: e.target.value } : r)) : d
|
||||
)
|
||||
}
|
||||
rows={Math.min(8, row.text.split("\n").length || 1)}
|
||||
spellCheck={false}
|
||||
aria-label={t("manage.settingsValue")}
|
||||
className="mt-1.5 w-full bg-surface-2 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((d) => [...(d ?? []), { key: "", text: '""', original: "", isNew: true }])
|
||||
}
|
||||
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("manage.settingsAddKey")}
|
||||
</button>
|
||||
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.settingsHelp")}</p>
|
||||
{redactedKeys.length > 0 && (
|
||||
<p className="text-[10px] text-fg-muted leading-relaxed inline-flex items-center gap-1.5">
|
||||
<Lock className="w-3 h-3 flex-shrink-0" />
|
||||
{t("manage.settingsRedactedKeys", {
|
||||
keys: redactedKeys.map(([k]) => k).join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
source.exists &&
|
||||
{source.exists &&
|
||||
(showRaw ? (
|
||||
<pre className="p-3 text-[11px] font-mono text-fg-secondary overflow-auto max-h-96">
|
||||
{JSON.stringify(source.data, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<SettingsKeyValueList data={source.data as Record<string, unknown> | null | undefined} />
|
||||
))
|
||||
)}
|
||||
<>
|
||||
<SettingsTree data={data} scope={source.scope} ancestry={[]} onSaved={handleSaved} />
|
||||
<div className="p-3 border-t border-border">
|
||||
{addOpen ? (
|
||||
<div className="rounded-md border border-border bg-surface-1 px-2.5 py-2 space-y-1.5">
|
||||
<input
|
||||
value={addKey}
|
||||
onChange={(e) => setAddKey(e.target.value)}
|
||||
placeholder={t("manage.settingsKeyPlaceholder")}
|
||||
aria-label={t("manage.settingsKey")}
|
||||
className="h-7 w-full 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"
|
||||
/>
|
||||
<textarea
|
||||
value={addValue}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
aria-label={t("manage.settingsValue")}
|
||||
className="w-full bg-surface-2 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
{addErr && <div className="text-[10px] text-status-danger">{addErr}</div>}
|
||||
<div className="flex items-center gap-1.5 justify-end">
|
||||
<ActionButton icon={X} onClick={() => setAddOpen(false)} disabled={addBusy}>
|
||||
{t("edit.cancel")}
|
||||
</ActionButton>
|
||||
<ActionButton icon={Save} onClick={() => void submitAdd()} disabled={addBusy}>
|
||||
{addBusy ? t("edit.saving") : t("edit.save")}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("manage.settingsAddKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsKeyValueList({ data }: { data: Record<string, unknown> | null | undefined }) {
|
||||
if (!data || typeof data !== "object") {
|
||||
return <div className="p-3 text-xs text-fg-muted">-</div>;
|
||||
}
|
||||
/** Recursively renders one settings.json layer: a plain-object value gets an
|
||||
* indented sub-tree, everything else is one editable leaf row. The dotted
|
||||
* path passed to `patchSettings` is rebuilt from `ancestry` at each leaf. */
|
||||
function SettingsTree({
|
||||
data,
|
||||
scope,
|
||||
ancestry,
|
||||
onSaved,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
scope: CcSettingsSource["scope"];
|
||||
ancestry: string[];
|
||||
onSaved: (dottedKey: string, result: CcSettingsPatchResult) => void;
|
||||
}) {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) {
|
||||
return <div className="p-3 text-xs text-fg-muted">{}</div>;
|
||||
return <div className="p-3 text-xs text-fg-muted">{"{}"}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map(([k, v]) => (
|
||||
<div
|
||||
key={k}
|
||||
className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-start"
|
||||
>
|
||||
<div className="font-mono text-[11px] text-fg-secondary truncate">{k}</div>
|
||||
<div className="min-w-0">
|
||||
<SettingsValue value={v} />
|
||||
</div>
|
||||
</div>
|
||||
{entries.map(([key, value]) => (
|
||||
<SettingsLeafRow
|
||||
key={key}
|
||||
keyName={key}
|
||||
value={value}
|
||||
scope={scope}
|
||||
ancestry={ancestry}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLeafRow({
|
||||
keyName,
|
||||
value,
|
||||
scope,
|
||||
ancestry,
|
||||
onSaved,
|
||||
}: {
|
||||
keyName: string;
|
||||
value: unknown;
|
||||
scope: CcSettingsSource["scope"];
|
||||
ancestry: string[];
|
||||
onSaved: (dottedKey: string, result: CcSettingsPatchResult) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const dotted = [...ancestry, keyName].join(".");
|
||||
|
||||
const startEdit = () => {
|
||||
setErr(null);
|
||||
// Strings edit as raw text (no surrounding quotes); everything else is
|
||||
// JSON so the type round-trips (42, true, ["a"]).
|
||||
setText(typeof value === "string" ? value : JSON.stringify(value));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
let parsed: unknown;
|
||||
if (typeof value === "string") {
|
||||
parsed = text;
|
||||
} else {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
setErr(t("manage.settingsBadJson", { key: dotted }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const result = await api.ccConfig.patchSettings({ scope, set: { [dotted]: parsed } });
|
||||
setEditing(false);
|
||||
onSaved(dotted, result);
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : "unknown error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<div className="font-mono text-[11px] text-fg-secondary mb-1">{keyName}</div>
|
||||
<div className="pl-3 border-l border-border">
|
||||
<SettingsTree
|
||||
data={value}
|
||||
scope={scope}
|
||||
ancestry={[...ancestry, keyName]}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRedacted(value)) {
|
||||
return (
|
||||
<div className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-center">
|
||||
<div className="font-mono text-[11px] text-fg-secondary truncate">{keyName}</div>
|
||||
<span
|
||||
title={t("manage.settingsRedactedTooltip")}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-muted border border-border inline-flex items-center gap-1 w-fit"
|
||||
>
|
||||
<Lock className="w-3 h-3" />
|
||||
{t("manage.settingsRedactedChip")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr_auto] gap-1 md:gap-3 items-start">
|
||||
<div className="font-mono text-[11px] text-fg-secondary truncate pt-0.5">{keyName}</div>
|
||||
<div className="min-w-0">
|
||||
{editing ? (
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label={t("manage.settingsValue")}
|
||||
className="w-full bg-surface-1 border border-border rounded px-2 py-1 text-[11px] font-mono text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<SettingsValue value={value} />
|
||||
)}
|
||||
{err && <div className="mt-1 text-[10px] text-status-danger">{err}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
aria-label={t("edit.save")}
|
||||
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-accent hover:bg-accent/10 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setErr(null);
|
||||
}}
|
||||
disabled={saving}
|
||||
aria-label={t("edit.cancel")}
|
||||
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
aria-label={t("manage.editKey", { key: keyName })}
|
||||
className="h-6 w-6 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsValue({ value }: { value: unknown }) {
|
||||
if (value === null || value === undefined)
|
||||
return <span className="text-[11px] text-fg-muted italic">null</span>;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file CcConfigSettings.test.tsx
|
||||
* @description Covers the settings.json editor added to the Claude Config
|
||||
* explorer's Settings tab: editing a leaf sends only its dotted key path
|
||||
* through `api.ccConfig.patchSettings`, a redacted value is never offered an
|
||||
* edit control, and a successful save surfaces the backup path it wrote so
|
||||
* the change is reversible in one glance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// vi.mock factories are hoisted above the rest of the module, so the mock
|
||||
// object they reference must be created inside vi.hoisted() too.
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
claudeJson: "/home/.claude.json",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
plugins: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
memory: 0,
|
||||
settingsFiles: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
plugins: r({
|
||||
manifestPath: "/home/.claude/plugins/installed_plugins.json",
|
||||
manifestExists: false,
|
||||
plugins: [],
|
||||
}),
|
||||
marketplaces: r({
|
||||
knownPath: "/home/.claude/plugins/known_marketplaces.json",
|
||||
knownExists: false,
|
||||
items: [],
|
||||
}),
|
||||
mcp: r({ user: [], projectScoped: [] }),
|
||||
hooks: r({ items: [] }),
|
||||
keybindings: r({ file: "/home/.claude/keybindings.json", exists: false, groups: [] }),
|
||||
settings: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
statusline: r({ config: null, scripts: [] }),
|
||||
hookScripts: r({ dir: "/home/.claude/hooks", items: [] }),
|
||||
patchSettings: r({ path: "/s.json", backupPath: null, created: false, changed: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...(actual.api as Record<string, unknown>),
|
||||
ccConfig: ccConfigMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
// jsdom leaves these unimplemented; the tab strip's scroll-into-view effect
|
||||
// calls scrollBy whenever the active tab changes (every getBoundingClientRect
|
||||
// is zeroed in jsdom, which the effect reads as "off-screen").
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
/** Render the Claude Config page and switch straight to the Settings tab,
|
||||
* optionally stubbing the settings sources and/or `patchSettings`. */
|
||||
async function renderSettingsTab(opts: {
|
||||
settings: {
|
||||
scope: "user" | "project" | "project-local";
|
||||
file: string;
|
||||
exists: boolean;
|
||||
data?: unknown;
|
||||
}[];
|
||||
patchSettings?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
ccConfigMock.settings.mockResolvedValue({ items: opts.settings });
|
||||
if (opts.patchSettings) ccConfigMock.patchSettings = opts.patchSettings as never;
|
||||
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByTestId("cc-tab-settings"));
|
||||
await settle();
|
||||
}
|
||||
|
||||
/** The "edit model → sonnet → save" flow shared by more than one test. */
|
||||
async function saveAnEdit() {
|
||||
// The page's own search box is also a textbox, so scope by accessible name
|
||||
// (the settings editor's value field is labeled "Value (JSON)").
|
||||
await userEvent.click(screen.getByRole("button", { name: /edit model/i }));
|
||||
const valueBox = screen.getByRole("textbox", { name: /value/i });
|
||||
await userEvent.clear(valueBox);
|
||||
await userEvent.type(valueBox, "sonnet");
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ccConfigMock.patchSettings = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ path: "/s.json", backupPath: null, created: false, changed: [] }) as never;
|
||||
});
|
||||
|
||||
describe("Claude Config - settings.json editor", () => {
|
||||
it("sends only the edited key, as a dotted path", async () => {
|
||||
const patchSettings = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
path: "/s.json",
|
||||
backupPath: "/s.bak",
|
||||
created: false,
|
||||
changed: ["model"],
|
||||
});
|
||||
await renderSettingsTab({
|
||||
settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }],
|
||||
patchSettings,
|
||||
});
|
||||
|
||||
await saveAnEdit();
|
||||
|
||||
expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } });
|
||||
});
|
||||
|
||||
it("will not let a redacted value be edited", async () => {
|
||||
await renderSettingsTab({
|
||||
settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "<redacted>" } }],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the backup it wrote, so the change is reversible in one glance", async () => {
|
||||
const patchSettings = vi.fn().mockResolvedValue({
|
||||
path: "/s.json",
|
||||
backupPath: "/s.json.2026.bak",
|
||||
created: false,
|
||||
changed: ["model"],
|
||||
});
|
||||
await renderSettingsTab({
|
||||
settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }],
|
||||
patchSettings,
|
||||
});
|
||||
|
||||
await saveAnEdit();
|
||||
|
||||
// Both the inline block note and the toast mention the backup - either is
|
||||
// fine, as long as it is visible somewhere without opening anything else.
|
||||
expect((await screen.findAllByText(/s\.json\.2026\.bak/)).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user