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
@@ -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);