diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs index f75eec55c..4e82e88b7 100644 --- a/src-tauri/src/commands/profile.rs +++ b/src-tauri/src/commands/profile.rs @@ -4,7 +4,7 @@ use serde::Serialize; use tauri::{Emitter, State}; use crate::database::Profile; -use crate::services::profile::{ProfilePayload, ProfileService, PROFILE_APPS}; +use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService}; use crate::store::AppState; #[derive(Debug, Serialize)] @@ -39,19 +39,33 @@ impl From for ProfileDto { } } +/// 每个分组当前激活的项目 id(未使用项目时为 null) +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrentProfileIds { + pub claude: Option, + pub codex: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProfilesResponse { pub profiles: Vec, - pub current_id: Option, + pub current_ids: CurrentProfileIds, } /// 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() { +/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须 +/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的 +/// provider-switched 监听依赖该形状)。 +pub fn emit_profile_apply_events( + app: &tauri::AppHandle, + state: &AppState, + profile_id: &str, + scope: ProfileScope, +) { + for app_type in scope.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) @@ -70,7 +84,7 @@ pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profi } if let Err(e) = app.emit( "profile-applied", - serde_json::json!({ "profileId": profile_id }), + serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }), ) { log::error!("发射 profile-applied 事件失败: {e}"); } @@ -79,16 +93,31 @@ pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profi #[tauri::command] pub fn list_profiles(state: State<'_, AppState>) -> Result { - let (profiles, current_id) = ProfileService::list(&state).map_err(|e| e.to_string())?; + let profiles = ProfileService::list(&state).map_err(|e| e.to_string())?; + let current_ids = CurrentProfileIds { + claude: state + .db + .get_current_profile_id(ProfileScope::Claude.as_str()) + .map_err(|e| e.to_string())?, + codex: state + .db + .get_current_profile_id(ProfileScope::Codex.as_str()) + .map_err(|e| e.to_string())?, + }; Ok(ProfilesResponse { profiles: profiles.into_iter().map(ProfileDto::from).collect(), - current_id, + current_ids, }) } #[tauri::command] -pub fn create_profile(state: State<'_, AppState>, name: String) -> Result { - ProfileService::create(&state, &name) +pub fn create_profile( + state: State<'_, AppState>, + name: String, + scope: String, +) -> Result { + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; + ProfileService::create(&state, &name, scope) .map(ProfileDto::from) .map_err(|e| e.to_string()) } @@ -99,8 +128,13 @@ pub fn update_profile( id: String, name: Option, resnapshot: Option, + scope: Option, ) -> Result { - ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false)) + let scope = scope + .map(|s| ProfileScope::parse(&s)) + .transpose() + .map_err(|e| e.to_string())?; + ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false), scope) .map(ProfileDto::from) .map_err(|e| e.to_string()) } @@ -111,14 +145,15 @@ pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), Stri } #[tauri::command] -pub fn clear_current_profile(state: State<'_, AppState>) -> Result<(), String> { +pub fn clear_current_profile(state: State<'_, AppState>, scope: String) -> Result<(), String> { + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; state .db - .set_current_profile_id(None) + .set_current_profile_id(scope.as_str(), None) .map_err(|e| e.to_string()) } -/// 应用项目快照。 +/// 应用项目快照(只作用于发起页所属分组内的应用)。 /// /// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch` /// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。 @@ -127,8 +162,10 @@ pub fn apply_profile( app: tauri::AppHandle, state: State<'_, AppState>, id: String, + scope: String, ) -> Result, String> { - let warnings = ProfileService::apply(&state, &id).map_err(|e| e.to_string())?; - emit_profile_apply_events(&app, &state, &id); + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; + let warnings = ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?; + emit_profile_apply_events(&app, &state, &id, scope); Ok(warnings) } diff --git a/src-tauri/src/database/dao/profiles.rs b/src-tauri/src/database/dao/profiles.rs index 6a9891b54..85e95ef1e 100644 --- a/src-tauri/src/database/dao/profiles.rs +++ b/src-tauri/src/database/dao/profiles.rs @@ -1,16 +1,21 @@ //! 项目 Profile 数据访问对象 //! -//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt), -//! payload 为原始 JSON 文本,解析在 service 层进行。 -//! current_profile_id 存放于 settings 表(key-value)。 +//! profiles 表存放全应用共享的项目实体(供应商/MCP/Skills/Prompt 快照), +//! payload 为原始 JSON 文本(按 app 分槽),解析在 service 层进行。 +//! 各应用分组(scope)独立的 current 标记存放于 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"; +/// 每个 scope 的 current 标记 key = 前缀 + scope(如 current_profile_id_claude) +const CURRENT_PROFILE_ID_KEY_PREFIX: &str = "current_profile_id_"; -/// 项目 Profile 记录 +fn current_profile_key(scope: &str) -> String { + format!("{CURRENT_PROFILE_ID_KEY_PREFIX}{scope}") +} + +/// 项目 Profile 记录(全应用共享,无所属分组) #[derive(Debug, Clone)] pub struct Profile { pub id: String, @@ -109,22 +114,20 @@ impl Database { Ok(affected > 0) } - /// 读取当前激活的项目 id(未使用项目时为 None) - pub fn get_current_profile_id(&self) -> Result, AppError> { - self.get_setting(CURRENT_PROFILE_ID_KEY) + /// 读取某分组当前激活的项目 id(未使用项目时为 None) + pub fn get_current_profile_id(&self, scope: &str) -> Result, AppError> { + self.get_setting(¤t_profile_key(scope)) } - /// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key) - pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> { + /// 设置某分组当前激活的项目 id;None 表示"不使用项目"(删除 key) + pub fn set_current_profile_id(&self, scope: &str, id: Option<&str>) -> Result<(), AppError> { + let key = current_profile_key(scope); match id { - Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id), + Some(id) => self.set_setting(&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()))?; + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } } @@ -180,17 +183,25 @@ mod tests { } #[test] - fn test_current_profile_id_set_and_clear() -> Result<(), AppError> { + fn test_current_profile_id_is_scoped() -> 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); + assert_eq!(db.get_current_profile_id("claude")?, None); + assert_eq!(db.get_current_profile_id("codex")?, None); + + // 两个分组的标记互不影响 + db.set_current_profile_id("claude", Some("a"))?; + db.set_current_profile_id("codex", Some("b"))?; + assert_eq!(db.get_current_profile_id("claude")?, Some("a".to_string())); + assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string())); + + db.set_current_profile_id("claude", None)?; + assert_eq!(db.get_current_profile_id("claude")?, None); + assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string())); + // 重复清除应幂等 - db.set_current_profile_id(None)?; - assert_eq!(db.get_current_profile_id()?, None); + db.set_current_profile_id("claude", None)?; + assert_eq!(db.get_current_profile_id("claude")?, None); Ok(()) } } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 9ab8454bd..9ee0f7ea2 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -295,7 +295,8 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 19. Profiles 表(项目配置方案:按 app 快照供应商/MCP/Skills/Prompt) + // 19. Profiles 表(全应用共享的项目实体,payload 按 app 分槽快照 + // 供应商/MCP/Skills/Prompt;各应用分组的 current 标记在 settings 表) conn.execute( "CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, @@ -309,6 +310,20 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组 + // (随 v12 定稿为 current_profile_id_,不单独 bump 版本) + if conn + .execute( + "INSERT OR REPLACE INTO settings (key, value) + SELECT 'current_profile_id_claude', value FROM settings + WHERE key = 'current_profile_id'", + [], + ) + .is_ok() + { + let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []); + } + // 尝试添加 live_takeover_active 列到 proxy_config 表 let _ = conn.execute( "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0", diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index c979ee731..84fe56837 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -426,6 +426,62 @@ fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() { ); } +#[test] +fn schema_create_tables_repairs_dev_global_profile_marker() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 模拟跑过未发布开发版的库:user_version 已是 12(迁移不会再跑), + // 但 current 标记还是全局 key(现按应用分组) + conn.execute_batch( + r#" + CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + ); + INSERT INTO profiles (id, name, payload) VALUES ('p1', 'Project A', '{}'); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO settings (key, value) VALUES ('current_profile_id', 'p1'); + "#, + ) + .expect("seed dev v12 shape"); + Database::set_user_version(&conn, 12).expect("set user_version=12"); + + Database::create_tables_on_conn(&conn).expect("create tables should repair marker"); + + // 全局 current 标记改名为 claude 组标记,旧 key 删除 + let claude_marker: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'current_profile_id_claude'", + [], + |row| row.get(0), + ) + .expect("scoped current marker"); + assert_eq!(claude_marker, "p1"); + let old_marker: i64 = conn + .query_row( + "SELECT COUNT(*) FROM settings WHERE key = 'current_profile_id'", + [], + |row| row.get(0), + ) + .expect("count old marker"); + assert_eq!(old_marker, 0); + + // 修复必须幂等:再跑一遍不应破坏已迁移的标记 + Database::create_tables_on_conn(&conn).expect("repair is idempotent"); + let claude_marker: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'current_profile_id_claude'", + [], + |row| row.get(0), + ) + .expect("scoped current marker survives"); + assert_eq!(claude_marker, "p1"); +} + #[test] fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() { let conn = Connection::open_in_memory().expect("open memory db"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 962606fb0..fa73a8d92 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,7 +54,7 @@ pub use mcp::{ pub use prompt::Prompt; pub use provider::{Provider, ProviderMeta}; pub use services::{ - profile::{ProfilePayload, ProfileService}, + profile::{ProfilePayload, ProfileScope, ProfileService}, skill::{migrate_skills_to_ssot, ImportSkillSelection}, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, SkillService, SpeedtestService, diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs index ff8f996e1..b229a7941 100644 --- a/src-tauri/src/services/profile.rs +++ b/src-tauri/src/services/profile.rs @@ -1,6 +1,10 @@ //! 项目 Profile 编排服务 //! -//! Profile 是一份按 app 的配置快照(供应商 / MCP / Skills / Prompt), +//! Profile 是**全应用共享的项目实体**(用户拥有的项目就那几个),payload +//! 按 app 分槽存配置快照(供应商 / MCP / Skills / Prompt)。快照与应用 +//! 均**按分组(scope)操作**:Claude Code 与 Codex 的工作目录往往不同 +//! (各在各的项目里),因此各组独立指向自己的当前项目、只拍/只应用组内 +//! 槽位,互不牵连;重命名/删除作用于共享实体本身。 //! 应用(apply)时复用现有切换原语批量落地: //! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方) //! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化) @@ -19,12 +23,62 @@ use crate::error::AppError; use crate::services::{McpService, PromptService, ProviderService, SkillService}; use crate::store::AppState; -/// 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段) +/// Profile 操作的应用分组:项目实体全应用共享,但快照/应用/当前指针按组进行 /// -/// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for` -/// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None), -/// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。 -pub const PROFILE_APPS: [AppType; 3] = [AppType::Claude, AppType::ClaudeDesktop, AppType::Codex]; +/// Claude Desktop 并入 Claude 组:它在 cc-switch 里只有聊天侧供应商一个 +/// 受管维度,且其内嵌 Code 标签页读的就是 Claude Code 的那套 live 文件 +/// (`~/.claude/*`,天然跟随),不足以构成独立的"项目"维度。 +/// 供应商 live 文件两组零交集(`~/.claude` / `~/.codex` / +/// `Application Support/Claude-3p`),分组切换互不干扰。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileScope { + Claude, + Codex, +} + +impl ProfileScope { + /// 全部分组(扩展新分组时同步扩展 apps/for_app 与前端 scope.ts 镜像) + pub const ALL: [ProfileScope; 2] = [ProfileScope::Claude, ProfileScope::Codex]; + + pub fn as_str(&self) -> &'static str { + match self { + ProfileScope::Claude => "claude", + ProfileScope::Codex => "codex", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "claude" => Ok(ProfileScope::Claude), + "codex" => Ok(ProfileScope::Codex), + other => Err(AppError::InvalidInput(format!( + "Unknown profile scope: {other}" + ))), + } + } + + /// 组内受管应用(快照与 apply 只作用于这些 app 的槽位) + /// + /// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for` + /// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None), + /// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。 + pub fn apps(&self) -> &'static [AppType] { + match self { + ProfileScope::Claude => &[AppType::Claude, AppType::ClaudeDesktop], + ProfileScope::Codex => &[AppType::Codex], + } + } + + /// 应用页 → 所属分组(Profile 不支持的应用返回 None) + pub fn for_app(app: &AppType) -> Option { + match app { + AppType::Claude | AppType::ClaudeDesktop => Some(ProfileScope::Claude), + AppType::Codex => Some(ProfileScope::Codex), + _ => None, + } + } +} /// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -57,19 +111,56 @@ impl PerApp { } /// Profile 的 JSON 快照结构(与前端 TS 类型严格对应) +/// +/// 所有槽位都是 Option:None = 该侧从未拍过快照(应用时不动), +/// 与"拍到的就是空集/无激活项"(Some(空),应用时清空启用)严格区分—— +/// 在 Codex 页选中一个只在 Claude 页建过的项目不能误清 Codex 的启用状态。 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProfilePayload { - /// 每 app 的当前供应商 id(None = 快照时无当前供应商,应用时不动) + /// 每 app 的当前供应商 id pub providers: PerApp>, /// 每 app 启用的 MCP server id 集合 - pub mcp: PerApp>, + pub mcp: PerApp>>, /// 每 app 启用的 Skill id 集合 - pub skills: PerApp>, - /// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动) + pub skills: PerApp>>, + /// 每 app 激活的 prompt id pub prompts: PerApp>, } +impl ProfilePayload { + /// 用另一份快照覆盖本载荷中某分组的槽位,其余分组原样保留 + /// ("以当前状态更新"只更新发起页所属分组,避免把别的应用 + /// 正处于其他项目的状态串进来) + pub fn merge_scope_from(&mut self, other: &ProfilePayload, scope: ProfileScope) { + for app in scope.apps() { + if let (Some(dst), Some(src)) = (self.providers.get_mut(app), other.providers.get(app)) + { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.mcp.get_mut(app), other.mcp.get(app)) { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.skills.get_mut(app), other.skills.get(app)) { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.prompts.get_mut(app), other.prompts.get(app)) { + *dst = src.clone(); + } + } + } + + /// 某分组是否拍过快照(任一槽位非 None 即视为拍过) + pub fn scope_captured(&self, scope: ProfileScope) -> bool { + scope.apps().iter().any(|app| { + self.providers.get(app).is_some_and(|s| s.is_some()) + || self.mcp.get(app).is_some_and(|s| s.is_some()) + || self.skills.get(app).is_some_and(|s| s.is_some()) + || self.prompts.get(app).is_some_and(|s| s.is_some()) + }) + } +} + /// 计算从当前启用状态到目标集合的最小 toggle 集 /// /// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表) @@ -98,29 +189,36 @@ fn plan_toggles( pub struct ProfileService; impl ProfileService { - /// 抓取当前配置状态生成快照 - pub fn snapshot_current(state: &AppState) -> Result { + /// 抓取分组内应用的当前配置状态生成快照(组外槽位保持默认值) + pub fn snapshot_current( + state: &AppState, + scope: ProfileScope, + ) -> Result { 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() { + for app in scope.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(); + *slot = Some( + 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(); + *slot = Some( + 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 @@ -134,20 +232,19 @@ impl ProfileService { Ok(payload) } - /// 列出所有项目及当前激活项目 id - pub fn list(state: &AppState) -> Result<(Vec, Option), AppError> { - let profiles = state.db.get_all_profiles()?; - let current = state.db.get_current_profile_id()?; - Ok((profiles, current)) + /// 列出所有项目(项目实体全应用共享,current 标记按分组单独读取) + pub fn list(state: &AppState) -> Result, AppError> { + state.db.get_all_profiles() } - /// 以当前配置状态创建新项目 - pub fn create(state: &AppState, name: &str) -> Result { + /// 创建新项目:只拍发起页所属分组的当前状态,其余分组槽位留 None + /// (其他应用可能正处于别的项目,不能替用户拍进来) + pub fn create(state: &AppState, name: &str, scope: ProfileScope) -> Result { 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 payload = Self::snapshot_current(state, scope)?; let now = chrono::Utc::now().timestamp(); let profile = Profile { id: uuid::Uuid::new_v4().to_string(), @@ -162,12 +259,14 @@ impl ProfileService { Ok(profile) } - /// 更新项目:重命名和/或以当前状态重拍快照 + /// 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照 + /// (resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留) pub fn update( state: &AppState, id: &str, name: Option, resnapshot: bool, + scope: Option, ) -> Result { let mut profile = state .db @@ -182,7 +281,12 @@ impl ProfileService { profile.name = name; } if resnapshot { - let payload = Self::snapshot_current(state)?; + let scope = scope.ok_or_else(|| { + AppError::InvalidInput("Resnapshot requires a profile scope".to_string()) + })?; + let mut payload: ProfilePayload = serde_json::from_str(&profile.payload) + .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; + payload.merge_scope_from(&Self::snapshot_current(state, scope)?, scope); profile.payload = serde_json::to_string(&payload) .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?; } @@ -191,20 +295,29 @@ impl ProfileService { 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)?; + for scope in ProfileScope::ALL { + if state.db.get_current_profile_id(scope.as_str())?.as_deref() == Some(id) { + state.db.set_current_profile_id(scope.as_str(), None)?; + } } Ok(()) } /// 应用项目快照(best-effort,返回 warnings) /// + /// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。 + /// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示 + /// (用户可先绑定项目、再"以当前状态更新"补拍该侧快照)。 /// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP /// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。 - pub fn apply(state: &AppState, profile_id: &str) -> Result, AppError> { + pub fn apply( + state: &AppState, + profile_id: &str, + scope: ProfileScope, + ) -> Result, AppError> { let profile = state .db .get_profile(profile_id)? @@ -213,8 +326,14 @@ impl ProfileService { .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; let mut warnings = Vec::new(); + if !payload.scope_captured(scope) { + warnings.push(format!( + "no {} configuration captured in this project yet; marked as current without changes", + scope.as_str() + )); + } - for app in PROFILE_APPS.iter() { + for app in scope.apps().iter() { let app_str = app.as_str(); // 1. 供应商 @@ -237,8 +356,8 @@ impl ProfileService { } } - // 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目) - if let Some(target_ids) = payload.mcp.get(app) { + // 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动) + if let Some(Some(target_ids)) = payload.mcp.get(app) { let servers = state.db.get_all_mcp_servers()?; let current: Vec<(String, bool)> = servers .values() @@ -258,7 +377,7 @@ impl ProfileService { } // 3. Skills diff(SkillService 返回 anyhow::Result,收进 warning) - if let Some(target_ids) = payload.skills.get(app) { + if let Some(Some(target_ids)) = payload.skills.get(app) { let skills = state.db.get_all_installed_skills()?; let current: Vec<(String, bool)> = skills .values() @@ -300,7 +419,9 @@ impl ProfileService { } } - state.db.set_current_profile_id(Some(profile_id))?; + state + .db + .set_current_profile_id(scope.as_str(), Some(profile_id))?; Ok(warnings) } } @@ -322,14 +443,14 @@ mod tests { codex: None, }, mcp: PerApp { - claude: ids(&["m1", "m2"]), - claude_desktop: vec![], - codex: vec![], + claude: Some(ids(&["m1", "m2"])), + claude_desktop: Some(vec![]), + codex: None, }, skills: PerApp { - claude: vec![], - claude_desktop: vec![], - codex: ids(&["s1"]), + claude: Some(vec![]), + claude_desktop: Some(vec![]), + codex: Some(ids(&["s1"])), }, prompts: PerApp { claude: None, @@ -348,21 +469,80 @@ mod tests { #[test] fn test_payload_tolerates_missing_fields() { - // 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错 - // (P1 存量 payload 没有 claude-desktop key,应用时对 Desktop 不动) + // 前向兼容:旧版/部分字段缺失时应落到 None("该侧未拍过")而不是报错, + // 应用时对缺失槽位不做任何改动 let back: ProfilePayload = - serde_json::from_str(r#"{"providers":{"claude":"p1"}}"#).unwrap(); + serde_json::from_str(r#"{"providers":{"claude":"p1"},"mcp":{"claude":["m1"]}}"#) + .unwrap(); assert_eq!(back.providers.claude, Some("p1".to_string())); assert_eq!(back.providers.claude_desktop, None); assert_eq!(back.providers.codex, None); - assert!(back.mcp.claude.is_empty()); - assert!(back.mcp.claude_desktop.is_empty()); + assert_eq!(back.mcp.claude, Some(ids(&["m1"]))); + assert_eq!(back.mcp.claude_desktop, None); + assert_eq!(back.mcp.codex, None, "missing slot means untouched"); assert_eq!(back.prompts.codex, None); let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); assert_eq!(empty, ProfilePayload::default()); } + #[test] + fn test_merge_scope_from_only_touches_scope_slots() { + // 项目 A:两侧都已拍过快照 + let mut payload = ProfilePayload { + providers: PerApp { + claude: Some("p1".into()), + claude_desktop: Some("d1".into()), + codex: Some("c1".into()), + }, + mcp: PerApp { + claude: Some(ids(&["m1"])), + claude_desktop: Some(vec![]), + codex: Some(ids(&["m9"])), + }, + ..Default::default() + }; + // 在 Claude 页"以当前状态更新":只覆盖 claude 组槽位 + let fresh = ProfilePayload { + providers: PerApp { + claude: Some("p2".into()), + claude_desktop: None, + codex: Some("SHOULD-NOT-LEAK".into()), + }, + mcp: PerApp { + claude: Some(ids(&["m2"])), + claude_desktop: Some(vec![]), + codex: None, + }, + ..Default::default() + }; + payload.merge_scope_from(&fresh, ProfileScope::Claude); + + assert_eq!(payload.providers.claude, Some("p2".to_string())); + assert_eq!(payload.providers.claude_desktop, None); + assert_eq!(payload.mcp.claude, Some(ids(&["m2"]))); + // codex 侧完好:既没被覆盖也没被 fresh 的值污染 + assert_eq!(payload.providers.codex, Some("c1".to_string())); + assert_eq!(payload.mcp.codex, Some(ids(&["m9"]))); + } + + #[test] + fn test_scope_captured_detects_per_scope_snapshot() { + let mut payload = ProfilePayload::default(); + assert!(!payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::Codex)); + + // 只拍过 claude 组(哪怕拍到的是空集) + payload.mcp.claude = Some(vec![]); + assert!(payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::Codex)); + + // Desktop 槽位属于 claude 组 + let mut desktop_only = ProfilePayload::default(); + desktop_only.providers.claude_desktop = Some("d1".into()); + assert!(desktop_only.scope_captured(ProfileScope::Claude)); + } + #[test] fn test_per_app_get_only_supports_profile_apps() { let per: PerApp> = PerApp::default(); @@ -372,6 +552,36 @@ mod tests { assert!(per.get(&AppType::Gemini).is_none()); } + #[test] + fn test_scope_serde_and_parse_roundtrip() { + for scope in ProfileScope::ALL { + // DB 存储字符串(as_str/parse)与 JSON 序列化必须是同一形式 + assert_eq!( + serde_json::to_string(&scope).unwrap(), + format!("\"{}\"", scope.as_str()) + ); + assert_eq!(ProfileScope::parse(scope.as_str()).unwrap(), scope); + } + assert!(ProfileScope::parse("gemini").is_err()); + assert!(ProfileScope::parse("").is_err()); + } + + #[test] + fn test_scope_app_grouping() { + // Desktop 并入 Claude 组;组内应用与 for_app 反向映射必须一致 + assert_eq!( + ProfileScope::Claude.apps(), + &[AppType::Claude, AppType::ClaudeDesktop] + ); + assert_eq!(ProfileScope::Codex.apps(), &[AppType::Codex]); + for scope in ProfileScope::ALL { + for app in scope.apps() { + assert_eq!(ProfileScope::for_app(app), Some(scope)); + } + } + assert_eq!(ProfileScope::for_app(&AppType::Gemini), None); + } + #[test] fn test_plan_toggles_minimal_diff() { let current = vec![ diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index b61b19051..1d34a7d31 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -338,34 +338,54 @@ fn sort_providers( /// 处理项目 Profile 托盘事件,返回是否已处理 /// -/// 事件 id 形如 `profile_`;`profile_none` 表示"不使用项目"(只清标记,不动配置)。 +/// 事件 id 形如 `profile__`(同一项目在各分组子菜单里各有一项, +/// 应用时只作用于该分组);`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(scope_str) = suffix.strip_prefix("none_") { + let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else { + log::error!("未知的项目分组托盘事件: {event_id}"); + return true; + }; if let Some(app_state) = app.try_state::() { - if let Err(e) = app_state.db.set_current_profile_id(None) { + if let Err(e) = app_state.db.set_current_profile_id(scope.as_str(), None) { log::error!("清除当前项目失败: {e}"); } } - // 通知主窗口刷新(profileId=null 表示已清除当前项目) - if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) { + // 通知主窗口刷新(profileId=null 表示该分组已清除当前项目) + if let Err(e) = app.emit( + "profile-applied", + serde_json::json!({ "profileId": null, "scope": scope.as_str() }), + ) { log::error!("发射 profile-applied 事件失败: {e}"); } refresh_tray_menu(app); return true; } - log::info!("应用项目: {suffix}"); + // scope 是固定枚举字符串(不含下划线),uuid 只含连字符,首个下划线即分界 + let Some((scope_str, profile_id)) = suffix.split_once('_') else { + log::error!("无法解析项目托盘事件: {event_id}"); + return true; + }; + let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else { + log::error!("未知的项目分组托盘事件: {event_id}"); + return true; + }; + + log::info!("应用项目: {profile_id}({scope_str} 组)"); let app_handle = app.clone(); - let profile_id = suffix.to_string(); + let profile_id = profile_id.to_string(); tauri::async_runtime::spawn_blocking(move || { let Some(app_state) = app_handle.try_state::() else { return; }; - match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id) { + match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id, scope) + { Ok(warnings) => { for warning in &warnings { log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}"); @@ -374,6 +394,7 @@ pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool &app_handle, app_state.inner(), &profile_id, + scope, ); } Err(e) => { @@ -664,40 +685,83 @@ pub fn create_tray_menu( menu_builder = menu_builder.separator(); } - // 项目 Profile 子菜单(受支持应用至少一个可见且存在项目时才显示) - if crate::services::profile::PROFILE_APPS - .iter() - .any(|app_type| visible_apps.is_visible(app_type)) + // 项目 Profile 子菜单:项目列表全应用共享,按分组嵌套子菜单各自勾选/应用 + // (组内应用可见且存在项目时才显示该组) { - 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); + use crate::services::profile::ProfileScope; + + let any_scope_visible = ProfileScope::ALL.iter().any(|scope| { + scope + .apps() + .iter() + .any(|app_type| visible_apps.is_visible(app_type)) + }); + let profiles = if any_scope_visible { + app_state.db.get_all_profiles()? + } else { + Vec::new() + }; + + let mut scope_submenus = Vec::new(); + for scope in ProfileScope::ALL { + if profiles.is_empty() + || !scope + .apps() + .iter() + .any(|app_type| visible_apps.is_visible(app_type)) + { + continue; + } + let current_profile_id = app_state + .db + .get_current_profile_id(scope.as_str())? + .unwrap_or_default(); + // 分组标签用产品名,不进 i18n + let scope_label = match scope { + ProfileScope::Claude => "Claude Code", + ProfileScope::Codex => "Codex", + }; + let mut scope_builder = SubmenuBuilder::with_id( + app, + format!("submenu_profiles_{}", scope.as_str()), + scope_label, + ); for profile in &profiles { let item = CheckMenuItem::with_id( app, - format!("profile_{}", profile.id), + format!("profile_{}_{}", scope.as_str(), 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); + scope_builder = scope_builder.item(&item); } let none_item = CheckMenuItem::with_id( app, - "profile_none", + format!("profile_none_{}", scope.as_str()), 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 + let scope_submenu = scope_builder .separator() .item(&none_item) + .build() + .map_err(|e| AppError::Message(format!("构建项目分组子菜单失败: {e}")))?; + scope_submenus.push(scope_submenu); + } + + if !scope_submenus.is_empty() { + let mut profiles_builder = + SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label); + for scope_submenu in &scope_submenus { + profiles_builder = profiles_builder.item(scope_submenu); + } + let profiles_submenu = profiles_builder .build() .map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?; menu_builder = menu_builder.item(&profiles_submenu).separator(); diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs index f17102b84..ae100182e 100644 --- a/src-tauri/tests/profile_roundtrip.rs +++ b/src-tauri/tests/profile_roundtrip.rs @@ -7,8 +7,8 @@ use std::fs; use serde_json::json; use cc_switch_lib::{ - AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt, - PromptService, Provider, ProviderService, SkillApps, SkillService, + AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService, + Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService, }; #[path = "support.rs"] @@ -174,23 +174,28 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .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"); + // ---- 保存项目 A(在 Claude 页新建:只拍 Claude + Desktop 当前状态)---- + let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude) + .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.mcp.claude, Some(vec!["m1".to_string()])); + assert_eq!( + payload.skills.claude, + Some(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" + "codex side not captured when creating from the claude group" ); - assert!(payload.mcp.codex.is_empty()); + assert_eq!(payload.mcp.codex, None, "uncaptured side stays None"); assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1")); - assert!( - payload.mcp.claude_desktop.is_empty() && payload.skills.claude_desktop.is_empty(), - "desktop has no MCP/Skills dimension" + assert_eq!( + (payload.mcp.claude_desktop, payload.skills.claude_desktop), + (Some(vec![]), Some(vec![])), + "desktop has no MCP/Skills dimension (captured as empty)" ); assert_eq!(payload.prompts.claude_desktop, None); @@ -206,8 +211,9 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .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"); + // ---- 应用项目 A(Claude 组):全部复原 ---- + let warnings = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude) + .expect("apply profile A"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); let current = state @@ -253,11 +259,133 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { assert_eq!( state .db - .get_current_profile_id() + .get_current_profile_id("claude") .expect("get current profile id") .as_deref(), Some(profile_a.id.as_str()), - "profile A marked as current" + "profile A marked as current for claude scope" + ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id"), + None, + "codex scope marker untouched by claude-group apply" + ); +} + +#[test] +fn shared_profile_sides_are_isolated_and_mergeable() { + 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"); + + // 种子:Claude 侧有当前供应商 + 启用的 MCP + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1")) + .expect("save provider p1"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set current provider p1"); + 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"); + + // 在 Codex 页新建项目:快照不应捕获 Claude 侧的任何状态 + let project = ProfileService::create(&state, "Shared Project", ProfileScope::Codex) + .expect("create project from codex tab"); + let payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse project payload"); + assert_eq!( + payload.providers.claude, None, + "claude slot not captured by codex-side snapshot" + ); + assert_eq!(payload.mcp.claude, None); + assert_eq!(payload.providers.claude_desktop, None); + assert_eq!(payload.mcp.codex, Some(vec![]), "codex side captured"); + + // 按 Codex 组应用:只动 codex 组的 current 标记,Claude 侧原样不动 + let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Codex) + .expect("apply project on codex side"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get claude current provider") + .as_deref(), + Some("p1"), + "claude provider untouched" + ); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "claude MCP untouched" + ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id") + .as_deref(), + Some(project.id.as_str()) + ); + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get claude current profile id"), + None, + "claude scope marker untouched by codex-side apply" + ); + + // 同一共享项目在 Claude 页应用:该侧未拍过快照 → 不动配置、标记 current、返回提示 + let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + .expect("apply project on claude side"); + assert_eq!(warnings.len(), 1, "uncaptured side yields one hint"); + assert!(warnings[0].contains("no claude configuration captured")); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "claude MCP still untouched by uncaptured apply" + ); + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get claude current profile id") + .as_deref(), + Some(project.id.as_str()), + "claude side now bound to the shared project" + ); + + // 在 Claude 页"以当前状态更新":补拍 claude 侧,codex 侧快照原样保留 + let updated = + ProfileService::update(&state, &project.id, None, true, Some(ProfileScope::Claude)) + .expect("resnapshot claude side"); + let payload: ProfilePayload = + serde_json::from_str(&updated.payload).expect("parse updated payload"); + assert_eq!(payload.providers.claude.as_deref(), Some("p1")); + assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()])); + assert_eq!( + payload.mcp.codex, + Some(vec![]), + "codex side snapshot preserved by claude-side resnapshot" ); } @@ -291,7 +419,8 @@ fn profile_apply_reports_dangling_references_and_continues() { }; state.db.save_profile(&profile).expect("save profile"); - let warnings = ProfileService::apply(&state, "dangling-test").expect("apply succeeds"); + let warnings = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude) + .expect("apply succeeds"); assert_eq!( warnings.len(), 4, @@ -305,11 +434,11 @@ fn profile_apply_reports_dangling_references_and_continues() { "m1 enabled despite warnings" ); - // best-effort 完成后仍标记为当前项目 + // best-effort 完成后仍标记为所属分组的当前项目 assert_eq!( state .db - .get_current_profile_id() + .get_current_profile_id("claude") .expect("get current profile id") .as_deref(), Some("dangling-test") @@ -317,7 +446,7 @@ fn profile_apply_reports_dangling_references_and_continues() { } #[test] -fn clear_current_profile_only_clears_marker() { +fn clear_current_profile_only_clears_scoped_marker() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); @@ -326,17 +455,31 @@ fn clear_current_profile_only_clears_marker() { state .db - .set_current_profile_id(Some("some-profile")) - .expect("set current profile"); + .set_current_profile_id("claude", Some("claude-profile")) + .expect("set claude current profile"); state .db - .set_current_profile_id(None) - .expect("clear current profile"); + .set_current_profile_id("codex", Some("codex-profile")) + .expect("set codex current profile"); + + // 清除 claude 组不影响 codex 组 + state + .db + .set_current_profile_id("claude", None) + .expect("clear claude current profile"); assert_eq!( state .db - .get_current_profile_id() - .expect("get current profile id"), + .get_current_profile_id("claude") + .expect("get claude current profile id"), None ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id") + .as_deref(), + Some("codex-profile") + ); } diff --git a/src/components/profiles/ProfileManageDialog.tsx b/src/components/profiles/ProfileManageDialog.tsx index 59746bf65..e41307df4 100644 --- a/src/components/profiles/ProfileManageDialog.tsx +++ b/src/components/profiles/ProfileManageDialog.tsx @@ -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} diff --git a/src/components/profiles/ProfileSwitcher.tsx b/src/components/profiles/ProfileSwitcher.tsx index 4843490ef..fb615545b 100644 --- a/src/components/profiles/ProfileSwitcher.tsx +++ b/src/components/profiles/ProfileSwitcher.tsx @@ -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) { )} /> {profile.name} + {!hasScopeSnapshot(profile, scope) && ( + + {t("profiles.noSnapshotForScope")} + + )} ))} @@ -167,7 +173,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { keywords={[t("profiles.none")]} onSelect={() => { setOpen(false); - clearMutation.mutate(); + clearMutation.mutate(scope); }} > @@ -203,7 +209,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { {t("profiles.createFromCurrent")} - {t("profiles.createDescription")} + {t(`profiles.createDescription.${scope}`)}
@@ -233,6 +239,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { setIsManageOpen(false)} /> diff --git a/src/components/profiles/scope.ts b/src/components/profiles/scope.ts new file mode 100644 index 000000000..5e2d394cf --- /dev/null +++ b/src/components/profiles/scope.ts @@ -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> = { + claude: "claude", + "claude-desktop": "claude", + codex: "codex", +}; + +/** 分组显示名(产品名,不进 i18n;与后端托盘子菜单标签一致) */ +export const PROFILE_SCOPE_LABELS: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** 分组内的 payload 槽位 key(后端 ProfileScope::apps 的前端镜像) */ +const SCOPE_SLOT_KEYS: Record)[]> = { + 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, + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e0f018665..26de81ce0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a88f820f..45c9b33d7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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}}」を削除してもよろしいですか?現在の設定は変更されません。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 705d3debe..3f2412eb9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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}}\" 嗎?不會改動任何現有設定。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index fa02290db..6dfb7a921 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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}}\" 吗?不会改动任何现有配置。", diff --git a/src/lib/api/profiles.ts b/src/lib/api/profiles.ts index 1dc90be45..7b52496d1 100644 --- a/src/lib/api/profiles.ts +++ b/src/lib/api/profiles.ts @@ -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 严格对应) */ @@ -11,11 +19,14 @@ export interface PerApp { /** * 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应) + * + * 所有槽位 null = 该侧从未拍过快照(应用时不动),与"拍到的就是空集" + * (空数组,应用时清空启用)严格区分。 */ export interface ProfilePayload { providers: PerApp; - mcp: PerApp; - skills: PerApp; + mcp: PerApp; + skills: PerApp; prompts: PerApp; } @@ -27,37 +38,42 @@ export interface Profile { updatedAt?: number; } +/** 每个分组当前激活的项目 id(未使用项目时为 null) */ +export type CurrentProfileIds = Record; + export interface ProfilesResponse { profiles: Profile[]; - currentId: string | null; + currentIds: CurrentProfileIds; } export const profilesApi = { /** - * 获取所有项目及当前激活项目 id + * 获取所有项目及各分组当前激活项目 id */ async list(): Promise { return await invoke("list_profiles"); }, /** - * 以当前配置状态创建新项目 + * 创建新项目(只拍发起页所属分组的当前状态,其余分组槽位留空) */ - async create(name: string): Promise { - return await invoke("create_profile", { name }); + async create(name: string, scope: ProfileScope): Promise { + 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 { return await invoke("update_profile", { id, name: options.name, resnapshot: options.resnapshot, + scope: options.scope, }); }, @@ -69,16 +85,17 @@ export const profilesApi = { }, /** - * 应用项目快照,返回 warnings(best-effort,部分失败不中断) + * 应用项目快照(只作用于发起页所属分组内的应用),返回 warnings + * (best-effort,部分失败不中断) */ - async apply(id: string): Promise { - return await invoke("apply_profile", { id }); + async apply(id: string, scope: ProfileScope): Promise { + return await invoke("apply_profile", { id, scope }); }, /** - * 不使用项目:仅清除激活标记,不改动任何配置 + * 不使用项目:仅清除某分组的激活标记,不改动任何配置 */ - async clearCurrent(): Promise { - return await invoke("clear_current_profile"); + async clearCurrent(scope: ProfileScope): Promise { + return await invoke("clear_current_profile", { scope }); }, }; diff --git a/src/lib/query/profiles.ts b/src/lib/query/profiles.ts index 6488ba094..079128d97 100644 --- a/src/lib/query/profiles.ts +++ b/src/lib/query/profiles.ts @@ -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({