feat: add project profiles for snapshot-based config switching

Add a profile feature that captures the current provider, MCP,
skills and prompt state for Claude Code and Codex as a named
snapshot, and re-applies it in one click from the header switcher
or the tray Projects submenu.

- New profiles table (schema v12) with current marker in settings
- ProfileService orchestrates the four existing switch primitives
  (provider first, then MCP diff, skills diff, prompt enable)
- Best-effort apply: dangling references become warnings, no rollback
- Header combobox switcher + snapshot-style manage dialog
- Tray Projects submenu shared with the UI apply/event pipeline
- i18n for zh/en/ja/zh-TW under the new profiles domain
- Integration tests covering roundtrip, dangling refs and clear
This commit is contained in:
Jason
2026-07-04 16:03:19 +08:00
parent b3e5e32c89
commit 8f018a2d45
22 changed files with 1955 additions and 3 deletions
+9
View File
@@ -58,6 +58,7 @@ import {
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
@@ -381,6 +382,13 @@ function App() {
}
});
// 托盘应用项目后刷新相关缓存(providers 由既有 provider-switched 监听承接)
useTauriEvent("profile-applied", async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
await queryClient.invalidateQueries({ queryKey: ["skills"] });
});
useTauriEvent<SyncStatusUpdatedPayload | null | undefined>(
"webdav-sync-status-updated",
async (payload) => {
@@ -1189,6 +1197,7 @@ function App() {
CC Switch
</a>
</div>
<ProfileSwitcher visibleApps={visibleApps} />
<Button
variant="ghost"
size="icon"
@@ -0,0 +1,211 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {
useDeleteProfileMutation,
useProfilesQuery,
useUpdateProfileMutation,
} from "@/lib/query/profiles";
interface ProfileManageDialogProps {
isOpen: boolean;
onClose: () => void;
}
type PendingConfirm = {
type: "resnapshot" | "delete";
id: string;
name: string;
};
/**
* 项目管理对话框(纯快照式)
*
* 只提供 重命名 / 以当前状态更新快照 / 删除 三个操作;
* 修改项目内容 = 先手动调好配置,再"以当前状态更新快照"。
*/
export function ProfileManageDialog({
isOpen,
onClose,
}: ProfileManageDialogProps) {
const { t } = useTranslation();
const { data } = useProfilesQuery();
const updateMutation = useUpdateProfileMutation();
const deleteMutation = useDeleteProfileMutation();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [confirm, setConfirm] = useState<PendingConfirm | null>(null);
const profiles = data?.profiles ?? [];
const startRename = (id: string, name: string) => {
setEditingId(id);
setEditingName(name);
};
const cancelRename = () => {
setEditingId(null);
setEditingName("");
};
const saveRename = () => {
const name = editingName.trim();
if (!name || !editingId) return;
updateMutation.mutate({ id: editingId, name }, { onSuccess: cancelRename });
};
const handleConfirm = () => {
if (!confirm) return;
if (confirm.type === "delete") {
deleteMutation.mutate(confirm.id);
} else {
updateMutation.mutate({ id: confirm.id, resnapshot: true });
}
setConfirm(null);
};
return (
<>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
cancelRename();
onClose();
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle>{t("profiles.manageTitle")}</DialogTitle>
<DialogDescription>
{t("profiles.manageDescription")}
</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-1 overflow-y-auto px-6 pb-4 pt-3">
{profiles.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("profiles.empty")}
</div>
)}
{profiles.map((profile) => (
<div
key={profile.id}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
{editingId === profile.id ? (
<>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="h-7 flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") cancelRename();
}}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("common.confirm")}
onClick={saveRename}
disabled={!editingName.trim() || updateMutation.isPending}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("common.cancel")}
onClick={cancelRename}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<span className="flex-1 truncate text-sm">
{profile.name}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("profiles.rename")}
onClick={() => startRename(profile.id, profile.name)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("profiles.updateSnapshot")}
onClick={() =>
setConfirm({
type: "resnapshot",
id: profile.id,
name: profile.name,
})
}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("profiles.delete")}
onClick={() =>
setConfirm({
type: "delete",
id: profile.id,
name: profile.name,
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</>
)}
</div>
))}
</div>
</DialogContent>
</Dialog>
{confirm && (
<ConfirmDialog
isOpen
title={
confirm.type === "delete"
? t("profiles.deleteConfirmTitle")
: t("profiles.updateSnapshot")
}
message={
confirm.type === "delete"
? t("profiles.deleteConfirmMessage", { name: confirm.name })
: t("profiles.updateSnapshotConfirm", { name: confirm.name })
}
variant={confirm.type === "delete" ? "destructive" : "info"}
onConfirm={handleConfirm}
onCancel={() => setConfirm(null)}
/>
)}
</>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronsUpDown,
FolderCog,
FolderOpen,
Plus,
X,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { VisibleApps } from "@/types";
import {
useApplyProfileMutation,
useClearProfileMutation,
useCreateProfileMutation,
useProfilesQuery,
} from "@/lib/query/profiles";
import { ProfileManageDialog } from "./ProfileManageDialog";
interface ProfileSwitcherProps {
visibleApps: VisibleApps;
}
/**
* 项目 Profile 切换器(header 左侧全局入口)
*
* Profile 是跨应用的配置快照(Claude Code + Codex 的供应商/MCP/Skills/记忆文件),
* 与右侧 AppSwitcher(仅切换查看的应用)语义不同。
*/
export function ProfileSwitcher({ visibleApps }: ProfileSwitcherProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isManageOpen, setIsManageOpen] = useState(false);
const [newName, setNewName] = useState("");
const { data } = useProfilesQuery();
const applyMutation = useApplyProfileMutation();
const clearMutation = useClearProfileMutation();
const createMutation = useCreateProfileMutation();
// Profile 仅支持 Claude Code + Codex,两者都被隐藏时该功能无意义
if (!visibleApps.claude && !visibleApps.codex) {
return null;
}
const profiles = data?.profiles ?? [];
const currentId = data?.currentId ?? null;
const currentProfile = profiles.find((p) => p.id === currentId);
const handleApply = (id: string) => {
setOpen(false);
if (id !== currentId) {
applyMutation.mutate(id);
}
};
const closeCreateDialog = () => {
setIsCreateOpen(false);
setNewName("");
};
const handleCreate = () => {
const name = newName.trim();
if (!name) return;
createMutation.mutate(name, { onSuccess: closeCreateDialog });
};
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
title={t("profiles.switcherTooltip")}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium transition-colors",
"hover:bg-black/5 dark:hover:bg-white/5",
currentProfile ? "text-foreground" : "text-muted-foreground",
)}
>
<FolderOpen className="h-4 w-4 shrink-0 opacity-70" />
<span className="max-w-[9rem] truncate">
{currentProfile?.name ?? t("profiles.none")}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={6}
className="z-[100] w-64 p-0"
>
<Command>
<CommandInput placeholder={t("profiles.searchPlaceholder")} />
<CommandList>
<CommandEmpty>{t("profiles.empty")}</CommandEmpty>
{profiles.length > 0 && (
<CommandGroup>
{profiles.map((profile) => (
<CommandItem
key={profile.id}
value={profile.id}
keywords={[profile.name]}
onSelect={() => handleApply(profile.id)}
>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
currentId === profile.id
? "opacity-100"
: "opacity-0",
)}
/>
<span className="truncate">{profile.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
<div className="mx-1 my-1 h-px bg-border" />
<CommandGroup>
<CommandItem
value="__create__"
keywords={[t("profiles.createFromCurrent")]}
onSelect={() => {
setOpen(false);
setIsCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4 shrink-0" />
{t("profiles.createFromCurrent")}
</CommandItem>
{currentId && (
<CommandItem
value="__clear__"
keywords={[t("profiles.none")]}
onSelect={() => {
setOpen(false);
clearMutation.mutate();
}}
>
<X className="mr-2 h-4 w-4 shrink-0" />
{t("profiles.none")}
</CommandItem>
)}
{profiles.length > 0 && (
<CommandItem
value="__manage__"
keywords={[t("profiles.manage")]}
onSelect={() => {
setOpen(false);
setIsManageOpen(true);
}}
>
<FolderCog className="mr-2 h-4 w-4 shrink-0" />
{t("profiles.manage")}
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open) closeCreateDialog();
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle>{t("profiles.createFromCurrent")}</DialogTitle>
<DialogDescription>
{t("profiles.createDescription")}
</DialogDescription>
</DialogHeader>
<div className="px-6 pt-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("profiles.namePlaceholder")}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={closeCreateDialog}>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProfileManageDialog
isOpen={isManageOpen}
onClose={() => setIsManageOpen(false)}
/>
</>
);
}
+4
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import { ConfirmDialog } from "../ConfirmDialog";
@@ -59,6 +60,9 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
};
}, [appId, reload]);
// 应用项目 Profile 会切换激活的 promptprompts 非 react-query,需主动 reload
useTauriEvent("profile-applied", reload);
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
+28
View File
@@ -1866,6 +1866,34 @@
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
}
},
"profiles": {
"switcherTooltip": "Projects: switch providers / MCP / Skills / memory files for Claude Code and Codex in one click",
"none": "No project",
"searchPlaceholder": "Search projects",
"empty": "No projects yet",
"createFromCurrent": "New project",
"createDescription": "Save the current provider, MCP, Skills and memory file configuration as a project you can switch to later.",
"manage": "Manage projects…",
"manageTitle": "Manage Projects",
"manageDescription": "Projects are configuration snapshots. To change a project, adjust your configuration first, then click \"Update from current\".",
"namePlaceholder": "e.g. Development, Drawing",
"rename": "Rename",
"updateSnapshot": "Update from current",
"updateSnapshotConfirm": "Overwrite the snapshot of project \"{{name}}\" with the current configuration?",
"delete": "Delete",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmMessage": "Are you sure you want to delete project \"{{name}}\"? Your current configuration will not be changed.",
"createSuccess": "Project created",
"createFailed": "Failed to create project: {{detail}}",
"updateSuccess": "Project updated",
"updateFailed": "Failed to update project: {{detail}}",
"deleteSuccess": "Project deleted",
"deleteFailed": "Failed to delete project: {{detail}}",
"applySuccess": "Project applied",
"applyFailed": "Failed to apply project: {{detail}}",
"applyWarnings": "Project applied with {{warningCount}} warning(s):\n{{details}}",
"clearSuccess": "Switched to no project"
},
"workspace": {
"title": "Workspace Files",
"manage": "Workspace",
+28
View File
@@ -1866,6 +1866,34 @@
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"profiles": {
"switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え",
"none": "プロジェクトを使用しない",
"searchPlaceholder": "プロジェクトを検索",
"empty": "プロジェクトはまだありません",
"createFromCurrent": "新規プロジェクト",
"createDescription": "現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。",
"manage": "プロジェクトを管理…",
"manageTitle": "プロジェクト管理",
"manageDescription": "プロジェクトは設定のスナップショットです。内容を変更するには、先に設定を調整してから「現在の状態で更新」をクリックしてください。",
"namePlaceholder": "例:開発、イラスト",
"rename": "名前を変更",
"updateSnapshot": "現在の状態で更新",
"updateSnapshotConfirm": "プロジェクト「{{name}}」のスナップショットを現在の設定で上書きしますか?",
"delete": "削除",
"deleteConfirmTitle": "削除の確認",
"deleteConfirmMessage": "プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。",
"createSuccess": "プロジェクトを作成しました",
"createFailed": "プロジェクトの作成に失敗しました: {{detail}}",
"updateSuccess": "プロジェクトを更新しました",
"updateFailed": "プロジェクトの更新に失敗しました: {{detail}}",
"deleteSuccess": "プロジェクトを削除しました",
"deleteFailed": "プロジェクトの削除に失敗しました: {{detail}}",
"applySuccess": "プロジェクトを適用しました",
"applyFailed": "プロジェクトの適用に失敗しました: {{detail}}",
"applyWarnings": "プロジェクトを適用しました(警告 {{warningCount}} 件):\n{{details}}",
"clearSuccess": "プロジェクトを使用しない状態に切り替えました"
},
"workspace": {
"title": "ワークスペースファイル",
"manage": "ワークスペース",
+28
View File
@@ -1838,6 +1838,34 @@
"deleteMessage": "確定要刪除提示詞 \"{{name}}\" 嗎?"
}
},
"profiles": {
"switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案",
"none": "不使用專案",
"searchPlaceholder": "搜尋專案",
"empty": "尚無專案",
"createFromCurrent": "新建專案",
"createDescription": "把目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。",
"manage": "管理專案…",
"manageTitle": "管理專案",
"manageDescription": "專案是設定快照。要修改專案內容,先手動調好設定,再點「以目前狀態更新」。",
"namePlaceholder": "例如:開發、繪圖",
"rename": "重新命名",
"updateSnapshot": "以目前狀態更新",
"updateSnapshotConfirm": "用目前的設定狀態覆蓋專案 \"{{name}}\" 的快照?",
"delete": "刪除",
"deleteConfirmTitle": "確認刪除",
"deleteConfirmMessage": "確定要刪除專案 \"{{name}}\" 嗎?不會改動任何現有設定。",
"createSuccess": "專案已建立",
"createFailed": "建立專案失敗: {{detail}}",
"updateSuccess": "專案已更新",
"updateFailed": "更新專案失敗: {{detail}}",
"deleteSuccess": "專案已刪除",
"deleteFailed": "刪除專案失敗: {{detail}}",
"applySuccess": "專案已套用",
"applyFailed": "套用專案失敗: {{detail}}",
"applyWarnings": "專案已套用,{{warningCount}} 條警告:\n{{details}}",
"clearSuccess": "已切換為不使用專案"
},
"workspace": {
"title": "Workspace 檔案管理",
"manage": "Workspace",
+28
View File
@@ -1866,6 +1866,34 @@
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
}
},
"profiles": {
"switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件",
"none": "不使用项目",
"searchPlaceholder": "搜索项目",
"empty": "暂无项目",
"createFromCurrent": "新建项目",
"createDescription": "把当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。",
"manage": "管理项目…",
"manageTitle": "管理项目",
"manageDescription": "项目是配置快照。要修改项目内容,先手动调好配置,再点\"以当前状态更新\"。",
"namePlaceholder": "例如:开发、绘图",
"rename": "重命名",
"updateSnapshot": "以当前状态更新",
"updateSnapshotConfirm": "用当前的配置状态覆盖项目 \"{{name}}\" 的快照?",
"delete": "删除",
"deleteConfirmTitle": "确认删除",
"deleteConfirmMessage": "确定要删除项目 \"{{name}}\" 吗?不会改动任何现有配置。",
"createSuccess": "项目已创建",
"createFailed": "创建项目失败: {{detail}}",
"updateSuccess": "项目已更新",
"updateFailed": "更新项目失败: {{detail}}",
"deleteSuccess": "项目已删除",
"deleteFailed": "删除项目失败: {{detail}}",
"applySuccess": "项目已应用",
"applyFailed": "应用项目失败: {{detail}}",
"applyWarnings": "项目已应用,{{warningCount}} 条警告:\n{{details}}",
"clearSuccess": "已切换为不使用项目"
},
"workspace": {
"title": "Workspace 文件管理",
"manage": "Workspace",
+2
View File
@@ -3,6 +3,7 @@ export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { backupsApi } from "./settings";
export { mcpApi } from "./mcp";
export { profilesApi } from "./profiles";
export { promptsApi } from "./prompts";
export { skillsApi } from "./skills";
export { usageApi } from "./usage";
@@ -17,6 +18,7 @@ export * as authApi from "./auth";
export * as copilotApi from "./copilot";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
export type { Profile, ProfilePayload, ProfilesResponse } from "./profiles";
export type {
CopilotDeviceCodeResponse,
CopilotAuthStatus,
+83
View File
@@ -0,0 +1,83 @@
import { invoke } from "@tauri-apps/api/core";
/**
* 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp<T> 严格对应)
*/
export interface PerApp<T> {
claude: T;
codex: T;
}
/**
* 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应)
*/
export interface ProfilePayload {
providers: PerApp<string | null>;
mcp: PerApp<string[]>;
skills: PerApp<string[]>;
prompts: PerApp<string | null>;
}
export interface Profile {
id: string;
name: string;
payload: ProfilePayload;
createdAt?: number;
updatedAt?: number;
}
export interface ProfilesResponse {
profiles: Profile[];
currentId: string | null;
}
export const profilesApi = {
/**
* 获取所有项目及当前激活项目 id
*/
async list(): Promise<ProfilesResponse> {
return await invoke("list_profiles");
},
/**
* 以当前配置状态创建新项目
*/
async create(name: string): Promise<Profile> {
return await invoke("create_profile", { name });
},
/**
* 更新项目(重命名和/或以当前状态重拍快照)
*/
async update(
id: string,
options: { name?: string; resnapshot?: boolean },
): Promise<Profile> {
return await invoke("update_profile", {
id,
name: options.name,
resnapshot: options.resnapshot,
});
},
/**
* 删除项目
*/
async delete(id: string): Promise<void> {
return await invoke("delete_profile", { id });
},
/**
* 应用项目快照,返回 warningsbest-effort,部分失败不中断)
*/
async apply(id: string): Promise<string[]> {
return await invoke("apply_profile", { id });
},
/**
* 不使用项目:仅清除激活标记,不改动任何配置
*/
async clearCurrent(): Promise<void> {
return await invoke("clear_current_profile");
},
};
+145
View File
@@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { profilesApi, providersApi } from "@/lib/api";
import { extractErrorMessage } from "@/utils/errorUtils";
const updateTrayMenuSafely = async () => {
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
console.error("Failed to update tray menu after profile change", trayError);
}
};
export const useProfilesQuery = () => {
return useQuery({
queryKey: ["profiles"],
queryFn: () => profilesApi.list(),
});
};
export const useCreateProfileMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (name: string) => profilesApi.create(name),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
toast.success(t("profiles.createSuccess"), { closeButton: true });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(t("profiles.createFailed", { detail }), {
closeButton: true,
});
},
});
};
export const useUpdateProfileMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({
id,
name,
resnapshot,
}: {
id: string;
name?: string;
resnapshot?: boolean;
}) => profilesApi.update(id, { name, resnapshot }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
toast.success(t("profiles.updateSuccess"), { closeButton: true });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(t("profiles.updateFailed", { detail }), {
closeButton: true,
});
},
});
};
export const useDeleteProfileMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (id: string) => profilesApi.delete(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
toast.success(t("profiles.deleteSuccess"), { closeButton: true });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(t("profiles.deleteFailed", { detail }), {
closeButton: true,
});
},
});
};
export const useClearProfileMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: () => profilesApi.clearCurrent(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
toast.success(t("profiles.clearSuccess"), { closeButton: true });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(t("profiles.applyFailed", { detail }), {
closeButton: true,
});
},
});
};
export const useApplyProfileMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (id: string) => profilesApi.apply(id),
onSuccess: async (warnings) => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await queryClient.invalidateQueries({
queryKey: ["providers", "claude"],
});
await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] });
await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
await queryClient.invalidateQueries({ queryKey: ["skills"] });
await updateTrayMenuSafely();
if (warnings.length > 0) {
toast.warning(
t("profiles.applyWarnings", {
warningCount: warnings.length,
details: warnings.join("\n"),
}),
{ closeButton: true, duration: 10000 },
);
} else {
toast.success(t("profiles.applySuccess"), { closeButton: true });
}
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(t("profiles.applyFailed", { detail }), {
closeButton: true,
});
},
});
};