mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
refactor(profiles): shared project entity with per-scope switching
Projects are now global shared entities; Claude and Codex groups switch independently via scoped current pointers and scoped payload slots. - Remove scope column from profiles; keep current_profile_id_<scope> - Use Option<Vec<String>> for mcp/skills to distinguish 'never captured' from 'captured empty', preventing cross-side accidental disable - Update/apply operations scoped to the active group via merge_scope_from - Tray menu nests same shared list under Claude Code/Codex groups - Add i18n for per-scope tooltips and 'not saved for this side' hint Refs: profile P1 shared-entity redesign
This commit is contained in:
@@ -4,7 +4,7 @@ use serde::Serialize;
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::database::Profile;
|
use crate::database::Profile;
|
||||||
use crate::services::profile::{ProfilePayload, ProfileService, PROFILE_APPS};
|
use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -39,19 +39,33 @@ impl From<Profile> for ProfileDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 每个分组当前激活的项目 id(未使用项目时为 null)
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CurrentProfileIds {
|
||||||
|
pub claude: Option<String>,
|
||||||
|
pub codex: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfilesResponse {
|
pub struct ProfilesResponse {
|
||||||
pub profiles: Vec<ProfileDto>,
|
pub profiles: Vec<ProfileDto>,
|
||||||
pub current_id: Option<String>,
|
pub current_ids: CurrentProfileIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
|
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
|
||||||
///
|
///
|
||||||
/// UI 与托盘两个入口必须共用此函数,保证事件 payload 形状一致
|
/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须
|
||||||
/// (前端 App.tsx 的 provider-switched 监听依赖该形状)。
|
/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的
|
||||||
pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profile_id: &str) {
|
/// provider-switched 监听依赖该形状)。
|
||||||
for app_type in PROFILE_APPS.iter() {
|
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 app_str = app_type.as_str();
|
||||||
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_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)
|
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(
|
if let Err(e) = app.emit(
|
||||||
"profile-applied",
|
"profile-applied",
|
||||||
serde_json::json!({ "profileId": profile_id }),
|
serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }),
|
||||||
) {
|
) {
|
||||||
log::error!("发射 profile-applied 事件失败: {e}");
|
log::error!("发射 profile-applied 事件失败: {e}");
|
||||||
}
|
}
|
||||||
@@ -79,16 +93,31 @@ pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profi
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
|
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
|
||||||
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 {
|
Ok(ProfilesResponse {
|
||||||
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
|
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
|
||||||
current_id,
|
current_ids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_profile(state: State<'_, AppState>, name: String) -> Result<ProfileDto, String> {
|
pub fn create_profile(
|
||||||
ProfileService::create(&state, &name)
|
state: State<'_, AppState>,
|
||||||
|
name: String,
|
||||||
|
scope: String,
|
||||||
|
) -> Result<ProfileDto, String> {
|
||||||
|
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
|
||||||
|
ProfileService::create(&state, &name, scope)
|
||||||
.map(ProfileDto::from)
|
.map(ProfileDto::from)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -99,8 +128,13 @@ pub fn update_profile(
|
|||||||
id: String,
|
id: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
resnapshot: Option<bool>,
|
resnapshot: Option<bool>,
|
||||||
|
scope: Option<String>,
|
||||||
) -> Result<ProfileDto, String> {
|
) -> Result<ProfileDto, String> {
|
||||||
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(ProfileDto::from)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -111,14 +145,15 @@ pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_profile_id(None)
|
.set_current_profile_id(scope.as_str(), None)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用项目快照。
|
/// 应用项目快照(只作用于发起页所属分组内的应用)。
|
||||||
///
|
///
|
||||||
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
|
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
|
||||||
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
|
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
|
||||||
@@ -127,8 +162,10 @@ pub fn apply_profile(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
|
scope: String,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
let warnings = ProfileService::apply(&state, &id).map_err(|e| e.to_string())?;
|
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
|
||||||
emit_profile_apply_events(&app, &state, &id);
|
let warnings = ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?;
|
||||||
|
emit_profile_apply_events(&app, &state, &id, scope);
|
||||||
Ok(warnings)
|
Ok(warnings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
//! 项目 Profile 数据访问对象
|
//! 项目 Profile 数据访问对象
|
||||||
//!
|
//!
|
||||||
//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt),
|
//! profiles 表存放全应用共享的项目实体(供应商/MCP/Skills/Prompt 快照),
|
||||||
//! payload 为原始 JSON 文本,解析在 service 层进行。
|
//! payload 为原始 JSON 文本(按 app 分槽),解析在 service 层进行。
|
||||||
//! current_profile_id 存放于 settings 表(key-value)。
|
//! 各应用分组(scope)独立的 current 标记存放于 settings 表(key-value)。
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use rusqlite::params;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -109,22 +114,20 @@ impl Database {
|
|||||||
Ok(affected > 0)
|
Ok(affected > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取当前激活的项目 id(未使用项目时为 None)
|
/// 读取某分组当前激活的项目 id(未使用项目时为 None)
|
||||||
pub fn get_current_profile_id(&self) -> Result<Option<String>, AppError> {
|
pub fn get_current_profile_id(&self, scope: &str) -> Result<Option<String>, AppError> {
|
||||||
self.get_setting(CURRENT_PROFILE_ID_KEY)
|
self.get_setting(¤t_profile_key(scope))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key)
|
/// 设置某分组当前激活的项目 id;None 表示"不使用项目"(删除 key)
|
||||||
pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> {
|
pub fn set_current_profile_id(&self, scope: &str, id: Option<&str>) -> Result<(), AppError> {
|
||||||
|
let key = current_profile_key(scope);
|
||||||
match id {
|
match id {
|
||||||
Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id),
|
Some(id) => self.set_setting(&key, id),
|
||||||
None => {
|
None => {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||||
"DELETE FROM settings WHERE key = ?1",
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
params![CURRENT_PROFILE_ID_KEY],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,17 +183,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_current_profile_id_set_and_clear() -> Result<(), AppError> {
|
fn test_current_profile_id_is_scoped() -> Result<(), AppError> {
|
||||||
let db = Database::memory()?;
|
let db = Database::memory()?;
|
||||||
|
|
||||||
assert_eq!(db.get_current_profile_id()?, None);
|
assert_eq!(db.get_current_profile_id("claude")?, None);
|
||||||
db.set_current_profile_id(Some("a"))?;
|
assert_eq!(db.get_current_profile_id("codex")?, None);
|
||||||
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("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)?;
|
db.set_current_profile_id("claude", None)?;
|
||||||
assert_eq!(db.get_current_profile_id()?, None);
|
assert_eq!(db.get_current_profile_id("claude")?, None);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,8 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.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(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS profiles (
|
"CREATE TABLE IF NOT EXISTS profiles (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -309,6 +310,20 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组
|
||||||
|
// (随 v12 定稿为 current_profile_id_<scope>,不单独 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 表
|
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub use mcp::{
|
|||||||
pub use prompt::Prompt;
|
pub use prompt::Prompt;
|
||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
profile::{ProfilePayload, ProfileService},
|
profile::{ProfilePayload, ProfileScope, ProfileService},
|
||||||
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||||
SkillService, SpeedtestService,
|
SkillService, SpeedtestService,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
//! 项目 Profile 编排服务
|
//! 项目 Profile 编排服务
|
||||||
//!
|
//!
|
||||||
//! Profile 是一份按 app 的配置快照(供应商 / MCP / Skills / Prompt),
|
//! Profile 是**全应用共享的项目实体**(用户拥有的项目就那几个),payload
|
||||||
|
//! 按 app 分槽存配置快照(供应商 / MCP / Skills / Prompt)。快照与应用
|
||||||
|
//! 均**按分组(scope)操作**:Claude Code 与 Codex 的工作目录往往不同
|
||||||
|
//! (各在各的项目里),因此各组独立指向自己的当前项目、只拍/只应用组内
|
||||||
|
//! 槽位,互不牵连;重命名/删除作用于共享实体本身。
|
||||||
//! 应用(apply)时复用现有切换原语批量落地:
|
//! 应用(apply)时复用现有切换原语批量落地:
|
||||||
//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方)
|
//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方)
|
||||||
//! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化)
|
//! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化)
|
||||||
@@ -19,12 +23,62 @@ use crate::error::AppError;
|
|||||||
use crate::services::{McpService, PromptService, ProviderService, SkillService};
|
use crate::services::{McpService, PromptService, ProviderService, SkillService};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
/// 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段)
|
/// Profile 操作的应用分组:项目实体全应用共享,但快照/应用/当前指针按组进行
|
||||||
///
|
///
|
||||||
/// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for`
|
/// Claude Desktop 并入 Claude 组:它在 cc-switch 里只有聊天侧供应商一个
|
||||||
/// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None),
|
/// 受管维度,且其内嵌 Code 标签页读的就是 Claude Code 的那套 live 文件
|
||||||
/// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。
|
/// (`~/.claude/*`,天然跟随),不足以构成独立的"项目"维度。
|
||||||
pub const PROFILE_APPS: [AppType; 3] = [AppType::Claude, AppType::ClaudeDesktop, AppType::Codex];
|
/// 供应商 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<Self, AppError> {
|
||||||
|
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<Self> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude | AppType::ClaudeDesktop => Some(ProfileScope::Claude),
|
||||||
|
AppType::Codex => Some(ProfileScope::Codex),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致
|
/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -57,19 +111,56 @@ impl<T> PerApp<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应)
|
/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应)
|
||||||
|
///
|
||||||
|
/// 所有槽位都是 Option:None = 该侧从未拍过快照(应用时不动),
|
||||||
|
/// 与"拍到的就是空集/无激活项"(Some(空),应用时清空启用)严格区分——
|
||||||
|
/// 在 Codex 页选中一个只在 Claude 页建过的项目不能误清 Codex 的启用状态。
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ProfilePayload {
|
pub struct ProfilePayload {
|
||||||
/// 每 app 的当前供应商 id(None = 快照时无当前供应商,应用时不动)
|
/// 每 app 的当前供应商 id
|
||||||
pub providers: PerApp<Option<String>>,
|
pub providers: PerApp<Option<String>>,
|
||||||
/// 每 app 启用的 MCP server id 集合
|
/// 每 app 启用的 MCP server id 集合
|
||||||
pub mcp: PerApp<Vec<String>>,
|
pub mcp: PerApp<Option<Vec<String>>>,
|
||||||
/// 每 app 启用的 Skill id 集合
|
/// 每 app 启用的 Skill id 集合
|
||||||
pub skills: PerApp<Vec<String>>,
|
pub skills: PerApp<Option<Vec<String>>>,
|
||||||
/// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动)
|
/// 每 app 激活的 prompt id
|
||||||
pub prompts: PerApp<Option<String>>,
|
pub prompts: PerApp<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 集
|
/// 计算从当前启用状态到目标集合的最小 toggle 集
|
||||||
///
|
///
|
||||||
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
|
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
|
||||||
@@ -98,29 +189,36 @@ fn plan_toggles(
|
|||||||
pub struct ProfileService;
|
pub struct ProfileService;
|
||||||
|
|
||||||
impl ProfileService {
|
impl ProfileService {
|
||||||
/// 抓取当前配置状态生成快照
|
/// 抓取分组内应用的当前配置状态生成快照(组外槽位保持默认值)
|
||||||
pub fn snapshot_current(state: &AppState) -> Result<ProfilePayload, AppError> {
|
pub fn snapshot_current(
|
||||||
|
state: &AppState,
|
||||||
|
scope: ProfileScope,
|
||||||
|
) -> Result<ProfilePayload, AppError> {
|
||||||
let mut payload = ProfilePayload::default();
|
let mut payload = ProfilePayload::default();
|
||||||
let mcp_servers = state.db.get_all_mcp_servers()?;
|
let mcp_servers = state.db.get_all_mcp_servers()?;
|
||||||
let skills = state.db.get_all_installed_skills()?;
|
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) {
|
if let Some(slot) = payload.providers.get_mut(app) {
|
||||||
*slot = crate::settings::get_effective_current_provider(&state.db, app)?;
|
*slot = crate::settings::get_effective_current_provider(&state.db, app)?;
|
||||||
}
|
}
|
||||||
if let Some(slot) = payload.mcp.get_mut(app) {
|
if let Some(slot) = payload.mcp.get_mut(app) {
|
||||||
*slot = mcp_servers
|
*slot = Some(
|
||||||
.values()
|
mcp_servers
|
||||||
.filter(|s| s.apps.is_enabled_for(app))
|
.values()
|
||||||
.map(|s| s.id.clone())
|
.filter(|s| s.apps.is_enabled_for(app))
|
||||||
.collect();
|
.map(|s| s.id.clone())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(slot) = payload.skills.get_mut(app) {
|
if let Some(slot) = payload.skills.get_mut(app) {
|
||||||
*slot = skills
|
*slot = Some(
|
||||||
.values()
|
skills
|
||||||
.filter(|s| s.apps.is_enabled_for(app))
|
.values()
|
||||||
.map(|s| s.id.clone())
|
.filter(|s| s.apps.is_enabled_for(app))
|
||||||
.collect();
|
.map(|s| s.id.clone())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(slot) = payload.prompts.get_mut(app) {
|
if let Some(slot) = payload.prompts.get_mut(app) {
|
||||||
*slot = state
|
*slot = state
|
||||||
@@ -134,20 +232,19 @@ impl ProfileService {
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出所有项目及当前激活项目 id
|
/// 列出所有项目(项目实体全应用共享,current 标记按分组单独读取)
|
||||||
pub fn list(state: &AppState) -> Result<(Vec<Profile>, Option<String>), AppError> {
|
pub fn list(state: &AppState) -> Result<Vec<Profile>, AppError> {
|
||||||
let profiles = state.db.get_all_profiles()?;
|
state.db.get_all_profiles()
|
||||||
let current = state.db.get_current_profile_id()?;
|
|
||||||
Ok((profiles, current))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 以当前配置状态创建新项目
|
/// 创建新项目:只拍发起页所属分组的当前状态,其余分组槽位留 None
|
||||||
pub fn create(state: &AppState, name: &str) -> Result<Profile, AppError> {
|
/// (其他应用可能正处于别的项目,不能替用户拍进来)
|
||||||
|
pub fn create(state: &AppState, name: &str, scope: ProfileScope) -> Result<Profile, AppError> {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(AppError::InvalidInput("Profile name is empty".to_string()));
|
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 now = chrono::Utc::now().timestamp();
|
||||||
let profile = Profile {
|
let profile = Profile {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
@@ -162,12 +259,14 @@ impl ProfileService {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新项目:重命名和/或以当前状态重拍快照
|
/// 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照
|
||||||
|
/// (resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留)
|
||||||
pub fn update(
|
pub fn update(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
id: &str,
|
id: &str,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
resnapshot: bool,
|
resnapshot: bool,
|
||||||
|
scope: Option<ProfileScope>,
|
||||||
) -> Result<Profile, AppError> {
|
) -> Result<Profile, AppError> {
|
||||||
let mut profile = state
|
let mut profile = state
|
||||||
.db
|
.db
|
||||||
@@ -182,7 +281,12 @@ impl ProfileService {
|
|||||||
profile.name = name;
|
profile.name = name;
|
||||||
}
|
}
|
||||||
if resnapshot {
|
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)
|
profile.payload = serde_json::to_string(&payload)
|
||||||
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?;
|
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?;
|
||||||
}
|
}
|
||||||
@@ -191,20 +295,29 @@ impl ProfileService {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除项目;若删除的是当前激活项目,一并清除激活标记
|
/// 删除项目;若删除的是某分组当前激活项目,一并清除该分组的激活标记
|
||||||
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> {
|
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> {
|
||||||
state.db.delete_profile(id)?;
|
state.db.delete_profile(id)?;
|
||||||
if state.db.get_current_profile_id()?.as_deref() == Some(id) {
|
for scope in ProfileScope::ALL {
|
||||||
state.db.set_current_profile_id(None)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用项目快照(best-effort,返回 warnings)
|
/// 应用项目快照(best-effort,返回 warnings)
|
||||||
///
|
///
|
||||||
|
/// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。
|
||||||
|
/// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示
|
||||||
|
/// (用户可先绑定项目、再"以当前状态更新"补拍该侧快照)。
|
||||||
/// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP
|
/// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP
|
||||||
/// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。
|
/// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。
|
||||||
pub fn apply(state: &AppState, profile_id: &str) -> Result<Vec<String>, AppError> {
|
pub fn apply(
|
||||||
|
state: &AppState,
|
||||||
|
profile_id: &str,
|
||||||
|
scope: ProfileScope,
|
||||||
|
) -> Result<Vec<String>, AppError> {
|
||||||
let profile = state
|
let profile = state
|
||||||
.db
|
.db
|
||||||
.get_profile(profile_id)?
|
.get_profile(profile_id)?
|
||||||
@@ -213,8 +326,14 @@ impl ProfileService {
|
|||||||
.map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?;
|
.map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?;
|
||||||
|
|
||||||
let mut warnings = Vec::new();
|
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();
|
let app_str = app.as_str();
|
||||||
|
|
||||||
// 1. 供应商
|
// 1. 供应商
|
||||||
@@ -237,8 +356,8 @@ impl ProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目)
|
// 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动)
|
||||||
if let Some(target_ids) = payload.mcp.get(app) {
|
if let Some(Some(target_ids)) = payload.mcp.get(app) {
|
||||||
let servers = state.db.get_all_mcp_servers()?;
|
let servers = state.db.get_all_mcp_servers()?;
|
||||||
let current: Vec<(String, bool)> = servers
|
let current: Vec<(String, bool)> = servers
|
||||||
.values()
|
.values()
|
||||||
@@ -258,7 +377,7 @@ impl ProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Skills diff(SkillService 返回 anyhow::Result,收进 warning)
|
// 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 skills = state.db.get_all_installed_skills()?;
|
||||||
let current: Vec<(String, bool)> = skills
|
let current: Vec<(String, bool)> = skills
|
||||||
.values()
|
.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)
|
Ok(warnings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,14 +443,14 @@ mod tests {
|
|||||||
codex: None,
|
codex: None,
|
||||||
},
|
},
|
||||||
mcp: PerApp {
|
mcp: PerApp {
|
||||||
claude: ids(&["m1", "m2"]),
|
claude: Some(ids(&["m1", "m2"])),
|
||||||
claude_desktop: vec![],
|
claude_desktop: Some(vec![]),
|
||||||
codex: vec![],
|
codex: None,
|
||||||
},
|
},
|
||||||
skills: PerApp {
|
skills: PerApp {
|
||||||
claude: vec![],
|
claude: Some(vec![]),
|
||||||
claude_desktop: vec![],
|
claude_desktop: Some(vec![]),
|
||||||
codex: ids(&["s1"]),
|
codex: Some(ids(&["s1"])),
|
||||||
},
|
},
|
||||||
prompts: PerApp {
|
prompts: PerApp {
|
||||||
claude: None,
|
claude: None,
|
||||||
@@ -348,21 +469,80 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_payload_tolerates_missing_fields() {
|
fn test_payload_tolerates_missing_fields() {
|
||||||
// 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错
|
// 前向兼容:旧版/部分字段缺失时应落到 None("该侧未拍过")而不是报错,
|
||||||
// (P1 存量 payload 没有 claude-desktop key,应用时对 Desktop 不动)
|
// 应用时对缺失槽位不做任何改动
|
||||||
let back: ProfilePayload =
|
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, Some("p1".to_string()));
|
||||||
assert_eq!(back.providers.claude_desktop, None);
|
assert_eq!(back.providers.claude_desktop, None);
|
||||||
assert_eq!(back.providers.codex, None);
|
assert_eq!(back.providers.codex, None);
|
||||||
assert!(back.mcp.claude.is_empty());
|
assert_eq!(back.mcp.claude, Some(ids(&["m1"])));
|
||||||
assert!(back.mcp.claude_desktop.is_empty());
|
assert_eq!(back.mcp.claude_desktop, None);
|
||||||
|
assert_eq!(back.mcp.codex, None, "missing slot means untouched");
|
||||||
assert_eq!(back.prompts.codex, None);
|
assert_eq!(back.prompts.codex, None);
|
||||||
|
|
||||||
let empty: ProfilePayload = serde_json::from_str("{}").unwrap();
|
let empty: ProfilePayload = serde_json::from_str("{}").unwrap();
|
||||||
assert_eq!(empty, ProfilePayload::default());
|
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]
|
#[test]
|
||||||
fn test_per_app_get_only_supports_profile_apps() {
|
fn test_per_app_get_only_supports_profile_apps() {
|
||||||
let per: PerApp<Option<String>> = PerApp::default();
|
let per: PerApp<Option<String>> = PerApp::default();
|
||||||
@@ -372,6 +552,36 @@ mod tests {
|
|||||||
assert!(per.get(&AppType::Gemini).is_none());
|
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]
|
#[test]
|
||||||
fn test_plan_toggles_minimal_diff() {
|
fn test_plan_toggles_minimal_diff() {
|
||||||
let current = vec![
|
let current = vec![
|
||||||
|
|||||||
+85
-21
@@ -338,34 +338,54 @@ fn sort_providers(
|
|||||||
|
|
||||||
/// 处理项目 Profile 托盘事件,返回是否已处理
|
/// 处理项目 Profile 托盘事件,返回是否已处理
|
||||||
///
|
///
|
||||||
/// 事件 id 形如 `profile_<uuid>`;`profile_none` 表示"不使用项目"(只清标记,不动配置)。
|
/// 事件 id 形如 `profile_<scope>_<uuid>`(同一项目在各分组子菜单里各有一项,
|
||||||
|
/// 应用时只作用于该分组);`profile_none_<scope>` 表示某分组"不使用项目"
|
||||||
|
/// (只清该分组标记,不动配置)。
|
||||||
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
let Some(suffix) = event_id.strip_prefix("profile_") else {
|
let Some(suffix) = event_id.strip_prefix("profile_") else {
|
||||||
return false;
|
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::<AppState>() {
|
if let Some(app_state) = app.try_state::<AppState>() {
|
||||||
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}");
|
log::error!("清除当前项目失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 通知主窗口刷新(profileId=null 表示已清除当前项目)
|
// 通知主窗口刷新(profileId=null 表示该分组已清除当前项目)
|
||||||
if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) {
|
if let Err(e) = app.emit(
|
||||||
|
"profile-applied",
|
||||||
|
serde_json::json!({ "profileId": null, "scope": scope.as_str() }),
|
||||||
|
) {
|
||||||
log::error!("发射 profile-applied 事件失败: {e}");
|
log::error!("发射 profile-applied 事件失败: {e}");
|
||||||
}
|
}
|
||||||
refresh_tray_menu(app);
|
refresh_tray_menu(app);
|
||||||
return true;
|
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 app_handle = app.clone();
|
||||||
let profile_id = suffix.to_string();
|
let profile_id = profile_id.to_string();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let Some(app_state) = app_handle.try_state::<AppState>() else {
|
let Some(app_state) = app_handle.try_state::<AppState>() else {
|
||||||
return;
|
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) => {
|
Ok(warnings) => {
|
||||||
for warning in &warnings {
|
for warning in &warnings {
|
||||||
log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}");
|
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_handle,
|
||||||
app_state.inner(),
|
app_state.inner(),
|
||||||
&profile_id,
|
&profile_id,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -664,40 +685,83 @@ pub fn create_tray_menu(
|
|||||||
menu_builder = menu_builder.separator();
|
menu_builder = menu_builder.separator();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 项目 Profile 子菜单(受支持应用至少一个可见且存在项目时才显示)
|
// 项目 Profile 子菜单:项目列表全应用共享,按分组嵌套子菜单各自勾选/应用
|
||||||
if crate::services::profile::PROFILE_APPS
|
// (组内应用可见且存在项目时才显示该组)
|
||||||
.iter()
|
|
||||||
.any(|app_type| visible_apps.is_visible(app_type))
|
|
||||||
{
|
{
|
||||||
let profiles = app_state.db.get_all_profiles()?;
|
use crate::services::profile::ProfileScope;
|
||||||
if !profiles.is_empty() {
|
|
||||||
let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default();
|
let any_scope_visible = ProfileScope::ALL.iter().any(|scope| {
|
||||||
let mut profiles_builder =
|
scope
|
||||||
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
|
.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 {
|
for profile in &profiles {
|
||||||
let item = CheckMenuItem::with_id(
|
let item = CheckMenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
format!("profile_{}", profile.id),
|
format!("profile_{}_{}", scope.as_str(), profile.id),
|
||||||
&profile.name,
|
&profile.name,
|
||||||
true,
|
true,
|
||||||
current_profile_id == profile.id,
|
current_profile_id == profile.id,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?;
|
.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(
|
let none_item = CheckMenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
"profile_none",
|
format!("profile_none_{}", scope.as_str()),
|
||||||
tray_texts.no_project_label,
|
tray_texts.no_project_label,
|
||||||
true,
|
true,
|
||||||
current_profile_id.is_empty(),
|
current_profile_id.is_empty(),
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
|
||||||
let profiles_submenu = profiles_builder
|
let scope_submenu = scope_builder
|
||||||
.separator()
|
.separator()
|
||||||
.item(&none_item)
|
.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()
|
.build()
|
||||||
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
|
||||||
menu_builder = menu_builder.item(&profiles_submenu).separator();
|
menu_builder = menu_builder.item(&profiles_submenu).separator();
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use std::fs;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt,
|
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
|
||||||
PromptService, Provider, ProviderService, SkillApps, SkillService,
|
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
@@ -174,23 +174,28 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
|||||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||||||
.expect("save prompt pr2");
|
.expect("save prompt pr2");
|
||||||
|
|
||||||
// ---- 保存项目 A(快照当前状态)----
|
// ---- 保存项目 A(在 Claude 页新建:只拍 Claude + Desktop 当前状态)----
|
||||||
let profile_a = ProfileService::create(&state, "Project A").expect("create profile A");
|
let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
|
||||||
|
.expect("create profile A");
|
||||||
let payload: ProfilePayload =
|
let payload: ProfilePayload =
|
||||||
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
|
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
|
||||||
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
||||||
assert_eq!(payload.mcp.claude, vec!["m1".to_string()]);
|
assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()]));
|
||||||
assert_eq!(payload.skills.claude, vec!["local:test-skill".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.prompts.claude.as_deref(), Some("pr1"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload.providers.codex, None,
|
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_eq!(payload.providers.claude_desktop.as_deref(), Some("d1"));
|
||||||
assert!(
|
assert_eq!(
|
||||||
payload.mcp.claude_desktop.is_empty() && payload.skills.claude_desktop.is_empty(),
|
(payload.mcp.claude_desktop, payload.skills.claude_desktop),
|
||||||
"desktop has no MCP/Skills dimension"
|
(Some(vec![]), Some(vec![])),
|
||||||
|
"desktop has no MCP/Skills dimension (captured as empty)"
|
||||||
);
|
);
|
||||||
assert_eq!(payload.prompts.claude_desktop, None);
|
assert_eq!(payload.prompts.claude_desktop, None);
|
||||||
|
|
||||||
@@ -206,8 +211,9 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
|||||||
.expect("disable skill");
|
.expect("disable skill");
|
||||||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||||||
|
|
||||||
// ---- 应用项目 A:全部复原 ----
|
// ---- 应用项目 A(Claude 组):全部复原 ----
|
||||||
let warnings = ProfileService::apply(&state, &profile_a.id).expect("apply profile A");
|
let warnings = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude)
|
||||||
|
.expect("apply profile A");
|
||||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||||
|
|
||||||
let current = state
|
let current = state
|
||||||
@@ -253,11 +259,133 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_current_profile_id()
|
.get_current_profile_id("claude")
|
||||||
.expect("get current profile id")
|
.expect("get current profile id")
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some(profile_a.id.as_str()),
|
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");
|
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!(
|
assert_eq!(
|
||||||
warnings.len(),
|
warnings.len(),
|
||||||
4,
|
4,
|
||||||
@@ -305,11 +434,11 @@ fn profile_apply_reports_dangling_references_and_continues() {
|
|||||||
"m1 enabled despite warnings"
|
"m1 enabled despite warnings"
|
||||||
);
|
);
|
||||||
|
|
||||||
// best-effort 完成后仍标记为当前项目
|
// best-effort 完成后仍标记为所属分组的当前项目
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_current_profile_id()
|
.get_current_profile_id("claude")
|
||||||
.expect("get current profile id")
|
.expect("get current profile id")
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("dangling-test")
|
Some("dangling-test")
|
||||||
@@ -317,7 +446,7 @@ fn profile_apply_reports_dangling_references_and_continues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
reset_test_fs();
|
reset_test_fs();
|
||||||
let _home = ensure_test_home();
|
let _home = ensure_test_home();
|
||||||
@@ -326,17 +455,31 @@ fn clear_current_profile_only_clears_marker() {
|
|||||||
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_profile_id(Some("some-profile"))
|
.set_current_profile_id("claude", Some("claude-profile"))
|
||||||
.expect("set current profile");
|
.expect("set claude current profile");
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_profile_id(None)
|
.set_current_profile_id("codex", Some("codex-profile"))
|
||||||
.expect("clear current profile");
|
.expect("set codex current profile");
|
||||||
|
|
||||||
|
// 清除 claude 组不影响 codex 组
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_current_profile_id("claude", None)
|
||||||
|
.expect("clear claude current profile");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_current_profile_id()
|
.get_current_profile_id("claude")
|
||||||
.expect("get current profile id"),
|
.expect("get claude current profile id"),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_current_profile_id("codex")
|
||||||
|
.expect("get codex current profile id")
|
||||||
|
.as_deref(),
|
||||||
|
Some("codex-profile")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import {
|
|||||||
useProfilesQuery,
|
useProfilesQuery,
|
||||||
useUpdateProfileMutation,
|
useUpdateProfileMutation,
|
||||||
} from "@/lib/query/profiles";
|
} from "@/lib/query/profiles";
|
||||||
|
import type { ProfileScope } from "@/lib/api/profiles";
|
||||||
|
import { PROFILE_SCOPE_LABELS } from "./scope";
|
||||||
|
|
||||||
interface ProfileManageDialogProps {
|
interface ProfileManageDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
scope: ProfileScope;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +34,12 @@ type PendingConfirm = {
|
|||||||
/**
|
/**
|
||||||
* 项目管理对话框(纯快照式)
|
* 项目管理对话框(纯快照式)
|
||||||
*
|
*
|
||||||
* 只提供 重命名 / 以当前状态更新快照 / 删除 三个操作;
|
* 项目列表全应用共享,重命名/删除作用于共享实体;
|
||||||
* 修改项目内容 = 先手动调好配置,再"以当前状态更新快照"。
|
* "以当前状态更新快照"只覆盖发起页所属分组(scope)的槽位。
|
||||||
*/
|
*/
|
||||||
export function ProfileManageDialog({
|
export function ProfileManageDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
|
scope,
|
||||||
onClose,
|
onClose,
|
||||||
}: ProfileManageDialogProps) {
|
}: ProfileManageDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -70,7 +74,7 @@ export function ProfileManageDialog({
|
|||||||
if (confirm.type === "delete") {
|
if (confirm.type === "delete") {
|
||||||
deleteMutation.mutate(confirm.id);
|
deleteMutation.mutate(confirm.id);
|
||||||
} else {
|
} else {
|
||||||
updateMutation.mutate({ id: confirm.id, resnapshot: true });
|
updateMutation.mutate({ id: confirm.id, resnapshot: true, scope });
|
||||||
}
|
}
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
};
|
};
|
||||||
@@ -199,7 +203,10 @@ export function ProfileManageDialog({
|
|||||||
message={
|
message={
|
||||||
confirm.type === "delete"
|
confirm.type === "delete"
|
||||||
? t("profiles.deleteConfirmMessage", { name: confirm.name })
|
? 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"}
|
variant={confirm.type === "delete" ? "destructive" : "info"}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ import {
|
|||||||
useProfilesQuery,
|
useProfilesQuery,
|
||||||
} from "@/lib/query/profiles";
|
} from "@/lib/query/profiles";
|
||||||
import { ProfileManageDialog } from "./ProfileManageDialog";
|
import { ProfileManageDialog } from "./ProfileManageDialog";
|
||||||
|
import { APP_PROFILE_SCOPE, hasScopeSnapshot } from "./scope";
|
||||||
/** 后端 services/profile.rs 的 PROFILE_APPS 前端镜像,扩展支持范围时两处同步 */
|
|
||||||
const PROFILE_SUPPORTED_APPS: AppId[] = ["claude", "claude-desktop", "codex"];
|
|
||||||
|
|
||||||
interface ProfileSwitcherProps {
|
interface ProfileSwitcherProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
@@ -51,8 +49,10 @@ interface ProfileSwitcherProps {
|
|||||||
/**
|
/**
|
||||||
* 项目 Profile 切换器(header 左侧入口)
|
* 项目 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) {
|
export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -67,19 +67,20 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
const createMutation = useCreateProfileMutation();
|
const createMutation = useCreateProfileMutation();
|
||||||
|
|
||||||
// Profile 仅作用于受支持的应用——在其他应用的标签页展示会误导用户
|
// Profile 仅作用于受支持的应用——在其他应用的标签页展示会误导用户
|
||||||
// 以为当前应用也被切换了,因此只在受支持应用的页面渲染
|
// 以为当前应用也被切换了,因此只在有所属分组的应用页面渲染
|
||||||
if (!PROFILE_SUPPORTED_APPS.includes(activeApp)) {
|
const scope = APP_PROFILE_SCOPE[activeApp];
|
||||||
|
if (!scope) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profiles = data?.profiles ?? [];
|
const profiles = data?.profiles ?? [];
|
||||||
const currentId = data?.currentId ?? null;
|
const currentId = data?.currentIds?.[scope] ?? null;
|
||||||
const currentProfile = profiles.find((p) => p.id === currentId);
|
const currentProfile = profiles.find((p) => p.id === currentId);
|
||||||
|
|
||||||
const handleApply = (id: string) => {
|
const handleApply = (id: string) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
if (id !== currentId) {
|
if (id !== currentId) {
|
||||||
applyMutation.mutate(id);
|
applyMutation.mutate({ id, scope });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
createMutation.mutate(name, { onSuccess: closeCreateDialog });
|
createMutation.mutate({ name, scope }, { onSuccess: closeCreateDialog });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +103,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
title={t("profiles.switcherTooltip")}
|
title={t(`profiles.switcherTooltip.${scope}`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium transition-colors",
|
"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",
|
"hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
@@ -144,6 +145,11 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{profile.name}</span>
|
<span className="truncate">{profile.name}</span>
|
||||||
|
{!hasScopeSnapshot(profile, scope) && (
|
||||||
|
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">
|
||||||
|
{t("profiles.noSnapshotForScope")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -167,7 +173,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
keywords={[t("profiles.none")]}
|
keywords={[t("profiles.none")]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
clearMutation.mutate();
|
clearMutation.mutate(scope);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-4 w-4 shrink-0" />
|
<X className="mr-2 h-4 w-4 shrink-0" />
|
||||||
@@ -203,7 +209,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||||
<DialogTitle>{t("profiles.createFromCurrent")}</DialogTitle>
|
<DialogTitle>{t("profiles.createFromCurrent")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("profiles.createDescription")}
|
{t(`profiles.createDescription.${scope}`)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="px-6 pt-3">
|
<div className="px-6 pt-3">
|
||||||
@@ -233,6 +239,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) {
|
|||||||
|
|
||||||
<ProfileManageDialog
|
<ProfileManageDialog
|
||||||
isOpen={isManageOpen}
|
isOpen={isManageOpen}
|
||||||
|
scope={scope}
|
||||||
onClose={() => setIsManageOpen(false)}
|
onClose={() => setIsManageOpen(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
import type { PerApp, Profile, ProfileScope } from "@/lib/api/profiles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用页 → 所属项目分组(后端 ProfileScope::for_app 的前端镜像,两处同步)
|
||||||
|
*
|
||||||
|
* 不在映射里的应用不支持 Profile,其标签页不渲染切换器。
|
||||||
|
*/
|
||||||
|
export const APP_PROFILE_SCOPE: Partial<Record<AppId, ProfileScope>> = {
|
||||||
|
claude: "claude",
|
||||||
|
"claude-desktop": "claude",
|
||||||
|
codex: "codex",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 分组显示名(产品名,不进 i18n;与后端托盘子菜单标签一致) */
|
||||||
|
export const PROFILE_SCOPE_LABELS: Record<ProfileScope, string> = {
|
||||||
|
claude: "Claude Code",
|
||||||
|
codex: "Codex",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 分组内的 payload 槽位 key(后端 ProfileScope::apps 的前端镜像) */
|
||||||
|
const SCOPE_SLOT_KEYS: Record<ProfileScope, (keyof PerApp<unknown>)[]> = {
|
||||||
|
claude: ["claude", "claude-desktop"],
|
||||||
|
codex: ["codex"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目在某分组是否拍过快照(任一槽位非 null 即视为拍过)
|
||||||
|
*
|
||||||
|
* 未拍过的项目在该分组应用时不改动配置,只绑定 current 标记。
|
||||||
|
*/
|
||||||
|
export function hasScopeSnapshot(profile: Profile, scope: ProfileScope) {
|
||||||
|
const { providers, mcp, skills, prompts } = profile.payload;
|
||||||
|
return SCOPE_SLOT_KEYS[scope].some(
|
||||||
|
(app) =>
|
||||||
|
providers[app] !== null ||
|
||||||
|
mcp[app] !== null ||
|
||||||
|
skills[app] !== null ||
|
||||||
|
prompts[app] !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1867,19 +1867,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profiles": {
|
"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",
|
"none": "No project",
|
||||||
"searchPlaceholder": "Search projects",
|
"searchPlaceholder": "Search projects",
|
||||||
"empty": "No projects yet",
|
"empty": "No projects yet",
|
||||||
"createFromCurrent": "New project",
|
"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…",
|
"manage": "Manage projects…",
|
||||||
"manageTitle": "Manage Projects",
|
"manageTitle": "Manage Projects",
|
||||||
"manageDescription": "Projects are configuration snapshots. To change a project, adjust your configuration first, then click \"Update from current\".",
|
"manageDescription": "Projects are configuration snapshots. To change a project, adjust your configuration first, then click \"Update from current\".",
|
||||||
"namePlaceholder": "e.g. Development, Drawing",
|
"namePlaceholder": "e.g. Development, Drawing",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"updateSnapshot": "Update from current",
|
"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",
|
"delete": "Delete",
|
||||||
"deleteConfirmTitle": "Confirm Delete",
|
"deleteConfirmTitle": "Confirm Delete",
|
||||||
"deleteConfirmMessage": "Are you sure you want to delete project \"{{name}}\"? Your current configuration will not be changed.",
|
"deleteConfirmMessage": "Are you sure you want to delete project \"{{name}}\"? Your current configuration will not be changed.",
|
||||||
|
|||||||
@@ -1867,19 +1867,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え",
|
"switcherTooltip": {
|
||||||
|
"claude": "プロジェクト:Claude Code のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え",
|
||||||
|
"codex": "プロジェクト:Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え"
|
||||||
|
},
|
||||||
"none": "プロジェクトを使用しない",
|
"none": "プロジェクトを使用しない",
|
||||||
"searchPlaceholder": "プロジェクトを検索",
|
"searchPlaceholder": "プロジェクトを検索",
|
||||||
"empty": "プロジェクトはまだありません",
|
"empty": "プロジェクトはまだありません",
|
||||||
"createFromCurrent": "新規プロジェクト",
|
"createFromCurrent": "新規プロジェクト",
|
||||||
"createDescription": "現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。",
|
"createDescription": {
|
||||||
|
"claude": "Claude Code の現在のプロバイダー、MCP、Skills、メモリファイル(および Claude Desktop のプロバイダー)をプロジェクトとして保存し、後からワンクリックで切り替えられます。",
|
||||||
|
"codex": "Codex の現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。"
|
||||||
|
},
|
||||||
"manage": "プロジェクトを管理…",
|
"manage": "プロジェクトを管理…",
|
||||||
"manageTitle": "プロジェクト管理",
|
"manageTitle": "プロジェクト管理",
|
||||||
"manageDescription": "プロジェクトは設定のスナップショットです。内容を変更するには、先に設定を調整してから「現在の状態で更新」をクリックしてください。",
|
"manageDescription": "プロジェクトは設定のスナップショットです。内容を変更するには、先に設定を調整してから「現在の状態で更新」をクリックしてください。",
|
||||||
"namePlaceholder": "例:開発、イラスト",
|
"namePlaceholder": "例:開発、イラスト",
|
||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"updateSnapshot": "現在の状態で更新",
|
"updateSnapshot": "現在の状態で更新",
|
||||||
"updateSnapshotConfirm": "プロジェクト「{{name}}」のスナップショットを現在の設定で上書きしますか?",
|
"updateSnapshotConfirm": "プロジェクト「{{name}}」の {{group}} 側スナップショットを現在の設定で上書きしますか?他のアプリ側には影響しません。",
|
||||||
|
"noSnapshotForScope": "このアプリ側は未保存",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteConfirmTitle": "削除の確認",
|
"deleteConfirmTitle": "削除の確認",
|
||||||
"deleteConfirmMessage": "プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。",
|
"deleteConfirmMessage": "プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。",
|
||||||
|
|||||||
@@ -1839,19 +1839,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商",
|
"switcherTooltip": {
|
||||||
|
"claude": "專案:一鍵切換 Claude Code 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商",
|
||||||
|
"codex": "專案:一鍵切換 Codex 的供應商 / MCP / Skills / 記憶檔案"
|
||||||
|
},
|
||||||
"none": "不使用專案",
|
"none": "不使用專案",
|
||||||
"searchPlaceholder": "搜尋專案",
|
"searchPlaceholder": "搜尋專案",
|
||||||
"empty": "尚無專案",
|
"empty": "尚無專案",
|
||||||
"createFromCurrent": "新建專案",
|
"createFromCurrent": "新建專案",
|
||||||
"createDescription": "把目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。",
|
"createDescription": {
|
||||||
|
"claude": "把 Claude Code 目前的供應商、MCP、Skills、記憶檔案(及 Claude Desktop 的供應商)儲存為一個專案,之後可一鍵切換。",
|
||||||
|
"codex": "把 Codex 目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。"
|
||||||
|
},
|
||||||
"manage": "管理專案…",
|
"manage": "管理專案…",
|
||||||
"manageTitle": "管理專案",
|
"manageTitle": "管理專案",
|
||||||
"manageDescription": "專案是設定快照。要修改專案內容,先手動調好設定,再點「以目前狀態更新」。",
|
"manageDescription": "專案是設定快照。要修改專案內容,先手動調好設定,再點「以目前狀態更新」。",
|
||||||
"namePlaceholder": "例如:開發、繪圖",
|
"namePlaceholder": "例如:開發、繪圖",
|
||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
"updateSnapshot": "以目前狀態更新",
|
"updateSnapshot": "以目前狀態更新",
|
||||||
"updateSnapshotConfirm": "用目前的設定狀態覆蓋專案 \"{{name}}\" 的快照?",
|
"updateSnapshotConfirm": "用目前設定覆蓋專案 \"{{name}}\" 的 {{group}} 側快照?其他應用側的快照不受影響。",
|
||||||
|
"noSnapshotForScope": "本側未儲存",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteConfirmTitle": "確認刪除",
|
"deleteConfirmTitle": "確認刪除",
|
||||||
"deleteConfirmMessage": "確定要刪除專案 \"{{name}}\" 嗎?不會改動任何現有設定。",
|
"deleteConfirmMessage": "確定要刪除專案 \"{{name}}\" 嗎?不會改動任何現有設定。",
|
||||||
|
|||||||
@@ -1867,19 +1867,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商",
|
"switcherTooltip": {
|
||||||
|
"claude": "项目:一键切换 Claude Code 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商",
|
||||||
|
"codex": "项目:一键切换 Codex 的供应商 / MCP / Skills / 记忆文件"
|
||||||
|
},
|
||||||
"none": "不使用项目",
|
"none": "不使用项目",
|
||||||
"searchPlaceholder": "搜索项目",
|
"searchPlaceholder": "搜索项目",
|
||||||
"empty": "暂无项目",
|
"empty": "暂无项目",
|
||||||
"createFromCurrent": "新建项目",
|
"createFromCurrent": "新建项目",
|
||||||
"createDescription": "把当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。",
|
"createDescription": {
|
||||||
|
"claude": "把 Claude Code 当前的供应商、MCP、Skills、记忆文件(及 Claude Desktop 的供应商)保存为一个项目,之后可一键切换。",
|
||||||
|
"codex": "把 Codex 当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。"
|
||||||
|
},
|
||||||
"manage": "管理项目…",
|
"manage": "管理项目…",
|
||||||
"manageTitle": "管理项目",
|
"manageTitle": "管理项目",
|
||||||
"manageDescription": "项目是配置快照。要修改项目内容,先手动调好配置,再点\"以当前状态更新\"。",
|
"manageDescription": "项目是配置快照。要修改项目内容,先手动调好配置,再点\"以当前状态更新\"。",
|
||||||
"namePlaceholder": "例如:开发、绘图",
|
"namePlaceholder": "例如:开发、绘图",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"updateSnapshot": "以当前状态更新",
|
"updateSnapshot": "以当前状态更新",
|
||||||
"updateSnapshotConfirm": "用当前的配置状态覆盖项目 \"{{name}}\" 的快照?",
|
"updateSnapshotConfirm": "用当前配置覆盖项目 \"{{name}}\" 的 {{group}} 侧快照?其他应用侧的快照不受影响。",
|
||||||
|
"noSnapshotForScope": "本侧未保存",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteConfirmTitle": "确认删除",
|
"deleteConfirmTitle": "确认删除",
|
||||||
"deleteConfirmMessage": "确定要删除项目 \"{{name}}\" 吗?不会改动任何现有配置。",
|
"deleteConfirmMessage": "确定要删除项目 \"{{name}}\" 吗?不会改动任何现有配置。",
|
||||||
|
|||||||
+32
-15
@@ -1,5 +1,13 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile 操作的应用分组(与后端 services/profile.rs 的 ProfileScope 严格对应)
|
||||||
|
*
|
||||||
|
* 项目实体全应用共享,但快照/应用/当前指针按组进行;Claude Desktop 并入
|
||||||
|
* claude 组(它只有聊天侧供应商一个受管维度,Code 标签页天然跟随 Claude Code)。
|
||||||
|
*/
|
||||||
|
export type ProfileScope = "claude" | "codex";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp<T> 严格对应)
|
* 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp<T> 严格对应)
|
||||||
*/
|
*/
|
||||||
@@ -11,11 +19,14 @@ export interface PerApp<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应)
|
* 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应)
|
||||||
|
*
|
||||||
|
* 所有槽位 null = 该侧从未拍过快照(应用时不动),与"拍到的就是空集"
|
||||||
|
* (空数组,应用时清空启用)严格区分。
|
||||||
*/
|
*/
|
||||||
export interface ProfilePayload {
|
export interface ProfilePayload {
|
||||||
providers: PerApp<string | null>;
|
providers: PerApp<string | null>;
|
||||||
mcp: PerApp<string[]>;
|
mcp: PerApp<string[] | null>;
|
||||||
skills: PerApp<string[]>;
|
skills: PerApp<string[] | null>;
|
||||||
prompts: PerApp<string | null>;
|
prompts: PerApp<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,37 +38,42 @@ export interface Profile {
|
|||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 每个分组当前激活的项目 id(未使用项目时为 null) */
|
||||||
|
export type CurrentProfileIds = Record<ProfileScope, string | null>;
|
||||||
|
|
||||||
export interface ProfilesResponse {
|
export interface ProfilesResponse {
|
||||||
profiles: Profile[];
|
profiles: Profile[];
|
||||||
currentId: string | null;
|
currentIds: CurrentProfileIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profilesApi = {
|
export const profilesApi = {
|
||||||
/**
|
/**
|
||||||
* 获取所有项目及当前激活项目 id
|
* 获取所有项目及各分组当前激活项目 id
|
||||||
*/
|
*/
|
||||||
async list(): Promise<ProfilesResponse> {
|
async list(): Promise<ProfilesResponse> {
|
||||||
return await invoke("list_profiles");
|
return await invoke("list_profiles");
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 以当前配置状态创建新项目
|
* 创建新项目(只拍发起页所属分组的当前状态,其余分组槽位留空)
|
||||||
*/
|
*/
|
||||||
async create(name: string): Promise<Profile> {
|
async create(name: string, scope: ProfileScope): Promise<Profile> {
|
||||||
return await invoke("create_profile", { name });
|
return await invoke("create_profile", { name, scope });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新项目(重命名和/或以当前状态重拍快照)
|
* 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照
|
||||||
|
* (resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留)
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
options: { name?: string; resnapshot?: boolean },
|
options: { name?: string; resnapshot?: boolean; scope?: ProfileScope },
|
||||||
): Promise<Profile> {
|
): Promise<Profile> {
|
||||||
return await invoke("update_profile", {
|
return await invoke("update_profile", {
|
||||||
id,
|
id,
|
||||||
name: options.name,
|
name: options.name,
|
||||||
resnapshot: options.resnapshot,
|
resnapshot: options.resnapshot,
|
||||||
|
scope: options.scope,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -69,16 +85,17 @@ export const profilesApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用项目快照,返回 warnings(best-effort,部分失败不中断)
|
* 应用项目快照(只作用于发起页所属分组内的应用),返回 warnings
|
||||||
|
* (best-effort,部分失败不中断)
|
||||||
*/
|
*/
|
||||||
async apply(id: string): Promise<string[]> {
|
async apply(id: string, scope: ProfileScope): Promise<string[]> {
|
||||||
return await invoke("apply_profile", { id });
|
return await invoke("apply_profile", { id, scope });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不使用项目:仅清除激活标记,不改动任何配置
|
* 不使用项目:仅清除某分组的激活标记,不改动任何配置
|
||||||
*/
|
*/
|
||||||
async clearCurrent(): Promise<void> {
|
async clearCurrent(scope: ProfileScope): Promise<void> {
|
||||||
return await invoke("clear_current_profile");
|
return await invoke("clear_current_profile", { scope });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { profilesApi, providersApi } from "@/lib/api";
|
import { profilesApi, providersApi } from "@/lib/api";
|
||||||
|
import type { ProfileScope } from "@/lib/api/profiles";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
const updateTrayMenuSafely = async () => {
|
const updateTrayMenuSafely = async () => {
|
||||||
@@ -24,7 +25,8 @@ export const useCreateProfileMutation = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (name: string) => profilesApi.create(name),
|
mutationFn: ({ name, scope }: { name: string; scope: ProfileScope }) =>
|
||||||
|
profilesApi.create(name, scope),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
await updateTrayMenuSafely();
|
await updateTrayMenuSafely();
|
||||||
@@ -48,11 +50,13 @@ export const useUpdateProfileMutation = () => {
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
resnapshot,
|
resnapshot,
|
||||||
|
scope,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
resnapshot?: boolean;
|
resnapshot?: boolean;
|
||||||
}) => profilesApi.update(id, { name, resnapshot }),
|
scope?: ProfileScope;
|
||||||
|
}) => profilesApi.update(id, { name, resnapshot, scope }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
await updateTrayMenuSafely();
|
await updateTrayMenuSafely();
|
||||||
@@ -92,7 +96,7 @@ export const useClearProfileMutation = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => profilesApi.clearCurrent(),
|
mutationFn: (scope: ProfileScope) => profilesApi.clearCurrent(scope),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
await updateTrayMenuSafely();
|
await updateTrayMenuSafely();
|
||||||
@@ -112,7 +116,8 @@ export const useApplyProfileMutation = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => profilesApi.apply(id),
|
mutationFn: ({ id, scope }: { id: string; scope: ProfileScope }) =>
|
||||||
|
profilesApi.apply(id, scope),
|
||||||
onSuccess: async (warnings) => {
|
onSuccess: async (warnings) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
|
|||||||
Reference in New Issue
Block a user