add disable/enabled in commands and mcp

This commit is contained in:
Muh. Fani Akbar
2026-02-05 12:15:44 +07:00
parent f0ce9c1ee2
commit 9201baad28
14 changed files with 1802 additions and 690 deletions
+964 -451
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,4 +1,5 @@
mod commands;
mod helper;
mod tray;
mod hook_server;
@@ -186,6 +187,7 @@ pub fn run() {
check_mcp_server_exists,
get_mcp_enabled_state,
toggle_mcp_server_state,
toggle_direct_mcp_server,
get_mcp_servers_with_state,
read_claude_projects,
read_claude_config_file,
@@ -209,7 +211,11 @@ pub fn run() {
toggle_claude_command,
read_claude_agents,
write_claude_agent,
delete_claude_agent
delete_claude_agent,
read_installed_plugins,
toggle_plugin,
read_plugin_commands,
read_plugin_agents
])
.on_window_event(|window, event| {
#[cfg(target_os = "macos")]
+6
View File
@@ -6,6 +6,7 @@ import {
CpuIcon,
FileJsonIcon,
FolderIcon,
PackageIcon,
SettingsIcon,
TerminalIcon,
} from "lucide-react";
@@ -52,6 +53,11 @@ export function Layout() {
icon: TerminalIcon,
label: t("navigation.commands"),
},
{
to: "/plugins",
icon: PackageIcon,
label: t("navigation.plugins"),
},
{
to: "/notification",
icon: BellIcon,
+32
View File
@@ -19,6 +19,8 @@
"agents.noAgents": "No sub agents found. Click \"Add Agent\" to create one.",
"agents.save": "Save",
"agents.saving": "Saving...",
"agents.sourceUser": "User",
"agents.sourcePlugin": "Plugin",
"agents.title": "Agents",
"agents.validationError": "Validation Error",
"app.title": "CC Mate",
@@ -46,6 +48,8 @@
"commands.noCommands": "No custom commands found. Click \"Add Command\" to create one.",
"commands.save": "Save",
"commands.saving": "Saving...",
"commands.sourceUser": "User",
"commands.sourcePlugin": "Plugin",
"commands.title": "Commands",
"commands.validationError": "Validation Error",
"commands.viewInClaude": "View in Claude",
@@ -72,6 +76,7 @@
"configSwitcher.originalConfig": "Claude",
"configSwitcher.originalConfigDescription": "Claude Code Original Configuration",
"configSwitcher.title": "Configurations",
"error.title": "Error",
"error.save": "Failed to save configuration",
"error.validation": "Invalid JSON format",
"glm.buyFromOfficial": "Buy from Official Website",
@@ -136,6 +141,10 @@
"mcp.runtimeDisabled": "Runtime Disabled",
"mcp.scope": "Scope",
"mcp.sourceType": "Source",
"mcp.projectScope": "Project",
"mcp.globalScope": "Global (User Scope)",
"mcp.searchProject": "Search projects...",
"mcp.noProjectFound": "No project found.",
"mcp.invalidConfigError": "Configuration must be a JSON object.",
"mcp.invalidJsonError": "Invalid JSON configuration for {{serverName}}",
"mcp.invalidJsonTitle": "Invalid JSON",
@@ -173,6 +182,7 @@
"navigation.mcp": "MCP",
"navigation.memory": "Memory",
"navigation.notifications": "Notifications",
"navigation.plugins": "Plugins",
"navigation.projects": "Projects",
"navigation.settings": "Settings",
"navigation.usage": "Usage",
@@ -188,6 +198,28 @@
"notifications.title": "Notifications",
"notifications.toolUse": "Tool Use Notifications",
"notifications.toolUseDescription": "Notify when Claude Code is using tools",
"plugins.title": "Plugins",
"plugins.description": "Manage Claude Code plugins",
"plugins.error": "Error loading plugins: {{error}}",
"plugins.noPlugins": "No plugins installed",
"plugins.none": "None",
"plugins.scope": "Scope",
"plugins.scopeUser": "Global",
"plugins.scopeLocal": "Local",
"plugins.status": "Status",
"plugins.enabled": "Enabled",
"plugins.disabled": "Disabled",
"plugins.enable": "Enable",
"plugins.disable": "Disable",
"plugins.packages": "Packages",
"plugins.packageAgents": "Agents",
"plugins.packageSkills": "Skills",
"plugins.packageCommands": "Commands",
"plugins.packageMcp": "MCP",
"plugins.projectPath": "Project Path",
"plugins.version": "Version",
"plugins.toggleLocalError": "Cannot toggle local plugin \"{{pluginName}}\" - project path is missing",
"plugins.validationError": "Validation Error",
"projects.detail.backToProjects": "Back to Projects",
"projects.detail.editor": "Project Configuration Editor",
"projects.detail.invalidJson": "Invalid JSON format. Please fix the errors before saving.",
+32
View File
@@ -19,6 +19,8 @@
"agents.noAgents": "Aucun sub agent trouvé. Cliquez sur \"Add Agent\" pour en créer un.",
"agents.save": "Sauvegarder",
"agents.saving": "Sauvegarde...",
"agents.sourceUser": "Utilisateur",
"agents.sourcePlugin": "Plugin",
"agents.title": "Agent",
"agents.validationError": "Erreur de validation",
"app.title": "CC Mate",
@@ -42,6 +44,8 @@
"commands.noCommands": "Aucune commande personnalisée trouvée. Cliquez sur \"Ajouter une commande\" pour en créer une.",
"commands.save": "Sauvegarder",
"commands.saving": "Sauvegarde...",
"commands.sourceUser": "Utilisateur",
"commands.sourcePlugin": "Plugin",
"commands.title": "Commandes",
"commands.validationError": "Erreur de validation",
"commands.viewInClaude": "Voir dans Claude",
@@ -68,6 +72,7 @@
"configSwitcher.originalConfig": "Claude",
"configSwitcher.originalConfigDescription": "Configuration d'origine de Claude Code",
"configSwitcher.title": "Configurations",
"error.title": "Erreur",
"error.save": "Échec de la sauvegarde de la configuration",
"error.validation": "Format JSON invalide",
"glm.buyFromOfficial": "Acheter sur le site officiel",
@@ -120,6 +125,10 @@
"mcp.runtimeDisabled": "Désactivé au runtime",
"mcp.scope": "Portée",
"mcp.sourceType": "Source",
"mcp.projectScope": "Projet",
"mcp.globalScope": "Global (Portée utilisateur)",
"mcp.searchProject": "Rechercher des projets...",
"mcp.noProjectFound": "Aucun projet trouvé.",
"mcp.invalidConfigError": "La configuration doit être un objet JSON.",
"mcp.invalidJsonError": "Configuration JSON invalide pour {{serverName}}",
"mcp.invalidJsonTitle": "JSON invalide",
@@ -157,6 +166,7 @@
"navigation.mcp": "MCP",
"navigation.memory": "Mémoire",
"navigation.notifications": "Notifications",
"navigation.plugins": "Plugins",
"navigation.projects": "Projets",
"navigation.settings": "Paramètres",
"navigation.usage": "Utilisation",
@@ -172,6 +182,28 @@
"notifications.title": "Notifications",
"notifications.toolUse": "Notifications d'utilisation d'outils",
"notifications.toolUseDescription": "Notifier lorsque Claude Code utilise des outils",
"plugins.title": "Plugins",
"plugins.description": "Gérer les plugins Claude Code",
"plugins.error": "Erreur lors du chargement des plugins : {{error}}",
"plugins.noPlugins": "Aucun plugin installé",
"plugins.none": "Aucun",
"plugins.scope": "Portée",
"plugins.scopeUser": "Global",
"plugins.scopeLocal": "Local",
"plugins.status": "Statut",
"plugins.enabled": "Activé",
"plugins.disabled": "Désactivé",
"plugins.enable": "Activer",
"plugins.disable": "Désactiver",
"plugins.packages": "Packages",
"plugins.packageAgents": "Agents",
"plugins.packageSkills": "Compétences",
"plugins.packageCommands": "Commandes",
"plugins.packageMcp": "MCP",
"plugins.projectPath": "Chemin du projet",
"plugins.version": "Version",
"plugins.toggleLocalError": "Impossible d'activer/désactiver le plugin \"{{pluginName}}\" - chemin du projet manquant",
"plugins.validationError": "Erreur de validation",
"projects.detail.backToProjects": "Retour aux projets",
"projects.detail.editor": "Éditeur de configuration de projet",
"projects.detail.invalidJson": "Format JSON invalide. Veuillez corriger les erreurs avant de sauvegarder.",
+32
View File
@@ -19,6 +19,8 @@
"agents.noAgents": "sub agent が見つかりません。「Add Agent」をクリックして作成してください。",
"agents.save": "保存",
"agents.saving": "保存中...",
"agents.sourceUser": "ユーザー",
"agents.sourcePlugin": "プラグイン",
"agents.title": "Agent",
"agents.validationError": "検証エラー",
"app.title": "CC Mate",
@@ -42,6 +44,8 @@
"commands.noCommands": "カスタムコマンドが見つかりません。「コマンドを追加」をクリックして作成してください。",
"commands.save": "保存",
"commands.saving": "保存中...",
"commands.sourceUser": "ユーザー",
"commands.sourcePlugin": "プラグイン",
"commands.title": "コマンド",
"commands.validationError": "検証エラー",
"commands.viewInClaude": "Claude で表示",
@@ -68,6 +72,7 @@
"configSwitcher.originalConfig": "Claude",
"configSwitcher.originalConfigDescription": "Claude Code の元の設定",
"configSwitcher.title": "設定",
"error.title": "エラー",
"error.save": "設定の保存に失敗しました",
"error.validation": "JSON 形式が無効です",
"glm.buyFromOfficial": "公式サイトで購入",
@@ -120,6 +125,10 @@
"mcp.runtimeDisabled": "ランタイム無効",
"mcp.scope": "スコープ",
"mcp.sourceType": "ソース",
"mcp.projectScope": "プロジェクト",
"mcp.globalScope": "グローバル(ユーザースコープ)",
"mcp.searchProject": "プロジェクトを検索...",
"mcp.noProjectFound": "プロジェクトが見つかりません。",
"mcp.invalidConfigError": "設定は JSON オブジェクトである必要があります。",
"mcp.invalidJsonError": "{{serverName}} の JSON 設定が無効です",
"mcp.invalidJsonTitle": "JSON が無効です",
@@ -157,6 +166,7 @@
"navigation.mcp": "MCP",
"navigation.memory": "メモリ",
"navigation.notifications": "通知",
"navigation.plugins": "プラグイン",
"navigation.projects": "プロジェクト",
"navigation.settings": "設定",
"navigation.usage": "使用量",
@@ -172,6 +182,28 @@
"notifications.title": "通知",
"notifications.toolUse": "ツール使用通知",
"notifications.toolUseDescription": "Claude Code がツールを使用する際に通知",
"plugins.title": "プラグイン",
"plugins.description": "Claude Code プラグインを管理",
"plugins.error": "プラグインの読み込みエラー: {{error}}",
"plugins.noPlugins": "プラグインがインストールされていません",
"plugins.none": "なし",
"plugins.scope": "スコープ",
"plugins.scopeUser": "グローバル",
"plugins.scopeLocal": "ローカル",
"plugins.status": "ステータス",
"plugins.enabled": "有効",
"plugins.disabled": "無効",
"plugins.enable": "有効化",
"plugins.disable": "無効化",
"plugins.packages": "パッケージ",
"plugins.packageAgents": "エージェント",
"plugins.packageSkills": "スキル",
"plugins.packageCommands": "コマンド",
"plugins.packageMcp": "MCP",
"plugins.projectPath": "プロジェクトパス",
"plugins.version": "バージョン",
"plugins.toggleLocalError": "ローカルプラグイン \"{{pluginName}}\" を切り替えできません - プロジェクトパスがありません",
"plugins.validationError": "検証エラー",
"projects.detail.backToProjects": "プロジェクトに戻る",
"projects.detail.editor": "プロジェクト設定エディター",
"projects.detail.invalidJson": "JSON 形式が無効です。保存前にエラーを修正してください。",
+32
View File
@@ -19,6 +19,8 @@
"agents.noAgents": "未找到 sub agent。点击「Add Agent」来创建一个。",
"agents.save": "保存",
"agents.saving": "保存中...",
"agents.sourceUser": "用户",
"agents.sourcePlugin": "插件",
"agents.title": "Agent",
"agents.validationError": "验证错误",
"app.title": "CC Mate",
@@ -42,6 +44,8 @@
"commands.noCommands": "未找到自定义命令。点击「添加命令」来创建一个。",
"commands.save": "保存",
"commands.saving": "保存中...",
"commands.sourceUser": "用户",
"commands.sourcePlugin": "插件",
"commands.title": "命令",
"commands.validationError": "验证错误",
"commands.viewInClaude": "在 Claude 中查看",
@@ -68,6 +72,7 @@
"configSwitcher.originalConfig": "Claude",
"configSwitcher.originalConfigDescription": "Claude Code 原有配置",
"configSwitcher.title": "配置",
"error.title": "错误",
"error.save": "保存配置失败",
"error.validation": "JSON 格式无效",
"glm.buyFromOfficial": "前往官网购买",
@@ -132,6 +137,10 @@
"mcp.runtimeDisabled": "运行时禁用",
"mcp.scope": "范围",
"mcp.sourceType": "来源",
"mcp.projectScope": "项目",
"mcp.globalScope": "全局(用户范围)",
"mcp.searchProject": "搜索项目...",
"mcp.noProjectFound": "未找到项目。",
"mcp.invalidConfigError": "配置必须是 JSON 对象。",
"mcp.invalidJsonError": "{{serverName}} 的 JSON 配置无效",
"mcp.invalidJsonTitle": "JSON 无效",
@@ -169,6 +178,7 @@
"navigation.mcp": "MCP",
"navigation.memory": "记忆",
"navigation.notifications": "通知",
"navigation.plugins": "插件",
"navigation.projects": "项目",
"navigation.settings": "设置",
"navigation.usage": "用量",
@@ -184,6 +194,28 @@
"notifications.title": "通知",
"notifications.toolUse": "工具使用通知",
"notifications.toolUseDescription": "当 Claude Code 使用工具时提醒",
"plugins.title": "插件",
"plugins.description": "管理 Claude Code 插件",
"plugins.error": "加载插件时出错:{{error}}",
"plugins.noPlugins": "未安装插件",
"plugins.none": "无",
"plugins.scope": "范围",
"plugins.scopeUser": "全局",
"plugins.scopeLocal": "本地",
"plugins.status": "状态",
"plugins.enabled": "已启用",
"plugins.disabled": "已禁用",
"plugins.enable": "启用",
"plugins.disable": "禁用",
"plugins.packages": "包",
"plugins.packageAgents": "代理",
"plugins.packageSkills": "技能",
"plugins.packageCommands": "命令",
"plugins.packageMcp": "MCP",
"plugins.projectPath": "项目路径",
"plugins.version": "版本",
"plugins.toggleLocalError": "无法切换本地插件 \"{{pluginName}}\" - 缺少项目路径",
"plugins.validationError": "验证错误",
"projects.detail.backToProjects": "返回项目列表",
"projects.detail.editor": "项目配置编辑器",
"projects.detail.invalidJson": "JSON 格式无效。请在保存前修复错误。",
+27
View File
@@ -0,0 +1,27 @@
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { yamlFrontmatter } from "@codemirror/lang-yaml";
import { EditorView } from "@uiw/react-codemirror";
export const codeMirrorBasicSetup = {
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
} as const;
export const markdownExtensions = [
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
];
+118 -12
View File
@@ -55,6 +55,25 @@ export interface CommandFile {
disabled: boolean;
}
export interface PluginCommandFile {
name: string;
content: string;
exists: boolean;
disabled: boolean;
pluginName: string;
pluginScope: string;
sourcePath: string;
}
export interface PluginAgentFile {
name: string;
content: string;
exists: boolean;
pluginName: string;
pluginScope: string;
sourcePath: string;
}
export const useConfigFiles = () => {
return useQuery({
queryKey: ["config-files"],
@@ -400,32 +419,42 @@ export const useDeleteGlobalMcpServer = () => {
export interface McpEnabledState {
enabledMcpjsonServers: string[];
disabledMcpjsonServers: string[];
disabledMcpServers: string[];
}
export const useGetMcpEnabledState = () => {
export const useGetMcpEnabledState = (cwd?: string) => {
return useSuspenseQuery({
queryKey: ["mcp-enabled-state"],
queryFn: () => invoke<McpEnabledState>("get_mcp_enabled_state"),
queryKey: ["mcp-enabled-state", cwd],
queryFn: () => invoke<McpEnabledState>("get_mcp_enabled_state", { cwd }),
});
};
export const useToggleMcpServer = () => {
export const useToggleMcpServer = (cwd?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
serverName,
enabled,
sourceType,
}: {
serverName: string;
enabled: boolean;
}) => invoke<void>("toggle_mcp_server_state", { serverName, enabled }),
sourceType: "mcpjson" | "direct" | "plugin";
}) => {
if (sourceType === "direct") {
return invoke<void>("toggle_direct_mcp_server", { serverName, enabled, cwd });
} else {
// Both "mcpjson" and "plugin" use the same toggle mechanism
return invoke<void>("toggle_mcp_server_state", { serverName, enabled, cwd });
}
},
onSuccess: (_, variables) => {
toast.success(
`MCP server ${variables.enabled ? "enabled" : "disabled"} successfully`,
);
queryClient.invalidateQueries({ queryKey: ["mcp-enabled-state"] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state"] });
queryClient.invalidateQueries({ queryKey: ["mcp-enabled-state", cwd] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state", cwd] });
},
onError: (error) => {
const errorMessage =
@@ -438,8 +467,8 @@ export const useToggleMcpServer = () => {
export interface McpServerState {
name: string;
config: Record<string, any>;
sourceType: "mcpjson" | "direct";
scope: "user" | "project" | "local";
sourceType: "mcpjson" | "direct" | "plugin";
scope: "user" | "project" | "local" | "plugin-user" | "plugin-local";
definedIn: string;
controllable: boolean;
state: "disabled" | "enabled" | "runtime-disabled";
@@ -447,10 +476,10 @@ export interface McpServerState {
inDisabledArray: boolean;
}
export const useGetMcpServersWithState = () => {
export const useGetMcpServersWithState = (cwd?: string) => {
return useSuspenseQuery({
queryKey: ["mcp-servers-with-state"],
queryFn: () => invoke<McpServerState[]>("get_mcp_servers_with_state"),
queryKey: ["mcp-servers-with-state", cwd],
queryFn: () => invoke<McpServerState[]>("get_mcp_servers_with_state", { cwd }),
});
};
@@ -589,6 +618,12 @@ export const useClaudeCommands = () =>
queryFn: () => invoke<CommandFile[]>("read_claude_commands"),
});
export const usePluginCommands = () =>
useQuery({
queryKey: ["plugin-commands"],
queryFn: () => invoke<PluginCommandFile[]>("read_plugin_commands"),
});
export const useWriteClaudeCommand = () => {
const queryClient = useQueryClient();
@@ -603,6 +638,7 @@ export const useWriteClaudeCommand = () => {
onSuccess: () => {
toast.success(i18n.t("toast.commandSaved"));
queryClient.invalidateQueries({ queryKey: ["claude-commands"] });
queryClient.invalidateQueries({ queryKey: ["plugin-commands"] });
},
onError: (error) => {
const errorMessage =
@@ -621,6 +657,7 @@ export const useDeleteClaudeCommand = () => {
onSuccess: () => {
toast.success(i18n.t("toast.commandDeleted"));
queryClient.invalidateQueries({ queryKey: ["claude-commands"] });
queryClient.invalidateQueries({ queryKey: ["plugin-commands"] });
},
onError: (error) => {
const errorMessage =
@@ -644,6 +681,7 @@ export const useToggleClaudeCommand = () => {
onSuccess: () => {
toast.success(i18n.t("toast.commandToggled"));
queryClient.invalidateQueries({ queryKey: ["claude-commands"] });
queryClient.invalidateQueries({ queryKey: ["plugin-commands"] });
},
onError: (error) => {
const errorMessage =
@@ -660,6 +698,12 @@ export const useClaudeAgents = () =>
queryFn: () => invoke<CommandFile[]>("read_claude_agents"),
});
export const usePluginAgents = () =>
useQuery({
queryKey: ["plugin-agents"],
queryFn: () => invoke<PluginAgentFile[]>("read_plugin_agents"),
});
export const useWriteClaudeAgent = () => {
const queryClient = useQueryClient();
@@ -674,6 +718,7 @@ export const useWriteClaudeAgent = () => {
onSuccess: () => {
toast.success("Agent saved successfully");
queryClient.invalidateQueries({ queryKey: ["claude-agents"] });
queryClient.invalidateQueries({ queryKey: ["plugin-agents"] });
},
onError: (error) => {
const errorMessage =
@@ -692,6 +737,7 @@ export const useDeleteClaudeAgent = () => {
onSuccess: () => {
toast.success("Agent deleted successfully");
queryClient.invalidateQueries({ queryKey: ["claude-agents"] });
queryClient.invalidateQueries({ queryKey: ["plugin-agents"] });
},
onError: (error) => {
const errorMessage =
@@ -709,3 +755,63 @@ const rebuildTrayMenu = async () => {
console.error("Failed to rebuild tray menu:", error);
}
};
// Plugin management types and hooks
export interface PluginPackages {
hasAgents: boolean;
hasSkills: boolean;
hasCommands: boolean;
hasMcp: boolean;
}
export interface PluginInfo {
name: string;
scope: "user" | "local";
version: string;
projectPath?: string;
enabled: boolean;
packages: PluginPackages;
installPath: string;
installedAt: string;
}
export const useInstalledPlugins = () =>
useQuery({
queryKey: ["installed-plugins"],
queryFn: () => invoke<PluginInfo[]>("read_installed_plugins"),
});
export const useTogglePlugin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
pluginName,
enabled,
scope,
projectPath,
}: {
pluginName: string;
enabled: boolean;
scope: string;
projectPath?: string;
}) =>
invoke<void>("toggle_plugin", {
pluginName,
enabled,
scope,
projectPath,
}),
onSuccess: () => {
toast.success("Plugin status updated successfully");
queryClient.invalidateQueries({ queryKey: ["installed-plugins"] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to toggle plugin: ${errorMessage}`);
},
});
};
+75 -73
View File
@@ -1,7 +1,5 @@
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { yamlFrontmatter } from "@codemirror/lang-yaml";
import { ask, message } from "@tauri-apps/plugin-dialog";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import CodeMirror from "@uiw/react-codemirror";
import { BotIcon, PlusIcon, SaveIcon, TrashIcon } from "lucide-react";
import { Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,6 +9,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,22 +22,57 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { codeMirrorBasicSetup, markdownExtensions } from "@/lib/codemirror-config";
import {
useClaudeAgents,
useDeleteClaudeAgent,
usePluginAgents,
useWriteClaudeAgent,
} from "@/lib/query";
import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme";
type UnifiedAgent = {
name: string;
content: string;
exists: boolean;
source: "user" | "plugin";
pluginName?: string;
pluginScope?: string;
sourcePath: string;
};
function AgentsPageContent() {
const { t } = useTranslation();
const { data: agents, isLoading, error } = useClaudeAgents();
const { data: userAgents, isLoading: isLoadingUser, error: errorUser } = useClaudeAgents();
const { data: pluginAgents, isLoading: isLoadingPlugin, error: errorPlugin } = usePluginAgents();
const writeAgent = useWriteClaudeAgent();
const deleteAgent = useDeleteClaudeAgent();
const [agentEdits, setAgentEdits] = useState<Record<string, string>>({});
const [isDialogOpen, setIsDialogOpen] = useState(false);
const codeMirrorTheme = useCodeMirrorTheme();
const isLoading = isLoadingUser || isLoadingPlugin;
const error = errorUser || errorPlugin;
const agents: UnifiedAgent[] = [
...(userAgents || []).map((agent): UnifiedAgent => ({
name: agent.name,
content: agent.content,
exists: agent.exists,
source: "user",
sourcePath: `~/.claude/agents/${agent.name}.md`,
})),
...(pluginAgents || []).map((agent): UnifiedAgent => ({
name: agent.name,
content: agent.content,
exists: agent.exists,
source: "plugin",
pluginName: agent.pluginName,
pluginScope: agent.pluginScope,
sourcePath: agent.sourcePath,
})),
].sort((a, b) => a.name.localeCompare(b.name));
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -86,9 +120,9 @@ function AgentsPageContent() {
};
return (
<div className="">
<div>
<div
className="flex items-center p-3 border-b px-3 justify-between sticky top-0 bg-background z-10"
className="flex items-center justify-between sticky top-0 z-10 border-b p-3 bg-background"
data-tauri-drag-region
>
<div data-tauri-drag-region>
@@ -108,9 +142,7 @@ function AgentsPageContent() {
</DialogTrigger>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle className="">
{t("agents.addAgentTitle")}
</DialogTitle>
<DialogTitle>{t("agents.addAgentTitle")}</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
{t("agents.addAgentDescription")}
</DialogDescription>
@@ -119,15 +151,15 @@ function AgentsPageContent() {
</DialogContent>
</Dialog>
</div>
<div className="">
{!agents || agents.length === 0 ? (
<div>
{agents.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{t("agents.noAgents")}
</div>
) : (
<ScrollArea className="h-full">
<div className="">
<Accordion type="multiple" className="">
<div>
<Accordion type="multiple">
{agents.map((agent) => (
<AccordionItem
key={agent.name}
@@ -135,11 +167,25 @@ function AgentsPageContent() {
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<BotIcon size={12} />
<span className="font-medium">{agent.name}</span>
{agent.source === "user" ? (
<Badge variant="default">
{t("agents.sourceUser")}
</Badge>
) : (
<>
<Badge variant="outline">
{agent.pluginName}
</Badge>
<Badge variant={agent.pluginScope === "user" ? "default" : "secondary"}>
{agent.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")}
</Badge>
</>
)}
<span className="text-sm text-muted-foreground font-normal">
{`~/.claude/agents/${agent.name}.md`}
{agent.sourcePath}
</span>
</div>
</AccordionTrigger>
@@ -147,39 +193,15 @@ function AgentsPageContent() {
<div className="px-3 pt-3 space-y-3">
<div className="rounded-lg overflow-hidden border">
<CodeMirror
value={
agentEdits[agent.name] !== undefined
? agentEdits[agent.name]
: agent.content
}
value={agentEdits[agent.name] ?? agent.content}
height="180px"
theme={codeMirrorTheme}
onChange={(value) =>
handleContentChange(agent.name, value)
}
placeholder={t("agents.contentPlaceholder")}
extensions={[
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
]}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
}}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
<div className="flex justify-between bg-card">
@@ -192,7 +214,7 @@ function AgentsPageContent() {
}
size="sm"
>
<SaveIcon size={14} className="" />
<SaveIcon size={14} />
{writeAgent.isPending
? t("agents.saving")
: t("agents.save")}
@@ -204,7 +226,7 @@ function AgentsPageContent() {
onClick={() => handleDeleteAgent(agent.name)}
disabled={deleteAgent.isPending}
>
<TrashIcon size={14} className="" />
<TrashIcon size={14} />
</Button>
</div>
</div>
@@ -236,7 +258,11 @@ export function AgentsPage() {
);
}
function CreateAgentPanel({ onClose }: { onClose?: () => void }) {
type CreateAgentPanelProps = {
onClose?: () => void;
};
function CreateAgentPanel({ onClose }: CreateAgentPanelProps) {
const { t } = useTranslation();
const [agentName, setAgentName] = useState("");
const [agentContent, setAgentContent] = useState(`---
@@ -257,7 +283,6 @@ the subagent should follow.`);
const codeMirrorTheme = useCodeMirrorTheme();
const handleCreateAgent = async () => {
// Validate agent name
if (!agentName.trim()) {
await message(t("agents.emptyNameError"), {
title: t("agents.validationError"),
@@ -266,9 +291,7 @@ the subagent should follow.`);
return;
}
// Check if agent already exists
const exists = agents?.some((agent) => agent.name === agentName);
if (exists) {
if (agents?.some((a) => a.name === agentName)) {
await message(t("agents.agentExistsError", { agentName }), {
title: t("agents.agentExistsTitle"),
kind: "info",
@@ -276,7 +299,6 @@ the subagent should follow.`);
return;
}
// Validate content
if (!agentContent.trim()) {
await message(t("agents.emptyContentError"), {
title: t("agents.validationError"),
@@ -324,29 +346,9 @@ the subagent should follow.`);
onChange={(value) => setAgentContent(value)}
height="200px"
theme={codeMirrorTheme}
placeholder={t("agents.contentPlaceholder")}
extensions={[
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
]}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
}}
placeholder={t("agents.contentPlaceholder")}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
</div>
+77 -73
View File
@@ -1,7 +1,5 @@
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { yamlFrontmatter } from "@codemirror/lang-yaml";
import { ask, message } from "@tauri-apps/plugin-dialog";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import CodeMirror from "@uiw/react-codemirror";
import { PlusIcon, SaveIcon, TerminalIcon, TrashIcon } from "lucide-react";
import { Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -24,17 +22,31 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { codeMirrorBasicSetup, markdownExtensions } from "@/lib/codemirror-config";
import {
useClaudeCommands,
useDeleteClaudeCommand,
usePluginCommands,
useToggleClaudeCommand,
useWriteClaudeCommand,
} from "@/lib/query";
import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme";
type UnifiedCommand = {
name: string;
content: string;
exists: boolean;
disabled: boolean;
source: "user" | "plugin";
pluginName?: string;
pluginScope?: string;
sourcePath: string;
};
function CommandsPageContent() {
const { t } = useTranslation();
const { data: commands, isLoading, error } = useClaudeCommands();
const { data: userCommands, isLoading: isLoadingUser, error: errorUser } = useClaudeCommands();
const { data: pluginCommands, isLoading: isLoadingPlugin, error: errorPlugin } = usePluginCommands();
const writeCommand = useWriteClaudeCommand();
const deleteCommand = useDeleteClaudeCommand();
const toggleCommand = useToggleClaudeCommand();
@@ -42,6 +54,30 @@ function CommandsPageContent() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const codeMirrorTheme = useCodeMirrorTheme();
const isLoading = isLoadingUser || isLoadingPlugin;
const error = errorUser || errorPlugin;
const commands: UnifiedCommand[] = [
...(userCommands || []).map((cmd): UnifiedCommand => ({
name: cmd.name,
content: cmd.content,
exists: cmd.exists,
disabled: cmd.disabled,
source: "user",
sourcePath: `~/.claude/commands/${cmd.name}.md${cmd.disabled ? '.disabled' : ''}`,
})),
...(pluginCommands || []).map((cmd): UnifiedCommand => ({
name: cmd.name,
content: cmd.content,
exists: cmd.exists,
disabled: cmd.disabled,
source: "plugin",
pluginName: cmd.pluginName,
pluginScope: cmd.pluginScope,
sourcePath: cmd.sourcePath,
})),
].sort((a, b) => a.name.localeCompare(b.name));
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -93,9 +129,9 @@ function CommandsPageContent() {
};
return (
<div className="">
<div>
<div
className="flex items-center p-3 border-b px-3 justify-between sticky top-0 bg-background z-10"
className="flex items-center justify-between sticky top-0 z-10 border-b p-3 bg-background"
data-tauri-drag-region
>
<div data-tauri-drag-region>
@@ -115,9 +151,7 @@ function CommandsPageContent() {
</DialogTrigger>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle className="">
{t("commands.addCommandTitle")}
</DialogTitle>
<DialogTitle>{t("commands.addCommandTitle")}</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
{t("commands.addCommandDescription")}
</DialogDescription>
@@ -126,15 +160,15 @@ function CommandsPageContent() {
</DialogContent>
</Dialog>
</div>
<div className="">
{!commands || commands.length === 0 ? (
<div>
{commands.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{t("commands.noCommands")}
</div>
) : (
<ScrollArea className="h-full">
<div className="">
<Accordion type="multiple" className="">
<div>
<Accordion type="multiple">
{commands.map((command) => (
<AccordionItem
key={command.name}
@@ -142,14 +176,28 @@ function CommandsPageContent() {
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<TerminalIcon size={12} />
<span className="font-medium">{command.name}</span>
<Badge variant={command.disabled ? "secondary" : "success"}>
{command.disabled ? t("commands.disabled") : t("commands.enabled")}
</Badge>
{command.source === "user" ? (
<Badge variant="default">
{t("commands.sourceUser")}
</Badge>
) : (
<>
<Badge variant="outline">
{command.pluginName}
</Badge>
<Badge variant={command.pluginScope === "user" ? "default" : "secondary"}>
{command.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")}
</Badge>
</>
)}
<span className="text-sm text-muted-foreground font-normal">
{`~/.claude/commands/${command.name}.md${command.disabled ? '.disabled' : ''}`}
{command.sourcePath}
</span>
</div>
</AccordionTrigger>
@@ -157,39 +205,15 @@ function CommandsPageContent() {
<div className="px-3 pt-3 space-y-3">
<div className="rounded-lg overflow-hidden border">
<CodeMirror
value={
commandEdits[command.name] !== undefined
? commandEdits[command.name]
: command.content
}
value={commandEdits[command.name] ?? command.content}
height="180px"
theme={codeMirrorTheme}
onChange={(value) =>
handleContentChange(command.name, value)
}
placeholder={t("commands.contentPlaceholder")}
extensions={[
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
]}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
}}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
<div className="flex justify-between bg-card">
@@ -203,7 +227,7 @@ function CommandsPageContent() {
}
size="sm"
>
<SaveIcon size={14} className="" />
<SaveIcon size={14} />
{writeCommand.isPending
? t("commands.saving")
: t("commands.save")}
@@ -225,7 +249,7 @@ function CommandsPageContent() {
onClick={() => handleDeleteCommand(command.name)}
disabled={deleteCommand.isPending}
>
<TrashIcon size={14} className="" />
<TrashIcon size={14} />
</Button>
</div>
</div>
@@ -257,7 +281,11 @@ export function CommandsPage() {
);
}
function CreateCommandPanel({ onClose }: { onClose?: () => void }) {
type CreateCommandPanelProps = {
onClose?: () => void;
};
function CreateCommandPanel({ onClose }: CreateCommandPanelProps) {
const { t } = useTranslation();
const [commandName, setCommandName] = useState("");
const [commandContent, setCommandContent] = useState("");
@@ -266,7 +294,6 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) {
const codeMirrorTheme = useCodeMirrorTheme();
const handleCreateCommand = async () => {
// Validate command name
if (!commandName.trim()) {
await message(t("commands.emptyNameError"), {
title: t("commands.validationError"),
@@ -275,9 +302,7 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) {
return;
}
// Check if command already exists
const exists = commands && commands.some((cmd) => cmd.name === commandName);
if (exists) {
if (commands?.some((cmd) => cmd.name === commandName)) {
await message(t("commands.commandExistsError", { commandName }), {
title: t("commands.commandExistsTitle"),
kind: "info",
@@ -285,7 +310,6 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) {
return;
}
// Validate content
if (!commandContent.trim()) {
await message(t("commands.emptyContentError"), {
title: t("commands.validationError"),
@@ -329,29 +353,9 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) {
onChange={(value) => setCommandContent(value)}
height="200px"
theme={codeMirrorTheme}
placeholder={t("commands.contentPlaceholder")}
extensions={[
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
]}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
}}
placeholder={t("commands.contentPlaceholder")}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
</div>
+177 -80
View File
@@ -3,7 +3,10 @@ import { ask, message } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import CodeMirror from "@uiw/react-codemirror";
import {
Check,
ChevronsUpDown,
ExternalLinkIcon,
FolderIcon,
HammerIcon,
PlusIcon,
SaveIcon,
@@ -11,7 +14,6 @@ import {
} from "lucide-react";
import { Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { match } from "ts-pattern";
import {
Accordion,
AccordionContent,
@@ -20,6 +22,14 @@ import {
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -28,31 +38,40 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { builtInMcpServers } from "@/lib/builtInMCP";
import {
type McpServerState,
useAddGlobalMcpServer,
useClaudeProjects,
useDeleteGlobalMcpServer,
useGetMcpServersWithState,
useToggleMcpServer,
useUpdateGlobalMcpServer,
} from "@/lib/query";
import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme";
import { cn } from "@/lib/utils";
function MCPPageContent() {
const { t } = useTranslation();
const { data: mcpServersWithState } = useGetMcpServersWithState();
const { data: projects } = useClaudeProjects();
const [currentCwd, setCurrentCwd] = useState<string | undefined>(undefined);
const { data: mcpServersWithState } = useGetMcpServersWithState(currentCwd);
const updateMcpServer = useUpdateGlobalMcpServer();
const deleteMcpServer = useDeleteGlobalMcpServer();
const toggleMcpServer = useToggleMcpServer();
const toggleMcpServer = useToggleMcpServer(currentCwd);
const [serverConfigs, setServerConfigs] = useState<Record<string, string>>(
{},
);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [comboboxOpen, setComboboxOpen] = useState(false);
const codeMirrorTheme = useCodeMirrorTheme();
// Helper functions for badge display
const getBadgeVariant = (state: string) => {
function getBadgeVariant(state: string) {
switch (state) {
case "enabled":
return "success";
@@ -60,12 +79,12 @@ function MCPPageContent() {
return "outline";
case "runtime-disabled":
return "secondary";
default:
return "outline";
}
};
default:
return "outline";
}
}
const getBadgeLabel = (state: string) => {
function getBadgeLabel(state: string) {
switch (state) {
case "enabled":
return t("mcp.enabled");
@@ -73,18 +92,17 @@ function MCPPageContent() {
return t("mcp.disabled");
case "runtime-disabled":
return t("mcp.runtimeDisabled");
default:
return t("mcp.disabled");
}
};
default:
return t("mcp.disabled");
}
}
const handleToggleServer = async (server: McpServerState) => {
if (!server.controllable) return;
const currentlyEnabled = server.state === "enabled" || server.state === "runtime-disabled";
toggleMcpServer.mutate({
serverName: server.name,
enabled: !currentlyEnabled,
sourceType: server.sourceType,
});
};
@@ -114,7 +132,6 @@ function MCPPageContent() {
};
const handleDeleteServer = async (serverName: string) => {
// Show confirmation dialog
const confirmed = await ask(t("mcp.deleteServerConfirm", { serverName }), {
title: t("mcp.deleteServerTitle"),
kind: "warning",
@@ -125,18 +142,23 @@ function MCPPageContent() {
}
};
const formatConfigForDisplay = (server: McpServerState): string => {
function formatConfigForDisplay(server: McpServerState): string {
return JSON.stringify(server.config, null, 2);
};
}
function getPluginNameFromDefinedIn(definedIn: string): string {
const match = definedIn.match(/Plugin: (.+?) \(/);
return match ? match[1] : "Plugin";
}
const sortedServers = [...mcpServersWithState].sort((a, b) =>
a.name.localeCompare(b.name),
);
return (
<div className="">
<div>
<div
className="flex items-center p-3 border-b px-3 justify-between sticky top-0 bg-background z-10"
className="flex items-center justify-between sticky top-0 z-10 border-b p-3 bg-background"
data-tauri-drag-region
>
<div data-tauri-drag-region>
@@ -166,24 +188,97 @@ function MCPPageContent() {
<div className="py-3 mt-3">
<MCPCreatePanel onClose={() => setIsDialogOpen(false)} />
</div>
{/* <div className="flex justify-end">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
关闭
</Button>
</div> */}
</DialogContent>
</Dialog>
</div>
<div className="">
{projects && projects.length > 0 && (
<div className="p-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{t("mcp.projectScope")}:
</span>
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
role="combobox"
aria-expanded={comboboxOpen}
className="justify-between min-w-[250px]"
>
<div className="flex items-center gap-2 truncate">
{currentCwd ? (
<>
<FolderIcon className="h-4 w-4 shrink-0" />
<span className="truncate">{currentCwd}</span>
</>
) : (
<span>{t("mcp.globalScope")}</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput
placeholder={t("mcp.searchProject")}
className="h-9"
/>
<CommandList>
<CommandEmpty>{t("mcp.noProjectFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="global"
onSelect={() => {
setCurrentCwd(undefined);
setComboboxOpen(false);
}}
>
<span className="truncate">{t("mcp.globalScope")}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
!currentCwd ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
{projects.map((project) => (
<CommandItem
key={project.path}
value={project.path}
onSelect={() => {
setCurrentCwd(project.path);
setComboboxOpen(false);
}}
>
<FolderIcon className="mr-2 h-4 w-4" />
<span className="truncate">{project.path}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
currentCwd === project.path
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
<div>
{sortedServers.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{t("mcp.noServersConfigured")}
</div>
) : (
<Accordion type="multiple" className="">
<Accordion type="multiple">
{sortedServers.map((server) => (
<AccordionItem
key={server.name}
@@ -191,15 +286,26 @@ function MCPPageContent() {
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<HammerIcon size={12} />
<span className="font-medium">{server.name}</span>
<Badge variant={getBadgeVariant(server.state)}>
{getBadgeLabel(server.state)}
</Badge>
<Badge variant="outline" className="text-xs">
{server.sourceType}
</Badge>
{server.sourceType === "plugin" ? (
<>
<Badge variant="outline" className="text-xs">
{getPluginNameFromDefinedIn(server.definedIn)}
</Badge>
<Badge variant={server.scope === "plugin-user" ? "default" : "secondary"} className="text-xs">
{server.scope === "plugin-user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")}
</Badge>
</>
) : (
<Badge variant="outline" className="text-xs">
{server.sourceType}
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -219,7 +325,7 @@ function MCPPageContent() {
placeholder="Enter MCP server configuration as JSON"
/>
</div>
<div className="flex justify-between bg-card">
<div className="flex justify-between bg-card">
<div className="flex gap-2">
<Button
variant="outline"
@@ -227,7 +333,7 @@ function MCPPageContent() {
disabled={updateMcpServer.isPending}
size="sm"
>
<SaveIcon size={14} className="" />
<SaveIcon size={14} />
{updateMcpServer.isPending
? t("mcp.saving")
: t("mcp.save")}
@@ -239,7 +345,7 @@ function MCPPageContent() {
onClick={() => handleDeleteServer(server.name)}
disabled={deleteMcpServer.isPending}
>
<TrashIcon size={14} className="" />
<TrashIcon size={14} />
</Button>
</div>
@@ -249,12 +355,7 @@ function MCPPageContent() {
}
size="sm"
onClick={() => handleToggleServer(server)}
disabled={!server.controllable || toggleMcpServer.isPending}
title={
!server.controllable
? t("mcp.notControllable")
: undefined
}
disabled={toggleMcpServer.isPending}
>
{server.state === "enabled"
? t("mcp.disable")
@@ -273,11 +374,13 @@ function MCPPageContent() {
}
export function MCPPage() {
const { t } = useTranslation();
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">Loading MCP servers...</div>
<div className="text-center">{t("loading")}</div>
</div>
}
>
@@ -286,14 +389,18 @@ export function MCPPage() {
);
}
function MCPCreatePanel({ onClose }: { onClose?: () => void }) {
type MCPCreatePanelProps = {
onClose?: () => void;
};
function MCPCreatePanel({ onClose }: MCPCreatePanelProps) {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState<"recommend" | "manual">(
"recommend",
);
return (
<div className="">
<div>
<div className="flex mb-3 gap-1">
<Button
size="sm"
@@ -313,19 +420,20 @@ function MCPCreatePanel({ onClose }: { onClose?: () => void }) {
</Button>
</div>
{match(currentTab)
.with("recommend", () => {
return <RecommendMCPPanel onClose={onClose} />;
})
.with("manual", () => {
return <CustomMCPPanel onClose={onClose} />;
})
.exhaustive()}
{currentTab === "recommend" ? (
<RecommendMCPPanel onClose={onClose} />
) : (
<CustomMCPPanel onClose={onClose} />
)}
</div>
);
}
function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
type RecommendMCPPanelProps = {
onClose?: () => void;
};
function RecommendMCPPanel({ onClose }: RecommendMCPPanelProps) {
const { t } = useTranslation();
const addMcpServer = useAddGlobalMcpServer();
const { data: mcpServersWithState } = useGetMcpServersWithState();
@@ -334,9 +442,8 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
mcpServer: (typeof builtInMcpServers)[0],
) => {
try {
// Check if MCP server already exists using cached data
const exists =
mcpServersWithState && mcpServersWithState.some(s => s.name === mcpServer.name);
mcpServersWithState?.some((s) => s.name === mcpServer.name);
if (exists) {
await message(
@@ -349,14 +456,12 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
return;
}
// Show confirmation dialog
const confirmed = await ask(
t("mcp.addServerConfirm", { serverName: mcpServer.name }),
{ title: t("mcp.addServerTitle"), kind: "info" },
);
if (confirmed) {
// Parse the prefill JSON to get the config object
const configObject = JSON.parse(`{${mcpServer.prefill}}`);
addMcpServer.mutate(
@@ -366,7 +471,6 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
},
{
onSuccess: () => {
// Close dialog after successful addition
onClose?.();
},
},
@@ -375,7 +479,7 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
} catch (error) {
console.error("Failed to add MCP server:", error);
await message(t("mcp.addServerError"), {
title: "Error",
title: t("error.title"),
kind: "error",
});
}
@@ -402,15 +506,10 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
{t("mcp.source")}
</a>
</div>
<div></div>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{mcpServer.description}
</p>
{/* <Button size="sm" variant="outline" className="w-full text-sm">
<PlusIcon />
添加
</Button> */}
</div>
</div>
))}
@@ -418,7 +517,11 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) {
);
}
function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
type CustomMCPPanelProps = {
onClose?: () => void;
};
function CustomMCPPanel({ onClose }: CustomMCPPanelProps) {
const { t } = useTranslation();
const [customConfig, setCustomConfig] = useState("");
const addMcpServer = useAddGlobalMcpServer();
@@ -427,11 +530,10 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
const handleAddCustomMcpServer = async () => {
try {
// Validate JSON format
let configObject;
try {
configObject = JSON.parse(customConfig);
} catch (error) {
} catch {
await message(t("mcp.addCustomServerError"), {
title: t("mcp.invalidJsonTitle"),
kind: "error",
@@ -439,10 +541,9 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
return;
}
// Check if it's an object with at least one server
if (typeof configObject !== "object" || configObject === null) {
await message(t("mcp.invalidConfigError"), {
title: "Invalid Configuration",
title: t("mcp.invalidJsonTitle"),
kind: "error",
});
return;
@@ -451,14 +552,13 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
const serverNames = Object.keys(configObject);
if (serverNames.length === 0) {
await message(t("mcp.noServersError"), {
title: "Invalid Configuration",
title: t("mcp.invalidJsonTitle"),
kind: "error",
});
return;
}
// Check for duplicate server names
const existingNames = mcpServersWithState ? mcpServersWithState.map(s => s.name) : [];
const existingNames = mcpServersWithState?.map((s) => s.name) ?? [];
const duplicateNames = serverNames.filter((name) =>
existingNames.includes(name),
);
@@ -476,14 +576,12 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
return;
}
// Show confirmation dialog
const confirmed = await ask(
t("mcp.addCustomServersConfirm", { count: serverNames.length }),
{ title: t("mcp.addCustomServersTitle"), kind: "info" },
);
if (confirmed) {
// Add each server
for (const [serverName, serverConfig] of Object.entries(configObject)) {
addMcpServer.mutate({
serverName,
@@ -491,21 +589,20 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) {
});
}
// Clear input and close dialog
setCustomConfig("");
onClose?.();
}
} catch (error) {
console.error("Failed to add custom MCP servers:", error);
await message(t("mcp.addServerError"), {
title: "Error",
title: t("error.title"),
kind: "error",
});
}
};
return (
<div className="">
<div>
<div className="space-y-3">
<div className="rounded-lg overflow-hidden border">
<CodeMirror
+214
View File
@@ -0,0 +1,214 @@
import { message } from "@tauri-apps/plugin-dialog";
import { BotIcon, PackageIcon, TerminalIcon } from "lucide-react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useInstalledPlugins, useTogglePlugin } from "@/lib/query";
function PluginsPageContent() {
const { t } = useTranslation();
const { data: plugins, isLoading, error } = useInstalledPlugins();
const togglePlugin = useTogglePlugin();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">{t("loading")}</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center text-red-500">
{t("plugins.error", { error: error.message })}
</div>
</div>
);
}
const sortedPlugins = [...(plugins ?? [])].sort((a, b) => {
const aDate = a.installedAt ?? "";
const bDate = b.installedAt ?? "";
return bDate.localeCompare(aDate);
});
const handleTogglePlugin = async (
pluginName: string,
currentEnabled: boolean,
scope: string,
projectPath?: string,
) => {
if (scope === "local" && !projectPath) {
await message(t("plugins.toggleLocalError", { pluginName }), {
title: t("plugins.validationError"),
kind: "error",
});
return;
}
togglePlugin.mutate({
pluginName,
enabled: !currentEnabled,
scope,
projectPath,
});
};
return (
<div>
<div
className="flex items-center justify-between sticky top-0 z-10 border-b p-3 bg-background"
data-tauri-drag-region
>
<div data-tauri-drag-region>
<h3 className="font-bold" data-tauri-drag-region>
{t("plugins.title")}
</h3>
<p className="text-sm text-muted-foreground" data-tauri-drag-region>
{t("plugins.description")}
</p>
</div>
</div>
<div>
{!plugins || plugins.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{t("plugins.noPlugins")}
</div>
) : (
<ScrollArea className="h-full">
<div>
<Accordion type="multiple">
{sortedPlugins.map((plugin) => (
<AccordionItem
key={`${plugin.name}-${plugin.scope}`}
value={`${plugin.name}-${plugin.scope}`}
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex flex-col items-start gap-1 w-full">
<div className="flex items-center gap-2 flex-wrap">
<PackageIcon size={12} />
<span className="font-medium">{plugin.name}</span>
<Badge
variant={
plugin.scope === "user" ? "default" : "secondary"
}
>
{plugin.scope === "user"
? t("plugins.scopeUser")
: t("plugins.scopeLocal")}
</Badge>
<Badge
variant={plugin.enabled ? "success" : "outline"}
>
{plugin.enabled
? t("plugins.enabled")
: t("plugins.disabled")}
</Badge>
{plugin.scope === "local" && plugin.projectPath ? (
<span className="text-xs text-muted-foreground font-mono">
{plugin.projectPath}
</span>
) : (
<span className="text-sm text-muted-foreground font-normal">
v{plugin.version}
</span>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="px-3 pt-3 space-y-3">
<div className="flex flex-wrap gap-2">
<span className="text-sm text-muted-foreground">
{t("plugins.packages")}:
</span>
{plugin.packages.hasAgents && (
<Badge variant="secondary">
<BotIcon size={12} className="mr-1" />
{t("plugins.packageAgents")}
</Badge>
)}
{plugin.packages.hasSkills && (
<Badge variant="secondary">
{t("plugins.packageSkills")}
</Badge>
)}
{plugin.packages.hasCommands && (
<Badge variant="secondary">
<TerminalIcon size={12} className="mr-1" />
{t("plugins.packageCommands")}
</Badge>
)}
{plugin.packages.hasMcp && (
<Badge variant="secondary">
{t("plugins.packageMcp")}
</Badge>
)}
{!plugin.packages.hasAgents &&
!plugin.packages.hasSkills &&
!plugin.packages.hasCommands &&
!plugin.packages.hasMcp && (
<span className="text-sm text-muted-foreground">
{t("plugins.none")}
</span>
)}
</div>
<div className="flex justify-between bg-card">
<Button
variant={plugin.enabled ? "outline" : "default"}
size="sm"
onClick={() =>
handleTogglePlugin(
plugin.name,
plugin.enabled,
plugin.scope,
plugin.projectPath,
)
}
disabled={togglePlugin.isPending}
>
{plugin.enabled
? t("plugins.disable")
: t("plugins.enable")}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</ScrollArea>
)}
</div>
</div>
);
}
export function PluginsPage() {
const { t } = useTranslation();
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">{t("loading")}</div>
</div>
}
>
<PluginsPageContent />
</Suspense>
);
}
+9
View File
@@ -8,6 +8,7 @@ import { ConfigSwitcherPage } from "./pages/ConfigSwitcherPage";
import { MCPPage } from "./pages/MCPPage";
import { MemoryPage } from "./pages/MemoryPage";
import { NotificationPage } from "./pages/NotificationPage";
import { PluginsPage } from "./pages/PluginsPage";
import { Detail } from "./pages/projects/Detail";
import { ProjectsLayout } from "./pages/projects/Layout";
import { List } from "./pages/projects/List";
@@ -95,6 +96,14 @@ const router = createBrowserRouter([
</RouteWrapper>
),
},
{
path: "plugins",
element: (
<RouteWrapper>
<PluginsPage />
</RouteWrapper>
),
},
{
path: "projects",
element: (