From b7a5ef2885e4dc872d1c7316ada143325d20d18e Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 2 Oct 2026 13:08:42 +0800 Subject: [PATCH] feat(ui): follow the spec for preset versions, tray problems and skills storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presets: name the Tencent family "Tencent Cloud" (腾讯云), since it also covers pay-as-you-go plans in some apps, and let a family list its versions in design order (Kimi by region, Tencent Lite before Pro). Families without an order keep the preset file order. Tray: a failed tray switch no longer expires after ten minutes. It stays until the app switches successfully or its page is opened in the main window, reported through the new tray_app_page_seen command. Skills storage and sync: turn the sheet into the spec's dialog with a real three-way sync method (auto, symlink only, copy only; auto was shown as symlink before), radio buttons for where the main copy lives, the real CC Switch skills path with an Open button, and a visible line explaining why "Move and switch" is unavailable. --- src-tauri/src/commands/skill.rs | 28 +- src-tauri/src/lib.rs | 3 + src-tauri/src/tray.rs | 34 +- src/App.tsx | 7 +- .../providers/forms/presetGroups.ts | 23 +- .../settings/SkillStorageLocationSettings.tsx | 163 --------- .../settings/SkillSyncMethodSettings.tsx | 76 ---- src/components/skills/SkillsStorageSheet.tsx | 331 +++++++++++++++--- src/config/presetFamilies.ts | 20 +- src/hooks/useSkills.ts | 12 + src/hooks/useTrayNavigation.ts | 18 + src/i18n/locales/en.json | 46 +-- src/i18n/locales/ja.json | 46 +-- src/i18n/locales/zh-TW.json | 46 +-- src/i18n/locales/zh.json | 46 +-- src/lib/api/skills.ts | 10 + .../ProviderPresetSelector.test.tsx | 42 ++- tests/components/SkillsStorageSheet.test.tsx | 160 ++++++++- tests/hooks/useTrayNavigation.test.tsx | 24 ++ tests/msw/handlers.ts | 5 + 20 files changed, 727 insertions(+), 413 deletions(-) delete mode 100644 src/components/settings/SkillStorageLocationSettings.tsx delete mode 100644 src/components/settings/SkillSyncMethodSettings.tsx diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 9e51ccec1..79c67c422 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -14,7 +14,8 @@ use crate::services::skill::{ use crate::store::AppState; use std::str::FromStr; use std::sync::Arc; -use tauri::State; +use tauri::{AppHandle, State}; +use tauri_plugin_opener::OpenerExt; /// SkillService 状态包装 pub struct SkillServiceState(pub Arc); @@ -178,6 +179,31 @@ pub async fn migrate_skill_storage( SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) } +/// CC Switch 目录下放 Skill 主副本的位置(改过配置目录就是改后的) +fn cc_switch_skills_dir() -> std::path::PathBuf { + crate::config::get_app_config_dir().join("skills") +} + +/// 「存储与同步」里显示的 CC Switch 目录路径 +#[tauri::command] +pub async fn get_cc_switch_skills_dir() -> Result { + Ok(cc_switch_skills_dir().to_string_lossy().to_string()) +} + +/// 在系统文件管理器里打开 CC Switch 目录下的 Skills 目录(不存在就先建) +#[tauri::command] +pub async fn open_cc_switch_skills_dir(handle: AppHandle) -> Result { + let dir = cc_switch_skills_dir(); + if !dir.exists() { + std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?; + } + handle + .opener() + .open_path(dir.to_string_lossy().to_string(), None::) + .map_err(|e| format!("打开文件夹失败: {e}"))?; + Ok(true) +} + /// 搜索 skills.sh 公共目录 #[tauri::command] pub async fn search_skills_sh( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ede36c201..65734edee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1544,6 +1544,8 @@ pub fn run() { commands::resync_skills_to_apps, commands::update_skill, commands::migrate_skill_storage, + commands::get_cc_switch_skills_dir, + commands::open_cc_switch_skills_dir, commands::search_skills_sh, // Skill management (legacy API compatibility) commands::get_skills, @@ -1568,6 +1570,7 @@ pub fn run() { commands::get_app_mode, commands::take_startup_attach_failures, tray::take_tray_navigation, + tray::tray_app_page_seen, commands::exit_proxy_apps_in_mode, commands::get_direct_provider, commands::get_proxy_status, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 510d78065..18428807f 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -64,8 +64,6 @@ const MAX_PROBLEM_ROWS: usize = 2; const MAX_NAME_CHARS: usize = 32; /// 问题区里切换失败的原因最多几个字。 const MAX_REASON_CHARS: usize = 60; -/// 托盘里切换失败的问题行留多久(之后多半已经在主界面处理过了)。 -const SWITCH_FAILURE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60); /// 额度剩余不到这个百分比算「快用完」(和前端 `quotaRules.WARN_BELOW_PERCENT` 一致)。 const WARN_BELOW_PERCENT: f64 = 10.0; @@ -1243,10 +1241,11 @@ static STARTUP_SETTLED: AtomicBool = AtomicBool::new(false); /// 启动时没能接上、已退回直连的应用(托盘自己留一份,界面照样取走它的那份)。 static ATTACH_FAILURES: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); +/// 托盘里切换失败的应用。规格 5.5:处理掉才消失(同一应用之后切换成功、或打开过它的页面), +/// 不按时间过期。 struct SwitchFailure { app: AppType, reason: String, - at: std::time::Instant, } static SWITCH_FAILURES: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); @@ -1275,9 +1274,25 @@ pub fn mark_startup_settled(app: &tauri::AppHandle) { refresh_tray_menu(app); } -fn clear_app_problems(app: &AppType) { - lock(&ATTACH_FAILURES).retain(|(existing, _)| existing != app); - lock(&SWITCH_FAILURES).retain(|failure| failure.app != *app); +/// 清掉某个应用的问题行,返回有没有清掉东西。 +fn clear_app_problems(app: &AppType) -> bool { + let mut attach = lock(&ATTACH_FAILURES); + let mut switch = lock(&SWITCH_FAILURES); + let before = attach.len() + switch.len(); + attach.retain(|(existing, _)| existing != app); + switch.retain(|failure| failure.app != *app); + attach.len() + switch.len() != before +} + +/// 主界面打开了某个应用的页面:这个应用的问题行算处理过了(规格 5.5「打开过对应页面」)。 +#[tauri::command] +pub fn tray_app_page_seen(app: tauri::AppHandle, app_type: String) { + let Ok(app_type) = app_type.parse::() else { + return; + }; + if clear_app_problems(&app_type) { + schedule_tray_status_check(&app); + } } fn record_switch_failure(app: &AppType, reason: String) { @@ -1286,7 +1301,6 @@ fn record_switch_failure(app: &AppType, reason: String) { failures.push(SwitchFailure { app: app.clone(), reason, - at: std::time::Instant::now(), }); } @@ -1325,8 +1339,7 @@ fn collect_problems( } } { - let mut failures = lock(&SWITCH_FAILURES); - failures.retain(|failure| failure.at.elapsed() < SWITCH_FAILURE_TTL); + let failures = lock(&SWITCH_FAILURES); for failure in failures.iter() { if visible.iter().any(|(app, _)| *app == failure.app) { problems.push(TrayProblem::SwitchFailed { @@ -4153,7 +4166,8 @@ mod tests { } ] ); - clear_app_problems(&AppType::Codex); + assert!(clear_app_problems(&AppType::Codex)); + assert!(!clear_app_problems(&AppType::Codex)); assert!(collect_problems(None, &rerouted).is_empty()); } } diff --git a/src/App.tsx b/src/App.tsx index d00bb57ad..7a6c9d53e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,10 @@ import { hermesApi } from "@/lib/api/hermes"; import type { ProviderEditorSave } from "@/lib/api/providers"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge"; -import { useTrayNavigation } from "@/hooks/useTrayNavigation"; +import { + useTrayAppPageSeen, + useTrayNavigation, +} from "@/hooks/useTrayNavigation"; import { useTauriEvent } from "@/hooks/useTauriEvent"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { useSidebarCollapsed } from "@/hooks/useSidebarCollapsed"; @@ -599,6 +602,8 @@ function App() { } setCurrentView(page); }; + useTrayAppPageSeen(currentView === "providers" ? activeApp : null); + useTrayNavigation((navigation) => { if (navigation.section) { openSettings(navigation.section); diff --git a/src/components/providers/forms/presetGroups.ts b/src/components/providers/forms/presetGroups.ts index 4a2d439c1..f2cc6b7a1 100644 --- a/src/components/providers/forms/presetGroups.ts +++ b/src/components/providers/forms/presetGroups.ts @@ -146,7 +146,11 @@ export function groupPresetRows(entries: PresetEntry[]): PresetRowItem[] { if (family && versions && versions.length > 1) { if (done.has(family)) continue; done.add(family); - rows.push({ key: `family:${family}`, family, versions }); + rows.push({ + key: `family:${family}`, + family, + versions: sortFamilyVersions(family, versions), + }); } else { rows.push({ key: entry.id, versions: [entry] }); } @@ -162,7 +166,22 @@ export function presetVersions( const family = entry.preset.family; if (!family) return [entry]; const versions = entries.filter((item) => item.preset.family === family); - return versions.length > 1 ? versions : [entry]; + return versions.length > 1 ? sortFamilyVersions(family, versions) : [entry]; +} + +/** 按 PRESET_FAMILIES 里的 versionOrder 排版本;没列到的保持文件顺序排在后面 */ +function sortFamilyVersions( + family: PresetFamilyId, + versions: PresetEntry[], +): PresetEntry[] { + const order: readonly string[] = + (PRESET_FAMILIES[family] as PresetFamilyInfo).versionOrder ?? []; + if (order.length === 0) return versions; + const rank = (entry: PresetEntry) => { + const index = order.indexOf(entry.preset.versionKey ?? ""); + return index === -1 ? order.length : index; + }; + return [...versions].sort((a, b) => rank(a) - rank(b)); } export function familyDisplayName(family: PresetFamilyId, t: Translate) { diff --git a/src/components/settings/SkillStorageLocationSettings.tsx b/src/components/settings/SkillStorageLocationSettings.tsx deleted file mode 100644 index f45089c20..000000000 --- a/src/components/settings/SkillStorageLocationSettings.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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(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 ( -
-
-

- {t("settings.skillStorage.title")} -

-

- {t("settings.skillStorage.description")} -

-
-
- handleSelect("cc_switch")} - > - {t("settings.skillStorage.ccSwitch")} - - handleSelect("unified")} - > - {isMigrating && value !== "unified" ? ( - - ) : null} - {t("settings.skillStorage.unified")} - -
-

- {value === "unified" - ? t("settings.skillStorage.unifiedHint") - : t("settings.skillStorage.ccSwitchHint")} -

- - {/* 迁移确认对话框 */} - { - if (!open) setPendingTarget(null); - }} - > - - - {t("settings.skillStorage.confirmTitle")} - - {t("settings.skillStorage.confirmMessage", { - count: installedCount, - })} - - - - - - - - -
- ); -} - -interface StorageButtonProps { - active: boolean; - disabled?: boolean; - onClick: () => void; - children: React.ReactNode; -} - -function StorageButton({ - active, - disabled, - onClick, - children, -}: StorageButtonProps) { - return ( - - ); -} diff --git a/src/components/settings/SkillSyncMethodSettings.tsx b/src/components/settings/SkillSyncMethodSettings.tsx deleted file mode 100644 index 328192e55..000000000 --- a/src/components/settings/SkillSyncMethodSettings.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 ( -
-
-

{t("settings.skillSync.title")}

-

- {t("settings.skillSync.description")} -

-
-
- onChange("symlink")} - > - {t("settings.skillSync.symlink")} - - onChange("copy")} - > - {t("settings.skillSync.copy")} - -
- {displayValue === "symlink" && ( -

- {t("settings.skillSync.symlinkHint")} -

- )} -
- ); -} - -interface SyncMethodButtonProps { - active: boolean; - onClick: () => void; - children: React.ReactNode; -} - -function SyncMethodButton({ - active, - onClick, - children, -}: SyncMethodButtonProps) { - return ( - - ); -} diff --git a/src/components/skills/SkillsStorageSheet.tsx b/src/components/skills/SkillsStorageSheet.tsx index cec3ecb6d..5b41a292d 100644 --- a/src/components/skills/SkillsStorageSheet.tsx +++ b/src/components/skills/SkillsStorageSheet.tsx @@ -1,21 +1,25 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Loader2, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { useSettings } from "@/hooks/useSettings"; -import { useInstalledSkills, useResyncSkillsToApps } from "@/hooks/useSkills"; import { - Sheet, - SheetBody, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; + useCcSwitchSkillsDir, + useInstalledSkills, + useResyncSkillsToApps, +} from "@/hooks/useSkills"; +import { DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { + CHECKBOX_CLASS, + V7ConfirmDialog, + V7Dialog, +} from "@/components/mcp/formBits"; import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph"; -import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings"; -import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings"; -import type { SkillAppSyncOutcome } from "@/lib/api/skills"; +import { shortenHomePath } from "@/components/sessions/utils"; +import { skillsApi, type SkillAppSyncOutcome } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; +import type { SkillStorageLocation, SkillSyncMethod } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; interface SkillsStorageSheetProps { @@ -25,7 +29,33 @@ interface SkillsStorageSheetProps { const appName = (app: string) => APP_DISPLAY_NAME[app as AppId] ?? app; -/** Skills 页 →「存储与同步」:存在哪、怎么同步到各应用(原来在设置 → 通用)。 */ +const DEFAULT_CC_SWITCH_DIR = "~/.cc-switch/skills"; +const UNIFIED_DIR = "~/.agents/skills"; + +/** 和后端 SyncMethod(auto / symlink / copy)一一对应。 */ +const SYNC_METHODS: { + id: SkillSyncMethod; + labelKey: string; + descKey?: string; +}[] = [ + { id: "auto", labelKey: "skills.storageSheet.syncAuto" }, + { + id: "symlink", + labelKey: "skills.storageSheet.syncSymlink", + descKey: "skills.storageSheet.syncSymlinkDesc", + }, + { + id: "copy", + labelKey: "skills.storageSheet.syncCopy", + descKey: "skills.storageSheet.syncCopyDesc", + }, +]; + +const LEGEND_CLASS = "mb-2 p-0 text-body font-semibold text-fg-1"; +const RADIO_LABEL_CLASS = + "flex cursor-pointer items-center gap-2.5 text-body text-fg-1 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60"; + +/** Skills 页 ⋯ →「存储与同步」对话框:主副本放在哪、怎么同步到各应用(原来在设置 → 通用)。 */ export function SkillsStorageSheet({ open, onOpenChange, @@ -33,8 +63,90 @@ export function SkillsStorageSheet({ const { t } = useTranslation(); const { settings, updateSettings, autoSaveSettings } = useSettings(); const { data: installedSkills } = useInstalledSkills(); + const { data: ccSwitchDirRaw } = useCcSwitchSkillsDir(open); const resyncMutation = useResyncSkillsToApps(); + const location: SkillStorageLocation = + settings?.skillStorageLocation ?? "cc_switch"; + const syncMethod: SkillSyncMethod = settings?.skillSyncMethod ?? "auto"; + const installedCount = installedSkills?.length ?? 0; + const ccSwitchDir = ccSwitchDirRaw + ? shortenHomePath(ccSwitchDirRaw) + : DEFAULT_CC_SWITCH_DIR; + + // 单选只是「想换到哪」;点「移动并切换…」才真正迁移 + const [pendingLocation, setPendingLocation] = + useState(location); + const [confirmMove, setConfirmMove] = useState(false); + const [isMigrating, setIsMigrating] = useState(false); + + useEffect(() => { + if (open) setPendingLocation(location); + }, [open, location]); + + const targetDir = pendingLocation === "unified" ? UNIFIED_DIR : ccSwitchDir; + // 对话框里的禁用原因写成看得见的一行(HELP_BRIEF:菜单 / 对话框例外),不挂悬停气泡 + const locationUnchanged = pendingLocation === location; + + const doMigrate = async (target: SkillStorageLocation) => { + setIsMigrating(true); + setConfirmMove(false); + const path = target === "unified" ? UNIFIED_DIR : ccSwitchDir; + try { + const result = await skillsApi.migrateStorage(target); + if (result.errors.length > 0) { + toast.warning( + t("skills.storageSheet.movePartial", { + migrated: result.migratedCount, + errors: result.errors.length, + }), + { description: result.errors.join("\n"), closeButton: true }, + ); + } else { + toast.success( + t("skills.storageSheet.moveDone", { + count: result.migratedCount, + path, + }), + { closeButton: true }, + ); + } + updateSettings({ skillStorageLocation: target }); + } catch (error) { + toast.error(t("common.error"), { + description: extractErrorMessage(error) || String(error), + }); + } finally { + setIsMigrating(false); + } + }; + + const handleMove = () => { + if (pendingLocation === location || isMigrating) return; + // 一个 Skill 都没有时没什么可移动的,直接切换 + if (installedCount > 0) { + setConfirmMove(true); + } else { + void doMigrate(pendingLocation); + } + }; + + const handleOpenFolder = async () => { + try { + await skillsApi.openCcSwitchSkillsDir(); + } catch (error) { + toast.error(t("common.error"), { + description: extractErrorMessage(error) || String(error), + }); + } + }; + + const handleSyncMethod = (method: SkillSyncMethod) => { + if (method === syncMethod) return; + updateSettings({ skillSyncMethod: method }); + void autoSaveSettings({ skillSyncMethod: method }).catch(() => undefined); + }; + const reportResync = (outcomes: SkillAppSyncOutcome[]) => { const failed = outcomes.filter((outcome) => !outcome.ok); if (failed.length === 0) { @@ -75,55 +187,184 @@ export function SkillsStorageSheet({ }; return ( - - - - {t("skills.storageSheet.title")} - - - {settings && ( - <> - - updateSettings({ skillStorageLocation: location }) - } - /> -
- { - updateSettings({ skillSyncMethod: method }); - void autoSaveSettings({ skillSyncMethod: method }).catch( - () => undefined, - ); - }} - /> -

- {t("skills.storageSheet.resyncHint")} + <> + { + if (!next && isMigrating) return; + onOpenChange(next); + }} + width={520} + > + {t("skills.storageSheet.title")} + + {settings && ( + <> +

+ + {t("skills.storageSheet.locationTitle")} + +
+ +
+ + {ccSwitchDir} + + +
+
+
+ +

+ {t("skills.storageSheet.unifiedWarning")} +

+
+
+

+ {t("skills.storageSheet.moveHint", { count: installedCount })}

+
+
+ +
+ +
+ + {t("skills.storageSheet.syncTitle")} + + {SYNC_METHODS.map((option) => ( + + ))} +
+

+ {t("skills.storageSheet.resyncHint")} +

+
- - )} - - - +
+ + )} + +
+ +
+ + + void doMigrate(pendingLocation)} + onCancel={() => setConfirmMove(false)} + /> + ); } diff --git a/src/config/presetFamilies.ts b/src/config/presetFamilies.ts index 62af424d6..e172f63da 100644 --- a/src/config/presetFamilies.ts +++ b/src/config/presetFamilies.ts @@ -11,20 +11,36 @@ export interface PresetFamilyInfo { name: string; /** 名称需要翻译时用的 i18n key */ nameKey?: string; + /** 版本的显示顺序(设计稿顺序);不写或没列到的版本按预设文件顺序排在后面 */ + versionOrder?: readonly PresetVersionKey[]; } export const PRESET_FAMILIES = { "aws-bedrock": { name: "AWS Bedrock" }, "baidu-qianfan": { name: "Baidu Qianfan" }, compshare: { name: "Compshare", nameKey: "providerForm.presets.ucloud" }, - kimi: { name: "Kimi" }, + kimi: { + name: "Kimi", + versionOrder: ["paygCn", "codingCn", "paygIntl", "codingIntl"], + }, minimax: { name: "MiniMax" }, qianwen: { name: "千问AI平台" }, qwencloud: { name: "QwenCloud" }, siliconflow: { name: "SiliconFlow" }, stepfun: { name: "StepFun" }, sudocode: { name: "SudoCode" }, - tencent: { name: "Tencent Token Plan" }, + tencent: { + name: "Tencent Cloud", + nameKey: "providerPreset.family.tencent", + versionOrder: [ + "tokenPlanCn", + "tokenPlanIntl", + "enterpriseLiteCn", + "enterpriseLiteIntl", + "enterpriseProCn", + "enterpriseProIntl", + ], + }, volcengine: { name: "Volcengine", nameKey: "providerPreset.family.volcengine", diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 543d396de..8dccb8a3f 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -427,3 +427,15 @@ export function useResyncSkillsToApps() { queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }), }); } + +/** + * CC Switch 目录下放 Skill 主副本的真实路径(改过配置目录就是改后的) + */ +export function useCcSwitchSkillsDir(enabled = true) { + return useQuery({ + queryKey: ["skills", "ccSwitchDir"], + queryFn: () => skillsApi.getCcSwitchSkillsDir(), + enabled, + staleTime: Infinity, + }); +} diff --git a/src/hooks/useTrayNavigation.ts b/src/hooks/useTrayNavigation.ts index bf54f7648..864600cb1 100644 --- a/src/hooks/useTrayNavigation.ts +++ b/src/hooks/useTrayNavigation.ts @@ -83,3 +83,21 @@ export function useTrayNavigation( }; }, []); } + +/** + * 主界面正显示某个应用的供应商页:告诉托盘这个应用的问题行处理过了(规格「打开过对应页面」)。 + * 切到这个页面、以及窗口重新拿到焦点时各报一次;`app` 为 null 表示当前不是应用页。 + */ +export function useTrayAppPageSeen(app: AppId | null): void { + useEffect(() => { + if (!app) return; + const report = () => { + invoke("tray_app_page_seen", { appType: app }).catch((error) => + console.error("Failed to report app page to tray", error), + ); + }; + report(); + window.addEventListener("focus", report); + return () => window.removeEventListener("focus", report); + }, [app]); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 33e9629f5..417f929a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -741,25 +741,6 @@ "showProfileSwitcher": "Show project switcher", "showProfileSwitcherDescription": "Show the project switcher in the main page header" }, - "skillStorage": { - "title": "Skill Storage Location", - "description": "Choose where CC Switch stores the master copies of your skills", - "ccSwitch": "CC Switch", - "unified": "~/.agents/skills", - "ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.", - "unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.", - "confirmTitle": "Migrate Skill Storage", - "confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?", - "migrationSuccess": "Successfully migrated {{count}} skill(s)", - "migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details." - }, - "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" - }, "terminal": { "title": "Preferred Terminal", "description": "Choose which terminal app to use when clicking the terminal button", @@ -1821,7 +1802,8 @@ "matchedVersions": "Matches {{name}} and {{more}} more", "versionLabel": "Version", "family": { - "volcengine": "Volcengine" + "volcengine": "Volcengine", + "tencent": "Tencent Cloud" }, "version": { "payg": "Pay-as-you-go", @@ -3049,10 +3031,30 @@ "open": "Storage and sync…", "resync": "Resync now", "resyncing": "Resyncing…", - "resyncHint": "Changes only apply to Skills you enable or update afterwards. Resync now to put enabled Skills back into each app's Skills folder using the current method.", + "resyncHint": "Changes only apply to Skills you enable or update afterwards. To switch Skills that are already enabled, resync now.", "resyncDone": "Resynced Skills to every app using the current method", "resyncPartial": "{{count}} apps didn't fully sync: {{apps}}", - "resyncSkillItem": "{{directory}}: {{error}}" + "resyncSkillItem": "{{directory}}: {{error}}", + "locationTitle": "Keep the master copies in", + "locationCcSwitch": "CC Switch folder", + "locationUnified": "Shared folder", + "openFolder": "Open", + "openFolderAria": "Open {{path}}", + "unifiedWarning": "Codex, Gemini CLI, OpenCode and others read this folder directly, so the per-app switches no longer fully apply to them.", + "move": "Move and switch…", + "moving": "Moving…", + "moveConfirmTitle": "Move {{count}} Skills to {{path}}?", + "moveConfirmBody": "Each master copy is moved, then the links in every app are pointed at the new location. Don't quit CC Switch until the move finishes.", + "moveConfirmButton": "Move and switch", + "moveDone": "Moved {{count}} Skills to {{path}}", + "movePartial": "Moved {{migrated}} Skills, {{errors}} failed. Check the logs for details.", + "syncTitle": "How Skills reach each app", + "syncAuto": "Automatic: symlink first, copy if that fails (recommended)", + "syncSymlink": "Symlink only", + "syncSymlinkDesc": "Requires Developer Mode on Windows", + "syncCopy": "Copy only", + "syncCopyDesc": "Uses more disk space; copied again after each update", + "moveHint": "Switching moves {{count}} Skills to the new location." }, "updatesIncomplete": "No updates found, but {{count}} repositories couldn't be read" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b754d6995..9ade5d262 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -741,25 +741,6 @@ "showProfileSwitcher": "プロジェクト切り替えを表示", "showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します" }, - "skillStorage": { - "title": "スキル保存場所", - "description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します", - "ccSwitch": "CC Switch", - "unified": "~/.agents/skills", - "ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。", - "unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。", - "confirmTitle": "スキル保存場所の移行", - "confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?", - "migrationSuccess": "{{count}} 個のスキルを移行しました", - "migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。" - }, - "skillSync": { - "title": "スキル同期方式", - "description": "スキルファイルの同期方法を選択", - "symlink": "シンボリックリンク", - "copy": "ファイルコピー", - "symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります" - }, "terminal": { "title": "優先ターミナル", "description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択", @@ -1821,7 +1802,8 @@ "matchedVersions": "一致:{{name}} ほか {{more}} 件", "versionLabel": "バージョン", "family": { - "volcengine": "Volcengine" + "volcengine": "Volcengine", + "tencent": "Tencent Cloud" }, "version": { "payg": "従量課金", @@ -3049,10 +3031,30 @@ "open": "保存と同期…", "resync": "今すぐ再同期", "resyncing": "再同期中…", - "resyncHint": "変更はこの後に有効化または更新した Skill にのみ適用されます。「今すぐ再同期」で、有効な Skill を現在の方式で各アプリの Skills フォルダに入れ直します。", + "resyncHint": "変更はこの後に有効化または更新した Skill にのみ適用されます。有効な Skill も現在の方式に切り替えるには「今すぐ再同期」を押してください。", "resyncDone": "現在の方式で Skill を各アプリに再同期しました", "resyncPartial": "{{count}} 個のアプリで同期が完了しませんでした:{{apps}}", - "resyncSkillItem": "{{directory}}:{{error}}" + "resyncSkillItem": "{{directory}}:{{error}}", + "locationTitle": "マスターコピーの保存先", + "locationCcSwitch": "CC Switch フォルダ", + "locationUnified": "共通フォルダ", + "openFolder": "開く", + "openFolderAria": "{{path}} を開く", + "unifiedWarning": "Codex、Gemini CLI、OpenCode などはこのフォルダを直接読むため、アプリごとのスイッチが完全には効かなくなります。", + "move": "移動して切り替え…", + "moving": "移動中…", + "moveConfirmTitle": "{{count}} 個の Skill を {{path}} に移動しますか?", + "moveConfirmBody": "マスターコピーを 1 つずつ移動し、各アプリのリンクを新しい場所に向け直します。移動が終わるまで CC Switch を終了しないでください。", + "moveConfirmButton": "移動して切り替え", + "moveDone": "{{count}} 個の Skill を {{path}} に移動しました", + "movePartial": "{{migrated}} 個の Skill を移動、{{errors}} 個が失敗しました。詳細はログを確認してください。", + "syncTitle": "各アプリへの同期方式", + "syncAuto": "自動:シンボリックリンクを優先し、失敗したらコピー(推奨)", + "syncSymlink": "シンボリックリンクのみ", + "syncSymlinkDesc": "Windows では開発者モードが必要です", + "syncCopy": "コピーのみ", + "syncCopyDesc": "ディスク容量を多く使い、更新のたびにコピーし直します", + "moveHint": "場所を切り替えると {{count}} 個の Skill を移動します。" }, "updatesIncomplete": "更新は見つかりませんでしたが、{{count}} 個のリポジトリを読み込めませんでした" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 69e129ff3..d11ba8b5e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -741,25 +741,6 @@ "showProfileSwitcher": "顯示專案切換", "showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口" }, - "skillStorage": { - "title": "Skills 儲存位置", - "description": "選擇 CC Switch 存放 Skills 主副本的目錄", - "ccSwitch": "CC Switch", - "unified": "~/.agents/skills", - "ccSwitchHint": "技能儲存在 ~/.cc-switch/skills/,由 CC Switch 統一管理並同步至各應用程式。", - "unifiedHint": "技能儲存在 ~/.agents/skills/,遵循 Agent Skills 開放標準。相容的工具(Claude Code、Codex、Gemini CLI 等)可直接發現此目錄中的技能。", - "confirmTitle": "遷移技能儲存位置", - "confirmMessage": "將移動 {{count}} 個技能至新位置,是否繼續?", - "migrationSuccess": "已成功遷移 {{count}} 個技能", - "migrationPartial": "遷移了 {{migrated}} 個技能,{{errors}} 個失敗,請查看日誌" - }, - "skillSync": { - "title": "Skills 同步方式", - "description": "選擇 Skills 的檔案同步策略", - "symlink": "軟連結", - "copy": "檔案複製", - "symlinkHint": "軟連結節省磁碟空間並支援即時同步。注意:Windows 可能需要管理員權限或開啟開發者模式" - }, "terminal": { "title": "首選終端機", "description": "選擇點擊終端機按鈕時使用的終端機應用程式", @@ -1822,7 +1803,8 @@ "matchedVersions": "符合:{{name}} 等 {{n}} 個版本", "versionLabel": "版本", "family": { - "volcengine": "火山引擎" + "volcengine": "火山引擎", + "tencent": "騰訊雲" }, "version": { "payg": "按量付費", @@ -3050,10 +3032,30 @@ "open": "儲存與同步…", "resync": "立即重新同步", "resyncing": "正在重新同步…", - "resyncHint": "修改只對之後啟用或更新的 Skill 生效。點「立即重新同步」,已啟用的 Skill 會按目前方式重新放進各應用的 Skills 目錄。", + "resyncHint": "修改只對之後啟用或更新的 Skill 生效。要讓已啟用的 Skill 也換成目前方式,點「立即重新同步」。", "resyncDone": "已按目前方式把 Skill 重新同步到各應用", "resyncPartial": "{{count}} 個應用沒有同步完整:{{apps}}", - "resyncSkillItem": "{{directory}}:{{error}}" + "resyncSkillItem": "{{directory}}:{{error}}", + "locationTitle": "主副本放在", + "locationCcSwitch": "CC Switch 目錄", + "locationUnified": "通用目錄", + "openFolder": "打開", + "openFolderAria": "打開 {{path}}", + "unifiedWarning": "Codex、Gemini CLI、OpenCode 等會直接讀這個目錄,各應用的開關對它們不再完全生效。", + "move": "移動並切換…", + "moving": "正在移動…", + "moveConfirmTitle": "把 {{count}} 個 Skill 移到 {{path}}?", + "moveConfirmBody": "會逐個移動主副本,再讓各應用裡的連結指向新位置。移動完成前請不要退出 CC Switch。", + "moveConfirmButton": "移動並切換", + "moveDone": "已把 {{count}} 個 Skill 移到 {{path}}", + "movePartial": "移動了 {{migrated}} 個 Skill,{{errors}} 個失敗,請查看日誌", + "syncTitle": "同步到各應用的方式", + "syncAuto": "自動:優先用軟連結,失敗時複製(推薦)", + "syncSymlink": "只用軟連結", + "syncSymlinkDesc": "Windows 需要開啟開發人員模式", + "syncCopy": "只用複製", + "syncCopyDesc": "佔用更多空間,更新後會重新複製", + "moveHint": "切換位置會把 {{count}} 個 Skill 移動過去。" }, "updatesIncomplete": "沒有發現更新,但有 {{count}} 個倉庫沒有讀到" }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a6e5bc981..b6c96ff78 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -741,25 +741,6 @@ "showProfileSwitcher": "显示项目切换", "showProfileSwitcherDescription": "在主页面顶部显示项目切换入口" }, - "skillStorage": { - "title": "Skills 存储位置", - "description": "选择 CC Switch 存放 Skills 主副本的目录", - "ccSwitch": "CC Switch", - "unified": "~/.agents/skills", - "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", - "unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。", - "confirmTitle": "迁移技能存储", - "confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?", - "migrationSuccess": "已成功迁移 {{count}} 个技能", - "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" - }, - "skillSync": { - "title": "Skills 同步方式", - "description": "选择 Skills 的文件同步策略", - "symlink": "软连接", - "copy": "文件复制", - "symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式" - }, "terminal": { "title": "首选终端", "description": "选择点击终端按钮时使用的终端应用", @@ -1821,7 +1802,8 @@ "matchedVersions": "匹配:{{name}} 等 {{n}} 个版本", "versionLabel": "版本", "family": { - "volcengine": "火山引擎" + "volcengine": "火山引擎", + "tencent": "腾讯云" }, "version": { "payg": "按量付费", @@ -3049,10 +3031,30 @@ "open": "存储与同步…", "resync": "立即重新同步", "resyncing": "正在重新同步…", - "resyncHint": "修改只对之后启用或更新的 Skill 生效。点「立即重新同步」,已启用的 Skill 会按当前方式重新放进各应用的 Skills 目录。", + "resyncHint": "修改只对之后启用或更新的 Skill 生效。要让已启用的 Skill 也换成当前方式,点「立即重新同步」。", "resyncDone": "已按当前方式把 Skill 重新同步到各应用", "resyncPartial": "{{count}} 个应用没有同步完整:{{apps}}", - "resyncSkillItem": "{{directory}}:{{error}}" + "resyncSkillItem": "{{directory}}:{{error}}", + "locationTitle": "主副本放在", + "locationCcSwitch": "CC Switch 目录", + "locationUnified": "通用目录", + "openFolder": "打开", + "openFolderAria": "打开 {{path}}", + "unifiedWarning": "Codex、Gemini CLI、OpenCode 等会直接读这个目录,各应用的开关对它们不再完全生效。", + "move": "移动并切换…", + "moving": "正在移动…", + "moveConfirmTitle": "把 {{count}} 个 Skill 移到 {{path}}?", + "moveConfirmBody": "会逐个移动主副本,再让各应用里的链接指向新位置。移动完成前请不要退出 CC Switch。", + "moveConfirmButton": "移动并切换", + "moveDone": "已把 {{count}} 个 Skill 移到 {{path}}", + "movePartial": "移动了 {{migrated}} 个 Skill,{{errors}} 个失败,请查看日志", + "syncTitle": "同步到各应用的方式", + "syncAuto": "自动:优先用软链接,失败时复制(推荐)", + "syncSymlink": "只用软链接", + "syncSymlinkDesc": "Windows 需要开启开发者模式", + "syncCopy": "只用复制", + "syncCopyDesc": "占用更多空间,更新后会重新复制", + "moveHint": "切换位置会把 {{count}} 个 Skill 移动过去。" }, "updatesIncomplete": "没有发现更新,但有 {{count}} 个仓库没有读到" }, diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 4fe997752..fb26802ff 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -268,6 +268,16 @@ export const skillsApi = { return await invoke("migrate_skill_storage", { target }); }, + /** CC Switch 目录下放 Skill 主副本的路径(改过配置目录就是改后的) */ + async getCcSwitchSkillsDir(): Promise { + return await invoke("get_cc_switch_skills_dir"); + }, + + /** 在文件管理器里打开 CC Switch 目录下的 Skills 目录 */ + async openCcSwitchSkillsDir(): Promise { + await invoke("open_cc_switch_skills_dir"); + }, + /** 搜索 skills.sh 公共目录 */ async searchSkillsSh( query: string, diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index ba47665cd..8293b8997 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -88,7 +88,7 @@ const entries: PresetEntry[] = [ }, ]; -// 同一家的三个版本(按预设文件顺序),外加一个单独的预设 +// 同一家的三个版本(按预设文件顺序;显示时按 versionOrder 排),外加一个单独的预设 const kimiEntries: PresetEntry[] = [ { id: "kimi-cn", @@ -152,8 +152,8 @@ describe("preset families", () => { const kimi = rows.find((row) => row.family === "kimi"); expect(kimi?.versions.map((entry) => entry.id)).toEqual([ "kimi-cn", - "kimi-intl", "kimi-coding", + "kimi-intl", ]); }); @@ -164,9 +164,9 @@ describe("preset families", () => { hits, ]); expect(hits("kimi")).toEqual([["family:kimi", []]]); - expect(hits("coding")).toEqual([["family:kimi", [2]]]); + expect(hits("coding")).toEqual([["family:kimi", [1]]]); // 带点的词可以命中某个版本自己的完整域名 - expect(hits("kimi.ai")).toEqual([["family:kimi", [1]]]); + expect(hits("kimi.ai")).toEqual([["family:kimi", [2]]]); // 别名算整家命中 expect(hits("moonshot")).toEqual([["family:kimi", []]]); }); @@ -178,6 +178,32 @@ describe("preset families", () => { expect(groupPresetRows(claudeEntries)).toHaveLength(71); }); + it("orders versions as the design does where a family says so", () => { + const claudeEntries = providerPresets + .filter((item) => !item.hidden) + .map((item, index) => ({ id: `claude-${index}`, preset: item })); + const versionsOf = (family: string) => + groupPresetRows(claudeEntries) + .find((row) => row.family === family) + ?.versions.map((entry) => entry.preset.versionKey); + expect(versionsOf("kimi")).toEqual([ + "paygCn", + "codingCn", + "paygIntl", + "codingIntl", + ]); + expect(versionsOf("tencent")?.slice(2, 4)).toEqual([ + "enterpriseLiteCn", + "enterpriseLiteIntl", + ]); + // 没写 versionOrder 的保持文件顺序 + expect(versionsOf("volcengine")).toEqual([ + "agentPlan", + "codingPlan", + "payg", + ]); + }); + it("has every family name and version label in all four locales", () => { const lookup = (data: unknown, key: string) => key @@ -321,8 +347,8 @@ describe("ProviderPresetSelector", () => { const buttons = within(versions).getAllByRole("button"); expect(buttons.map((button) => button.textContent)).toEqual([ "providerPreset.version.paygCn", - "providerPreset.version.paygIntl", "providerPreset.version.codingCn", + "providerPreset.version.paygIntl", ]); expect(buttons[0]).toHaveAttribute("aria-pressed", "true"); @@ -331,7 +357,7 @@ describe("ProviderPresetSelector", () => { expect( screen.queryByText("providerPreset.switchVersionTitle"), ).not.toBeInTheDocument(); - expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding"); + expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl"); // 换版本留在第 2 步 expect(screen.queryByTestId("host")).not.toBeInTheDocument(); expect( @@ -385,13 +411,13 @@ describe("ProviderPresetSelector", () => { name: "providerPreset.switchVersionConfirm", }), ); - expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl"); + expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding"); expect(screen.getByLabelText("api-key")).toHaveValue(""); await user.click(versionButtons()[2]); expect( screen.queryByText("providerPreset.switchVersionTitle"), ).not.toBeInTheDocument(); - expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding"); + expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl"); }); it("selects the version a search matched", async () => { diff --git a/tests/components/SkillsStorageSheet.test.tsx b/tests/components/SkillsStorageSheet.test.tsx index bb053f62c..59cbff391 100644 --- a/tests/components/SkillsStorageSheet.test.tsx +++ b/tests/components/SkillsStorageSheet.test.tsx @@ -5,7 +5,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SkillsStorageSheet } from "@/components/skills/SkillsStorageSheet"; const m = vi.hoisted(() => ({ + settings: { + skillStorageLocation: "cc_switch", + skillSyncMethod: "auto", + } as Record, + installed: [] as { id: string }[], + updateSettings: vi.fn(), + autoSaveSettings: vi.fn(), resync: vi.fn(), + migrate: vi.fn(), + openDir: vi.fn(), toastSuccess: vi.fn(), toastWarning: vi.fn(), toastError: vi.fn(), @@ -21,34 +30,142 @@ vi.mock("sonner", () => ({ vi.mock("@/hooks/useSettings", () => ({ useSettings: () => ({ - settings: { skillStorageLocation: "cc_switch", skillSyncMethod: "auto" }, - updateSettings: vi.fn(), - autoSaveSettings: vi.fn().mockResolvedValue(undefined), + settings: m.settings, + updateSettings: m.updateSettings, + autoSaveSettings: m.autoSaveSettings, }), })); vi.mock("@/hooks/useSkills", () => ({ - useInstalledSkills: () => ({ data: [] }), + useInstalledSkills: () => ({ data: m.installed }), + useCcSwitchSkillsDir: () => ({ data: "/Users/jason/.cc-switch/skills" }), useResyncSkillsToApps: () => ({ mutateAsync: m.resync, isPending: false }), })); -vi.mock("@/components/settings/SkillStorageLocationSettings", () => ({ - SkillStorageLocationSettings: () =>
, +vi.mock("@/lib/api/skills", () => ({ + skillsApi: { + migrateStorage: m.migrate, + openCcSwitchSkillsDir: m.openDir, + }, })); -vi.mock("@/components/settings/SkillSyncMethodSettings", () => ({ - SkillSyncMethodSettings: () =>
, -})); - -const renderSheet = () => +const renderDialog = () => render(); +const radio = (name: string | RegExp) => screen.getByRole("radio", { name }); + +const moveButton = () => + screen.getByRole("button", { name: /skills\.storageSheet\.move$/ }); + describe("SkillsStorageSheet", () => { beforeEach(() => { - m.resync.mockReset(); - m.toastSuccess.mockReset(); - m.toastWarning.mockReset(); - m.toastError.mockReset(); + m.settings = { skillStorageLocation: "cc_switch", skillSyncMethod: "auto" }; + m.installed = []; + m.autoSaveSettings.mockResolvedValue(undefined); + }); + + it.each(["auto", "symlink", "copy"] as const)( + "shows the backend sync method %s as the checked option", + (method) => { + m.settings = { ...m.settings, skillSyncMethod: method }; + renderDialog(); + + const options = { + auto: radio("skills.storageSheet.syncAuto"), + symlink: radio(/skills\.storageSheet\.syncSymlink/), + copy: radio(/skills\.storageSheet\.syncCopy/), + }; + for (const [id, element] of Object.entries(options)) { + if (id === method) expect(element).toBeChecked(); + else expect(element).not.toBeChecked(); + } + }, + ); + + it("treats a missing sync method as automatic, like the backend default", () => { + m.settings = { skillStorageLocation: "cc_switch" }; + renderDialog(); + + expect(radio("skills.storageSheet.syncAuto")).toBeChecked(); + }); + + it("saves the picked sync method", async () => { + renderDialog(); + + await userEvent.click(radio(/skills\.storageSheet\.syncCopy/)); + + expect(m.updateSettings).toHaveBeenCalledWith({ skillSyncMethod: "copy" }); + expect(m.autoSaveSettings).toHaveBeenCalledWith({ + skillSyncMethod: "copy", + }); + }); + + it("shows the real CC Switch folder and opens it", async () => { + m.openDir.mockResolvedValue(undefined); + renderDialog(); + + expect(screen.getByText("~/.cc-switch/skills")).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { + name: "skills.storageSheet.openFolderAria", + }), + ); + + expect(m.openDir).toHaveBeenCalledTimes(1); + }); + + it("keeps move-and-switch unavailable until another location is picked", async () => { + m.installed = [{ id: "a" }, { id: "b" }]; + renderDialog(); + + expect(radio("skills.storageSheet.locationCcSwitch")).toBeChecked(); + expect(moveButton()).toBeDisabled(); + // 原因是看得见的一行,按钮用 aria-describedby 指向它 + expect(moveButton()).toHaveAccessibleDescription( + "skills.storageSheet.moveHint", + ); + + await userEvent.click(moveButton()); + expect( + screen.queryByText("skills.storageSheet.moveConfirmTitle"), + ).not.toBeInTheDocument(); + expect(m.migrate).not.toHaveBeenCalled(); + }); + + it("confirms before moving Skills to the other location", async () => { + m.installed = [{ id: "a" }, { id: "b" }]; + m.migrate.mockResolvedValue({ + migratedCount: 2, + skippedCount: 0, + errors: [], + }); + renderDialog(); + + await userEvent.click(radio(/skills\.storageSheet\.locationUnified/)); + expect(moveButton()).toBeEnabled(); + await userEvent.click(moveButton()); + + expect( + screen.getByText("skills.storageSheet.moveConfirmTitle"), + ).toBeInTheDocument(); + expect(m.migrate).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { + name: "skills.storageSheet.moveConfirmButton", + }), + ); + + await waitFor(() => expect(m.migrate).toHaveBeenCalledWith("unified")); + await waitFor(() => + expect(m.updateSettings).toHaveBeenCalledWith({ + skillStorageLocation: "unified", + }), + ); + expect(m.toastSuccess).toHaveBeenCalledWith( + "skills.storageSheet.moveDone", + expect.anything(), + ); }); it("resyncs Skills to every app on demand", async () => { @@ -56,7 +173,7 @@ describe("SkillsStorageSheet", () => { { app: "claude", ok: true, failedSkills: [] }, { app: "codex", ok: true, failedSkills: [] }, ]); - renderSheet(); + renderDialog(); expect( screen.getByText("skills.storageSheet.resyncHint"), @@ -87,7 +204,7 @@ describe("SkillsStorageSheet", () => { failedSkills: [], }, ]); - renderSheet(); + renderDialog(); await userEvent.click( screen.getByRole("button", { name: "skills.storageSheet.resync" }), @@ -100,4 +217,13 @@ describe("SkillsStorageSheet", () => { expect(options.description).toContain("Hermes: permission denied"); expect(m.toastSuccess).not.toHaveBeenCalled(); }); + + it("closes from the solid Done button", async () => { + const onOpenChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "common.done" })); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); }); diff --git a/tests/hooks/useTrayNavigation.test.tsx b/tests/hooks/useTrayNavigation.test.tsx index f989e8009..eaacfdf0a 100644 --- a/tests/hooks/useTrayNavigation.test.tsx +++ b/tests/hooks/useTrayNavigation.test.tsx @@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { parseTrayNavigation, + useTrayAppPageSeen, useTrayNavigation, } from "@/hooks/useTrayNavigation"; import { server } from "../msw/server"; @@ -88,3 +89,26 @@ describe("useTrayNavigation", () => { ); }); }); + +describe("useTrayAppPageSeen", () => { + it("reports the app page when shown and when the window regains focus", async () => { + const seen: unknown[] = []; + server.use( + http.post(`${TAURI_ENDPOINT}/tray_app_page_seen`, async ({ request }) => { + seen.push(await request.json()); + return HttpResponse.json(null); + }), + ); + const { rerender } = renderHook( + ({ app }: { app: "codex" | null }) => useTrayAppPageSeen(app), + { initialProps: { app: null as "codex" | null } }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(seen).toEqual([]); + + rerender({ app: "codex" }); + await waitFor(() => expect(seen).toEqual([{ appType: "codex" }])); + window.dispatchEvent(new Event("focus")); + await waitFor(() => expect(seen).toHaveLength(2)); + }); +}); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 2e41f97be..b85bd1880 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -212,6 +212,10 @@ export const handlers = [ // Skills / 用量:新加的只读或重投影命令,测试里默认都成功 http.post(`${TAURI_ENDPOINT}/resync_skills_to_apps`, () => success([])), + http.post(`${TAURI_ENDPOINT}/get_cc_switch_skills_dir`, () => + success("/home/test/.cc-switch/skills"), + ), + http.post(`${TAURI_ENDPOINT}/open_cc_switch_skills_dir`, () => success(true)), http.post(`${TAURI_ENDPOINT}/get_session_usage_last_sync`, () => success(null), ), @@ -422,6 +426,7 @@ export const handlers = [ success([]), ), http.post(`${TAURI_ENDPOINT}/take_tray_navigation`, () => success(null)), + http.post(`${TAURI_ENDPOINT}/tray_app_page_seen`, () => success(null)), http.post(`${TAURI_ENDPOINT}/get_available_providers_for_failover`, () => success([]), ),