feat(skills): add skill sync method setting (symlink/copy)

- Add SyncMethod enum (Auto/Symlink/Copy) in Rust backend
- Implement sync_to_app_dir with symlink support (cross-platform)
- Add SkillSyncMethodSettings UI component (simplified 2-button selector)
- Add i18n support for zh/en/ja
- Replace copy_to_app with configurable sync_to_app_dir
- Add skill_sync_method field to AppSettings

User can now choose between symlink (disk space saving) or copy (best compatibility) in Settings > General.
This commit is contained in:
Jason
2026-01-24 18:24:05 +08:00
parent 096c1d57c4
commit 779fefd86d
10 changed files with 252 additions and 18 deletions
@@ -51,19 +51,19 @@ export function useModelState({
}: UseModelStateProps) {
// Initialize state by parsing config directly (fixes edit mode backfill)
const [claudeModel, setClaudeModel] = useState(
() => parseModelsFromConfig(settingsConfig).model
() => parseModelsFromConfig(settingsConfig).model,
);
const [reasoningModel, setReasoningModel] = useState(
() => parseModelsFromConfig(settingsConfig).reasoning
() => parseModelsFromConfig(settingsConfig).reasoning,
);
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
() => parseModelsFromConfig(settingsConfig).haiku
() => parseModelsFromConfig(settingsConfig).haiku,
);
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
() => parseModelsFromConfig(settingsConfig).sonnet
() => parseModelsFromConfig(settingsConfig).sonnet,
);
const [defaultOpusModel, setDefaultOpusModel] = useState(
() => parseModelsFromConfig(settingsConfig).opus
() => parseModelsFromConfig(settingsConfig).opus,
);
const isUserEditingRef = useRef(false);
+7
View File
@@ -35,6 +35,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 { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
@@ -249,6 +250,12 @@ export function SettingsPage({
settings={settings}
onChange={handleAutoSave}
/>
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
onChange={(method) =>
handleAutoSave({ skillSyncMethod: method })
}
/>
</motion.div>
) : null}
</TabsContent>
@@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { SkillSyncMethod } from "@/types";
export interface SkillSyncMethodSettingsProps {
value: SkillSyncMethod;
onChange: (value: SkillSyncMethod) => void;
}
export function SkillSyncMethodSettings({
value,
onChange,
}: SkillSyncMethodSettingsProps) {
const { t } = useTranslation();
// Handle default values: undefined or "auto" defaults to symlink display
const displayValue = value === "copy" ? "copy" : "symlink";
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.skillSync.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<SyncMethodButton
active={displayValue === "symlink"}
onClick={() => onChange("symlink")}
>
{t("settings.skillSync.symlink")}
</SyncMethodButton>
<SyncMethodButton
active={displayValue === "copy"}
onClick={() => onChange("copy")}
>
{t("settings.skillSync.copy")}
</SyncMethodButton>
</div>
{displayValue === "symlink" && (
<p className="text-xs text-muted-foreground">
{t("settings.skillSync.symlinkHint")}
</p>
)}
</section>
);
}
interface SyncMethodButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
function SyncMethodButton({
active,
onClick,
children,
}: SyncMethodButtonProps) {
return (
<Button
type="button"
onClick={onClick}
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>
);
}
+7
View File
@@ -280,6 +280,13 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillSync": {
"title": "Skill Sync Method",
"description": "Choose how to sync Skills files",
"symlink": "Symlink",
"copy": "Copy Files",
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
},
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
"appConfigDir": "CC Switch Configuration Directory",
+7
View File
@@ -280,6 +280,13 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillSync": {
"title": "スキル同期方式",
"description": "スキルファイルの同期方法を選択",
"symlink": "シンボリックリンク",
"copy": "ファイルコピー",
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
},
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
+7
View File
@@ -280,6 +280,13 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI"
},
"skillSync": {
"title": "Skill 同步方式",
"description": "选择 Skills 的文件同步策略",
"symlink": "软连接",
"copy": "文件复制",
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
},
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
"appConfigDir": "CC Switch 配置目录",
+3
View File
@@ -25,6 +25,9 @@ export const settingsSchema = z.object({
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
// Skill 同步设置
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+7
View File
@@ -137,6 +137,9 @@ export interface ProviderMeta {
proxyConfig?: ProviderProxyConfig;
}
// Skill 同步方式
export type SkillSyncMethod = "auto" | "symlink" | "copy";
// 主页面显示的应用配置
export interface VisibleApps {
claude: boolean;
@@ -182,6 +185,10 @@ export interface Settings {
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
// ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod;
}
// MCP 服务器连接参数(宽松:允许扩展字段)