Skip to content

Commit 779fefd

Browse files
committed
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.
1 parent 096c1d5 commit 779fefd

File tree

10 files changed

+252
-18
lines changed

10 files changed

+252
-18
lines changed

src-tauri/src/services/skill.rs

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ use crate::error::format_skill_error;
2121

2222
// ========== 数据结构 ==========
2323

24+
/// Skill 同步方式
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26+
#[serde(rename_all = "lowercase")]
27+
pub enum SyncMethod {
28+
/// 自动选择:优先 symlink,失败时回退到 copy
29+
#[default]
30+
Auto,
31+
/// 符号链接(推荐,节省磁盘空间)
32+
Symlink,
33+
/// 文件复制(兼容模式)
34+
Copy,
35+
}
36+
2437
/// 可发现的技能(来自仓库)
2538
#[derive(Debug, Clone, Serialize, Deserialize)]
2639
pub struct DiscoverableSkill {
@@ -305,7 +318,7 @@ impl SkillService {
305318
db.save_skill(&installed_skill)?;
306319

307320
// 同步到当前应用目录
308-
Self::copy_to_app(&install_name, current_app)?;
321+
Self::sync_to_app_dir(&install_name, current_app)?;
309322

310323
log::info!(
311324
"Skill {} 安装成功,已启用 {:?}",
@@ -368,7 +381,7 @@ impl SkillService {
368381

369382
// 同步文件
370383
if enabled {
371-
Self::copy_to_app(&skill.directory, app)?;
384+
Self::sync_to_app_dir(&skill.directory, app)?;
372385
} else {
373386
Self::remove_from_app(&skill.directory, app)?;
374387
}
@@ -566,8 +579,41 @@ impl SkillService {
566579

567580
// ========== 文件同步方法 ==========
568581

569-
/// 复制 Skill 到应用目录
570-
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
582+
/// 创建符号链接(跨平台)
583+
///
584+
/// - Unix: 使用 std::os::unix::fs::symlink
585+
/// - Windows: 使用 std::os::windows::fs::symlink_dir
586+
#[cfg(unix)]
587+
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
588+
std::os::unix::fs::symlink(src, dest)
589+
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
590+
}
591+
592+
#[cfg(windows)]
593+
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
594+
std::os::windows::fs::symlink_dir(src, dest)
595+
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
596+
}
597+
598+
/// 检查路径是否为符号链接
599+
fn is_symlink(path: &Path) -> bool {
600+
path.symlink_metadata()
601+
.map(|m| m.file_type().is_symlink())
602+
.unwrap_or(false)
603+
}
604+
605+
/// 获取当前同步方式配置
606+
fn get_sync_method() -> SyncMethod {
607+
crate::settings::get_skill_sync_method()
608+
}
609+
610+
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
611+
///
612+
/// 根据配置和平台选择最佳同步方式:
613+
/// - Auto: 优先尝试 symlink,失败时回退到 copy
614+
/// - Symlink: 仅使用 symlink
615+
/// - Copy: 仅使用文件复制
616+
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
571617
let ssot_dir = Self::get_ssot_dir()?;
572618
let source = ssot_dir.join(directory);
573619

@@ -580,25 +626,77 @@ impl SkillService {
580626

581627
let dest = app_dir.join(directory);
582628

583-
// 如果已存在则先删除
584-
if dest.exists() {
585-
fs::remove_dir_all(&dest)?;
629+
// 如果已存在则先删除(无论是 symlink 还是真实目录)
630+
if dest.exists() || Self::is_symlink(&dest) {
631+
Self::remove_path(&dest)?;
586632
}
587633

588-
Self::copy_dir_recursive(&source, &dest)?;
634+
let sync_method = Self::get_sync_method();
589635

590-
log::debug!("Skill {directory} 已复制到 {app:?}");
636+
match sync_method {
637+
SyncMethod::Auto => {
638+
// 优先尝试 symlink
639+
match Self::create_symlink(&source, &dest) {
640+
Ok(()) => {
641+
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
642+
return Ok(());
643+
}
644+
Err(err) => {
645+
log::warn!(
646+
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
647+
source.display(),
648+
dest.display()
649+
);
650+
}
651+
}
652+
// Fallback 到 copy
653+
Self::copy_dir_recursive(&source, &dest)?;
654+
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
655+
}
656+
SyncMethod::Symlink => {
657+
Self::create_symlink(&source, &dest)?;
658+
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
659+
}
660+
SyncMethod::Copy => {
661+
Self::copy_dir_recursive(&source, &dest)?;
662+
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
663+
}
664+
}
591665

592666
Ok(())
593667
}
594668

595-
/// 从应用目录删除 Skill
669+
/// 复制 Skill 到应用目录(保留用于向后兼容)
670+
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
671+
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
672+
Self::sync_to_app_dir(directory, app)
673+
}
674+
675+
/// 删除路径(支持 symlink 和真实目录)
676+
fn remove_path(path: &Path) -> Result<()> {
677+
if Self::is_symlink(path) {
678+
// 符号链接:仅删除链接本身,不影响源文件
679+
#[cfg(unix)]
680+
fs::remove_file(path)?;
681+
#[cfg(windows)]
682+
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
683+
} else if path.is_dir() {
684+
// 真实目录:递归删除
685+
fs::remove_dir_all(path)?;
686+
} else if path.exists() {
687+
// 普通文件
688+
fs::remove_file(path)?;
689+
}
690+
Ok(())
691+
}
692+
693+
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
596694
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
597695
let app_dir = Self::get_app_skills_dir(app)?;
598696
let skill_path = app_dir.join(directory);
599697

600-
if skill_path.exists() {
601-
fs::remove_dir_all(&skill_path)?;
698+
if skill_path.exists() || Self::is_symlink(&skill_path) {
699+
Self::remove_path(&skill_path)?;
602700
log::debug!("Skill {directory} 已从 {app:?} 删除");
603701
}
604702

@@ -611,7 +709,7 @@ impl SkillService {
611709

612710
for skill in skills.values() {
613711
if skill.apps.is_enabled_for(app) {
614-
Self::copy_to_app(&skill.directory, app)?;
712+
Self::sync_to_app_dir(&skill.directory, app)?;
615713
}
616714
}
617715

src-tauri/src/settings.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::{OnceLock, RwLock};
55

66
use crate::app_config::AppType;
77
use crate::error::AppError;
8+
use crate::services::skill::SyncMethod;
89

910
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
1011
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -108,6 +109,11 @@ pub struct AppSettings {
108109
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
109110
#[serde(default, skip_serializing_if = "Option::is_none")]
110111
pub current_provider_opencode: Option<String>,
112+
113+
// ===== Skill 同步设置 =====
114+
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
115+
#[serde(default)]
116+
pub skill_sync_method: SyncMethod,
111117
}
112118

113119
fn default_show_in_tray() -> bool {
@@ -136,6 +142,7 @@ impl Default for AppSettings {
136142
current_provider_codex: None,
137143
current_provider_gemini: None,
138144
current_provider_opencode: None,
145+
skill_sync_method: SyncMethod::default(),
139146
}
140147
}
141148
}
@@ -382,3 +389,16 @@ pub fn get_effective_current_provider(
382389
// Fallback 到数据库的 is_current
383390
db.get_current_provider(app_type.as_str())
384391
}
392+
393+
// ===== Skill 同步方式管理函数 =====
394+
395+
/// 获取 Skill 同步方式配置
396+
pub fn get_skill_sync_method() -> SyncMethod {
397+
settings_store()
398+
.read()
399+
.unwrap_or_else(|e| {
400+
log::warn!("设置锁已毒化,使用恢复值: {e}");
401+
e.into_inner()
402+
})
403+
.skill_sync_method
404+
}

src/components/providers/forms/hooks/useModelState.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,19 @@ export function useModelState({
5151
}: UseModelStateProps) {
5252
// Initialize state by parsing config directly (fixes edit mode backfill)
5353
const [claudeModel, setClaudeModel] = useState(
54-
() => parseModelsFromConfig(settingsConfig).model
54+
() => parseModelsFromConfig(settingsConfig).model,
5555
);
5656
const [reasoningModel, setReasoningModel] = useState(
57-
() => parseModelsFromConfig(settingsConfig).reasoning
57+
() => parseModelsFromConfig(settingsConfig).reasoning,
5858
);
5959
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
60-
() => parseModelsFromConfig(settingsConfig).haiku
60+
() => parseModelsFromConfig(settingsConfig).haiku,
6161
);
6262
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
63-
() => parseModelsFromConfig(settingsConfig).sonnet
63+
() => parseModelsFromConfig(settingsConfig).sonnet,
6464
);
6565
const [defaultOpusModel, setDefaultOpusModel] = useState(
66-
() => parseModelsFromConfig(settingsConfig).opus
66+
() => parseModelsFromConfig(settingsConfig).opus,
6767
);
6868

6969
const isUserEditingRef = useRef(false);

src/components/settings/SettingsPage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings";
3535
import { ThemeSettings } from "@/components/settings/ThemeSettings";
3636
import { WindowSettings } from "@/components/settings/WindowSettings";
3737
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
38+
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
3839
import { DirectorySettings } from "@/components/settings/DirectorySettings";
3940
import { ImportExportSection } from "@/components/settings/ImportExportSection";
4041
import { AboutSection } from "@/components/settings/AboutSection";
@@ -249,6 +250,12 @@ export function SettingsPage({
249250
settings={settings}
250251
onChange={handleAutoSave}
251252
/>
253+
<SkillSyncMethodSettings
254+
value={settings.skillSyncMethod ?? "auto"}
255+
onChange={(method) =>
256+
handleAutoSave({ skillSyncMethod: method })
257+
}
258+
/>
252259
</motion.div>
253260
) : null}
254261
</TabsContent>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useTranslation } from "react-i18next";
2+
import { Button } from "@/components/ui/button";
3+
import { cn } from "@/lib/utils";
4+
import type { SkillSyncMethod } from "@/types";
5+
6+
export interface SkillSyncMethodSettingsProps {
7+
value: SkillSyncMethod;
8+
onChange: (value: SkillSyncMethod) => void;
9+
}
10+
11+
export function SkillSyncMethodSettings({
12+
value,
13+
onChange,
14+
}: SkillSyncMethodSettingsProps) {
15+
const { t } = useTranslation();
16+
17+
// Handle default values: undefined or "auto" defaults to symlink display
18+
const displayValue = value === "copy" ? "copy" : "symlink";
19+
20+
return (
21+
<section className="space-y-2">
22+
<header className="space-y-1">
23+
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
24+
<p className="text-xs text-muted-foreground">
25+
{t("settings.skillSync.description")}
26+
</p>
27+
</header>
28+
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
29+
<SyncMethodButton
30+
active={displayValue === "symlink"}
31+
onClick={() => onChange("symlink")}
32+
>
33+
{t("settings.skillSync.symlink")}
34+
</SyncMethodButton>
35+
<SyncMethodButton
36+
active={displayValue === "copy"}
37+
onClick={() => onChange("copy")}
38+
>
39+
{t("settings.skillSync.copy")}
40+
</SyncMethodButton>
41+
</div>
42+
{displayValue === "symlink" && (
43+
<p className="text-xs text-muted-foreground">
44+
{t("settings.skillSync.symlinkHint")}
45+
</p>
46+
)}
47+
</section>
48+
);
49+
}
50+
51+
interface SyncMethodButtonProps {
52+
active: boolean;
53+
onClick: () => void;
54+
children: React.ReactNode;
55+
}
56+
57+
function SyncMethodButton({
58+
active,
59+
onClick,
60+
children,
61+
}: SyncMethodButtonProps) {
62+
return (
63+
<Button
64+
type="button"
65+
onClick={onClick}
66+
size="sm"
67+
variant={active ? "default" : "ghost"}
68+
className={cn(
69+
"min-w-[96px]",
70+
active
71+
? "shadow-sm"
72+
: "text-muted-foreground hover:text-foreground hover:bg-muted",
73+
)}
74+
>
75+
{children}
76+
</Button>
77+
);
78+
}

src/i18n/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@
280280
"geminiDesc": "Google Gemini CLI",
281281
"opencodeDesc": "OpenCode CLI"
282282
},
283+
"skillSync": {
284+
"title": "Skill Sync Method",
285+
"description": "Choose how to sync Skills files",
286+
"symlink": "Symlink",
287+
"copy": "Copy Files",
288+
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
289+
},
283290
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
284291
"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.",
285292
"appConfigDir": "CC Switch Configuration Directory",

src/i18n/locales/ja.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@
280280
"geminiDesc": "Google Gemini CLI",
281281
"opencodeDesc": "OpenCode CLI"
282282
},
283+
"skillSync": {
284+
"title": "スキル同期方式",
285+
"description": "スキルファイルの同期方法を選択",
286+
"symlink": "シンボリックリンク",
287+
"copy": "ファイルコピー",
288+
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
289+
},
283290
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
284291
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
285292
"appConfigDir": "CC Switch 設定ディレクトリ",

src/i18n/locales/zh.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@
280280
"geminiDesc": "Google Gemini CLI",
281281
"opencodeDesc": "OpenCode CLI"
282282
},
283+
"skillSync": {
284+
"title": "Skill 同步方式",
285+
"description": "选择 Skills 的文件同步策略",
286+
"symlink": "软连接",
287+
"copy": "文件复制",
288+
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
289+
},
283290
"configDirectoryOverride": "配置目录覆盖(高级)",
284291
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
285292
"appConfigDir": "CC Switch 配置目录",

src/lib/schemas/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export const settingsSchema = z.object({
2525
currentProviderClaude: z.string().optional(),
2626
currentProviderCodex: z.string().optional(),
2727
currentProviderGemini: z.string().optional(),
28+
29+
// Skill 同步设置
30+
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
2831
});
2932

3033
export type SettingsFormData = z.infer<typeof settingsSchema>;

0 commit comments

Comments
 (0)