mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
feat(ui): follow the spec for preset versions, tray problems and skills storage
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.
This commit is contained in:
1 parent
e9170adb65
commit
b7a5ef2885
20 files changed
+727
-413
No files matched your search
@@ -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<SkillService>);
|
||||
@@ -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<String, String> {
|
||||
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<bool, String> {
|
||||
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::<String>)
|
||||
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 搜索 skills.sh 公共目录
|
||||
#[tauri::command]
|
||||
pub async fn search_skills_sh(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24
-10
@@ -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<Mutex<Vec<(AppType, bool)>>> = Lazy::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
/// 托盘里切换失败的应用。规格 5.5:处理掉才消失(同一应用之后切换成功、或打开过它的页面),
|
||||
/// 不按时间过期。
|
||||
struct SwitchFailure {
|
||||
app: AppType,
|
||||
reason: String,
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
static SWITCH_FAILURES: Lazy<Mutex<Vec<SwitchFailure>>> = 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::<AppType>() 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());
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<SkillStorageLocation | null>(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 (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.skillStorage.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-2">
|
||||
{t("settings.skillStorage.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="inline-flex gap-1 rounded-md border border-border bg-surface p-1">
|
||||
<StorageButton
|
||||
active={value === "cc_switch"}
|
||||
disabled={isMigrating}
|
||||
onClick={() => handleSelect("cc_switch")}
|
||||
>
|
||||
{t("settings.skillStorage.ccSwitch")}
|
||||
</StorageButton>
|
||||
<StorageButton
|
||||
active={value === "unified"}
|
||||
disabled={isMigrating}
|
||||
onClick={() => handleSelect("unified")}
|
||||
>
|
||||
{isMigrating && value !== "unified" ? (
|
||||
<Loader2 size={14} className="mr-1 animate-spin" />
|
||||
) : null}
|
||||
{t("settings.skillStorage.unified")}
|
||||
</StorageButton>
|
||||
</div>
|
||||
<p className="text-xs text-fg-2">
|
||||
{value === "unified"
|
||||
? t("settings.skillStorage.unifiedHint")
|
||||
: t("settings.skillStorage.ccSwitchHint")}
|
||||
</p>
|
||||
|
||||
{/* 迁移确认对话框 */}
|
||||
<Dialog
|
||||
open={pendingTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" zIndex="alert">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.skillStorage.confirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.skillStorage.confirmMessage", {
|
||||
count: installedCount,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingTarget(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => pendingTarget && doMigrate(pendingTarget)}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StorageButtonProps {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function StorageButton({
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: StorageButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"min-w-[96px]",
|
||||
active ? "shadow-sm" : "text-fg-2 hover:text-fg-1 hover:bg-subtle",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
|
||||
<p className="text-xs text-fg-2">
|
||||
{t("settings.skillSync.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="inline-flex gap-1 rounded-md border border-border bg-surface p-1">
|
||||
<SyncMethodButton
|
||||
active={displayValue === "symlink"}
|
||||
onClick={() => onChange("symlink")}
|
||||
>
|
||||
{t("settings.skillSync.symlink")}
|
||||
</SyncMethodButton>
|
||||
<SyncMethodButton
|
||||
active={displayValue === "copy"}
|
||||
onClick={() => onChange("copy")}
|
||||
>
|
||||
{t("settings.skillSync.copy")}
|
||||
</SyncMethodButton>
|
||||
</div>
|
||||
{displayValue === "symlink" && (
|
||||
<p className="text-xs text-fg-2">
|
||||
{t("settings.skillSync.symlinkHint")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SyncMethodButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SyncMethodButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: SyncMethodButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"min-w-[96px]",
|
||||
active ? "shadow-sm" : "text-fg-2 hover:text-fg-1 hover:bg-subtle",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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<SkillStorageLocation>(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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent width={480} closeLabel={t("common.close")}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("skills.storageSheet.title")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetBody className="space-y-6">
|
||||
{settings && (
|
||||
<>
|
||||
<SkillStorageLocationSettings
|
||||
value={settings.skillStorageLocation ?? "cc_switch"}
|
||||
installedCount={installedSkills?.length ?? 0}
|
||||
onMigrated={(location) =>
|
||||
updateSettings({ skillStorageLocation: location })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<SkillSyncMethodSettings
|
||||
value={settings.skillSyncMethod ?? "auto"}
|
||||
onChange={(method) => {
|
||||
updateSettings({ skillSyncMethod: method });
|
||||
void autoSaveSettings({ skillSyncMethod: method }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="m-0 text-caption text-fg-2">
|
||||
{t("skills.storageSheet.resyncHint")}
|
||||
<>
|
||||
<V7Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && isMigrating) return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
width={520}
|
||||
>
|
||||
<DialogTitle>{t("skills.storageSheet.title")}</DialogTitle>
|
||||
|
||||
{settings && (
|
||||
<>
|
||||
<fieldset className="m-0 flex min-w-0 flex-col gap-2.5 border-0 p-0">
|
||||
<legend className={LEGEND_CLASS}>
|
||||
{t("skills.storageSheet.locationTitle")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className={RADIO_LABEL_CLASS}>
|
||||
<input
|
||||
type="radio"
|
||||
name="skills-storage-location"
|
||||
className={CHECKBOX_CLASS}
|
||||
checked={pendingLocation === "cc_switch"}
|
||||
disabled={isMigrating}
|
||||
aria-describedby="skills-storage-cc-path"
|
||||
onChange={() => setPendingLocation("cc_switch")}
|
||||
/>
|
||||
{t("skills.storageSheet.locationCcSwitch")}
|
||||
</label>
|
||||
<div className="flex min-w-0 items-center gap-2 pl-[26px]">
|
||||
<code
|
||||
id="skills-storage-cc-path"
|
||||
className="min-w-0 truncate font-mono text-caption text-fg-2"
|
||||
title={ccSwitchDirRaw ?? ccSwitchDir}
|
||||
>
|
||||
{ccSwitchDir}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("skills.storageSheet.openFolderAria", {
|
||||
path: ccSwitchDir,
|
||||
})}
|
||||
onClick={() => void handleOpenFolder()}
|
||||
className="shrink-0 whitespace-nowrap text-caption font-medium text-fg-1 underline decoration-border-strong underline-offset-[3px] hover:decoration-fg-1"
|
||||
>
|
||||
{t("skills.storageSheet.openFolder")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className={RADIO_LABEL_CLASS}>
|
||||
<input
|
||||
type="radio"
|
||||
name="skills-storage-location"
|
||||
className={CHECKBOX_CLASS}
|
||||
checked={pendingLocation === "unified"}
|
||||
disabled={isMigrating}
|
||||
aria-describedby="skills-storage-unified-warn"
|
||||
onChange={() => setPendingLocation("unified")}
|
||||
/>
|
||||
<span>
|
||||
{t("skills.storageSheet.locationUnified")}{" "}
|
||||
<code className="whitespace-nowrap font-mono text-caption">
|
||||
{UNIFIED_DIR}
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
id="skills-storage-unified-warn"
|
||||
className="m-0 pl-[26px] text-caption text-warning-text"
|
||||
>
|
||||
{t("skills.storageSheet.unifiedWarning")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 pt-0.5">
|
||||
<p
|
||||
id="skills-storage-move-hint"
|
||||
className="m-0 min-w-0 text-caption text-fg-2"
|
||||
>
|
||||
{t("skills.storageSheet.moveHint", { count: installedCount })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="regular"
|
||||
className="shrink-0"
|
||||
disabled={isMigrating || locationUnchanged}
|
||||
aria-describedby="skills-storage-move-hint"
|
||||
onClick={handleMove}
|
||||
>
|
||||
{isMigrating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isMigrating
|
||||
? t("skills.storageSheet.moving")
|
||||
: t("skills.storageSheet.move")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<fieldset className="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
|
||||
<legend className={LEGEND_CLASS}>
|
||||
{t("skills.storageSheet.syncTitle")}
|
||||
</legend>
|
||||
{SYNC_METHODS.map((option) => (
|
||||
<label key={option.id} className={RADIO_LABEL_CLASS}>
|
||||
<input
|
||||
type="radio"
|
||||
name="skills-sync-method"
|
||||
value={option.id}
|
||||
className={CHECKBOX_CLASS}
|
||||
checked={syncMethod === option.id}
|
||||
onChange={() => handleSyncMethod(option.id)}
|
||||
/>
|
||||
<span>
|
||||
{t(option.labelKey)}
|
||||
{option.descKey && (
|
||||
<span className="ml-2 text-caption text-fg-2">
|
||||
{t(option.descKey)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="flex items-center gap-3 pt-0.5">
|
||||
<p className="m-0 min-w-0 flex-1 text-caption text-fg-2">
|
||||
{t("skills.storageSheet.resyncHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="compact"
|
||||
className="shrink-0"
|
||||
disabled={resyncMutation.isPending}
|
||||
onClick={() => void handleResync()}
|
||||
>
|
||||
{resyncMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" strokeWidth={2} />
|
||||
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
)}
|
||||
{resyncMutation.isPending
|
||||
? t("skills.storageSheet.resyncing")
|
||||
: t("skills.storageSheet.resync")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
size="regular"
|
||||
autoFocus
|
||||
disabled={isMigrating}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</V7Dialog>
|
||||
|
||||
<V7ConfirmDialog
|
||||
open={confirmMove}
|
||||
danger={false}
|
||||
title={t("skills.storageSheet.moveConfirmTitle", {
|
||||
count: installedCount,
|
||||
path: targetDir,
|
||||
})}
|
||||
body={t("skills.storageSheet.moveConfirmBody")}
|
||||
confirmLabel={t("skills.storageSheet.moveConfirmButton")}
|
||||
pending={isMigrating}
|
||||
onConfirm={() => void doMigrate(pendingLocation)}
|
||||
onCancel={() => setConfirmMove(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
+24
-22
@@ -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"
|
||||
},
|
||||
|
||||
+24
-22
@@ -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}} 個のリポジトリを読み込めませんでした"
|
||||
},
|
||||
|
||||
+24
-22
@@ -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}} 個倉庫沒有讀到"
|
||||
},
|
||||
|
||||
+24
-22
@@ -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}} 个仓库没有读到"
|
||||
},
|
||||
|
||||
@@ -268,6 +268,16 @@ export const skillsApi = {
|
||||
return await invoke("migrate_skill_storage", { target });
|
||||
},
|
||||
|
||||
/** CC Switch 目录下放 Skill 主副本的路径(改过配置目录就是改后的) */
|
||||
async getCcSwitchSkillsDir(): Promise<string> {
|
||||
return await invoke("get_cc_switch_skills_dir");
|
||||
},
|
||||
|
||||
/** 在文件管理器里打开 CC Switch 目录下的 Skills 目录 */
|
||||
async openCcSwitchSkillsDir(): Promise<void> {
|
||||
await invoke("open_cc_switch_skills_dir");
|
||||
},
|
||||
|
||||
/** 搜索 skills.sh 公共目录 */
|
||||
async searchSkillsSh(
|
||||
query: string,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string | undefined>,
|
||||
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: () => <div data-testid="location" />,
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: {
|
||||
migrateStorage: m.migrate,
|
||||
openCcSwitchSkillsDir: m.openDir,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/SkillSyncMethodSettings", () => ({
|
||||
SkillSyncMethodSettings: () => <div data-testid="sync-method" />,
|
||||
}));
|
||||
|
||||
const renderSheet = () =>
|
||||
const renderDialog = () =>
|
||||
render(<SkillsStorageSheet open onOpenChange={vi.fn()} />);
|
||||
|
||||
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(<SkillsStorageSheet open onOpenChange={onOpenChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "common.done" }));
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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([]),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user