refactor(profiles): shared project entity with per-scope switching

Projects are now global shared entities; Claude and Codex groups switch
independently via scoped current pointers and scoped payload slots.

- Remove scope column from profiles; keep current_profile_id_<scope>
- Use Option<Vec<String>> for mcp/skills to distinguish 'never captured'
  from 'captured empty', preventing cross-side accidental disable
- Update/apply operations scoped to the active group via merge_scope_from
- Tray menu nests same shared list under Claude Code/Codex groups
- Add i18n for per-scope tooltips and 'not saved for this side' hint

Refs: profile P1 shared-entity redesign
This commit is contained in:
Jason
2026-07-04 21:34:30 +08:00
parent 65a5464fcc
commit dbb5999d1e
17 changed files with 831 additions and 190 deletions
@@ -16,9 +16,12 @@ import {
useProfilesQuery,
useUpdateProfileMutation,
} from "@/lib/query/profiles";
import type { ProfileScope } from "@/lib/api/profiles";
import { PROFILE_SCOPE_LABELS } from "./scope";
interface ProfileManageDialogProps {
isOpen: boolean;
scope: ProfileScope;
onClose: () => void;
}
@@ -31,11 +34,12 @@ type PendingConfirm = {
/**
* 项目管理对话框(纯快照式)
*
* 只提供 重命名 / 以当前状态更新快照 / 删除 三个操作
* 修改项目内容 = 先手动调好配置,再"以当前状态更新快照"。
* 项目列表全应用共享,重命名/删除作用于共享实体
* "以当前状态更新快照"只覆盖发起页所属分组(scope)的槽位
*/
export function ProfileManageDialog({
isOpen,
scope,
onClose,
}: ProfileManageDialogProps) {
const { t } = useTranslation();
@@ -70,7 +74,7 @@ export function ProfileManageDialog({
if (confirm.type === "delete") {
deleteMutation.mutate(confirm.id);
} else {
updateMutation.mutate({ id: confirm.id, resnapshot: true });
updateMutation.mutate({ id: confirm.id, resnapshot: true, scope });
}
setConfirm(null);
};
@@ -199,7 +203,10 @@ export function ProfileManageDialog({
message={
confirm.type === "delete"
? t("profiles.deleteConfirmMessage", { name: confirm.name })
: t("profiles.updateSnapshotConfirm", { name: confirm.name })
: t("profiles.updateSnapshotConfirm", {
name: confirm.name,
group: PROFILE_SCOPE_LABELS[scope],
})
}
variant={confirm.type === "delete" ? "destructive" : "info"}
onConfirm={handleConfirm}
+20 -13
View File
@@ -40,9 +40,7 @@ import {
useProfilesQuery,
} from "@/lib/query/profiles";
import { ProfileManageDialog } from "./ProfileManageDialog";
/** 后端 services/profile.rs 的 PROFILE_APPS 前端镜像,扩展支持范围时两处同步 */
const PROFILE_SUPPORTED_APPS: AppId[] = ["claude", "claude-desktop", "codex"];
import { APP_PROFILE_SCOPE, hasScopeSnapshot } from "./scope";
interface ProfileSwitcherProps {
activeApp: AppId;
@@ -51,8 +49,10 @@ interface ProfileSwitcherProps {
/**
* 项目 Profile 切换器(header 左侧入口)
*
* Profile 是跨应用的配置快照(Claude Code + Codex 的供应商/MCP/Skills/记忆文件,
* 以及 Claude Desktop 的供应商),与右侧 AppSwitcher(仅切换查看的应用)语义不同。
* 项目列表全应用共享(用户拥有的项目就那几个),但切换按分组进行:
* Claude 组(Claude Code 的供应商/MCP/Skills/记忆文件 + Claude Desktop
* 的供应商)与 Codex 组各自指向自己的当前项目、只应用组内快照。
* 与右侧 AppSwitcher(仅切换查看的应用)语义不同。
*/
export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
const { t } = useTranslation();
@@ -67,19 +67,20 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
const createMutation = useCreateProfileMutation();
// Profile 仅作用于受支持的应用——在其他应用的标签页展示会误导用户
// 以为当前应用也被切换了,因此只在受支持应用页面渲染
if (!PROFILE_SUPPORTED_APPS.includes(activeApp)) {
// 以为当前应用也被切换了,因此只在有所属分组的应用页面渲染
const scope = APP_PROFILE_SCOPE[activeApp];
if (!scope) {
return null;
}
const profiles = data?.profiles ?? [];
const currentId = data?.currentId ?? null;
const currentId = data?.currentIds?.[scope] ?? null;
const currentProfile = profiles.find((p) => p.id === currentId);
const handleApply = (id: string) => {
setOpen(false);
if (id !== currentId) {
applyMutation.mutate(id);
applyMutation.mutate({ id, scope });
}
};
@@ -91,7 +92,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
const handleCreate = () => {
const name = newName.trim();
if (!name) return;
createMutation.mutate(name, { onSuccess: closeCreateDialog });
createMutation.mutate({ name, scope }, { onSuccess: closeCreateDialog });
};
return (
@@ -102,7 +103,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
type="button"
role="combobox"
aria-expanded={open}
title={t("profiles.switcherTooltip")}
title={t(`profiles.switcherTooltip.${scope}`)}
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",
@@ -144,6 +145,11 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
)}
/>
<span className="truncate">{profile.name}</span>
{!hasScopeSnapshot(profile, scope) && (
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">
{t("profiles.noSnapshotForScope")}
</span>
)}
</CommandItem>
))}
</CommandGroup>
@@ -167,7 +173,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
keywords={[t("profiles.none")]}
onSelect={() => {
setOpen(false);
clearMutation.mutate();
clearMutation.mutate(scope);
}}
>
<X className="mr-2 h-4 w-4 shrink-0" />
@@ -203,7 +209,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle>{t("profiles.createFromCurrent")}</DialogTitle>
<DialogDescription>
{t("profiles.createDescription")}
{t(`profiles.createDescription.${scope}`)}
</DialogDescription>
</DialogHeader>
<div className="px-6 pt-3">
@@ -233,6 +239,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
<ProfileManageDialog
isOpen={isManageOpen}
scope={scope}
onClose={() => setIsManageOpen(false)}
/>
</>
+41
View File
@@ -0,0 +1,41 @@
import type { AppId } from "@/lib/api/types";
import type { PerApp, Profile, ProfileScope } from "@/lib/api/profiles";
/**
* 应用页 → 所属项目分组(后端 ProfileScope::for_app 的前端镜像,两处同步)
*
* 不在映射里的应用不支持 Profile,其标签页不渲染切换器。
*/
export const APP_PROFILE_SCOPE: Partial<Record<AppId, ProfileScope>> = {
claude: "claude",
"claude-desktop": "claude",
codex: "codex",
};
/** 分组显示名(产品名,不进 i18n;与后端托盘子菜单标签一致) */
export const PROFILE_SCOPE_LABELS: Record<ProfileScope, string> = {
claude: "Claude Code",
codex: "Codex",
};
/** 分组内的 payload 槽位 key(后端 ProfileScope::apps 的前端镜像) */
const SCOPE_SLOT_KEYS: Record<ProfileScope, (keyof PerApp<unknown>)[]> = {
claude: ["claude", "claude-desktop"],
codex: ["codex"],
};
/**
* 项目在某分组是否拍过快照(任一槽位非 null 即视为拍过)
*
* 未拍过的项目在该分组应用时不改动配置,只绑定 current 标记。
*/
export function hasScopeSnapshot(profile: Profile, scope: ProfileScope) {
const { providers, mcp, skills, prompts } = profile.payload;
return SCOPE_SLOT_KEYS[scope].some(
(app) =>
providers[app] !== null ||
mcp[app] !== null ||
skills[app] !== null ||
prompts[app] !== null,
);
}
+10 -3
View File
@@ -1867,19 +1867,26 @@
}
},
"profiles": {
"switcherTooltip": "Projects: switch providers / MCP / Skills / memory files for Claude Code and Codex, plus the Claude Desktop provider, in one click",
"switcherTooltip": {
"claude": "Projects: switch the Claude Code provider / MCP / Skills / memory files, plus the Claude Desktop provider, in one click",
"codex": "Projects: switch the Codex provider / MCP / Skills / memory files 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.",
"createDescription": {
"claude": "Save the current Claude Code provider, MCP, Skills and memory file (plus the Claude Desktop provider) as a project you can switch to later.",
"codex": "Save the current Codex 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?",
"updateSnapshotConfirm": "Overwrite the {{group}} side of project \"{{name}}\" with the current configuration? Other apps' snapshots are unaffected.",
"noSnapshotForScope": "Not saved for this app yet",
"delete": "Delete",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmMessage": "Are you sure you want to delete project \"{{name}}\"? Your current configuration will not be changed.",
+10 -3
View File
@@ -1867,19 +1867,26 @@
}
},
"profiles": {
"switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え",
"switcherTooltip": {
"claude": "プロジェクト:Claude Code のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え",
"codex": "プロジェクト:Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え"
},
"none": "プロジェクトを使用しない",
"searchPlaceholder": "プロジェクトを検索",
"empty": "プロジェクトはまだありません",
"createFromCurrent": "新規プロジェクト",
"createDescription": "現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。",
"createDescription": {
"claude": "Claude Code の現在のプロバイダー、MCP、Skills、メモリファイル(および Claude Desktop のプロバイダー)をプロジェクトとして保存し、後からワンクリックで切り替えられます。",
"codex": "Codex の現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。"
},
"manage": "プロジェクトを管理…",
"manageTitle": "プロジェクト管理",
"manageDescription": "プロジェクトは設定のスナップショットです。内容を変更するには、先に設定を調整してから「現在の状態で更新」をクリックしてください。",
"namePlaceholder": "例:開発、イラスト",
"rename": "名前を変更",
"updateSnapshot": "現在の状態で更新",
"updateSnapshotConfirm": "プロジェクト「{{name}}」のスナップショットを現在の設定で上書きしますか?",
"updateSnapshotConfirm": "プロジェクト「{{name}}」の {{group}} 側スナップショットを現在の設定で上書きしますか?他のアプリ側には影響しません。",
"noSnapshotForScope": "このアプリ側は未保存",
"delete": "削除",
"deleteConfirmTitle": "削除の確認",
"deleteConfirmMessage": "プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。",
+10 -3
View File
@@ -1839,19 +1839,26 @@
}
},
"profiles": {
"switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商",
"switcherTooltip": {
"claude": "專案:一鍵切換 Claude Code 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商",
"codex": "專案:一鍵切換 Codex 的供應商 / MCP / Skills / 記憶檔案"
},
"none": "不使用專案",
"searchPlaceholder": "搜尋專案",
"empty": "尚無專案",
"createFromCurrent": "新建專案",
"createDescription": "把目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。",
"createDescription": {
"claude": "把 Claude Code 目前的供應商、MCP、Skills、記憶檔案(及 Claude Desktop 的供應商)儲存為一個專案,之後可一鍵切換。",
"codex": "把 Codex 目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。"
},
"manage": "管理專案…",
"manageTitle": "管理專案",
"manageDescription": "專案是設定快照。要修改專案內容,先手動調好設定,再點「以目前狀態更新」。",
"namePlaceholder": "例如:開發、繪圖",
"rename": "重新命名",
"updateSnapshot": "以目前狀態更新",
"updateSnapshotConfirm": "用目前設定狀態覆蓋專案 \"{{name}}\" 的快照?",
"updateSnapshotConfirm": "用目前設定覆蓋專案 \"{{name}}\" 的 {{group}} 側快照?其他應用側的快照不受影響。",
"noSnapshotForScope": "本側未儲存",
"delete": "刪除",
"deleteConfirmTitle": "確認刪除",
"deleteConfirmMessage": "確定要刪除專案 \"{{name}}\" 嗎?不會改動任何現有設定。",
+10 -3
View File
@@ -1867,19 +1867,26 @@
}
},
"profiles": {
"switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商",
"switcherTooltip": {
"claude": "项目:一键切换 Claude Code 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商",
"codex": "项目:一键切换 Codex 的供应商 / MCP / Skills / 记忆文件"
},
"none": "不使用项目",
"searchPlaceholder": "搜索项目",
"empty": "暂无项目",
"createFromCurrent": "新建项目",
"createDescription": "把当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。",
"createDescription": {
"claude": "把 Claude Code 当前的供应商、MCP、Skills、记忆文件(及 Claude Desktop 的供应商)保存为一个项目,之后可一键切换。",
"codex": "把 Codex 当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。"
},
"manage": "管理项目…",
"manageTitle": "管理项目",
"manageDescription": "项目是配置快照。要修改项目内容,先手动调好配置,再点\"以当前状态更新\"。",
"namePlaceholder": "例如:开发、绘图",
"rename": "重命名",
"updateSnapshot": "以当前状态更新",
"updateSnapshotConfirm": "用当前配置状态覆盖项目 \"{{name}}\" 的快照?",
"updateSnapshotConfirm": "用当前配置覆盖项目 \"{{name}}\" 的 {{group}} 侧快照?其他应用侧的快照不受影响。",
"noSnapshotForScope": "本侧未保存",
"delete": "删除",
"deleteConfirmTitle": "确认删除",
"deleteConfirmMessage": "确定要删除项目 \"{{name}}\" 吗?不会改动任何现有配置。",
+32 -15
View File
@@ -1,5 +1,13 @@
import { invoke } from "@tauri-apps/api/core";
/**
* Profile 操作的应用分组(与后端 services/profile.rs 的 ProfileScope 严格对应)
*
* 项目实体全应用共享,但快照/应用/当前指针按组进行;Claude Desktop 并入
* claude 组(它只有聊天侧供应商一个受管维度,Code 标签页天然跟随 Claude Code)。
*/
export type ProfileScope = "claude" | "codex";
/**
* 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp<T> 严格对应)
*/
@@ -11,11 +19,14 @@ export interface PerApp<T> {
/**
* 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应)
*
* 所有槽位 null = 该侧从未拍过快照(应用时不动),与"拍到的就是空集"
* (空数组,应用时清空启用)严格区分。
*/
export interface ProfilePayload {
providers: PerApp<string | null>;
mcp: PerApp<string[]>;
skills: PerApp<string[]>;
mcp: PerApp<string[] | null>;
skills: PerApp<string[] | null>;
prompts: PerApp<string | null>;
}
@@ -27,37 +38,42 @@ export interface Profile {
updatedAt?: number;
}
/** 每个分组当前激活的项目 id(未使用项目时为 null) */
export type CurrentProfileIds = Record<ProfileScope, string | null>;
export interface ProfilesResponse {
profiles: Profile[];
currentId: string | null;
currentIds: CurrentProfileIds;
}
export const profilesApi = {
/**
* 获取所有项目及当前激活项目 id
* 获取所有项目及各分组当前激活项目 id
*/
async list(): Promise<ProfilesResponse> {
return await invoke("list_profiles");
},
/**
* 以当前配置状态创建新项目
* 创建新项目(只拍发起页所属分组的当前状态,其余分组槽位留空)
*/
async create(name: string): Promise<Profile> {
return await invoke("create_profile", { name });
async create(name: string, scope: ProfileScope): Promise<Profile> {
return await invoke("create_profile", { name, scope });
},
/**
* 更新项目重命名和/或以当前状态重拍快照
* 更新项目重命名(作用于共享实体)和/或以当前状态重拍快照
* resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留)
*/
async update(
id: string,
options: { name?: string; resnapshot?: boolean },
options: { name?: string; resnapshot?: boolean; scope?: ProfileScope },
): Promise<Profile> {
return await invoke("update_profile", {
id,
name: options.name,
resnapshot: options.resnapshot,
scope: options.scope,
});
},
@@ -69,16 +85,17 @@ export const profilesApi = {
},
/**
* 应用项目快照,返回 warningsbest-effort,部分失败不中断)
* 应用项目快照(只作用于发起页所属分组内的应用),返回 warnings
* best-effort,部分失败不中断)
*/
async apply(id: string): Promise<string[]> {
return await invoke("apply_profile", { id });
async apply(id: string, scope: ProfileScope): Promise<string[]> {
return await invoke("apply_profile", { id, scope });
},
/**
* 不使用项目:仅清除激活标记,不改动任何配置
* 不使用项目:仅清除某分组的激活标记,不改动任何配置
*/
async clearCurrent(): Promise<void> {
return await invoke("clear_current_profile");
async clearCurrent(scope: ProfileScope): Promise<void> {
return await invoke("clear_current_profile", { scope });
},
};
+9 -4
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { profilesApi, providersApi } from "@/lib/api";
import type { ProfileScope } from "@/lib/api/profiles";
import { extractErrorMessage } from "@/utils/errorUtils";
const updateTrayMenuSafely = async () => {
@@ -24,7 +25,8 @@ export const useCreateProfileMutation = () => {
const { t } = useTranslation();
return useMutation({
mutationFn: (name: string) => profilesApi.create(name),
mutationFn: ({ name, scope }: { name: string; scope: ProfileScope }) =>
profilesApi.create(name, scope),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
@@ -48,11 +50,13 @@ export const useUpdateProfileMutation = () => {
id,
name,
resnapshot,
scope,
}: {
id: string;
name?: string;
resnapshot?: boolean;
}) => profilesApi.update(id, { name, resnapshot }),
scope?: ProfileScope;
}) => profilesApi.update(id, { name, resnapshot, scope }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
@@ -92,7 +96,7 @@ export const useClearProfileMutation = () => {
const { t } = useTranslation();
return useMutation({
mutationFn: () => profilesApi.clearCurrent(),
mutationFn: (scope: ProfileScope) => profilesApi.clearCurrent(scope),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await updateTrayMenuSafely();
@@ -112,7 +116,8 @@ export const useApplyProfileMutation = () => {
const { t } = useTranslation();
return useMutation({
mutationFn: (id: string) => profilesApi.apply(id),
mutationFn: ({ id, scope }: { id: string; scope: ProfileScope }) =>
profilesApi.apply(id, scope),
onSuccess: async (warnings) => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await queryClient.invalidateQueries({