mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature Implement complete Skills management system with repository discovery, installation, and lifecycle management capabilities. Backend: - Add SkillService with GitHub integration and installation logic - Implement skill commands (list, install, uninstall, check updates) - Support multiple skill repositories with caching Frontend: - Add Skills management page with repository browser - Create SkillCard and RepoManager components - Add badge, card, table UI components - Integrate Skills API with Tauri commands Files: 10 files changed, 1488 insertions(+) * feat(skills): integrate Skills feature into application Integrate Skills management feature with complete dependency updates, configuration structure extensions, and internationalization support. Dependencies: - Add @radix-ui/react-visually-hidden for accessibility - Add anyhow, zip, serde_yaml, tempfile for Skills backend - Enable chrono serde feature for timestamp serialization Backend Integration: - Extend MultiAppConfig with SkillStore field - Implement skills.json migration from legacy location - Register SkillService and skill commands in main app - Export skill module in commands and services Frontend Integration: - Add Skills page route and dialog in App - Integrate Skills UI with main navigation Internationalization: - Add complete Chinese translations for Skills UI - Add complete English translations for Skills UI Code Quality: - Remove redundant blank lines in gemini_mcp.rs - Format log statements in mcp.rs Tests: - Update import_export_sync tests for SkillStore - Update mcp_commands tests for new structure Files: 16 files changed, 540 insertions(+), 39 deletions(-) * style(skills): improve SkillsPage typography and spacing Optimize visual hierarchy and readability of Skills page: - Reduce title size from 2xl to lg with tighter tracking - Improve description spacing and color contrast - Enhance empty state with better text hierarchy - Use explicit gray colors for better dark mode support * feat(skills): support custom subdirectory path for skill scanning Add optional skillsPath field to SkillRepo to enable scanning skills from subdirectories (e.g., "skills/") instead of repository root. Changes: - Backend: Add skillsPath field with subdirectory scanning logic - Frontend: Add skillsPath input field and display in repo list - Presets: Add cexll/myclaude repo with skills/ subdirectory - Code quality: Fix clippy warnings (dedup logic, string formatting) Backward compatible: skillsPath is optional, defaults to root scanning. * refactor(skills): improve repo manager dialog layout Optimize dialog structure with fixed header and scrollable content: - Add flexbox layout with fixed header and scrollable body - Remove outer border wrapper for cleaner appearance - Match SkillsPage design pattern for consistency - Improve UX with better content hierarchy
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repos: SkillRepo[];
|
||||
skills: Skill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepoManager({
|
||||
open: isOpen,
|
||||
onOpenChange,
|
||||
repos,
|
||||
skills,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: RepoManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [skillsPath, setSkillsPath] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const getSkillCount = (repo: SkillRepo) =>
|
||||
skills.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
|
||||
const parseRepoUrl = (
|
||||
url: string,
|
||||
): { owner: string; name: string } | null => {
|
||||
// 支持格式:
|
||||
// - https://github.com/owner/name
|
||||
// - owner/name
|
||||
// - https://github.com/owner/name.git
|
||||
|
||||
let cleaned = url.trim();
|
||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||
cleaned = cleaned.replace(/\.git$/, "");
|
||||
|
||||
const parts = cleaned.split("/");
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { owner: parts[0], name: parts[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError("");
|
||||
|
||||
const parsed = parseRepoUrl(repoUrl);
|
||||
if (!parsed) {
|
||||
setError(t("skills.repo.invalidUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAdd({
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
branch: branch || "main",
|
||||
enabled: true,
|
||||
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
|
||||
});
|
||||
|
||||
setRepoUrl("");
|
||||
setBranch("");
|
||||
setSkillsPath("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = async (owner: string, name: string) => {
|
||||
try {
|
||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||
{/* 固定头部 */}
|
||||
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
|
||||
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
|
||||
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder={t("skills.repo.urlPlaceholder")}
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
id="branch"
|
||||
placeholder={t("skills.repo.branchPlaceholder")}
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
id="skills-path"
|
||||
placeholder={t("skills.repo.pathPlaceholder")}
|
||||
value={skillsPath}
|
||||
onChange={(e) => setSkillsPath(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className="w-full sm:w-auto sm:px-4"
|
||||
variant="mcp"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("skills.repo.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 仓库列表 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
|
||||
{repos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("skills.repo.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={`${repo.owner}/${repo.name}`}
|
||||
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{repo.owner}/{repo.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||
{repo.skillsPath && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
{t("skills.repo.path")}: {repo.skillsPath}
|
||||
</>
|
||||
)}
|
||||
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||
{t("skills.repo.skillCount", {
|
||||
count: getSkillCount(repo),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||
title={t("common.view", { defaultValue: "查看" })}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onRemove(repo.owner, repo.name)}
|
||||
title={t("common.delete")}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user