feat(editor): add JSON format button to editors

Add one-click format functionality for JSON editors with toast notifications:

- Add format button to JsonEditor (CodeMirror)
- Add format button to CodexAuthSection (auth.json)
- Add format button to CommonConfigEditor (settings + modal)
- Add formatJSON utility function
- Add i18n keys: format, formatSuccess, formatError

Note: TOML formatting was intentionally NOT added to avoid losing comments
during parse/stringify operations. TOML validation remains available via
the existing useCodexTomlValidation hook.
This commit is contained in:
Jason
2025-11-01 21:05:01 +08:00
parent b1f7840e45
commit 87f408c163
6 changed files with 194 additions and 23 deletions

View File

@@ -7,6 +7,9 @@ import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view"; import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint"; import { linter, Diagnostic } from "@codemirror/lint";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface JsonEditorProps { interface JsonEditorProps {
value: string; value: string;
@@ -170,7 +173,44 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
} }
}, [value]); }, [value]);
return <div ref={editorRef} style={{ width: "100%" }} />; // 格式化处理函数
const handleFormat = () => {
if (!viewRef.current) return;
const currentValue = viewRef.current.state.doc.toString();
if (!currentValue.trim()) return;
try {
const formatted = formatJSON(currentValue);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<div style={{ width: "100%" }}>
<div ref={editorRef} style={{ width: "100%" }} />
{language === "json" && (
<button
type="button"
onClick={handleFormat}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
)}
</div>
);
}; };
export default JsonEditor; export default JsonEditor;

View File

@@ -1,5 +1,8 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface CodexAuthSectionProps { interface CodexAuthSectionProps {
value: string; value: string;
@@ -19,6 +22,25 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleFormat = () => {
if (!value.trim()) return;
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label <label
@@ -47,13 +69,26 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
data-enable-grammarly="false" data-enable-grammarly="false"
/> />
{error && ( <div className="flex items-center justify-between">
<p className="text-xs text-red-500 dark:text-red-400">{error}</p> <button
)} type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400"> {error && (
{t("codexConfig.authJsonHint")} <p className="text-xs text-red-500 dark:text-red-400">{error}</p>
</p> )}
</div>
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.authJsonHint")}
</p>
)}
</div> </div>
); );
}; };
@@ -141,9 +176,11 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p> <p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)} )}
<p className="text-xs text-gray-500 dark:text-gray-400"> {!configError && (
{t("codexConfig.configTomlHint")} <p className="text-xs text-gray-500 dark:text-gray-400">
</p> {t("codexConfig.configTomlHint")}
</p>
)}
</div> </div>
); );
}; };

View File

@@ -8,7 +8,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Save } from "lucide-react"; import { Save, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface CommonConfigEditorProps { interface CommonConfigEditorProps {
value: string; value: string;
@@ -37,6 +39,44 @@ export function CommonConfigEditor({
}: CommonConfigEditorProps) { }: CommonConfigEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleFormatMain = () => {
if (!value.trim()) return;
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
const handleFormatModal = () => {
if (!commonConfigSnippet.trim()) return;
try {
const formatted = formatJSON(commonConfigSnippet);
onCommonConfigSnippetChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return ( return (
<> <>
<div className="space-y-2"> <div className="space-y-2">
@@ -97,11 +137,21 @@ export function CommonConfigEditor({
data-gramm_editor="false" data-gramm_editor="false"
data-enable-grammarly="false" data-enable-grammarly="false"
/> />
<p className="text-xs text-muted-foreground"> <div className="flex items-center justify-between">
{t("claudeConfig.fullSettingsHint", { <button
defaultValue: "请填写完整的 Claude Code 配置", type="button"
})} onClick={handleFormatMain}
</p> className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
<p className="text-xs text-muted-foreground">
{t("claudeConfig.fullSettingsHint", {
defaultValue: "请填写完整的 Claude Code 配置",
})}
</p>
</div>
</div> </div>
<Dialog <Dialog
@@ -137,11 +187,21 @@ export function CommonConfigEditor({
data-gramm_editor="false" data-gramm_editor="false"
data-enable-grammarly="false" data-enable-grammarly="false"
/> />
{commonConfigError && ( <div className="flex items-center justify-between">
<p className="text-sm text-red-500 dark:text-red-400"> <button
{commonConfigError} type="button"
</p> onClick={handleFormatModal}
)} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onModalClose}> <Button type="button" variant="outline" onClick={onModalClose}>

View File

@@ -22,7 +22,10 @@
"unknown": "Unknown", "unknown": "Unknown",
"enterValidValue": "Please enter a valid value", "enterValidValue": "Please enter a valid value",
"clear": "Clear", "clear": "Clear",
"toggleTheme": "Toggle theme" "toggleTheme": "Toggle theme",
"format": "Format",
"formatSuccess": "Formatted successfully",
"formatError": "Format failed: {{error}}"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "Enter API Key", "placeholder": "Enter API Key",

View File

@@ -22,7 +22,10 @@
"unknown": "未知", "unknown": "未知",
"enterValidValue": "请输入有效的内容", "enterValidValue": "请输入有效的内容",
"clear": "清除", "clear": "清除",
"toggleTheme": "切换主题" "toggleTheme": "切换主题",
"format": "格式化",
"formatSuccess": "格式化成功",
"formatError": "格式化失败:{{error}}"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "请输入API Key", "placeholder": "请输入API Key",

28
src/utils/formatters.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 格式化 JSON 字符串
* @param value - 原始 JSON 字符串
* @returns 格式化后的 JSON 字符串2 空格缩进)
* @throws 如果 JSON 格式无效
*/
export function formatJSON(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const parsed = JSON.parse(trimmed);
return JSON.stringify(parsed, null, 2);
}
/**
* TOML 格式化功能已禁用
*
* 原因smol-toml 的 parse/stringify 会丢失所有注释和原有排版。
* 由于 TOML 常用于配置文件,注释是重要的文档说明,丢失注释会造成严重的用户体验问题。
*
* 未来可选方案:
* - 使用 @ltd/j-toml支持注释保留但需额外依赖和复杂的 API
* - 实现仅格式化缩进/空白的轻量级方案
* - 使用 toml-eslint-parser + 自定义生成器
*
* 暂时建议:依赖现有的 TOML 语法校验useCodexTomlValidation不提供格式化功能。
*/