diff --git a/src-tauri/src/live/patch/json.rs b/src-tauri/src/live/patch/json.rs index 46e0571b..b8ca8531 100644 --- a/src-tauri/src/live/patch/json.rs +++ b/src-tauri/src/live/patch/json.rs @@ -35,11 +35,25 @@ pub struct JsonPatch { pub seed: Vec<(KeyPath, Value)>, /// 当前值等于其中之一才删除(独有字段切走、残留清理)。`set` 里有同一路径时跳过。 pub remove_if: Vec<(KeyPath, Vec)>, + /// 写前是非空对象、写后变成空对象时删除(被删键留下的空父对象)。写前就为空的不动。 + pub prune_emptied: Vec, } impl JsonPatch { pub fn apply_to(&self, path: &Path, doc: &mut Value) -> Result<(), LiveWriteError> { let targets: HashSet<&KeyPath> = self.set.iter().map(|(key_path, _)| key_path).collect(); + let mut prune: Vec<&KeyPath> = self + .prune_emptied + .iter() + .filter(|key_path| { + !key_path.0.is_empty() + && value_at(doc, key_path) + .and_then(Value::as_object) + .is_some_and(|map| !map.is_empty()) + }) + .collect(); + // 先删深层,父对象才能随之变空。 + prune.sort_by_key(|key_path| std::cmp::Reverse(key_path.0.len())); for scope in &self.clear { let Some(map) = object_at_mut(path, doc, &scope.parent.0)? else { @@ -97,6 +111,17 @@ impl JsonPatch { } } + for key_path in prune { + if value_at(doc, key_path) + .is_some_and(|value| value.as_object().is_some_and(Map::is_empty)) + { + let (parent, key) = split(key_path); + if let Some(map) = object_at_mut(path, doc, parent)? { + map.shift_remove(key); + } + } + } + Ok(()) } } @@ -432,6 +457,31 @@ mod tests { assert_eq!(value, json!({"env": {"WINDOW": "983616", "BETAS": "0"}})); } + #[test] + fn prune_emptied_drops_only_objects_this_write_emptied() { + let patch = JsonPatch { + remove_if: vec![ + ( + KeyPath::new(&["statusLine", "type"]), + vec![json!("command")], + ), + (KeyPath::new(&["a", "b", "c"]), vec![json!(1)]), + (KeyPath::new(&["kept", "x"]), vec![json!(1)]), + ], + prune_emptied: vec![ + KeyPath::new(&["statusLine"]), + KeyPath::new(&["a"]), + KeyPath::new(&["a", "b"]), + KeyPath::new(&["kept"]), + KeyPath::new(&["hooks"]), + ], + ..JsonPatch::default() + }; + let pre = r#"{"hooks":{},"statusLine":{"type":"command"},"a":{"b":{"c":1}},"kept":{"x":1,"y":2}}"#; + let out: Value = serde_json::from_str(&apply(&patch, pre)).unwrap(); + assert_eq!(out, json!({ "hooks": {}, "kept": { "y": 2 } })); + } + #[test] fn second_write_is_byte_stable() { let patch = claude_patch(&[("ANTHROPIC_AUTH_TOKEN", json!("sk-b"))]); diff --git a/src-tauri/src/services/provider/key_field_live.rs b/src-tauri/src/services/provider/key_field_live.rs index 49b2c029..75f493e3 100644 --- a/src-tauri/src/services/provider/key_field_live.rs +++ b/src-tauri/src/services/provider/key_field_live.rs @@ -123,11 +123,22 @@ pub(super) fn edit_common( patch.set.extend(set); let mut old = Vec::new(); entries(&previous, KeyPath::root(), &mut old); - patch.remove_if.extend( - old.into_iter() - .filter(|(path, _)| global_claude(path)) - .map(|(path, value)| (path, vec![value])), - ); + old.retain(|(path, _)| global_claude(path)); + // Objects that only held removed common keys would otherwise stay + // behind as `{}`; objects the new snippet still declares are kept. + for (path, _) in &old { + for depth in 1..path.0.len() { + let parent = KeyPath(path.0[..depth].to_vec()); + if crate::live::patch::json::value_at(¤t, &parent).is_none() + && !patch.prune_emptied.contains(&parent) + { + patch.prune_emptied.push(parent); + } + } + } + patch + .remove_if + .extend(old.into_iter().map(|(path, value)| (path, vec![value]))); } PreparedLiveWrite::Gemini { env, .. } => { for (key, value) in current.as_object().unwrap() { diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index ad10a6c1..268591cd 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -5290,6 +5290,158 @@ fn setting_claude_common_snippet_does_not_infer_existing_provider_opt_in() { ); } +const CLAUDE_COMMON_WITH_STATUS_LINE: &str = + r#"{"env":{"MY_TEST_KEY":"1"},"statusLine":{"type":"command","command":"/bin/true"}}"#; + +fn claude_state_using_common_with_status_line() -> AppState { + let provider = with_common_enabled(Provider::with_id( + "p1".to_string(), + "First".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.invalid" + } + }), + None, + )); + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + config.common_config_snippets.claude = Some(CLAUDE_COMMON_WITH_STATUS_LINE.to_string()); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "p1".to_string(); + manager.providers.insert("p1".to_string(), provider); + } + let state = state_from_config(config); + state.save().expect("persist config snapshot to db"); + state +} + +fn assert_status_line_removed_and_user_objects_kept(live: &Value) { + assert!( + live.get("statusLine").is_none(), + "an object emptied by removing common keys should not stay behind: {live}" + ); + assert_eq!( + live.get("hooks"), + Some(&json!({})), + "an empty object the user wrote must be kept" + ); + assert_eq!( + live.get("permissions"), + Some(&json!({ "allow": ["Bash"] })), + "unrelated user settings must be kept" + ); +} + +#[test] +#[serial] +fn shrinking_or_clearing_claude_common_snippet_drops_emptied_parent_objects() { + for next in [Some(r#"{"env":{"MY_TEST_KEY":"1"}}"#), None] { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + std::fs::create_dir_all(crate::config::get_claude_config_dir()) + .expect("create ~/.claude (initialized)"); + write_json_file( + &get_claude_settings_path(), + &json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.invalid", + "MY_TEST_KEY": "1" + }, + "hooks": {}, + "permissions": { "allow": ["Bash"] }, + "statusLine": { "type": "command", "command": "/bin/true" } + }), + ) + .expect("seed live settings with common config applied"); + let state = claude_state_using_common_with_status_line(); + + ProviderService::set_common_config_snippet( + &state, + AppType::Claude, + next.map(str::to_string), + ) + .expect("update common snippet"); + + let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); + assert_status_line_removed_and_user_objects_kept(&live); + assert_eq!( + live["env"].get("MY_TEST_KEY").is_some(), + next.is_some(), + "MY_TEST_KEY follows the new snippet ({next:?})" + ); + assert_eq!( + live["env"]["ANTHROPIC_BASE_URL"], "https://claude.invalid", + "provider env is kept" + ); + } +} + +#[tokio::test] +#[serial] +async fn clearing_claude_common_snippet_during_takeover_drops_emptied_parent_objects() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + std::fs::create_dir_all(crate::config::get_claude_config_dir()) + .expect("create ~/.claude (initialized)"); + let state = claude_state_using_common_with_status_line(); + let provider = state + .config + .read() + .expect("read config") + .get_manager(&AppType::Claude) + .and_then(|manager| manager.providers.get("p1").cloned()) + .expect("provider p1"); + let backup = ProviderService::build_live_backup_snapshot( + &AppType::Claude, + &provider, + Some(CLAUDE_COMMON_WITH_STATUS_LINE), + true, + ) + .expect("build effective backup"); + state + .db + .save_live_backup( + AppType::Claude.as_str(), + &serde_json::to_string(&backup).expect("serialize backup"), + ) + .await + .expect("seed takeover backup"); + write_json_file( + &get_claude_settings_path(), + &json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", + "MY_TEST_KEY": "1" + }, + "hooks": {}, + "permissions": { "allow": ["Bash"] }, + "statusLine": { "type": "command", "command": "/bin/true" } + }), + ) + .expect("seed takeover-owned live settings"); + + ProviderService::clear_common_config_snippet(&state, AppType::Claude) + .expect("clear common snippet during takeover"); + + let live: Value = read_json_file(&get_claude_settings_path()).expect("read takeover live"); + assert_status_line_removed_and_user_objects_kept(&live); + assert_eq!( + live["env"], + json!({ + "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" + }), + "proxy fields stay attached and the common env key is removed" + ); +} + #[test] #[serial] fn clearing_claude_common_snippet_tolerates_invalid_stored_snippet() {