From b8a435a7f671a85c6ea46b1492e46783cfd5d334 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 17 Oct 2025 18:19:06 +0800 Subject: [PATCH] refactor: extract MCP business logic to useMcpActions hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before optimization: - McpPanel.tsx: 298 lines (component + business logic) After optimization: - McpPanel.tsx: 234 lines (-21%, UI focused) - useMcpActions.ts: 137 lines (business logic) Benefits: ✅ Separation of concerns: UI vs business logic ✅ Reusability: MCP operations can be used in other components ✅ Testability: business logic can be tested independently ✅ Consistency: follows same pattern as useProviderActions ✅ Optimistic updates: toggle enabled status with rollback on error ✅ Unified error handling: all MCP errors use toast notifications Technical details: - Extract reload, toggleEnabled, saveServer, deleteServer - Implement optimistic UI updates for toggle - Centralize error handling and toast messages - Remove duplicate error handling code from component --- src/components/mcp/McpPanel.tsx | 99 +++++------------------ src/hooks/useMcpActions.ts | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 81 deletions(-) create mode 100644 src/hooks/useMcpActions.ts diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index cd4f3cb..684344a 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; import { Plus, Server, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -9,15 +8,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { mcpApi, type AppType } from "@/lib/api"; +import { type AppType } from "@/lib/api"; import { McpServer } from "@/types"; +import { useMcpActions } from "@/hooks/useMcpActions"; import McpListItem from "./McpListItem"; import McpFormModal from "./McpFormModal"; import { ConfirmDialog } from "../ConfirmDialog"; -import { - extractErrorMessage, - translateMcpBackendError, -} from "@/utils/errorUtils"; interface McpPanelProps { open: boolean; @@ -35,8 +31,6 @@ const McpPanel: React.FC = ({ appType, }) => { const { t } = useTranslation(); - const [servers, setServers] = useState>({}); - const [loading, setLoading] = useState(true); const [isFormOpen, setIsFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ @@ -46,64 +40,30 @@ const McpPanel: React.FC = ({ onConfirm: () => void; } | null>(null); - const reload = async () => { - setLoading(true); - try { - const cfg = await mcpApi.getConfig(appType); - setServers(cfg.servers || {}); - } finally { - setLoading(false); - } - }; + // Use MCP actions hook + const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } = + useMcpActions(appType); useEffect(() => { const setup = async () => { try { - // 初始化:仅从对应客户端导入已有 MCP,不做“预设落库” + // Initialize: only import existing MCPs from corresponding client if (appType === "claude") { + const mcpApi = await import("@/lib/api").then((m) => m.mcpApi); await mcpApi.importFromClaude(); } else if (appType === "codex") { + const mcpApi = await import("@/lib/api").then((m) => m.mcpApi); await mcpApi.importFromCodex(); } } catch (e) { - console.warn("MCP 初始化导入失败(忽略继续)", e); + console.warn("MCP initialization import failed (ignored)", e); } finally { await reload(); } }; setup(); - // appType 改变时重新初始化 - }, [appType]); - - const handleToggle = async (id: string, enabled: boolean) => { - // 乐观更新:立即更新 UI - const previousServers = servers; - setServers((prev) => ({ - ...prev, - [id]: { - ...prev[id], - enabled, - }, - })); - - try { - // 后台调用 API - await mcpApi.setEnabled(appType, id, enabled); - toast.success( - enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), - { duration: 1500 }, - ); - } catch (e: any) { - // 失败时回滚 - setServers(previousServers); - const detail = extractErrorMessage(e); - const mapped = translateMcpBackendError(detail, t); - toast.error( - mapped || detail || t("mcp.error.saveFailed"), - { duration: mapped || detail ? 6000 : 5000 }, - ); - } - }; + // Re-initialize when appType changes + }, [appType, reload]); const handleEdit = (id: string) => { setEditingId(id); @@ -122,17 +82,10 @@ const McpPanel: React.FC = ({ message: t("mcp.confirm.deleteMessage", { id }), onConfirm: async () => { try { - await mcpApi.deleteServerInConfig(appType, id); - await reload(); + await deleteServer(id); setConfirmDialog(null); - toast.success(t("mcp.msg.deleted"), { duration: 1500 }); - } catch (e: any) { - const detail = extractErrorMessage(e); - const mapped = translateMcpBackendError(detail, t); - toast.error( - mapped || detail || t("mcp.error.deleteFailed"), - { duration: mapped || detail ? 6000 : 5000 }, - ); + } catch (e) { + // Error already handled by useMcpActions } }, }); @@ -143,25 +96,9 @@ const McpPanel: React.FC = ({ server: McpServer, options?: { syncOtherSide?: boolean }, ) => { - try { - const payload: McpServer = { ...server, id }; - await mcpApi.upsertServerInConfig(appType, id, payload, { - syncOtherSide: options?.syncOtherSide, - }); - await reload(); - setIsFormOpen(false); - setEditingId(null); - toast.success(t("mcp.msg.saved"), { duration: 1500 }); - } catch (e: any) { - const detail = extractErrorMessage(e); - const mapped = translateMcpBackendError(detail, t); - toast.error( - mapped || detail || t("mcp.error.saveFailed"), - { duration: mapped || detail ? 6000 : 5000 }, - ); - // 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡) - throw e; - } + await saveServer(id, server, options); + setIsFormOpen(false); + setEditingId(null); }; const handleCloseForm = () => { @@ -240,7 +177,7 @@ const McpPanel: React.FC = ({ key={`installed-${id}`} id={id} server={server} - onToggle={handleToggle} + onToggle={toggleEnabled} onEdit={handleEdit} onDelete={handleDelete} /> diff --git a/src/hooks/useMcpActions.ts b/src/hooks/useMcpActions.ts new file mode 100644 index 0000000..c7e698b --- /dev/null +++ b/src/hooks/useMcpActions.ts @@ -0,0 +1,137 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { mcpApi, type AppType } from "@/lib/api"; +import type { McpServer } from "@/types"; +import { + extractErrorMessage, + translateMcpBackendError, +} from "@/utils/errorUtils"; + +export interface UseMcpActionsResult { + servers: Record; + loading: boolean; + reload: () => Promise; + toggleEnabled: (id: string, enabled: boolean) => Promise; + saveServer: ( + id: string, + server: McpServer, + options?: { syncOtherSide?: boolean }, + ) => Promise; + deleteServer: (id: string) => Promise; +} + +/** + * useMcpActions - MCP management business logic + * Responsibilities: + * - Load MCP servers + * - Toggle enable/disable status + * - Save server configuration + * - Delete server + * - Error handling and toast notifications + */ +export function useMcpActions(appType: AppType): UseMcpActionsResult { + const { t } = useTranslation(); + const [servers, setServers] = useState>({}); + const [loading, setLoading] = useState(false); + + const reload = useCallback(async () => { + setLoading(true); + try { + const cfg = await mcpApi.getConfig(appType); + setServers(cfg.servers || {}); + } catch (error) { + console.error("[useMcpActions] Failed to load MCP config", error); + const detail = extractErrorMessage(error); + const mapped = translateMcpBackendError(detail, t); + toast.error(mapped || detail || t("mcp.error.loadFailed"), { + duration: mapped || detail ? 6000 : 5000, + }); + } finally { + setLoading(false); + } + }, [appType, t]); + + const toggleEnabled = useCallback( + async (id: string, enabled: boolean) => { + // Optimistic update + const previousServers = servers; + setServers((prev) => ({ + ...prev, + [id]: { + ...prev[id], + enabled, + }, + })); + + try { + await mcpApi.setEnabled(appType, id, enabled); + toast.success( + enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), + { duration: 1500 }, + ); + } catch (error) { + // Rollback on failure + setServers(previousServers); + const detail = extractErrorMessage(error); + const mapped = translateMcpBackendError(detail, t); + toast.error(mapped || detail || t("mcp.error.saveFailed"), { + duration: mapped || detail ? 6000 : 5000, + }); + } + }, + [appType, servers, t], + ); + + const saveServer = useCallback( + async ( + id: string, + server: McpServer, + options?: { syncOtherSide?: boolean }, + ) => { + try { + const payload: McpServer = { ...server, id }; + await mcpApi.upsertServerInConfig(appType, id, payload, { + syncOtherSide: options?.syncOtherSide, + }); + await reload(); + toast.success(t("mcp.msg.saved"), { duration: 1500 }); + } catch (error) { + const detail = extractErrorMessage(error); + const mapped = translateMcpBackendError(detail, t); + const msg = mapped || detail || t("mcp.error.saveFailed"); + toast.error(msg, { duration: mapped || detail ? 6000 : 5000 }); + // Re-throw to allow form-level error handling + throw error; + } + }, + [appType, reload, t], + ); + + const deleteServer = useCallback( + async (id: string) => { + try { + await mcpApi.deleteServerInConfig(appType, id); + await reload(); + toast.success(t("mcp.msg.deleted"), { duration: 1500 }); + } catch (error) { + const detail = extractErrorMessage(error); + const mapped = translateMcpBackendError(detail, t); + toast.error(mapped || detail || t("mcp.error.deleteFailed"), { + duration: mapped || detail ? 6000 : 5000, + }); + throw error; + } + }, + [appType, reload, t], + ); + + return { + servers, + loading, + reload, + toggleEnabled, + saveServer, + deleteServer, + }; +}