mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user