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
+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,
});
},
});
};