Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src-tauri/src/live/patch/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,25 @@ pub struct JsonPatch {
pub seed: Vec<(KeyPath, Value)>,
/// 当前值等于其中之一才删除(独有字段切走、残留清理)。`set` 里有同一路径时跳过。
pub remove_if: Vec<(KeyPath, Vec<Value>)>,
/// 写前是非空对象、写后变成空对象时删除(被删键留下的空父对象)。写前就为空的不动。
pub prune_emptied: Vec<KeyPath>,
}

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 {
Expand Down Expand Up @@ -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(())
}
}
Expand Down Expand Up @@ -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"))]);
Expand Down
21 changes: 16 additions & 5 deletions src-tauri/src/services/provider/key_field_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current, &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() {
Expand Down
152 changes: 152 additions & 0 deletions src-tauri/src/services/provider/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading