feat: add skill storage location toggle between CC Switch and ~/.agents/skills

Allow users to choose between storing skills in CC Switch's managed
directory (~/.cc-switch/skills/) or the Agent Skills open standard
directory (~/.agents/skills/). Includes migration logic that safely
moves files before updating settings, with confirmation dialog for
non-empty installations.
This commit is contained in:
Jason
2026-04-05 20:21:51 +08:00
parent 6d220b2528
commit 8cfce8abfc
12 changed files with 383 additions and 5 deletions
+11
View File
@@ -32,6 +32,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
@@ -44,6 +45,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import { useInstalledSkills } from "@/hooks/useSkills";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
@@ -96,6 +98,8 @@ export function SettingsPage({
resetStatus,
} = useImportExport({ onImportSuccess });
const { data: installedSkills } = useInstalledSkills();
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
@@ -229,6 +233,13 @@ export function SettingsPage({
settings={settings}
onChange={handleAutoSave}
/>
<SkillStorageLocationSettings
value={settings.skillStorageLocation ?? "cc_switch"}
installedCount={installedSkills?.length ?? 0}
onMigrated={(location) =>
updateSettings({ skillStorageLocation: location })
}
/>
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
onChange={(method) =>
@@ -0,0 +1,165 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { skillsApi, type MigrationResult } from "@/lib/api/skills";
import type { SkillStorageLocation } from "@/types";
export interface SkillStorageLocationSettingsProps {
value: SkillStorageLocation;
installedCount: number;
onMigrated: (target: SkillStorageLocation) => void;
}
export function SkillStorageLocationSettings({
value,
installedCount,
onMigrated,
}: SkillStorageLocationSettingsProps) {
const { t } = useTranslation();
const [pendingTarget, setPendingTarget] =
useState<SkillStorageLocation | null>(null);
const [isMigrating, setIsMigrating] = useState(false);
const handleSelect = (target: SkillStorageLocation) => {
if (target === value) return;
if (installedCount > 0) {
setPendingTarget(target);
} else {
doMigrate(target);
}
};
const doMigrate = async (target: SkillStorageLocation) => {
setIsMigrating(true);
setPendingTarget(null);
try {
const result: MigrationResult = await skillsApi.migrateStorage(target);
if (result.errors.length > 0) {
toast.warning(
t("settings.skillStorage.migrationPartial", {
migrated: result.migratedCount,
errors: result.errors.length,
}),
);
} else {
toast.success(
t("settings.skillStorage.migrationSuccess", {
count: result.migratedCount,
}),
);
}
onMigrated(target);
} catch (error) {
toast.error(String(error));
} finally {
setIsMigrating(false);
}
};
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.skillStorage.title")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.skillStorage.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<StorageButton
active={value === "cc_switch"}
disabled={isMigrating}
onClick={() => handleSelect("cc_switch")}
>
{t("settings.skillStorage.ccSwitch")}
</StorageButton>
<StorageButton
active={value === "unified"}
disabled={isMigrating}
onClick={() => handleSelect("unified")}
>
{isMigrating && value !== "unified" ? (
<Loader2 size={14} className="mr-1 animate-spin" />
) : null}
{t("settings.skillStorage.unified")}
</StorageButton>
</div>
<p className="text-xs text-muted-foreground">
{value === "unified"
? t("settings.skillStorage.unifiedHint")
: t("settings.skillStorage.ccSwitchHint")}
</p>
{/* 迁移确认对话框 */}
<Dialog
open={pendingTarget !== null}
onOpenChange={(open) => {
if (!open) setPendingTarget(null);
}}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>{t("settings.skillStorage.confirmTitle")}</DialogTitle>
<DialogDescription>
{t("settings.skillStorage.confirmMessage", {
count: installedCount,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingTarget(null)}>
{t("common.cancel")}
</Button>
<Button onClick={() => pendingTarget && doMigrate(pendingTarget)}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
interface StorageButtonProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}
function StorageButton({
active,
disabled,
onClick,
children,
}: StorageButtonProps) {
return (
<Button
type="button"
onClick={onClick}
disabled={disabled}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"min-w-[96px]",
active
? "shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{children}
</Button>
);
}