+
{/* Import and Export Buttons Side by Side */}
{/* Import Button */}
diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx
index 1d4c48ab7..ef4e2c544 100644
--- a/src/components/settings/WebdavSyncSection.tsx
+++ b/src/components/settings/WebdavSyncSection.tsx
@@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -162,6 +163,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: config?.password ?? "",
remoteRoot: config?.remoteRoot ?? "cc-switch-sync",
profile: config?.profile ?? "default",
+ autoSync: config?.autoSync ?? false,
}));
// Preset selector — derived from initial URL, updated on user selection
@@ -196,6 +198,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: config.password ?? "",
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
profile: config.profile ?? "default",
+ autoSync: config.autoSync ?? false,
});
setPasswordTouched(false);
setPresetId(detectPreset(config.baseUrl ?? ""));
@@ -237,6 +240,16 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
}
}, [form.baseUrl, presetId]);
+ const handleAutoSyncChange = useCallback((checked: boolean) => {
+ setForm((prev) => ({ ...prev, autoSync: checked }));
+ setDirty(true);
+ setJustSaved(false);
+ if (justSavedTimerRef.current) {
+ clearTimeout(justSavedTimerRef.current);
+ justSavedTimerRef.current = null;
+ }
+ }, []);
+
const buildSettings = useCallback((): WebDavSyncSettings | null => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) return null;
@@ -247,6 +260,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: form.password,
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
profile: form.profile.trim() || "default",
+ autoSync: form.autoSync,
};
}, [form]);
@@ -433,6 +447,9 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
const lastSyncDisplay = lastSyncAt
? new Date(lastSyncAt * 1000).toLocaleString()
: null;
+ const lastError = config?.status?.lastError?.trim();
+ const showAutoSyncError =
+ !!lastError && config?.status?.lastErrorSource === "auto";
// ─── Render ─────────────────────────────────────────────
@@ -559,6 +576,23 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
disabled={isLoading}
/>
+
+
+
+ {t("settings.webdavSync.autoSync")}
+
+ {t("settings.webdavSync.autoSyncHint")}
+
+
+
+
+
+
{/* Last sync time */}
@@ -567,6 +601,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
)}
+ {showAutoSyncError && (
+
+
+ {t("settings.webdavSync.autoSyncLastErrorTitle")}
+
+
{lastError}
+
+ {t("settings.webdavSync.autoSyncLastErrorHint")}
+
+
+ )}
{/* Config buttons + save status */}
diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx
index 1968d7793..3fa2c3f5e 100644
--- a/src/components/skills/SkillsPage.tsx
+++ b/src/components/skills/SkillsPage.tsx
@@ -213,14 +213,12 @@ export const SkillsPage = forwardRef(
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
- const description = skill.description?.toLowerCase() || "";
- const directory = skill.directory?.toLowerCase() || "";
+ const repo =
+ skill.repoOwner && skill.repoName
+ ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
+ : "";
- return (
- name.includes(query) ||
- description.includes(query) ||
- directory.includes(query)
- );
+ return name.includes(query) || repo.includes(query);
});
}, [skills, searchQuery, filterRepo, filterStatus]);
diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx
index aa647c70d..17e23a86b 100644
--- a/src/components/skills/UnifiedSkillsPanel.tsx
+++ b/src/components/skills/UnifiedSkillsPanel.tsx
@@ -306,6 +306,7 @@ interface ImportSkillsDialogProps {
name: string;
description?: string;
foundIn: string[];
+ path: string;
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
@@ -362,8 +363,11 @@ const ImportSkillsDialog: React.FC = ({
{skill.description}
)}
-
- {t("skills.foundIn")}: {skill.foundIn.join(", ")}
+
+ {skill.path}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 57093de1c..8b82e3085 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -58,7 +58,10 @@
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
- "saveFailed": "Save failed: {{error}}"
+ "saveFailed": "Save failed: {{error}}",
+ "hideAttribution": "Hide AI Attribution",
+ "alwaysThinking": "Extended Thinking",
+ "enableTeammates": "Teammates Mode"
},
"header": {
"viewOnGithub": "View on GitHub",
@@ -291,6 +294,8 @@
"passwordPlaceholder": "App password",
"remoteRoot": "Remote Root Directory",
"profile": "Sync Profile Name",
+ "autoSync": "Auto Sync",
+ "autoSyncHint": "When enabled, each database change triggers an automatic WebDAV upload.",
"test": "Test Connection",
"testing": "Testing...",
"testSuccess": "Connection successful",
@@ -302,11 +307,14 @@
"uploading": "Uploading...",
"uploadSuccess": "Uploaded to WebDAV",
"uploadFailed": "Upload failed: {{error}}",
+ "autoSyncFailedToast": "Auto sync failed: {{error}}",
"download": "Download from Cloud",
"downloading": "Downloading...",
"downloadSuccess": "Downloaded and restored from WebDAV",
"downloadFailed": "Download failed: {{error}}",
"lastSync": "Last sync: {{time}}",
+ "autoSyncLastErrorTitle": "Last auto sync failed",
+ "autoSyncLastErrorHint": "Please check network or WebDAV settings. Auto sync will retry on future changes.",
"missingUrl": "Please enter the WebDAV server URL",
"presets": {
"label": "Provider",
@@ -1336,7 +1344,7 @@
"skillCount": "{{count}} skills detected"
},
"search": "Search Skills",
- "searchPlaceholder": "Search skill name or description...",
+ "searchPlaceholder": "Search skill name or repo...",
"filter": {
"placeholder": "Filter by status",
"all": "All",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 5a17014d3..6d54131f8 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -58,7 +58,10 @@
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
- "saveFailed": "保存に失敗しました: {{error}}"
+ "saveFailed": "保存に失敗しました: {{error}}",
+ "hideAttribution": "AI署名を非表示",
+ "alwaysThinking": "拡張思考",
+ "enableTeammates": "Teammates モード"
},
"header": {
"viewOnGithub": "GitHub で見る",
@@ -291,6 +294,8 @@
"passwordPlaceholder": "アプリパスワード",
"remoteRoot": "リモートルートディレクトリ",
"profile": "同期プロファイル名",
+ "autoSync": "自動同期",
+ "autoSyncHint": "有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。",
"test": "接続テスト",
"testing": "テスト中...",
"testSuccess": "接続成功",
@@ -302,11 +307,14 @@
"uploading": "アップロード中...",
"uploadSuccess": "WebDAV にアップロードしました",
"uploadFailed": "アップロードに失敗しました:{{error}}",
+ "autoSyncFailedToast": "自動同期に失敗しました:{{error}}",
"download": "クラウドからダウンロード",
"downloading": "ダウンロード中...",
"downloadSuccess": "WebDAV からダウンロード・復元しました",
"downloadFailed": "ダウンロードに失敗しました:{{error}}",
"lastSync": "前回の同期:{{time}}",
+ "autoSyncLastErrorTitle": "前回の自動同期に失敗しました",
+ "autoSyncLastErrorHint": "ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。",
"missingUrl": "WebDAV サーバー URL を入力してください",
"presets": {
"label": "サービス",
@@ -1334,7 +1342,7 @@
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
- "searchPlaceholder": "スキル名または説明で検索...",
+ "searchPlaceholder": "スキル名またはリポジトリで検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 20b04d538..6f3fc6cb2 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -58,7 +58,10 @@
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
- "saveFailed": "保存失败: {{error}}"
+ "saveFailed": "保存失败: {{error}}",
+ "hideAttribution": "隐藏 AI 署名",
+ "alwaysThinking": "扩展思考",
+ "enableTeammates": "Teammates 模式"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
@@ -291,6 +294,8 @@
"passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)",
"remoteRoot": "远程根目录",
"profile": "同步配置名",
+ "autoSync": "自动同步",
+ "autoSyncHint": "开启后每次数据库变更都会自动上传到 WebDAV。",
"test": "测试连接",
"testing": "测试中...",
"testSuccess": "连接成功",
@@ -302,11 +307,14 @@
"uploading": "上传中...",
"uploadSuccess": "已上传到 WebDAV",
"uploadFailed": "上传失败:{{error}}",
+ "autoSyncFailedToast": "自动同步失败:{{error}}",
"download": "从云端下载",
"downloading": "下载中...",
"downloadSuccess": "已从 WebDAV 下载并恢复",
"downloadFailed": "下载失败:{{error}}",
"lastSync": "上次同步:{{time}}",
+ "autoSyncLastErrorTitle": "上次自动同步失败",
+ "autoSyncLastErrorHint": "请检查网络或 WebDAV 配置,系统会在后续变更时继续自动重试。",
"missingUrl": "请填写 WebDAV 服务器地址",
"presets": {
"label": "服务商",
@@ -1336,7 +1344,7 @@
"skillCount": "识别到 {{count}} 个技能"
},
"search": "搜索技能",
- "searchPlaceholder": "搜索技能名称或描述...",
+ "searchPlaceholder": "搜索技能名称或仓库名称...",
"filter": {
"placeholder": "状态筛选",
"all": "全部",
diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts
index 673cf7a34..151090773 100644
--- a/src/lib/api/skills.ts
+++ b/src/lib/api/skills.ts
@@ -45,6 +45,7 @@ export interface UnmanagedSkill {
name: string;
description?: string;
foundIn: string[];
+ path: string;
}
/** 技能对象(兼容旧 API) */
diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts
index d226ef34c..87436c0d6 100644
--- a/src/lib/schemas/settings.ts
+++ b/src/lib/schemas/settings.ts
@@ -33,6 +33,7 @@ export const settingsSchema = z.object({
webdavSync: z
.object({
enabled: z.boolean().optional(),
+ autoSync: z.boolean().optional(),
baseUrl: z.string().trim().optional().or(z.literal("")),
username: z.string().trim().optional().or(z.literal("")),
password: z.string().optional(),
@@ -42,6 +43,7 @@ export const settingsSchema = z.object({
.object({
lastSyncAt: z.number().nullable().optional(),
lastError: z.string().nullable().optional(),
+ lastErrorSource: z.string().nullable().optional(),
lastRemoteEtag: z.string().nullable().optional(),
lastLocalManifestHash: z.string().nullable().optional(),
lastRemoteManifestHash: z.string().nullable().optional(),
diff --git a/src/types.ts b/src/types.ts
index d7e6fac13..d216f8ff7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -170,6 +170,7 @@ export interface VisibleApps {
export interface WebDavSyncStatus {
lastSyncAt?: number | null;
lastError?: string | null;
+ lastErrorSource?: string | null;
lastRemoteEtag?: string | null;
lastLocalManifestHash?: string | null;
lastRemoteManifestHash?: string | null;
@@ -178,6 +179,7 @@ export interface WebDavSyncStatus {
// WebDAV v2 同步配置
export interface WebDavSyncSettings {
enabled?: boolean;
+ autoSync?: boolean;
baseUrl?: string;
username?: string;
password?: string;
diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx
index 27a487a7b..ddffd51ed 100644
--- a/tests/components/WebdavSyncSection.test.tsx
+++ b/tests/components/WebdavSyncSection.test.tsx
@@ -34,6 +34,17 @@ vi.mock("@/components/ui/input", () => ({
Input: (props: any) =>
,
}));
+vi.mock("@/components/ui/switch", () => ({
+ Switch: ({ checked, onCheckedChange, ...props }: any) => (
+
onCheckedChange?.(!checked)}
+ {...props}
+ />
+ ),
+}));
+
vi.mock("@/components/ui/select", () => ({
Select: ({ value, onValueChange, children }: any) => (
{
settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: "downloaded" });
});
+ it("shows auto sync error callout when last auto sync failed", () => {
+ renderSection({
+ ...baseConfig,
+ status: {
+ lastError: "network timeout",
+ lastErrorSource: "auto",
+ },
+ });
+
+ expect(
+ screen.getByText("settings.webdavSync.autoSyncLastErrorTitle"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("network timeout")).toBeInTheDocument();
+ });
+
+ it("does not show auto sync error callout for manual sync errors", () => {
+ renderSection({
+ ...baseConfig,
+ status: {
+ lastError: "manual upload failed",
+ lastErrorSource: "manual",
+ },
+ });
+
+ expect(
+ screen.queryByText("settings.webdavSync.autoSyncLastErrorTitle"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not show auto sync error callout when source is missing", () => {
+ renderSection({
+ ...baseConfig,
+ autoSync: true,
+ status: {
+ lastError: "legacy error without source",
+ },
+ });
+
+ expect(
+ screen.queryByText("settings.webdavSync.autoSyncLastErrorTitle"),
+ ).not.toBeInTheDocument();
+ });
+
it("shows validation error when saving without base url", async () => {
renderSection({ ...baseConfig, baseUrl: "" });
@@ -150,6 +205,7 @@ describe("WebdavSyncSection", () => {
baseUrl: "https://dav.example.com/dav/",
username: "alice",
password: "secret",
+ autoSync: false,
}),
false,
);
@@ -166,6 +222,24 @@ describe("WebdavSyncSection", () => {
);
});
+ it("saves auto sync as true after toggle", async () => {
+ renderSection(baseConfig);
+
+ fireEvent.click(
+ screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
+
+ await waitFor(() => {
+ expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(
+ expect.objectContaining({
+ autoSync: true,
+ }),
+ false,
+ );
+ });
+ });
+
it("blocks upload when there are unsaved changes", async () => {
renderSection(baseConfig);
diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx
index c8f7198ac..14f8439da 100644
--- a/tests/integration/App.test.tsx
+++ b/tests/integration/App.test.tsx
@@ -209,4 +209,25 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
});
+
+ it("shows toast when auto sync fails in background", async () => {
+ const { default: App } = await import("@/App");
+ renderApp(App);
+
+ await waitFor(() =>
+ expect(screen.getByTestId("provider-list").textContent).toContain(
+ "claude-1",
+ ),
+ );
+
+ emitTauriEvent("webdav-sync-status-updated", {
+ source: "auto",
+ status: "error",
+ error: "network timeout",
+ });
+
+ await waitFor(() => {
+ expect(toastErrorMock).toHaveBeenCalled();
+ });
+ });
});