diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 6eb40215..e5c848cb 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -7,7 +7,8 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; use crate::services::skill::{ - DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService, SkillUninstallResult, + DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService, + SkillUninstallResult, }; use crate::store::AppState; use std::sync::Arc; @@ -35,6 +36,17 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result Result, String> { + SkillService::list_backups().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_skill_backup(backup_id: String) -> Result { + SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?; + Ok(true) +} + /// 安装 Skill(新版统一安装) /// /// 参数: @@ -65,6 +77,17 @@ pub fn uninstall_skill_unified( SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn restore_skill_backup( + backup_id: String, + current_app: String, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(¤t_app)?; + SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type) + .map_err(|e| e.to_string()) +} + /// 切换 Skill 的应用启用状态 #[tauri::command] pub fn toggle_skill_app( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab977d81..5042eb1b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -944,8 +944,11 @@ pub fn run() { commands::restore_env_backup, // Skill management (v3.10.0+ unified) commands::get_installed_skills, + commands::get_skill_backups, + commands::delete_skill_backup, commands::install_skill_unified, commands::uninstall_skill_unified, + commands::restore_skill_backup, commands::toggle_skill_app, commands::scan_unmanaged_skills, commands::import_skills_from_apps, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 603ad520..289396e2 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -160,6 +160,15 @@ pub struct SkillUninstallResult { pub backup_path: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillBackupEntry { + pub backup_id: String, + pub backup_path: String, + pub created_at: i64, + pub skill: InstalledSkill, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SkillBackupMetadata { @@ -697,6 +706,125 @@ impl SkillService { Ok(SkillUninstallResult { backup_path }) } + pub fn list_backups() -> Result> { + let backup_dir = Self::get_backup_dir()?; + let mut entries = Vec::new(); + + for entry in fs::read_dir(&backup_dir)? { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!("读取 Skill 备份目录项失败: {err}"); + continue; + } + }; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + match Self::read_backup_metadata(&path) { + Ok(metadata) => entries.push(SkillBackupEntry { + backup_id: entry.file_name().to_string_lossy().to_string(), + backup_path: path.to_string_lossy().to_string(), + created_at: metadata.backup_created_at, + skill: metadata.skill, + }), + Err(err) => { + log::warn!("解析 Skill 备份失败 {}: {err:#}", path.display()); + } + } + } + + entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(entries) + } + + pub fn delete_backup(backup_id: &str) -> Result<()> { + let backup_path = Self::backup_path_for_id(backup_id)?; + let metadata = fs::symlink_metadata(&backup_path) + .with_context(|| format!("failed to access {}", backup_path.display()))?; + + if !metadata.is_dir() { + return Err(anyhow!( + "Skill backup is not a directory: {}", + backup_path.display() + )); + } + + fs::remove_dir_all(&backup_path) + .with_context(|| format!("failed to delete {}", backup_path.display()))?; + + log::info!("Skill 备份已删除: {}", backup_path.display()); + Ok(()) + } + + pub fn restore_from_backup( + db: &Arc, + backup_id: &str, + current_app: &AppType, + ) -> Result { + let backup_path = Self::backup_path_for_id(backup_id)?; + let metadata = Self::read_backup_metadata(&backup_path)?; + let backup_skill_dir = backup_path.join("skill"); + if !backup_skill_dir.join("SKILL.md").exists() { + return Err(anyhow!( + "Skill backup is invalid or missing SKILL.md: {}", + backup_path.display() + )); + } + + let existing_skills = db.get_all_installed_skills()?; + if existing_skills.contains_key(&metadata.skill.id) + || existing_skills.values().any(|skill| { + skill + .directory + .eq_ignore_ascii_case(&metadata.skill.directory) + }) + { + return Err(anyhow!( + "Skill already exists, please uninstall the current one first: {}", + metadata.skill.directory + )); + } + + let ssot_dir = Self::get_ssot_dir()?; + let restore_path = ssot_dir.join(&metadata.skill.directory); + if restore_path.exists() || Self::is_symlink(&restore_path) { + return Err(anyhow!( + "Restore target already exists: {}", + restore_path.display() + )); + } + + let mut restored_skill = metadata.skill; + restored_skill.installed_at = Utc::now().timestamp(); + restored_skill.apps = SkillApps::only(current_app); + + Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?; + + if let Err(err) = db.save_skill(&restored_skill) { + let _ = fs::remove_dir_all(&restore_path); + return Err(err.into()); + } + + if !restored_skill.apps.is_empty() { + if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) { + let _ = db.delete_skill(&restored_skill.id); + let _ = fs::remove_dir_all(&restore_path); + return Err(err); + } + } + + log::info!( + "Skill {} 已从备份恢复到 {}", + restored_skill.name, + restore_path.display() + ); + + Ok(restored_skill) + } + /// 切换应用启用状态 /// /// 启用:复制到应用目录 @@ -1599,6 +1727,26 @@ impl SkillService { Ok(()) } + fn backup_path_for_id(backup_id: &str) -> Result { + if backup_id.contains("..") + || backup_id.contains('/') + || backup_id.contains('\\') + || backup_id.trim().is_empty() + { + return Err(anyhow!("Invalid backup id: {backup_id}")); + } + + Ok(Self::get_backup_dir()?.join(backup_id)) + } + + fn read_backup_metadata(backup_path: &Path) -> Result { + let metadata_path = backup_path.join("meta.json"); + let content = fs::read_to_string(&metadata_path) + .with_context(|| format!("failed to read {}", metadata_path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", metadata_path.display())) + } + fn create_uninstall_backup(skill: &InstalledSkill) -> Result> { let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else { log::warn!( diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index 015bd5f2..3d4cf681 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -193,6 +193,150 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { ); } +#[test] +fn restore_skill_backup_restores_files_to_ssot_and_current_app() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let ssot_skill_dir = home.join(".cc-switch").join("skills").join("restore-skill"); + write_skill(&ssot_skill_dir, "Restore Skill"); + fs::write(ssot_skill_dir.join("prompt.md"), "restore me").expect("write prompt.md"); + + let state = create_test_state().expect("create test state"); + state + .db + .save_skill(&InstalledSkill { + id: "local:restore-skill".to_string(), + name: "Restore Skill".to_string(), + description: Some("Bring the files back".to_string()), + directory: "restore-skill".to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + }, + installed_at: 456, + }) + .expect("save skill"); + + let uninstall = + SkillService::uninstall(&state.db, "local:restore-skill").expect("uninstall skill"); + let backup_id = std::path::Path::new( + &uninstall + .backup_path + .expect("backup path should be returned on uninstall"), + ) + .file_name() + .expect("backup dir name") + .to_string_lossy() + .to_string(); + + let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude) + .expect("restore from backup"); + + assert_eq!(restored.directory, "restore-skill"); + assert!(restored.apps.claude, "restored skill should enable Claude"); + assert!( + !restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode, + "restore should only enable the selected app" + ); + assert!( + home.join(".cc-switch") + .join("skills") + .join("restore-skill") + .join("prompt.md") + .exists(), + "restored skill should exist in SSOT" + ); + assert!( + home.join(".claude") + .join("skills") + .join("restore-skill") + .join("prompt.md") + .exists(), + "restored skill should sync to the selected app" + ); + assert!( + state + .db + .get_installed_skill("local:restore-skill") + .expect("query restored skill") + .is_some(), + "restored skill should be written back to the database" + ); +} + +#[test] +fn delete_skill_backup_removes_backup_directory() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let ssot_skill_dir = home + .join(".cc-switch") + .join("skills") + .join("delete-backup-skill"); + write_skill(&ssot_skill_dir, "Delete Backup Skill"); + + let state = create_test_state().expect("create test state"); + state + .db + .save_skill(&InstalledSkill { + id: "local:delete-backup-skill".to_string(), + name: "Delete Backup Skill".to_string(), + description: Some("Remove my backup".to_string()), + directory: "delete-backup-skill".to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps { + claude: true, + codex: false, + gemini: false, + opencode: false, + }, + installed_at: 789, + }) + .expect("save skill"); + + let uninstall = + SkillService::uninstall(&state.db, "local:delete-backup-skill").expect("uninstall skill"); + let backup_path = uninstall + .backup_path + .expect("backup path should be returned on uninstall"); + let backup_id = std::path::Path::new(&backup_path) + .file_name() + .expect("backup dir name") + .to_string_lossy() + .to_string(); + + assert!( + std::path::Path::new(&backup_path).exists(), + "backup directory should exist before deletion" + ); + + SkillService::delete_backup(&backup_id).expect("delete backup"); + + assert!( + !std::path::Path::new(&backup_path).exists(), + "backup directory should be removed" + ); + assert!( + SkillService::list_backups() + .expect("list backups") + .into_iter() + .all(|entry| entry.backup_id != backup_id), + "deleted backup should no longer appear in backup list" + ); +} + #[test] fn migration_snapshot_overrides_multi_source_directory_inference() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src/App.tsx b/src/App.tsx index f9f4715b..d4436da0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1034,6 +1034,17 @@ function App() { )} {currentView === "skills" && ( <> + + + + + + ))} + + )} + + + + + + + + ); +}; + const ImportSkillsDialog: React.FC = ({ skills, onImport, diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index f50f00d4..21b9153c 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { skillsApi, + type SkillBackupEntry, type DiscoverableSkill, type ImportSkillSelection, type InstalledSkill, @@ -17,6 +18,24 @@ export function useInstalledSkills() { }); } +export function useSkillBackups() { + return useQuery({ + queryKey: ["skills", "backups"], + queryFn: () => skillsApi.getBackups(), + enabled: false, + }); +} + +export function useDeleteSkillBackup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (backupId: string) => skillsApi.deleteBackup(backupId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }); + }, + }); +} + /** * 发现可安装的 Skills(从仓库获取) */ @@ -62,6 +81,23 @@ export function useUninstallSkill() { }); } +export function useRestoreSkillBackup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + backupId, + currentApp, + }: { + backupId: string; + currentApp: AppId; + }) => skillsApi.restoreBackup(backupId, currentApp), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "backups"] }); + }, + }); +} + /** * 切换 Skill 在特定应用的启用状态 */ @@ -170,4 +206,10 @@ export function useInstallSkillsFromZip() { // ========== 辅助类型 ========== -export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId }; +export type { + InstalledSkill, + DiscoverableSkill, + ImportSkillSelection, + SkillBackupEntry, + AppId, +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8ede3a17..c96e7fe4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1570,6 +1570,24 @@ "backup": { "location": "Backup location: {{path}}" }, + "restoreFromBackup": { + "button": "Restore Backup", + "title": "Restore From Backup", + "description": "Choose a Skills backup to restore its files locally and add it back to the current list.", + "empty": "No Skills backups available to restore", + "createdAt": "Backed up at", + "path": "Backup path", + "restore": "Restore", + "restoring": "Restoring...", + "delete": "Delete", + "deleting": "Deleting...", + "deleteSuccess": "Deleted backup for {{name}}", + "deleteFailed": "Failed to delete skill backup", + "deleteConfirmTitle": "Delete Backup", + "deleteConfirmMessage": "Are you sure you want to delete the backup for \"{{name}}\"? This action cannot be undone.", + "success": "Skill {{name}} restored from backup", + "failed": "Failed to restore from backup" + }, "apps": { "claude": "Claude", "codex": "Codex", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index de14d608..0a4e6b51 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1570,6 +1570,24 @@ "backup": { "location": "バックアップ場所: {{path}}" }, + "restoreFromBackup": { + "button": "バックアップから復元", + "title": "バックアップから復元", + "description": "Skills バックアップを選択して、ローカルにファイルを復元し、現在の一覧へ戻します。", + "empty": "復元できる Skills バックアップはありません", + "createdAt": "バックアップ日時", + "path": "バックアップパス", + "restore": "復元", + "restoring": "復元中...", + "delete": "削除", + "deleting": "削除中...", + "deleteSuccess": "スキルバックアップ {{name}} を削除しました", + "deleteFailed": "スキルバックアップの削除に失敗しました", + "deleteConfirmTitle": "バックアップを削除", + "deleteConfirmMessage": "スキルバックアップ「{{name}}」を削除しますか?この操作は元に戻せません。", + "success": "スキル {{name}} をバックアップから復元しました", + "failed": "バックアップからの復元に失敗しました" + }, "apps": { "claude": "Claude", "codex": "Codex", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 437e9cc1..3b2e6df0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1570,6 +1570,24 @@ "backup": { "location": "备份位置: {{path}}" }, + "restoreFromBackup": { + "button": "从备份中恢复", + "title": "从备份中恢复", + "description": "选择一个 Skills 备份,将文件恢复到本地并重新加入当前列表。", + "empty": "暂无可恢复的 Skills 备份", + "createdAt": "备份时间", + "path": "备份路径", + "restore": "恢复", + "restoring": "恢复中...", + "delete": "删除", + "deleting": "删除中...", + "deleteSuccess": "技能备份 {{name}} 已删除", + "deleteFailed": "删除技能备份失败", + "deleteConfirmTitle": "确认删除备份", + "deleteConfirmMessage": "确定要删除技能备份 \"{{name}}\" 吗?此操作无法撤销。", + "success": "技能 {{name}} 已从备份恢复", + "failed": "从备份恢复失败" + }, "apps": { "claude": "Claude", "codex": "Codex", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3d56cc90..c854ba5a 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -31,6 +31,13 @@ export interface SkillUninstallResult { backupPath?: string; } +export interface SkillBackupEntry { + backupId: string; + backupPath: string; + createdAt: number; + skill: InstalledSkill; +} + /** 可发现的 Skill(来自仓库) */ export interface DiscoverableSkill { key: string; @@ -89,6 +96,16 @@ export const skillsApi = { return await invoke("get_installed_skills"); }, + /** 获取可恢复的 Skill 备份列表 */ + async getBackups(): Promise { + return await invoke("get_skill_backups"); + }, + + /** 删除 Skill 备份 */ + async deleteBackup(backupId: string): Promise { + return await invoke("delete_skill_backup", { backupId }); + }, + /** 安装 Skill(统一安装) */ async installUnified( skill: DiscoverableSkill, @@ -102,6 +119,14 @@ export const skillsApi = { return await invoke("uninstall_skill_unified", { id }); }, + /** 从备份恢复 Skill */ + async restoreBackup( + backupId: string, + currentApp: AppId, + ): Promise { + return await invoke("restore_skill_backup", { backupId, currentApp }); + }, + /** 切换 Skill 的应用启用状态 */ async toggleApp(id: string, app: AppId, enabled: boolean): Promise { return await invoke("toggle_skill_app", { id, app, enabled });