mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-02 04:41:14 +08:00
fix: respect visible apps in skills and mcp UI
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
@@ -194,14 +194,52 @@ pub async fn toggle_mcp_app(
|
||||
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
|
||||
#[tauri::command]
|
||||
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
fn import_mcp_from_app(state: &AppState, app: &AppType) -> usize {
|
||||
match app {
|
||||
AppType::Claude => McpService::import_from_claude(state),
|
||||
AppType::ClaudeDesktop => Ok(0),
|
||||
AppType::Codex => McpService::import_from_codex(state),
|
||||
AppType::Gemini => McpService::import_from_gemini(state),
|
||||
AppType::OpenCode => McpService::import_from_opencode(state),
|
||||
AppType::OpenClaw => Ok(0),
|
||||
AppType::Hermes => McpService::import_from_hermes(state),
|
||||
}
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn import_mcp_from_selected_apps(
|
||||
state: &AppState,
|
||||
apps: Option<Vec<String>>,
|
||||
) -> Result<usize, String> {
|
||||
let mut apps_to_import = match apps {
|
||||
Some(apps) => apps
|
||||
.iter()
|
||||
.map(|app| AppType::from_str(app).map_err(|e| e.to_string()))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
None => vec![
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::Hermes,
|
||||
],
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
apps_to_import.retain(|app| seen.insert(app.clone()));
|
||||
|
||||
let mut total = 0;
|
||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
total += McpService::import_from_hermes(&state).unwrap_or(0);
|
||||
for app in apps_to_import {
|
||||
total += import_mcp_from_app(state, &app);
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 从指定应用导入 MCP 服务器(复用已有的导入逻辑);未指定时保持全量导入。
|
||||
#[tauri::command]
|
||||
pub async fn import_mcp_from_apps(
|
||||
state: State<'_, AppState>,
|
||||
apps: Option<Vec<String>>,
|
||||
) -> Result<usize, String> {
|
||||
import_mcp_from_selected_apps(&state, apps)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::fs;
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||
AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook,
|
||||
import_mcp_from_selected_apps, AppError, AppType, McpApps, McpServer, McpService,
|
||||
MultiAppConfig,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
@@ -528,6 +529,59 @@ command = "echo"
|
||||
assert!(entry.apps.codex, "shared should enable Codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_mcp_from_selected_apps_skips_hidden_apps() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
let claude_json = json!({
|
||||
"mcpServers": {
|
||||
"claude-only": {
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
&mcp_path,
|
||||
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
|
||||
)
|
||||
.expect("seed ~/.claude.json");
|
||||
|
||||
let gemini_dir = home.join(".gemini");
|
||||
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
|
||||
let gemini_settings = json!({
|
||||
"mcpServers": {
|
||||
"hidden-gemini": {
|
||||
"type": "stdio",
|
||||
"command": "gemini-echo"
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
gemini_dir.join("settings.json"),
|
||||
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
|
||||
)
|
||||
.expect("seed ~/.gemini/settings.json");
|
||||
|
||||
let state = support::create_test_state().expect("create test state");
|
||||
let changed = import_mcp_from_selected_apps(&state, Some(vec!["claude".to_string()]))
|
||||
.expect("import selected MCP apps");
|
||||
|
||||
assert_eq!(changed, 1, "only Claude server should be imported");
|
||||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||||
assert!(
|
||||
servers.contains_key("claude-only"),
|
||||
"visible Claude server should be imported"
|
||||
);
|
||||
assert!(
|
||||
!servers.contains_key("hidden-gemini"),
|
||||
"hidden Gemini server should not be imported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_mcp_from_gemini_sse_url_only_is_valid() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
62
src/App.tsx
62
src/App.tsx
@@ -89,6 +89,12 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
||||
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
|
||||
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
|
||||
import {
|
||||
EMPTY_VISIBLE_APPS,
|
||||
getFirstVisibleApp,
|
||||
getSkillTargetApp,
|
||||
resolveVisibleApps,
|
||||
} from "@/config/appConfig";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -181,32 +187,22 @@ function App() {
|
||||
isLinux() && (settingsData?.useAppWindowControls ?? false);
|
||||
const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
|
||||
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
|
||||
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
||||
claude: true,
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
};
|
||||
|
||||
const getFirstVisibleApp = (): AppId => {
|
||||
if (visibleApps.claude) return "claude";
|
||||
if (visibleApps["claude-desktop"]) return "claude-desktop";
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
if (visibleApps.hermes) return "hermes";
|
||||
return "claude"; // fallback
|
||||
};
|
||||
const visibleApps: VisibleApps | null = useMemo(
|
||||
() => (settingsData ? resolveVisibleApps(settingsData.visibleApps) : null),
|
||||
[settingsData],
|
||||
);
|
||||
const uiVisibleApps = visibleApps ?? EMPTY_VISIBLE_APPS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleApps) return;
|
||||
if (!visibleApps[activeApp]) {
|
||||
setActiveApp(getFirstVisibleApp());
|
||||
setActiveApp(getFirstVisibleApp(visibleApps));
|
||||
}
|
||||
}, [visibleApps, activeApp]);
|
||||
const skillTargetApp = useMemo(
|
||||
() => (visibleApps ? getSkillTargetApp(activeApp, visibleApps) : null),
|
||||
[activeApp, visibleApps],
|
||||
);
|
||||
|
||||
// Fallback from sessions view when switching to an app without session support
|
||||
useEffect(() => {
|
||||
@@ -944,18 +940,15 @@ function App() {
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
currentApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
currentApp={skillTargetApp}
|
||||
visibleApps={uiVisibleApps}
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
initialApp={skillTargetApp ?? null}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -963,6 +956,7 @@ function App() {
|
||||
<UnifiedMcpPanel
|
||||
ref={mcpPanelRef}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
visibleApps={uiVisibleApps}
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
@@ -1403,12 +1397,14 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
{visibleApps && (
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Monitor, Terminal } from "lucide-react";
|
||||
import { APP_IDS, filterVisibleAppIds } from "@/config/appConfig";
|
||||
|
||||
const APP_BADGE_ICON: Partial<
|
||||
Record<AppId, { icon: typeof Terminal; offsetY?: number }>
|
||||
@@ -18,15 +19,6 @@ interface AppSwitcherProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = [
|
||||
"claude",
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
export function AppSwitcher({
|
||||
@@ -60,11 +52,7 @@ export function AppSwitcher({
|
||||
hermes: "Hermes",
|
||||
};
|
||||
|
||||
// Filter apps based on visibility settings (default all visible)
|
||||
const appsToShow = ALL_APPS.filter((app) => {
|
||||
if (!visibleApps) return true;
|
||||
return visibleApps[app];
|
||||
});
|
||||
const appsToShow = filterVisibleAppIds(APP_IDS, visibleApps);
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { McpServer, McpServerSpec } from "@/types";
|
||||
import type { McpApps, McpServer, McpServerSpec } from "@/types";
|
||||
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
||||
import { MCP_APP_IDS } from "@/config/appConfig";
|
||||
import McpWizardModal from "./McpWizardModal";
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -33,6 +34,7 @@ interface McpFormModalProps {
|
||||
existingIds?: string[];
|
||||
defaultFormat?: "json" | "toml";
|
||||
defaultEnabledApps?: AppId[];
|
||||
visibleAppIds?: AppId[];
|
||||
}
|
||||
|
||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
@@ -43,6 +45,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
existingIds = [],
|
||||
defaultFormat = "json",
|
||||
defaultEnabledApps = ["claude", "codex", "gemini"],
|
||||
visibleAppIds = MCP_APP_IDS,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||
@@ -61,26 +64,38 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
||||
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
||||
|
||||
const [enabledApps, setEnabledApps] = useState<{
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
}>(() => {
|
||||
const [enabledApps, setEnabledApps] = useState<McpApps>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
}
|
||||
return {
|
||||
claude: defaultEnabledApps.includes("claude"),
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
openclaw: defaultEnabledApps.includes("openclaw"),
|
||||
hermes: defaultEnabledApps.includes("hermes"),
|
||||
claude:
|
||||
visibleAppIds.includes("claude") &&
|
||||
defaultEnabledApps.includes("claude"),
|
||||
codex:
|
||||
visibleAppIds.includes("codex") && defaultEnabledApps.includes("codex"),
|
||||
gemini:
|
||||
visibleAppIds.includes("gemini") &&
|
||||
defaultEnabledApps.includes("gemini"),
|
||||
opencode:
|
||||
visibleAppIds.includes("opencode") &&
|
||||
defaultEnabledApps.includes("opencode"),
|
||||
openclaw:
|
||||
visibleAppIds.includes("openclaw") &&
|
||||
defaultEnabledApps.includes("openclaw"),
|
||||
hermes:
|
||||
visibleAppIds.includes("hermes") &&
|
||||
defaultEnabledApps.includes("hermes"),
|
||||
};
|
||||
});
|
||||
const enabledAppOptions = useMemo(
|
||||
() => MCP_APP_IDS.filter((app) => visibleAppIds.includes(app)),
|
||||
[visibleAppIds],
|
||||
);
|
||||
const visibleAppSet = useMemo(
|
||||
() => new Set<AppId>(visibleAppIds),
|
||||
[visibleAppIds],
|
||||
);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
|
||||
@@ -282,6 +297,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getSubmitApps = () => {
|
||||
if (isEditing) {
|
||||
return enabledApps;
|
||||
}
|
||||
|
||||
return MCP_APP_IDS.reduce(
|
||||
(apps, app) => ({
|
||||
...apps,
|
||||
[app]: visibleAppSet.has(app) && enabledApps[app],
|
||||
}),
|
||||
{ ...enabledApps, openclaw: false },
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedId = formId.trim();
|
||||
if (!trimmedId) {
|
||||
@@ -362,7 +391,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
id: trimmedId,
|
||||
name: finalName,
|
||||
server: serverSpec,
|
||||
apps: enabledApps,
|
||||
apps: getSubmitApps(),
|
||||
};
|
||||
|
||||
const descriptionTrimmed = formDescription.trim();
|
||||
@@ -518,85 +547,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{t("mcp.form.enabledApps")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-claude"
|
||||
checked={enabledApps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, claude: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-claude"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-codex"
|
||||
checked={enabledApps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, codex: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-codex"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-gemini"
|
||||
checked={enabledApps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, gemini: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-gemini"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-opencode"
|
||||
checked={enabledApps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, opencode: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-opencode"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-hermes"
|
||||
checked={enabledApps.hermes}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, hermes: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-hermes"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.hermes")}
|
||||
</label>
|
||||
</div>
|
||||
{enabledAppOptions.map((app) => (
|
||||
<div key={app} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`enable-${app}`}
|
||||
checked={enabledApps[app]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, [app]: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`enable-${app}`}
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t(`mcp.unifiedPanel.apps.${app}`)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useDeleteMcpServer,
|
||||
useImportMcpFromApps,
|
||||
} from "@/hooks/useMcp";
|
||||
import type { McpServer } from "@/types";
|
||||
import type { McpServer, VisibleApps } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
@@ -17,13 +17,14 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
import { MCP_APP_IDS } from "@/config/appConfig";
|
||||
import { filterVisibleAppIds, MCP_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
|
||||
interface UnifiedMcpPanelProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
visibleApps?: VisibleApps;
|
||||
}
|
||||
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
@@ -34,7 +35,7 @@ export interface UnifiedMcpPanelHandle {
|
||||
const UnifiedMcpPanel = React.forwardRef<
|
||||
UnifiedMcpPanelHandle,
|
||||
UnifiedMcpPanelProps
|
||||
>(({ onOpenChange: _onOpenChange }, ref) => {
|
||||
>(({ onOpenChange: _onOpenChange, visibleApps }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -49,6 +50,10 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
const visibleMcpAppIds = useMemo(
|
||||
() => filterVisibleAppIds(MCP_APP_IDS, visibleApps),
|
||||
[visibleApps],
|
||||
);
|
||||
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
if (!serversMap) return [];
|
||||
@@ -97,7 +102,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const count = await importMutation.mutateAsync();
|
||||
const count = await importMutation.mutateAsync(visibleMcpAppIds);
|
||||
if (count === 0) {
|
||||
toast.success(t("mcp.unifiedPanel.noImportFound"), {
|
||||
closeButton: true,
|
||||
@@ -144,7 +149,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
appIds={MCP_APP_IDS}
|
||||
appIds={visibleMcpAppIds}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
@@ -175,6 +180,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
visibleAppIds={visibleMcpAppIds}
|
||||
isLast={index === serverEntries.length - 1}
|
||||
/>
|
||||
))}
|
||||
@@ -191,6 +197,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}
|
||||
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||
defaultFormat="json"
|
||||
visibleAppIds={visibleMcpAppIds}
|
||||
onSave={async () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingId(null);
|
||||
@@ -220,6 +227,7 @@ interface UnifiedMcpListItemProps {
|
||||
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
visibleAppIds: AppId[];
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
@@ -229,6 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
onToggleApp,
|
||||
onEdit,
|
||||
onDelete,
|
||||
visibleAppIds,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -286,7 +295,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<AppToggleGroup
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
appIds={MCP_APP_IDS}
|
||||
appIds={visibleAppIds}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { resolveVisibleApps } from "@/config/appConfig";
|
||||
|
||||
interface AppVisibilitySettingsProps {
|
||||
settings: SettingsFormState;
|
||||
@@ -35,15 +36,7 @@ export function AppVisibilitySettings({
|
||||
}: AppVisibilitySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleApps: VisibleApps = settings.visibleApps ?? {
|
||||
claude: true,
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
};
|
||||
const visibleApps: VisibleApps = resolveVisibleApps(settings.visibleApps);
|
||||
|
||||
// Count how many apps are currently visible
|
||||
const visibleCount = Object.values(visibleApps).filter(Boolean).length;
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppId;
|
||||
initialApp?: AppId | null;
|
||||
}
|
||||
|
||||
export interface SkillsPageHandle {
|
||||
@@ -211,6 +211,11 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentApp) {
|
||||
toast.error(t("skills.noVisibleTargetApp"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await installMutation.mutateAsync({
|
||||
skill,
|
||||
|
||||
@@ -31,10 +31,15 @@ import type { AppId } from "@/lib/api/types";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import {
|
||||
filterVisibleAppIds,
|
||||
getSkillTargetApp,
|
||||
SKILLS_APP_IDS,
|
||||
} from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -46,7 +51,8 @@ import {
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
currentApp: AppId;
|
||||
currentApp: AppId | null;
|
||||
visibleApps?: VisibleApps;
|
||||
}
|
||||
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
@@ -67,7 +73,7 @@ function formatSkillBackupDate(unixSeconds: number): string {
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
UnifiedSkillsPanelHandle,
|
||||
UnifiedSkillsPanelProps
|
||||
>(({ onOpenDiscovery, currentApp }, ref) => {
|
||||
>(({ onOpenDiscovery, currentApp, visibleApps }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -101,6 +107,14 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
} = useCheckSkillUpdates();
|
||||
const updateSkillMutation = useUpdateSkill();
|
||||
const [isUpdatingAll, setIsUpdatingAll] = useState(false);
|
||||
const visibleSkillAppIds = useMemo(
|
||||
() => filterVisibleAppIds(SKILLS_APP_IDS, visibleApps),
|
||||
[visibleApps],
|
||||
);
|
||||
const targetApp = useMemo(
|
||||
() => (currentApp ? getSkillTargetApp(currentApp, visibleApps) : null),
|
||||
[currentApp, visibleApps],
|
||||
);
|
||||
|
||||
const updatesMap = useMemo(() => {
|
||||
const map: Record<string, SkillUpdateInfo> = {};
|
||||
@@ -197,12 +211,17 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
const handleInstallFromZip = async () => {
|
||||
try {
|
||||
if (!targetApp) {
|
||||
toast.error(t("skills.noVisibleTargetApp"));
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) return;
|
||||
|
||||
const installed = await installFromZipMutation.mutateAsync({
|
||||
filePath,
|
||||
currentApp,
|
||||
currentApp: targetApp,
|
||||
});
|
||||
|
||||
if (installed.length === 0) {
|
||||
@@ -287,9 +306,14 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
const handleRestoreFromBackup = async (backupId: string) => {
|
||||
try {
|
||||
if (!targetApp) {
|
||||
toast.error(t("skills.noVisibleTargetApp"));
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = await restoreBackupMutation.mutateAsync({
|
||||
backupId,
|
||||
currentApp,
|
||||
currentApp: targetApp,
|
||||
});
|
||||
setRestoreDialogOpen(false);
|
||||
toast.success(
|
||||
@@ -350,7 +374,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
appIds={SKILLS_APP_IDS}
|
||||
appIds={visibleSkillAppIds}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
@@ -431,6 +455,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
onUpdate={() => handleUpdateSkill(skill)}
|
||||
visibleAppIds={visibleSkillAppIds}
|
||||
isLast={index === skills.length - 1}
|
||||
/>
|
||||
))}
|
||||
@@ -458,6 +483,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
isImporting={importMutation.isPending}
|
||||
onImport={handleImport}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
visibleAppIds={visibleSkillAppIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -484,6 +510,7 @@ interface InstalledSkillListItemProps {
|
||||
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
onUpdate?: () => void;
|
||||
visibleAppIds: AppId[];
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
@@ -494,6 +521,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
onUpdate,
|
||||
visibleAppIds,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -555,7 +583,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
<AppToggleGroup
|
||||
apps={skill.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||
appIds={SKILLS_APP_IDS}
|
||||
appIds={visibleAppIds}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -605,6 +633,7 @@ interface ImportSkillsDialogProps {
|
||||
isImporting: boolean;
|
||||
onImport: (imports: ImportSkillSelection[]) => void;
|
||||
onClose: () => void;
|
||||
visibleAppIds: AppId[];
|
||||
}
|
||||
|
||||
interface RestoreSkillsDialogProps {
|
||||
@@ -730,8 +759,37 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
isImporting,
|
||||
onImport,
|
||||
onClose,
|
||||
visibleAppIds,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const visibleAppSet = useMemo(() => new Set(visibleAppIds), [visibleAppIds]);
|
||||
const emptyApps = (): ImportSkillSelection["apps"] => ({
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
});
|
||||
const applyVisibleAppMask = (
|
||||
apps: ImportSkillSelection["apps"],
|
||||
): ImportSkillSelection["apps"] => ({
|
||||
claude: visibleAppSet.has("claude") && apps.claude,
|
||||
codex: visibleAppSet.has("codex") && apps.codex,
|
||||
gemini: visibleAppSet.has("gemini") && apps.gemini,
|
||||
opencode: visibleAppSet.has("opencode") && apps.opencode,
|
||||
openclaw: visibleAppSet.has("openclaw") && apps.openclaw,
|
||||
hermes: visibleAppSet.has("hermes") && apps.hermes,
|
||||
});
|
||||
const appsFromFoundIn = (foundIn: string[]): ImportSkillSelection["apps"] =>
|
||||
applyVisibleAppMask({
|
||||
claude: foundIn.includes("claude"),
|
||||
codex: foundIn.includes("codex"),
|
||||
gemini: foundIn.includes("gemini"),
|
||||
opencode: foundIn.includes("opencode"),
|
||||
openclaw: false,
|
||||
hermes: foundIn.includes("hermes"),
|
||||
});
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(skills.map((s) => s.directory)),
|
||||
);
|
||||
@@ -739,17 +797,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
Record<string, ImportSkillSelection["apps"]>
|
||||
>(() =>
|
||||
Object.fromEntries(
|
||||
skills.map((skill) => [
|
||||
skill.directory,
|
||||
{
|
||||
claude: skill.foundIn.includes("claude"),
|
||||
codex: skill.foundIn.includes("codex"),
|
||||
gemini: skill.foundIn.includes("gemini"),
|
||||
opencode: skill.foundIn.includes("opencode"),
|
||||
openclaw: false,
|
||||
hermes: skill.foundIn.includes("hermes"),
|
||||
},
|
||||
]),
|
||||
skills.map((skill) => [skill.directory, appsFromFoundIn(skill.foundIn)]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -767,14 +815,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
onImport(
|
||||
Array.from(selected).map((directory) => ({
|
||||
directory,
|
||||
apps: selectedApps[directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
},
|
||||
apps: applyVisibleAppMask(selectedApps[directory] ?? emptyApps()),
|
||||
})),
|
||||
);
|
||||
};
|
||||
@@ -809,33 +850,17 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<AppToggleGroup
|
||||
apps={
|
||||
selectedApps[skill.directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
}
|
||||
}
|
||||
apps={selectedApps[skill.directory] ?? emptyApps()}
|
||||
onToggle={(app, enabled) => {
|
||||
setSelectedApps((prev) => ({
|
||||
...prev,
|
||||
[skill.directory]: {
|
||||
...(prev[skill.directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
}),
|
||||
...(prev[skill.directory] ?? emptyApps()),
|
||||
[app]: enabled,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
appIds={SKILLS_APP_IDS}
|
||||
appIds={visibleAppIds}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import {
|
||||
ClaudeIcon,
|
||||
CodexIcon,
|
||||
@@ -25,6 +26,45 @@ export const APP_IDS: AppId[] = [
|
||||
"hermes",
|
||||
];
|
||||
|
||||
export const DEFAULT_VISIBLE_APPS: VisibleApps = {
|
||||
claude: true,
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
};
|
||||
|
||||
export const EMPTY_VISIBLE_APPS: VisibleApps = {
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
};
|
||||
|
||||
export function resolveVisibleApps(visibleApps?: VisibleApps): VisibleApps {
|
||||
return {
|
||||
...DEFAULT_VISIBLE_APPS,
|
||||
...visibleApps,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterVisibleAppIds(
|
||||
appIds: readonly AppId[],
|
||||
visibleApps?: VisibleApps,
|
||||
): AppId[] {
|
||||
const resolved = resolveVisibleApps(visibleApps);
|
||||
return appIds.filter((app) => resolved[app]);
|
||||
}
|
||||
|
||||
export function getFirstVisibleApp(visibleApps?: VisibleApps): AppId {
|
||||
return filterVisibleAppIds(APP_IDS, visibleApps)[0] ?? "claude";
|
||||
}
|
||||
|
||||
/** App IDs shown in Skills panels (excludes OpenClaw — it doesn't support Skills) */
|
||||
export const SKILLS_APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
@@ -34,6 +74,16 @@ export const SKILLS_APP_IDS: AppId[] = [
|
||||
"hermes",
|
||||
];
|
||||
|
||||
export function getSkillTargetApp(
|
||||
currentApp: AppId,
|
||||
visibleApps?: VisibleApps,
|
||||
): AppId | null {
|
||||
const visibleSkillAppIds = filterVisibleAppIds(SKILLS_APP_IDS, visibleApps);
|
||||
return visibleSkillAppIds.includes(currentApp)
|
||||
? currentApp
|
||||
: (visibleSkillAppIds[0] ?? null);
|
||||
}
|
||||
|
||||
/** App IDs shown in MCP panels (excludes OpenClaw) */
|
||||
export const MCP_APP_IDS: AppId[] = [...SKILLS_APP_IDS];
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function useDeleteMcpServer() {
|
||||
export function useImportMcpFromApps() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => mcpApi.importFromApps(),
|
||||
mutationFn: (apps?: AppId[]) => mcpApi.importFromApps(apps),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
},
|
||||
|
||||
@@ -1856,6 +1856,7 @@
|
||||
"loadFailed": "Failed to load",
|
||||
"installSuccess": "Skill {{name}} installed",
|
||||
"installFailed": "Failed to install",
|
||||
"noVisibleTargetApp": "Enable Claude, Codex, Gemini, OpenCode, or Hermes before installing skills",
|
||||
"uninstallSuccess": "Skill {{name}} uninstalled",
|
||||
"uninstallFailed": "Failed to uninstall",
|
||||
"update": "Update",
|
||||
|
||||
@@ -1856,6 +1856,7 @@
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"installSuccess": "スキル {{name}} をインストールしました",
|
||||
"installFailed": "インストールに失敗しました",
|
||||
"noVisibleTargetApp": "スキルをインストールする前に Claude、Codex、Gemini、OpenCode、Hermes のいずれかを表示してください",
|
||||
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
|
||||
"uninstallFailed": "アンインストールに失敗しました",
|
||||
"update": "更新",
|
||||
|
||||
@@ -1856,6 +1856,7 @@
|
||||
"loadFailed": "加载失败",
|
||||
"installSuccess": "技能 {{name}} 已安装",
|
||||
"installFailed": "安装失败",
|
||||
"noVisibleTargetApp": "请先显示 Claude、Codex、Gemini、OpenCode 或 Hermes,再安装技能",
|
||||
"uninstallSuccess": "技能 {{name}} 已卸载",
|
||||
"uninstallFailed": "卸载失败",
|
||||
"update": "更新",
|
||||
|
||||
@@ -123,7 +123,7 @@ export const mcpApi = {
|
||||
/**
|
||||
* 从所有应用导入 MCP 服务器
|
||||
*/
|
||||
async importFromApps(): Promise<number> {
|
||||
return await invoke("import_mcp_from_apps");
|
||||
async importFromApps(apps?: AppId[]): Promise<number> {
|
||||
return await invoke("import_mcp_from_apps", { apps });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface Settings {
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh" | "ja";
|
||||
|
||||
// 主页面显示的应用(默认全部显示)
|
||||
// 主页面显示的应用(默认显示 Claude/Codex/Gemini/OpenCode/OpenClaw,Hermes 默认隐藏)
|
||||
visibleApps?: VisibleApps;
|
||||
|
||||
// ===== 设备级目录覆盖 =====
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("McpFormModal", () => {
|
||||
} = props ?? {};
|
||||
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = overrideOnClose ?? vi.fn();
|
||||
render(
|
||||
const renderResult = render(
|
||||
<McpFormModal
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
@@ -165,7 +165,7 @@ describe("McpFormModal", () => {
|
||||
{...rest}
|
||||
/>,
|
||||
);
|
||||
return { onSave, onClose };
|
||||
return { onSave, onClose, ...renderResult };
|
||||
};
|
||||
|
||||
it("应用预设后填充 ID 与配置内容", async () => {
|
||||
@@ -395,6 +395,117 @@ type = "stdio"
|
||||
expect(onSave).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("隐藏应用不显示复选框,保存时保留隐藏应用已有状态", async () => {
|
||||
const initialData: McpServer = {
|
||||
id: "existing",
|
||||
name: "Existing",
|
||||
server: { type: "stdio", command: "old" },
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
},
|
||||
};
|
||||
|
||||
renderForm({
|
||||
editingId: "existing",
|
||||
initialData,
|
||||
visibleAppIds: ["claude", "codex"],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
||||
target: { value: '{"type":"stdio","command":"updated"}' },
|
||||
});
|
||||
fireEvent.click(screen.getByText("common.save"));
|
||||
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||
expect(entry.apps).toEqual({
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("新增时隐藏应用不会被默认启用", async () => {
|
||||
renderForm({ visibleAppIds: ["claude", "codex"] });
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||
target: { value: "visible-only" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
||||
target: { value: '{"type":"stdio","command":"run"}' },
|
||||
});
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||
expect(entry.apps).toEqual({
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("新增表单打开后应用变为隐藏时不会保存隐藏应用启用状态", async () => {
|
||||
const { onSave, onClose, rerender } = renderForm({
|
||||
visibleAppIds: ["claude", "codex", "gemini"],
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("mcp.unifiedPanel.apps.gemini")).toBeChecked();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||
target: { value: "visible-changed" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
||||
target: { value: '{"type":"stdio","command":"run"}' },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<McpFormModal
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
existingIds={[]}
|
||||
defaultFormat="json"
|
||||
visibleAppIds={["claude", "codex"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||
expect(entry.apps).toEqual({
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("允许未选择任何应用保存配置,并保持 apps 全 false", async () => {
|
||||
const { onSave } = renderForm();
|
||||
|
||||
|
||||
102
tests/components/UnifiedMcpPanel.test.tsx
Normal file
102
tests/components/UnifiedMcpPanel.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { createRef } from "react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import UnifiedMcpPanel, {
|
||||
type UnifiedMcpPanelHandle,
|
||||
} from "@/components/mcp/UnifiedMcpPanel";
|
||||
|
||||
const importMcpMock = vi.fn();
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMcp", () => ({
|
||||
useAllMcpServers: () => ({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
}),
|
||||
useToggleMcpApp: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useDeleteMcpServer: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useImportMcpFromApps: () => ({
|
||||
mutateAsync: (...args: unknown[]) => importMcpMock(...args),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, type = "button", ...rest }: any) => (
|
||||
<button type={type} onClick={onClick} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/badge", () => ({
|
||||
Badge: ({ children, ...rest }: any) => <span {...rest}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: any) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: any) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: any) => <>{children}</>,
|
||||
TooltipContent: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mcp/McpFormModal", () => ({
|
||||
default: () => <div data-testid="mcp-form" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
describe("UnifiedMcpPanel", () => {
|
||||
beforeEach(() => {
|
||||
importMcpMock.mockReset();
|
||||
importMcpMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it("imports MCP servers only from visible apps", async () => {
|
||||
const ref = createRef<UnifiedMcpPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedMcpPanel
|
||||
ref={ref}
|
||||
onOpenChange={() => {}}
|
||||
visibleApps={{
|
||||
claude: true,
|
||||
"claude-desktop": false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openImport();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(importMcpMock).toHaveBeenCalledTimes(1));
|
||||
expect(importMcpMock).toHaveBeenCalledWith(["claude", "codex", "opencode"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createRef } from "react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
act,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import UnifiedSkillsPanel, {
|
||||
@@ -13,12 +19,26 @@ const importSkillsMock = vi.fn();
|
||||
const installFromZipMock = vi.fn();
|
||||
const deleteSkillBackupMock = vi.fn();
|
||||
const restoreSkillBackupMock = vi.fn();
|
||||
const openZipFileDialogMock = vi.hoisted(() => vi.fn());
|
||||
const toastErrorMock = vi.hoisted(() => vi.fn());
|
||||
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const toastInfoMock = vi.hoisted(() => vi.fn());
|
||||
let unmanagedSkillsData = [
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
];
|
||||
let skillBackupsData: any[] = [];
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
info: (...args: unknown[]) => toastInfoMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -28,7 +48,7 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
isLoading: false,
|
||||
}),
|
||||
useSkillBackups: () => ({
|
||||
data: [],
|
||||
data: skillBackupsData,
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
@@ -47,15 +67,7 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
mutateAsync: uninstallSkillMock,
|
||||
}),
|
||||
useScanUnmanagedSkills: () => ({
|
||||
data: [
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
data: unmanagedSkillsData,
|
||||
refetch: scanUnmanagedMock,
|
||||
}),
|
||||
useImportSkillsFromApps: () => ({
|
||||
@@ -75,23 +87,39 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
settingsApi: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
skillsApi: {
|
||||
openZipFileDialog: openZipFileDialogMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("UnifiedSkillsPanel", () => {
|
||||
beforeEach(() => {
|
||||
unmanagedSkillsData = [
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
];
|
||||
skillBackupsData = [];
|
||||
scanUnmanagedMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
data: unmanagedSkillsData,
|
||||
});
|
||||
toggleSkillAppMock.mockReset();
|
||||
uninstallSkillMock.mockReset();
|
||||
importSkillsMock.mockReset();
|
||||
installFromZipMock.mockReset();
|
||||
openZipFileDialogMock.mockReset();
|
||||
openZipFileDialogMock.mockResolvedValue(null);
|
||||
toastErrorMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
toastInfoMock.mockReset();
|
||||
deleteSkillBackupMock.mockReset();
|
||||
restoreSkillBackupMock.mockReset();
|
||||
});
|
||||
@@ -117,4 +145,244 @@ describe("UnifiedSkillsPanel", () => {
|
||||
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enable hidden apps when importing unmanaged skills", async () => {
|
||||
unmanagedSkillsData = [
|
||||
{
|
||||
directory: "hermes-only-skill",
|
||||
name: "Hermes Only Skill",
|
||||
description: "Imported from Hermes",
|
||||
foundIn: ["hermes"],
|
||||
path: "/tmp/hermes-only-skill",
|
||||
},
|
||||
];
|
||||
scanUnmanagedMock.mockResolvedValue({ data: unmanagedSkillsData });
|
||||
importSkillsMock.mockResolvedValue([]);
|
||||
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
visibleApps={{
|
||||
claude: true,
|
||||
"claude-desktop": false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openImport();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hermes Only Skill")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Hermes")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("skills.importSelected"));
|
||||
|
||||
await waitFor(() => expect(importSkillsMock).toHaveBeenCalledTimes(1));
|
||||
expect(importSkillsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
directory: "hermes-only-skill",
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the first visible skills app for ZIP install when current app cannot host skills", async () => {
|
||||
installFromZipMock.mockResolvedValue([]);
|
||||
openZipFileDialogMock.mockResolvedValue("/tmp/skills.zip");
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="openclaw"
|
||||
visibleApps={{
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openInstallFromZip();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(installFromZipMock).toHaveBeenCalledTimes(1));
|
||||
expect(installFromZipMock).toHaveBeenCalledWith({
|
||||
filePath: "/tmp/skills.zip",
|
||||
currentApp: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first visible skills app for backup restore when current app cannot host skills", async () => {
|
||||
skillBackupsData = [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
backupPath: "/tmp/backup-1",
|
||||
createdAt: 1,
|
||||
skill: {
|
||||
id: "restored-skill",
|
||||
name: "Restored Skill",
|
||||
directory: "restored-skill",
|
||||
apps: {
|
||||
claude: true,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
},
|
||||
installedAt: 1,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
restoreSkillBackupMock.mockResolvedValue({
|
||||
name: "Restored Skill",
|
||||
});
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp="claude"
|
||||
visibleApps={{
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openRestoreFromBackup();
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("skills.restoreFromBackup.restore"),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(restoreSkillBackupMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(restoreSkillBackupMock).toHaveBeenCalledWith({
|
||||
backupId: "backup-1",
|
||||
currentApp: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not install from ZIP when only OpenClaw is visible", async () => {
|
||||
openZipFileDialogMock.mockResolvedValue("/tmp/skills.zip");
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp={null}
|
||||
visibleApps={{
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openInstallFromZip();
|
||||
});
|
||||
|
||||
expect(openZipFileDialogMock).not.toHaveBeenCalled();
|
||||
expect(installFromZipMock).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("skills.noVisibleTargetApp");
|
||||
});
|
||||
|
||||
it("does not restore a backup when only OpenClaw is visible", async () => {
|
||||
skillBackupsData = [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
backupPath: "/tmp/backup-1",
|
||||
createdAt: 1,
|
||||
skill: {
|
||||
id: "restored-skill",
|
||||
name: "Restored Skill",
|
||||
directory: "restored-skill",
|
||||
apps: {
|
||||
claude: true,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
},
|
||||
installedAt: 1,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
const ref = createRef<UnifiedSkillsPanelHandle>();
|
||||
|
||||
render(
|
||||
<UnifiedSkillsPanel
|
||||
ref={ref}
|
||||
onOpenDiscovery={() => {}}
|
||||
currentApp={null}
|
||||
visibleApps={{
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await ref.current?.openRestoreFromBackup();
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("skills.restoreFromBackup.restore"),
|
||||
);
|
||||
|
||||
expect(restoreSkillBackupMock).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("skills.noVisibleTargetApp");
|
||||
});
|
||||
});
|
||||
|
||||
55
tests/config/appConfig.test.ts
Normal file
55
tests/config/appConfig.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_VISIBLE_APPS,
|
||||
EMPTY_VISIBLE_APPS,
|
||||
filterVisibleAppIds,
|
||||
getSkillTargetApp,
|
||||
resolveVisibleApps,
|
||||
} from "@/config/appConfig";
|
||||
|
||||
describe("appConfig visibility helpers", () => {
|
||||
it("keeps Hermes hidden by default and hides every app before settings resolve", () => {
|
||||
expect(DEFAULT_VISIBLE_APPS.hermes).toBe(false);
|
||||
expect(Object.values(EMPTY_VISIBLE_APPS).every((visible) => !visible)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolveVisibleApps().hermes).toBe(false);
|
||||
});
|
||||
|
||||
it("filters app ids using persisted defaults after settings load", () => {
|
||||
expect(filterVisibleAppIds(["claude", "hermes"], undefined)).toEqual([
|
||||
"claude",
|
||||
]);
|
||||
expect(
|
||||
filterVisibleAppIds(["claude", "hermes"], EMPTY_VISIBLE_APPS),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("selects a visible app for skill install flows", () => {
|
||||
const visibleApps = {
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
};
|
||||
|
||||
expect(getSkillTargetApp("openclaw", visibleApps)).toBe("codex");
|
||||
expect(getSkillTargetApp("claude", visibleApps)).toBe("codex");
|
||||
expect(getSkillTargetApp("gemini", visibleApps)).toBe("gemini");
|
||||
expect(
|
||||
getSkillTargetApp("openclaw", {
|
||||
claude: false,
|
||||
"claude-desktop": false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: true,
|
||||
hermes: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user