From 333c9f277b65707c6c9e6f80549acc09609715b4 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 16 Mar 2026 00:00:31 +0800 Subject: [PATCH] feat(skills): add restore and delete for skill backups Introduce list/restore/delete commands for skill backups created during uninstall. Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure. Delete removes the backup directory after a confirmation dialog. ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking. --- src-tauri/src/commands/skill.rs | 25 ++- src-tauri/src/lib.rs | 3 + src-tauri/src/services/skill.rs | 148 ++++++++++++ src-tauri/tests/skill_sync.rs | 144 ++++++++++++ src/App.tsx | 11 + src/components/ConfirmDialog.tsx | 4 +- src/components/skills/UnifiedSkillsPanel.tsx | 223 +++++++++++++++++++ src/hooks/useSkills.ts | 44 +++- src/i18n/locales/en.json | 18 ++ src/i18n/locales/ja.json | 18 ++ src/i18n/locales/zh.json | 18 ++ src/lib/api/skills.ts | 25 +++ 12 files changed, 678 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 6eb402152..e5c848cb8 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 ab977d814..5042eb1b5 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 603ad520f..289396e25 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 015bd5f21..3d4cf6814 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 f9f4715b7..d4436da04 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 f50f00d4a..21b9153cb 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 8ede3a17b..c96e7fe49 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 de14d6088..0a4e6b517 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 437e9cc11..3b2e6df06 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 3d56cc906..c854ba5aa 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 });