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:
2026-09-09 08:44:57 +07:00
parent 2ade30f670
commit ce64132771
4 changed files with 474 additions and 167 deletions
+274 -157
View File
@@ -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>;