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:
Jason
2026-07-04 21:34:30 +08:00
parent 65a5464fcc
commit dbb5999d1e
17 changed files with 831 additions and 190 deletions
+168 -25
View File
@@ -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");
// ---- 应用项目 AClaude 组):全部复原 ----
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")
);
}