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
@@ -18,6 +18,7 @@ mod model_fetch;
mod omo;
mod openclaw;
mod plugin;
mod profile;
mod prompt;
mod provider;
mod proxy;
@@ -52,6 +53,7 @@ pub use model_fetch::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
pub use profile::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
+134
View File
@@ -0,0 +1,134 @@
//! 项目 Profile 管理命令
use serde::Serialize;
use tauri::{Emitter, State};
use crate::database::Profile;
use crate::services::profile::{ProfilePayload, ProfileService, PROFILE_APPS};
use crate::store::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDto {
pub id: String,
pub name: String,
pub payload: ProfilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
impl From<Profile> for ProfileDto {
fn from(profile: Profile) -> Self {
// 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志
let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| {
log::warn!(
"解析 profile '{}' payload 失败,使用默认值: {e}",
profile.id
);
ProfilePayload::default()
});
Self {
id: profile.id,
name: profile.name,
payload,
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfilesResponse {
pub profiles: Vec<ProfileDto>,
pub current_id: Option<String>,
}
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
///
/// UI 与托盘两个入口必须共用此函数,保证事件 payload 形状一致
/// (前端 App.tsx 的 provider-switched 监听依赖该形状)。
pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profile_id: &str) {
for app_type in PROFILE_APPS.iter() {
let app_str = app_type.as_str();
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str);
let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type)
.ok()
.flatten()
.unwrap_or_default();
let event_data = serde_json::json!({
"appType": app_str,
"proxyEnabled": proxy_enabled,
"autoFailoverEnabled": auto_failover_enabled,
"providerId": provider_id,
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射 provider-switched 事件失败: {e}");
}
}
if let Err(e) = app.emit(
"profile-applied",
serde_json::json!({ "profileId": profile_id }),
) {
log::error!("发射 profile-applied 事件失败: {e}");
}
crate::tray::refresh_tray_menu(app);
}
#[tauri::command]
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
let (profiles, current_id) = ProfileService::list(&state).map_err(|e| e.to_string())?;
Ok(ProfilesResponse {
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
current_id,
})
}
#[tauri::command]
pub fn create_profile(state: State<'_, AppState>, name: String) -> Result<ProfileDto, String> {
ProfileService::create(&state, &name)
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_profile(
state: State<'_, AppState>,
id: String,
name: Option<String>,
resnapshot: Option<bool>,
) -> Result<ProfileDto, String> {
ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false))
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> {
ProfileService::delete(&state, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_current_profile(state: State<'_, AppState>) -> Result<(), String> {
state
.db
.set_current_profile_id(None)
.map_err(|e| e.to_string())
}
/// 应用项目快照。
///
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
#[tauri::command]
pub fn apply_profile(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<Vec<String>, String> {
let warnings = ProfileService::apply(&state, &id).map_err(|e| e.to_string())?;
emit_profile_apply_events(&app, &state, &id);
Ok(warnings)
}
+3 -1
View File
@@ -4,6 +4,7 @@
pub mod failover;
pub mod mcp;
pub mod profiles;
pub mod prompts;
pub mod providers;
pub mod providers_seed;
@@ -15,5 +16,6 @@ pub mod universal_providers;
pub mod usage_rollup;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
// 导出 FailoverQueueItem / Profile 供外部使用
pub use failover::FailoverQueueItem;
pub use profiles::Profile;
+196
View File
@@ -0,0 +1,196 @@
//! 项目 Profile 数据访问对象
//!
//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt),
//! payload 为原始 JSON 文本,解析在 service 层进行。
//! current_profile_id 存放于 settings 表(key-value)。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::params;
const CURRENT_PROFILE_ID_KEY: &str = "current_profile_id";
/// 项目 Profile 记录
#[derive(Debug, Clone)]
pub struct Profile {
pub id: String,
pub name: String,
/// 原始 JSON 快照文本(ProfilePayload),解析在 service 层
pub payload: String,
pub sort_order: Option<i64>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
impl Database {
/// 获取所有项目(按 sort_order 优先、created_at 兜底排序)
pub fn get_all_profiles(&self) -> Result<Vec<Profile>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, payload, sort_order, created_at, updated_at
FROM profiles
ORDER BY sort_order IS NULL, sort_order, created_at, id",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(Profile {
id: row.get(0)?,
name: row.get(1)?,
payload: row.get(2)?,
sort_order: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut profiles = Vec::new();
for row in rows {
profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(profiles)
}
/// 获取单个项目
pub fn get_profile(&self, id: &str) -> Result<Option<Profile>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, payload, sort_order, created_at, updated_at
FROM profiles WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
match stmt.query_row(params![id], |row| {
Ok(Profile {
id: row.get(0)?,
name: row.get(1)?,
payload: row.get(2)?,
sort_order: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
}) {
Ok(profile) => Ok(Some(profile)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存项目(插入或整行替换)
pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO profiles
(id, name, payload, sort_order, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
profile.id,
profile.name,
profile.payload,
profile.sort_order,
profile.created_at,
profile.updated_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除项目,返回是否实际删除了记录
pub fn delete_profile(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM profiles WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 读取当前激活的项目 id(未使用项目时为 None)
pub fn get_current_profile_id(&self) -> Result<Option<String>, AppError> {
self.get_setting(CURRENT_PROFILE_ID_KEY)
}
/// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key
pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> {
match id {
Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id),
None => {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![CURRENT_PROFILE_ID_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(id: &str, name: &str, sort_order: Option<i64>) -> Profile {
Profile {
id: id.to_string(),
name: name.to_string(),
payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(),
sort_order,
created_at: Some(1_000),
updated_at: Some(1_000),
}
}
#[test]
fn test_profile_crud_roundtrip() -> Result<(), AppError> {
let db = Database::memory()?;
db.save_profile(&sample("a", "Dev", Some(2)))?;
db.save_profile(&sample("b", "Draw", Some(1)))?;
db.save_profile(&sample("c", "Misc", None))?;
// sort_order 优先,NULL 排最后
let all = db.get_all_profiles()?;
assert_eq!(
all.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
vec!["b", "a", "c"]
);
let got = db.get_profile("a")?.expect("profile a exists");
assert_eq!(got.name, "Dev");
assert!(got.payload.contains("providers"));
// 整行替换更新
let mut updated = sample("a", "Dev Renamed", Some(2));
updated.updated_at = Some(2_000);
db.save_profile(&updated)?;
let got = db.get_profile("a")?.expect("profile a exists");
assert_eq!(got.name, "Dev Renamed");
assert_eq!(got.updated_at, Some(2_000));
assert!(db.delete_profile("a")?);
assert!(!db.delete_profile("a")?);
assert!(db.get_profile("a")?.is_none());
Ok(())
}
#[test]
fn test_current_profile_id_set_and_clear() -> Result<(), AppError> {
let db = Database::memory()?;
assert_eq!(db.get_current_profile_id()?, None);
db.set_current_profile_id(Some("a"))?;
assert_eq!(db.get_current_profile_id()?, Some("a".to_string()));
db.set_current_profile_id(None)?;
assert_eq!(db.get_current_profile_id()?, None);
// 重复清除应幂等
db.set_current_profile_id(None)?;
assert_eq!(db.get_current_profile_id()?, None);
Ok(())
}
}
+2 -1
View File
@@ -38,6 +38,7 @@ pub(crate) use dao::proxy::{
PRICING_SOURCE_RESPONSE,
};
pub use dao::FailoverQueueItem;
pub use dao::Profile;
use crate::config::get_app_config_dir;
use crate::error::AppError;
@@ -49,7 +50,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 11;
pub(crate) const SCHEMA_VERSION: i32 = 12;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+37
View File
@@ -295,6 +295,20 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 19. Profiles 表(项目配置方案:按 app 快照供应商/MCP/Skills/Prompt
conn.execute(
"CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
sort_order INTEGER,
created_at INTEGER,
updated_at INTEGER
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 live_takeover_active 列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
@@ -444,6 +458,11 @@ impl Database {
Self::migrate_v10_to_v11(conn)?;
Self::set_user_version(conn, 11)?;
}
11 => {
log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)");
Self::migrate_v11_to_v12(conn)?;
Self::set_user_version(conn, 12)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1270,6 +1289,24 @@ impl Database {
Ok(())
}
/// v11 -> v12 迁移:添加项目 Profiles 表
/// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等)
fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
sort_order INTEGER,
created_at INTEGER,
updated_at INTEGER
)",
[],
)
.map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?;
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+10 -1
View File
@@ -42,7 +42,7 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
pub use database::{Database, Profile};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
@@ -51,8 +51,10 @@ pub use mcp::{
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
pub use services::{
profile::{ProfilePayload, ProfileService},
skill::{migrate_skills_to_ssot, ImportSkillSelection},
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
SkillService, SpeedtestService,
@@ -1270,6 +1272,13 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// Profile management (项目配置方案)
commands::list_profiles,
commands::create_profile,
commands::update_profile,
commands::delete_profile,
commands::clear_current_profile,
commands::apply_profile,
// model list fetch (OpenAI-compatible /v1/models)
commands::fetch_models_for_config,
// ours: endpoint speed test + custom endpoint management
+1
View File
@@ -7,6 +7,7 @@ pub mod env_manager;
pub mod mcp;
pub mod model_fetch;
pub mod omo;
pub mod profile;
pub mod prompt;
pub mod provider;
pub mod proxy;
+382
View File
@@ -0,0 +1,382 @@
//! 项目 Profile 编排服务
//!
//! Profile 是一份按 app 的配置快照(供应商 / MCP / Skills / Prompt),
//! 应用(apply)时复用现有切换原语批量落地:
//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方)
//! - MCP`McpService::toggle_app`(改标志 + 单 server 物化)
//! - Skills`SkillService::toggle_app`(改标志 + 单 skill 物化)
//! - Prompt`PromptService::enable_prompt`(互斥激活 + 原子写 live
//!
//! apply 为 best-effort:单项失败收集为 warning 继续,不整体回滚。
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::app_config::AppType;
use crate::database::Profile;
use crate::error::AppError;
use crate::services::{McpService, PromptService, ProviderService, SkillService};
use crate::store::AppState;
/// P1 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段)
pub const PROFILE_APPS: [AppType; 2] = [AppType::Claude, AppType::Codex];
/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 小写形式一致
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PerApp<T> {
pub claude: T,
pub codex: T,
}
impl<T> PerApp<T> {
pub fn get(&self, app: &AppType) -> Option<&T> {
match app {
AppType::Claude => Some(&self.claude),
AppType::Codex => Some(&self.codex),
_ => None,
}
}
pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> {
match app {
AppType::Claude => Some(&mut self.claude),
AppType::Codex => Some(&mut self.codex),
_ => None,
}
}
}
/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应)
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProfilePayload {
/// 每 app 的当前供应商 id(None = 快照时无当前供应商,应用时不动)
pub providers: PerApp<Option<String>>,
/// 每 app 启用的 MCP server id 集合
pub mcp: PerApp<Vec<String>>,
/// 每 app 启用的 Skill id 集合
pub skills: PerApp<Vec<String>>,
/// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动)
pub prompts: PerApp<Option<String>>,
}
/// 计算从当前启用状态到目标集合的最小 toggle 集
///
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
fn plan_toggles(
current: &[(String, bool)],
target_ids: &[String],
) -> (Vec<(String, bool)>, Vec<String>) {
let existing: HashSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect();
let target: HashSet<&str> = target_ids.iter().map(|s| s.as_str()).collect();
let toggles = current
.iter()
.filter(|(id, enabled)| target.contains(id.as_str()) != *enabled)
.map(|(id, enabled)| (id.clone(), !enabled))
.collect();
let dangling = target_ids
.iter()
.filter(|id| !existing.contains(id.as_str()))
.cloned()
.collect();
(toggles, dangling)
}
pub struct ProfileService;
impl ProfileService {
/// 抓取当前配置状态生成快照
pub fn snapshot_current(state: &AppState) -> Result<ProfilePayload, AppError> {
let mut payload = ProfilePayload::default();
let mcp_servers = state.db.get_all_mcp_servers()?;
let skills = state.db.get_all_installed_skills()?;
for app in PROFILE_APPS.iter() {
if let Some(slot) = payload.providers.get_mut(app) {
*slot = crate::settings::get_effective_current_provider(&state.db, app)?;
}
if let Some(slot) = payload.mcp.get_mut(app) {
*slot = mcp_servers
.values()
.filter(|s| s.apps.is_enabled_for(app))
.map(|s| s.id.clone())
.collect();
}
if let Some(slot) = payload.skills.get_mut(app) {
*slot = skills
.values()
.filter(|s| s.apps.is_enabled_for(app))
.map(|s| s.id.clone())
.collect();
}
if let Some(slot) = payload.prompts.get_mut(app) {
*slot = state
.db
.get_prompts(app.as_str())?
.values()
.find(|p| p.enabled)
.map(|p| p.id.clone());
}
}
Ok(payload)
}
/// 列出所有项目及当前激活项目 id
pub fn list(state: &AppState) -> Result<(Vec<Profile>, Option<String>), AppError> {
let profiles = state.db.get_all_profiles()?;
let current = state.db.get_current_profile_id()?;
Ok((profiles, current))
}
/// 以当前配置状态创建新项目
pub fn create(state: &AppState, name: &str) -> Result<Profile, AppError> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::InvalidInput("Profile name is empty".to_string()));
}
let payload = Self::snapshot_current(state)?;
let now = chrono::Utc::now().timestamp();
let profile = Profile {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
payload: serde_json::to_string(&payload)
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?,
sort_order: None,
created_at: Some(now),
updated_at: Some(now),
};
state.db.save_profile(&profile)?;
Ok(profile)
}
/// 更新项目:重命名和/或以当前状态重拍快照
pub fn update(
state: &AppState,
id: &str,
name: Option<String>,
resnapshot: bool,
) -> Result<Profile, AppError> {
let mut profile = state
.db
.get_profile(id)?
.ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {id}")))?;
if let Some(name) = name {
let name = name.trim().to_string();
if name.is_empty() {
return Err(AppError::InvalidInput("Profile name is empty".to_string()));
}
profile.name = name;
}
if resnapshot {
let payload = Self::snapshot_current(state)?;
profile.payload = serde_json::to_string(&payload)
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?;
}
profile.updated_at = Some(chrono::Utc::now().timestamp());
state.db.save_profile(&profile)?;
Ok(profile)
}
/// 删除项目;若删除的是当前激活项目,一并清除激活标记
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> {
state.db.delete_profile(id)?;
if state.db.get_current_profile_id()?.as_deref() == Some(id) {
state.db.set_current_profile_id(None)?;
}
Ok(())
}
/// 应用项目快照(best-effort,返回 warnings
///
/// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP
/// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。
pub fn apply(state: &AppState, profile_id: &str) -> Result<Vec<String>, AppError> {
let profile = state
.db
.get_profile(profile_id)?
.ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {profile_id}")))?;
let payload: ProfilePayload = serde_json::from_str(&profile.payload)
.map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?;
let mut warnings = Vec::new();
for app in PROFILE_APPS.iter() {
let app_str = app.as_str();
// 1. 供应商
if let Some(Some(target_pid)) = payload.providers.get(app) {
let providers = state.db.get_all_providers(app_str)?;
if !providers.contains_key(target_pid) {
warnings.push(format!(
"[{app_str}] provider '{target_pid}' no longer exists, skipped"
));
} else {
let current = crate::settings::get_effective_current_provider(&state.db, app)?;
if current.as_deref() != Some(target_pid.as_str()) {
match ProviderService::switch(state, app.clone(), target_pid) {
Ok(result) => warnings.extend(result.warnings),
Err(e) => warnings.push(format!(
"[{app_str}] switch provider '{target_pid}' failed: {e}"
)),
}
}
}
}
// 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目)
if let Some(target_ids) = payload.mcp.get(app) {
let servers = state.db.get_all_mcp_servers()?;
let current: Vec<(String, bool)> = servers
.values()
.map(|s| (s.id.clone(), s.apps.is_enabled_for(app)))
.collect();
let (toggles, dangling) = plan_toggles(&current, target_ids);
for id in dangling {
warnings.push(format!("[{app_str}] MCP '{id}' no longer exists, skipped"));
}
for (id, enabled) in toggles {
if let Err(e) = McpService::toggle_app(state, &id, app.clone(), enabled) {
warnings.push(format!(
"[{app_str}] toggle MCP '{id}' -> {enabled} failed: {e}"
));
}
}
}
// 3. Skills diffSkillService 返回 anyhow::Result,收进 warning
if let Some(target_ids) = payload.skills.get(app) {
let skills = state.db.get_all_installed_skills()?;
let current: Vec<(String, bool)> = skills
.values()
.map(|s| (s.id.clone(), s.apps.is_enabled_for(app)))
.collect();
let (toggles, dangling) = plan_toggles(&current, target_ids);
for id in dangling {
warnings.push(format!(
"[{app_str}] skill '{id}' no longer exists, skipped"
));
}
for (id, enabled) in toggles {
if let Err(e) = SkillService::toggle_app(&state.db, &id, app, enabled) {
warnings.push(format!(
"[{app_str}] toggle skill '{id}' -> {enabled} failed: {e}"
));
}
}
}
// 4. PromptNone = 不动;已激活则幂等跳过,避免无谓的文件写与备份)
if let Some(Some(target_prompt)) = payload.prompts.get(app) {
let prompts = state.db.get_prompts(app_str)?;
match prompts.get(target_prompt) {
None => warnings.push(format!(
"[{app_str}] prompt '{target_prompt}' no longer exists, skipped"
)),
Some(p) if p.enabled => {}
Some(_) => {
if let Err(e) =
PromptService::enable_prompt(state, app.clone(), target_prompt)
{
warnings.push(format!(
"[{app_str}] enable prompt '{target_prompt}' failed: {e}"
));
}
}
}
}
}
state.db.set_current_profile_id(Some(profile_id))?;
Ok(warnings)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_payload_serde_roundtrip() {
let payload = ProfilePayload {
providers: PerApp {
claude: Some("p1".into()),
codex: None,
},
mcp: PerApp {
claude: ids(&["m1", "m2"]),
codex: vec![],
},
skills: PerApp {
claude: vec![],
codex: ids(&["s1"]),
},
prompts: PerApp {
claude: None,
codex: Some("pr1".into()),
},
};
let json = serde_json::to_string(&payload).unwrap();
// per-app key 必须是 AppType serde 的小写形式
assert!(json.contains("\"claude\""));
assert!(json.contains("\"codex\""));
let back: ProfilePayload = serde_json::from_str(&json).unwrap();
assert_eq!(back, payload);
}
#[test]
fn test_payload_tolerates_missing_fields() {
// 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错
let back: ProfilePayload =
serde_json::from_str(r#"{"providers":{"claude":"p1"}}"#).unwrap();
assert_eq!(back.providers.claude, Some("p1".to_string()));
assert_eq!(back.providers.codex, None);
assert!(back.mcp.claude.is_empty());
assert_eq!(back.prompts.codex, None);
let empty: ProfilePayload = serde_json::from_str("{}").unwrap();
assert_eq!(empty, ProfilePayload::default());
}
#[test]
fn test_per_app_get_only_supports_profile_apps() {
let per: PerApp<Option<String>> = PerApp::default();
assert!(per.get(&AppType::Claude).is_some());
assert!(per.get(&AppType::Codex).is_some());
assert!(per.get(&AppType::Gemini).is_none());
assert!(per.get(&AppType::ClaudeDesktop).is_none());
}
#[test]
fn test_plan_toggles_minimal_diff() {
let current = vec![
("a".to_string(), true), // 目标含 a:不动
("b".to_string(), false), // 目标含 b:开
("c".to_string(), true), // 目标不含 c:关
("d".to_string(), false), // 目标不含 d:不动
];
let (toggles, dangling) = plan_toggles(&current, &ids(&["a", "b", "ghost"]));
assert_eq!(
toggles,
vec![("b".to_string(), true), ("c".to_string(), false)]
);
assert_eq!(dangling, ids(&["ghost"]));
}
#[test]
fn test_plan_toggles_empty_target_disables_all_enabled() {
let current = vec![("a".to_string(), true), ("b".to_string(), false)];
let (toggles, dangling) = plan_toggles(&current, &[]);
assert_eq!(toggles, vec![("a".to_string(), false)]);
assert!(dangling.is_empty());
}
}
+99
View File
@@ -54,6 +54,8 @@ pub struct TrayTexts {
pub lightweight_mode: &'static str,
pub quit: &'static str,
pub _auto_label: &'static str,
pub projects_label: &'static str,
pub no_project_label: &'static str,
}
impl TrayTexts {
@@ -66,6 +68,8 @@ impl TrayTexts {
lightweight_mode: "Lightweight Mode",
quit: "Quit",
_auto_label: "Auto (Failover)",
projects_label: "Projects",
no_project_label: "No project",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
@@ -74,6 +78,8 @@ impl TrayTexts {
lightweight_mode: "軽量モード",
quit: "終了",
_auto_label: "自動 (フェイルオーバー)",
projects_label: "プロジェクト",
no_project_label: "プロジェクトを使用しない",
},
"zh-TW" => Self {
show_main: "開啟主介面",
@@ -82,6 +88,8 @@ impl TrayTexts {
lightweight_mode: "輕量模式",
quit: "退出",
_auto_label: "自動 (故障轉移)",
projects_label: "專案",
no_project_label: "不使用專案",
},
_ => Self {
show_main: "打开主界面",
@@ -90,6 +98,8 @@ impl TrayTexts {
lightweight_mode: "轻量模式",
quit: "退出",
_auto_label: "自动 (故障转移)",
projects_label: "项目",
no_project_label: "不使用项目",
},
}
}
@@ -326,6 +336,55 @@ fn sort_providers(
sorted
}
/// 处理项目 Profile 托盘事件,返回是否已处理
///
/// 事件 id 形如 `profile_<uuid>``profile_none` 表示"不使用项目"(只清标记,不动配置)。
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
let Some(suffix) = event_id.strip_prefix("profile_") else {
return false;
};
if suffix == "none" {
if let Some(app_state) = app.try_state::<AppState>() {
if let Err(e) = app_state.db.set_current_profile_id(None) {
log::error!("清除当前项目失败: {e}");
}
}
// 通知主窗口刷新(profileId=null 表示已清除当前项目)
if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) {
log::error!("发射 profile-applied 事件失败: {e}");
}
refresh_tray_menu(app);
return true;
}
log::info!("应用项目: {suffix}");
let app_handle = app.clone();
let profile_id = suffix.to_string();
tauri::async_runtime::spawn_blocking(move || {
let Some(app_state) = app_handle.try_state::<AppState>() else {
return;
};
match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id) {
Ok(warnings) => {
for warning in &warnings {
log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}");
}
crate::commands::emit_profile_apply_events(
&app_handle,
app_state.inner(),
&profile_id,
);
}
Err(e) => {
log::error!("应用项目 {profile_id} 失败: {e}");
refresh_tray_menu(&app_handle);
}
}
});
true
}
/// 处理供应商托盘事件
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
@@ -605,6 +664,43 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator();
}
// 项目 Profile 子菜单(Claude/Codex 至少一个可见且存在项目时才显示)
if visible_apps.is_visible(&AppType::Claude) || visible_apps.is_visible(&AppType::Codex) {
let profiles = app_state.db.get_all_profiles()?;
if !profiles.is_empty() {
let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default();
let mut profiles_builder =
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
for profile in &profiles {
let item = CheckMenuItem::with_id(
app,
format!("profile_{}", profile.id),
&profile.name,
true,
current_profile_id == profile.id,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?;
profiles_builder = profiles_builder.item(&item);
}
let none_item = CheckMenuItem::with_id(
app,
"profile_none",
tray_texts.no_project_label,
true,
current_profile_id.is_empty(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
let profiles_submenu = profiles_builder
.separator()
.item(&none_item)
.build()
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
menu_builder = menu_builder.item(&profiles_submenu).separator();
}
}
let lightweight_item = CheckMenuItem::with_id(
app,
"lightweight_mode",
@@ -750,6 +846,9 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
app.exit(0);
}
_ => {
if handle_profile_tray_event(app, event_id) {
return;
}
if handle_provider_tray_event(app, event_id) {
return;
}
+287
View File
@@ -0,0 +1,287 @@
//! 项目 Profile 快照/应用的端到端集成测试
//!
//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。
use std::fs;
use serde_json::json;
use cc_switch_lib::{
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt,
PromptService, Provider, ProviderService, SkillApps, SkillService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
fn claude_provider(id: &str, token: &str) -> Provider {
Provider::with_id(
id.to_string(),
id.to_uppercase(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": token,
"ANTHROPIC_BASE_URL": "https://api.test"
}
}),
None,
)
}
fn mcp_server(id: &str, claude_enabled: bool) -> McpServer {
serde_json::from_value(json!({
"id": id,
"name": id,
"server": { "command": "echo", "args": [] },
"apps": { "claude": claude_enabled }
}))
.expect("construct mcp server")
}
fn prompt(id: &str, enabled: bool) -> Prompt {
Prompt {
id: id.to_string(),
name: id.to_uppercase(),
content: format!("# prompt {id}\n"),
description: None,
enabled,
created_at: Some(1_000),
updated_at: Some(1_000),
}
}
fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
name: id.to_string(),
description: None,
directory: directory.to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: claude_enabled,
..Default::default()
},
installed_at: 1_000,
content_hash: None,
updated_at: 0,
}
}
fn write_ssot_skill(directory: &str) {
let dir = SkillService::get_ssot_dir()
.expect("resolve skills SSOT dir")
.join(directory);
fs::create_dir_all(&dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {directory}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
#[test]
fn profile_snapshot_apply_roundtrip_restores_configuration() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
.expect("save provider p2");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1");
// 让 live settings.json 与 p1 一致(switch_normal 回填需要)
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).expect("create .claude dir");
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
.expect("serialize p1 settings"),
)
.expect("seed live settings.json");
state
.db
.save_mcp_server(&mcp_server("m1", true))
.expect("save mcp m1");
state
.db
.save_mcp_server(&mcp_server("m2", false))
.expect("save mcp m2");
write_ssot_skill("test-skill");
state
.db
.save_skill(&installed_skill("local:test-skill", "test-skill", true))
.expect("save skill");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
.expect("save prompt pr1");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
.expect("save prompt pr2");
// ---- 保存项目 A(快照当前状态)----
let profile_a = ProfileService::create(&state, "Project A").expect("create profile A");
let payload: ProfilePayload =
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
assert_eq!(payload.mcp.claude, vec!["m1".to_string()]);
assert_eq!(payload.skills.claude, vec!["local:test-skill".to_string()]);
assert_eq!(payload.prompts.claude.as_deref(), Some("pr1"));
assert_eq!(
payload.providers.codex, None,
"codex has no current provider"
);
assert!(payload.mcp.codex.is_empty());
// ---- 改动全部四类配置(走真实切换路径)----
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false)
.expect("disable skill");
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
// ---- 应用项目 A:全部复原 ----
let warnings = ProfileService::apply(&state, &profile_a.id).expect("apply profile A");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let current = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1");
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled");
assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled");
let skills = state.db.get_all_installed_skills().expect("get skills");
assert!(
skills.get("local:test-skill").expect("skill").apps.claude,
"skill re-enabled"
);
let prompts = state
.db
.get_prompts(AppType::Claude.as_str())
.expect("get prompts");
assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled");
assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled");
let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md");
assert_eq!(
live_prompt,
prompt("pr1", true).content,
"live memory file restored"
);
assert_eq!(
state
.db
.get_current_profile_id()
.expect("get current profile id")
.as_deref(),
Some(profile_a.id.as_str()),
"profile A marked as current"
);
}
#[test]
fn profile_apply_reports_dangling_references_and_continues() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.save_mcp_server(&mcp_server("m1", false))
.expect("save mcp m1");
// 手工构造引用了不存在资源的 payload
let payload = json!({
"providers": { "claude": "ghost-provider" },
"mcp": { "claude": ["m1", "ghost-mcp"] },
"skills": { "claude": ["ghost-skill"] },
"prompts": { "claude": "ghost-prompt" }
});
let profile = cc_switch_lib::Profile {
id: "dangling-test".to_string(),
name: "Dangling".to_string(),
payload: payload.to_string(),
sort_order: None,
created_at: Some(1_000),
updated_at: Some(1_000),
};
state.db.save_profile(&profile).expect("save profile");
let warnings = ProfileService::apply(&state, "dangling-test").expect("apply succeeds");
assert_eq!(
warnings.len(),
4,
"each dangling reference yields one warning: {warnings:?}"
);
// 有效条目照常生效:m1 被启用
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(
servers.get("m1").expect("m1").apps.claude,
"m1 enabled despite warnings"
);
// best-effort 完成后仍标记为当前项目
assert_eq!(
state
.db
.get_current_profile_id()
.expect("get current profile id")
.as_deref(),
Some("dangling-test")
);
}
#[test]
fn clear_current_profile_only_clears_marker() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.set_current_profile_id(Some("some-profile"))
.expect("set current profile");
state
.db
.set_current_profile_id(None)
.expect("clear current profile");
assert_eq!(
state
.db
.get_current_profile_id()
.expect("get current profile id"),
None
);
}
+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,
});
},
});
};