mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(profiles): autosave previous profile state on switch
When switching to a different project, the current configuration of the previously active project is automatically captured into that project's snapshot before the target project's snapshot is applied. Scope is limited to the active group (Claude/Codex); autosave failures are collected as warnings and do not block the switch. - Move autosave logic into ProfileService::apply for consistent behavior across UI, tray, and tests - Add integration test covering bidirectional autosave roundtrip Refs: profile switch B方案
This commit is contained in:
@@ -311,6 +311,12 @@ impl ProfileService {
|
||||
/// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。
|
||||
/// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示
|
||||
/// (用户可先绑定项目、再"以当前状态更新"补拍该侧快照)。
|
||||
///
|
||||
/// **切换前会自动保存旧项目**:若当前分组已绑定到另一个项目,先把当前
|
||||
/// 状态写入那个旧项目(仅当前分组槽位),再加载目标项目。这样切走后
|
||||
/// 旧项目仍保留离开时的配置,回来时状态一致。自动保存失败时作为 warning
|
||||
/// 继续,不阻塞切换。
|
||||
///
|
||||
/// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP
|
||||
/// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。
|
||||
pub fn apply(
|
||||
@@ -318,6 +324,19 @@ impl ProfileService {
|
||||
profile_id: &str,
|
||||
scope: ProfileScope,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 自动保存旧项目当前状态(仅当前分组),失败不阻塞切换
|
||||
if let Some(current_id) = state.db.get_current_profile_id(scope.as_str())? {
|
||||
if current_id != profile_id {
|
||||
if let Err(e) = Self::update(state, ¤t_id, None, true, Some(scope)) {
|
||||
warnings.push(format!(
|
||||
"autosave profile '{current_id}' before switch failed: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profile = state
|
||||
.db
|
||||
.get_profile(profile_id)?
|
||||
|
||||
@@ -483,3 +483,159 @@ fn clear_current_profile_only_clears_scoped_marker() {
|
||||
Some("codex-profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_profile_autosaves_previous_profile_state() {
|
||||
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 / Prompt ----
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||
.expect("save provider p2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
.expect("set current provider p1");
|
||||
|
||||
let claude_dir = home.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||||
.expect("serialize p1 settings"),
|
||||
)
|
||||
.expect("seed live settings.json");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m1", true))
|
||||
.expect("save mcp m1");
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m2", false))
|
||||
.expect("save mcp m2");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
|
||||
.expect("save prompt pr1");
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||||
.expect("save prompt pr2");
|
||||
|
||||
// ---- Project A:状态 X(p1 / m1 / pr1)----
|
||||
let project_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
|
||||
.expect("create project A");
|
||||
let warnings = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
|
||||
.expect("apply project A");
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
|
||||
// ---- 在 A 下改到状态 Y(p2 / m2 / pr2),然后据此创建 Project B ----
|
||||
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
|
||||
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
|
||||
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
|
||||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||||
|
||||
let project_b = ProfileService::create(&state, "Project B", ProfileScope::Claude)
|
||||
.expect("create project B");
|
||||
|
||||
// ---- 从 A 切换到 B:自动把当前状态 Y 保存到 A,再加载 B 的 Y ----
|
||||
let warnings = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude)
|
||||
.expect("switch to project B");
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider")
|
||||
.as_deref(),
|
||||
Some("p2"),
|
||||
"provider switched to p2"
|
||||
);
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(!servers.get("m1").expect("m1").apps.claude, "m1 disabled");
|
||||
assert!(servers.get("m2").expect("m2").apps.claude, "m2 enabled");
|
||||
let prompts = state
|
||||
.db
|
||||
.get_prompts(AppType::Claude.as_str())
|
||||
.expect("get prompts");
|
||||
assert!(!prompts.get("pr1").expect("pr1").enabled, "pr1 disabled");
|
||||
assert!(prompts.get("pr2").expect("pr2").enabled, "pr2 enabled");
|
||||
|
||||
// Project A 被自动保存为离开时的状态 Y
|
||||
let saved_a = state
|
||||
.db
|
||||
.get_profile(&project_a.id)
|
||||
.expect("get project A")
|
||||
.expect("project A exists");
|
||||
let payload_a: ProfilePayload =
|
||||
serde_json::from_str(&saved_a.payload).expect("parse project A payload");
|
||||
assert_eq!(payload_a.providers.claude.as_deref(), Some("p2"));
|
||||
assert_eq!(payload_a.mcp.claude, Some(vec!["m2".to_string()]));
|
||||
assert_eq!(payload_a.prompts.claude.as_deref(), Some("pr2"));
|
||||
|
||||
// ---- 在 B 下改回状态 X,再切换回 A ----
|
||||
ProviderService::switch(&state, AppType::Claude, "p1").expect("switch to p1");
|
||||
McpService::toggle_app(&state, "m1", AppType::Claude, true).expect("enable m1");
|
||||
McpService::toggle_app(&state, "m2", AppType::Claude, false).expect("disable m2");
|
||||
PromptService::enable_prompt(&state, AppType::Claude, "pr1").expect("enable pr1");
|
||||
|
||||
let warnings = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
|
||||
.expect("switch back to project A");
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
|
||||
// 切回 A 时:先自动保存 B 为状态 X,再加载 A 的上次离开状态 Y
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider")
|
||||
.as_deref(),
|
||||
Some("p2"),
|
||||
"project A restored to the state when we left it (p2)"
|
||||
);
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(
|
||||
!servers.get("m1").expect("m1").apps.claude,
|
||||
"m1 stays disabled"
|
||||
);
|
||||
assert!(
|
||||
servers.get("m2").expect("m2").apps.claude,
|
||||
"m2 stays enabled"
|
||||
);
|
||||
let prompts = state
|
||||
.db
|
||||
.get_prompts(AppType::Claude.as_str())
|
||||
.expect("get prompts");
|
||||
assert!(
|
||||
!prompts.get("pr1").expect("pr1").enabled,
|
||||
"pr1 stays disabled"
|
||||
);
|
||||
assert!(
|
||||
prompts.get("pr2").expect("pr2").enabled,
|
||||
"pr2 stays enabled"
|
||||
);
|
||||
|
||||
// Project B 被自动保存为离开时的状态 X
|
||||
let saved_b = state
|
||||
.db
|
||||
.get_profile(&project_b.id)
|
||||
.expect("get project B")
|
||||
.expect("project B exists");
|
||||
let payload_b: ProfilePayload =
|
||||
serde_json::from_str(&saved_b.payload).expect("parse project B payload");
|
||||
assert_eq!(payload_b.providers.claude.as_deref(), Some("p1"));
|
||||
assert_eq!(payload_b.mcp.claude, Some(vec!["m1".to_string()]));
|
||||
assert_eq!(payload_b.prompts.claude.as_deref(), Some("pr1"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user