Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
450 changes: 430 additions & 20 deletions src-tauri/src/services/skill.rs

Large diffs are not rendered by default.

473 changes: 473 additions & 0 deletions src-tauri/tests/skill_sync.rs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/components/skills/SkillCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type SkillCardSkill = DiscoverableSkill & { installed: boolean };

interface SkillCardProps {
skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>;
onInstall: (skill: SkillCardSkill) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
installs?: number;
}
Expand All @@ -35,7 +35,7 @@ export function SkillCard({
const handleInstall = async () => {
setLoading(true);
try {
await onInstall(skill.directory);
await onInstall(skill);
} finally {
setLoading(false);
}
Expand Down
43 changes: 9 additions & 34 deletions src/components/skills/SkillsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
useRemoveSkillRepo,
useSearchSkillsSh,
} from "@/hooks/useSkills";
import {
buildInstalledSkillIdentityKey,
buildSkillIdentityKey,
} from "@/lib/api/skills";
import type { AppId } from "@/lib/api/types";
import type {
DiscoverableSkill,
Expand Down Expand Up @@ -121,12 +125,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const installedKeys = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(
installedSkills.map((s) => {
// 构建唯一 key:directory + repoOwner + repoName
const owner = s.repoOwner?.toLowerCase() || "";
const name = s.repoName?.toLowerCase() || "";
return `${s.directory.toLowerCase()}:${owner}:${name}`;
}),
installedSkills.map((skill) => buildInstalledSkillIdentityKey(skill)),
);
}, [installedSkills]);

Expand All @@ -148,15 +147,11 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const skills: DiscoverableSkillItem[] = useMemo(() => {
if (!discoverableSkills) return [];
return discoverableSkills.map((d) => {
// 同时处理 / 和 \ 路径分隔符(兼容 Windows 和 Unix)
const installName =
d.directory.split(/[/\\]/).pop()?.toLowerCase() ||
d.directory.toLowerCase();
// 使用 directory + repoOwner + repoName 组合判断是否已安装
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
return {
...d,
installed: installedKeys.has(key),
installed: installedKeys.has(
buildSkillIdentityKey(d.directory, d.repoOwner, d.repoName),
),
};
});
}, [discoverableSkills, installedKeys]);
Expand Down Expand Up @@ -194,27 +189,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
readmeUrl: s.readmeUrl,
});

const handleInstall = async (directory: string) => {
let skill: DiscoverableSkill | undefined;

if (searchSource === "skillssh") {
const found = accumulatedResults.find((s) => s.directory === directory);
if (found) {
skill = toDiscoverableSkill(found);
}
} else {
skill = discoverableSkills?.find(
(s) =>
s.directory === directory ||
s.directory.split("/").pop() === directory,
);
}

if (!skill) {
toast.error(t("skills.notFound"));
return;
}

const handleInstall = async (skill: DiscoverableSkill) => {
try {
await installMutation.mutateAsync({
skill,
Expand Down
22 changes: 17 additions & 5 deletions src/components/skills/UnifiedSkillsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
import type { AppId } from "@/lib/api/types";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi, skillsApi } from "@/lib/api";
import {
buildInstalledSkillIdentityKey,
getInstalledSkillDirectory,
} from "@/lib/api/skills";
import { toast } from "sonner";
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
import { AppCountBar } from "@/components/common/AppCountBar";
Expand Down Expand Up @@ -138,11 +142,7 @@ const UnifiedSkillsPanel = React.forwardRef<
message: t("skills.uninstallConfirm", { name: skill.name }),
onConfirm: async () => {
try {
// 构建 skillKey 用于更新 discoverable 缓存
const installName =
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
skill.directory.toLowerCase();
const skillKey = `${installName}:${skill.repoOwner?.toLowerCase() || ""}:${skill.repoName?.toLowerCase() || ""}`;
const skillKey = buildInstalledSkillIdentityKey(skill);

const result = await uninstallMutation.mutateAsync({
id: skill.id,
Expand Down Expand Up @@ -504,6 +504,10 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
}
return t("skills.local");
}, [skill.repoOwner, skill.repoName, t]);
const installedDirectory = getInstalledSkillDirectory(skill);
const nestedDirectory = /[\\/]/.test(installedDirectory)
? installedDirectory
: null;

return (
<ListItemRow isLast={isLast}>
Expand Down Expand Up @@ -541,6 +545,14 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
{skill.description}
</p>
)}
{nestedDirectory && (
<p
className="text-[11px] text-muted-foreground/70 font-mono truncate mt-0.5"
title={nestedDirectory}
>
{nestedDirectory}
</p>
)}
</div>

<AppToggleGroup
Expand Down
20 changes: 14 additions & 6 deletions src/hooks/useSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@tanstack/react-query";
import {
skillsApi,
buildSkillIdentityKey,
type SkillBackupEntry,
type DiscoverableSkill,
type ImportSkillSelection,
Expand Down Expand Up @@ -87,17 +88,21 @@ export function useInstallSkill() {
);

// 更新 discoverable 缓存中对应技能的 installed 状态
const installName =
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
skill.directory.toLowerCase();
const skillKey = `${installName}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`;
const skillKey = buildSkillIdentityKey(
skill.directory,
skill.repoOwner,
skill.repoName,
);

queryClient.setQueryData<DiscoverableSkill[]>(
["skills", "discoverable"],
(oldData) => {
if (!oldData) return oldData;
return oldData.map((s) => {
if (s.key === skillKey) {
if (
buildSkillIdentityKey(s.directory, s.repoOwner, s.repoName) ===
skillKey
) {
return { ...s, installed: true };
}
return s;
Expand Down Expand Up @@ -135,7 +140,10 @@ export function useUninstallSkill() {
(oldData) => {
if (!oldData) return oldData;
return oldData.map((s) => {
if (s.key === skillKey) {
if (
buildSkillIdentityKey(s.directory, s.repoOwner, s.repoName) ===
skillKey
) {
return { ...s, installed: false };
}
return s;
Expand Down
34 changes: 34 additions & 0 deletions src/lib/api/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,40 @@ export interface SkillRepo {
enabled: boolean;
}

function normalizeSkillKeyPart(value?: string): string {
return (value ?? "").replace(/\\/g, "/").toLowerCase();
}

export function buildSkillIdentityKey(
directory: string,
repoOwner?: string,
repoName?: string,
): string {
return `${normalizeSkillKeyPart(directory)}:${normalizeSkillKeyPart(repoOwner)}:${normalizeSkillKeyPart(repoName)}`;
}

export function getInstalledSkillDirectory(
skill: Pick<InstalledSkill, "id" | "directory">,
): string {
const separatorIndex = skill.id.indexOf(":");
if (separatorIndex === -1) {
return skill.directory;
}

const directoryFromId = skill.id.slice(separatorIndex + 1);
return directoryFromId || skill.directory;
}

export function buildInstalledSkillIdentityKey(
skill: Pick<InstalledSkill, "id" | "directory" | "repoOwner" | "repoName">,
): string {
return buildSkillIdentityKey(
getInstalledSkillDirectory(skill),
skill.repoOwner,
skill.repoName,
);
}

// ========== API ==========

export const skillsApi = {
Expand Down
168 changes: 168 additions & 0 deletions tests/components/SkillsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";

import { SkillsPage } from "@/components/skills/SkillsPage";

let discoverableSkillsData: any[] = [];
let installedSkillsData: any[] = [];
const installSkillMock = vi.fn();

vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));

vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...props }: any) => (
<button {...props}>{children}</button>
),
}));

vi.mock("@/components/ui/input", () => ({
Input: (props: any) => <input {...props} />,
}));

vi.mock("@/components/ui/select", () => ({
Select: ({ children }: any) => <div>{children}</div>,
SelectContent: ({ children }: any) => <div>{children}</div>,
SelectItem: ({ children }: any) => <div>{children}</div>,
SelectTrigger: ({ children }: any) => <div>{children}</div>,
SelectValue: () => null,
}));

vi.mock("@/components/skills/RepoManagerPanel", () => ({
RepoManagerPanel: () => null,
}));

vi.mock("@/components/skills/SkillCard", () => ({
SkillCard: ({ skill, onInstall }: any) => (
<div>
<span>{skill.name}</span>
<span>{skill.installed ? "installed" : "uninstalled"}</span>
<button
onClick={() => onInstall(skill)}
>{`install-${skill.repoName}`}</button>
</div>
),
}));

vi.mock("@/hooks/useSkills", () => ({
useDiscoverableSkills: () => ({
data: discoverableSkillsData,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
}),
useInstalledSkills: () => ({
data: installedSkillsData,
}),
useInstallSkill: () => ({
mutateAsync: installSkillMock,
}),
useSkillRepos: () => ({
data: [],
refetch: vi.fn(),
}),
useAddSkillRepo: () => ({
mutateAsync: vi.fn(),
}),
useRemoveSkillRepo: () => ({
mutateAsync: vi.fn(),
}),
useSearchSkillsSh: () => ({
data: undefined,
isLoading: false,
isFetching: false,
}),
}));

describe("SkillsPage", () => {
beforeEach(() => {
installSkillMock.mockReset();
discoverableSkillsData = [
{
key: "owner/repo:superpowers/using-superpowers",
name: "using-superpowers",
description: "Nested skill",
directory: "superpowers/using-superpowers",
repoOwner: "owner",
repoName: "repo",
repoBranch: "main",
},
];
installedSkillsData = [
{
id: "owner/repo:superpowers/using-superpowers",
name: "using-superpowers",
description: "Nested skill",
directory: "using-superpowers",
repoOwner: "owner",
repoName: "repo",
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
installedAt: 1,
},
];
});

it("marks nested discoverable skills as installed using the full directory key", () => {
render(<SkillsPage initialApp="claude" />);

expect(screen.getByText("using-superpowers")).toBeInTheDocument();
expect(screen.getByText("installed")).toBeInTheDocument();
});

it("installs the exact discoverable skill even when directories are duplicated across repos", async () => {
installSkillMock.mockResolvedValue({});
discoverableSkillsData = [
{
key: "owner-a/repo-a:shared/skill",
name: "shared-skill-a",
description: "Repo A",
directory: "shared/skill",
repoOwner: "owner-a",
repoName: "repo-a",
repoBranch: "main",
},
{
key: "owner-b/repo-b:shared/skill",
name: "shared-skill-b",
description: "Repo B",
directory: "shared/skill",
repoOwner: "owner-b",
repoName: "repo-b",
repoBranch: "main",
},
];
installedSkillsData = [];

render(<SkillsPage initialApp="claude" />);

fireEvent.click(screen.getByRole("button", { name: "install-repo-b" }));

await waitFor(() => {
expect(installSkillMock).toHaveBeenCalledWith({
skill: expect.objectContaining({
key: "owner-b/repo-b:shared/skill",
repoOwner: "owner-b",
repoName: "repo-b",
}),
currentApp: "claude",
});
});
});
});
Loading
Loading