diff --git a/README.md b/README.md
index 36b2e0ec1..111d7bdd8 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register!
+
+
+Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click here to purchase!
+
+
diff --git a/README_JA.md b/README_JA.md
index 31138266e..79bfd7ea1 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちら から登録してください!
+
+
+ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。こちら から購入してください!
+
+
diff --git a/README_ZH.md b/README_ZH.md
index bf13f2887..5e7df7892 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -101,6 +101,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里 注册!
+
+
+感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击这里 购买!
+
+
diff --git a/assets/partners/logos/chefshop.png b/assets/partners/logos/chefshop.png
new file mode 100644
index 000000000..85fe37f23
Binary files /dev/null and b/assets/partners/logos/chefshop.png differ
diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs
index e07f1482f..4e491b5f2 100644
--- a/src-tauri/src/commands/misc.rs
+++ b/src-tauri/src/commands/misc.rs
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
@@ -720,8 +720,10 @@ pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
+ cwd: Option,
) -> Result {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
+ let launch_cwd = resolve_launch_cwd(cwd)?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
@@ -736,7 +738,8 @@ pub async fn open_provider_terminal(
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
- launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
+ launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
+ .map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
@@ -791,11 +794,49 @@ fn extract_env_vars_from_config(
env_vars
}
+fn resolve_launch_cwd(cwd: Option) -> Result, String> {
+ let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
+ return Ok(None);
+ };
+
+ if raw_path.contains('\n') || raw_path.contains('\r') {
+ return Err("目录路径包含非法换行符".to_string());
+ }
+
+ let path = Path::new(&raw_path);
+ if !path.exists() {
+ return Err(format!("目录不存在: {raw_path}"));
+ }
+
+ let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
+ if !resolved.is_dir() {
+ return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
+ }
+
+ // Strip Windows extended-length prefix that canonicalize produces,
+ // as it can break batch scripts and other shell commands.
+ // Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
+ #[cfg(target_os = "windows")]
+ let resolved = {
+ let s = resolved.to_string_lossy();
+ if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
+ PathBuf::from(format!(r"\\{unc}"))
+ } else if let Some(stripped) = s.strip_prefix(r"\\?\") {
+ PathBuf::from(stripped)
+ } else {
+ resolved
+ }
+ };
+
+ Ok(Some(resolved))
+}
+
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
+ cwd: Option<&Path>,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
@@ -809,19 +850,19 @@ fn launch_terminal_with_env(
#[cfg(target_os = "macos")]
{
- launch_macos_terminal(&config_file)?;
+ launch_macos_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
- launch_linux_terminal(&config_file)?;
+ launch_linux_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
- launch_windows_terminal(&temp_dir, &config_file)?;
+ launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
}
@@ -851,7 +892,7 @@ fn write_claude_config(
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
-fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
+fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
@@ -860,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
+ let cd_command = build_shell_cd_command(cwd);
// Write the shell script to a temp file
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
+{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
- script_file = script_file.display()
+ script_file = script_file.display(),
+ cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1007,7 +1051,7 @@ fn launch_macos_open_app(
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
-fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
+fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
@@ -1029,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
+ let cd_command = build_shell_cd_command(cwd);
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
+{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
- script_file = script_file.display()
+ script_file = script_file.display(),
+ cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1118,22 +1165,28 @@ fn which_command(cmd: &str) -> bool {
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
+ cwd: Option<&Path>,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
- let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
+ let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
+ let cwd_command = build_windows_cwd_command(cwd);
let content = format!(
"@echo off
+{cwd_command}
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
- config_path_for_batch, config_path_for_batch, config_path_for_batch
+ config_path_for_batch,
+ config_path_for_batch,
+ config_path_for_batch,
+ cwd_command = cwd_command,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
@@ -1164,6 +1217,55 @@ del \"%~f0\" >nul 2>&1
result
}
+fn build_shell_cd_command(cwd: Option<&Path>) -> String {
+ cwd.map(|dir| {
+ format!(
+ "cd {} || exit 1\n",
+ shell_single_quote(&dir.to_string_lossy())
+ )
+ })
+ .unwrap_or_default()
+}
+
+fn shell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', "'\"'\"'"))
+}
+
+#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
+fn is_windows_unc_path(path: &str) -> bool {
+ path.starts_with(r"\\")
+}
+
+#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
+fn build_windows_cwd_command_str(path: &str) -> String {
+ let escaped = escape_windows_batch_value(path);
+
+ if is_windows_unc_path(path) {
+ // `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
+ format!("pushd \"{escaped}\" || exit /b 1\r\n")
+ } else {
+ format!("cd /d \"{escaped}\" || exit /b 1\r\n")
+ }
+}
+
+#[cfg(target_os = "windows")]
+fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
+ cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
+ .unwrap_or_default()
+}
+
+#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
+fn escape_windows_batch_value(value: &str) -> String {
+ value
+ .replace('^', "^^")
+ .replace('%', "%%")
+ .replace('&', "^&")
+ .replace('|', "^|")
+ .replace('<', "^<")
+ .replace('>', "^>")
+ .replace('(', "^(")
+ .replace(')', "^)")
+}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
@@ -1338,4 +1440,62 @@ mod tests {
]
);
}
+
+ #[test]
+ fn resolve_launch_cwd_accepts_existing_directory() {
+ let resolved =
+ resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
+ .expect("temp dir should resolve")
+ .expect("temp dir should be present");
+
+ assert!(resolved.is_dir());
+ }
+
+ #[test]
+ fn resolve_launch_cwd_rejects_missing_directory() {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock should be after epoch")
+ .as_nanos();
+ let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
+
+ let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
+ .expect_err("missing directory should fail");
+
+ assert!(error.contains("目录不存在"));
+ }
+
+ #[test]
+ fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
+ let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
+
+ assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
+ }
+
+ #[test]
+ fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
+ let command = build_windows_cwd_command_str(r"C:\work\repo");
+
+ assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
+ }
+
+ #[test]
+ fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
+ let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
+
+ assert_eq!(
+ command,
+ "pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
+ );
+ }
+
+ #[test]
+ fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
+ let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
+
+ assert_eq!(
+ command,
+ "pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
+ );
+ }
}
diff --git a/src-tauri/src/commands/session_manager.rs b/src-tauri/src/commands/session_manager.rs
index 8f3caee1b..434cd4265 100644
--- a/src-tauri/src/commands/session_manager.rs
+++ b/src-tauri/src/commands/session_manager.rs
@@ -74,3 +74,12 @@ pub async fn delete_session(
.await
.map_err(|e| format!("Failed to delete session: {e}"))?
}
+
+#[tauri::command]
+pub async fn delete_sessions(
+ items: Vec,
+) -> Result, String> {
+ tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
+ .await
+ .map_err(|e| format!("Failed to delete sessions: {e}"))
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 05463cee5..b90118340 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1021,6 +1021,7 @@ pub fn run() {
commands::list_sessions,
commands::get_session_messages,
commands::delete_session,
+ commands::delete_sessions,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs
index 34806d678..36d644a78 100644
--- a/src-tauri/src/opencode_config.rs
+++ b/src-tauri/src/opencode_config.rs
@@ -6,6 +6,32 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
+const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
+const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
+
+fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
+ plugin_name == prefix
+ || plugin_name
+ .strip_prefix(prefix)
+ .map(|suffix| suffix.starts_with('@'))
+ .unwrap_or(false)
+}
+
+fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
+ prefixes
+ .iter()
+ .any(|prefix| matches_plugin_prefix(plugin_name, prefix))
+}
+
+fn canonicalize_plugin_name(plugin_name: &str) -> String {
+ if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
+ if suffix.is_empty() || suffix.starts_with('@') {
+ return format!("oh-my-openagent{suffix}");
+ }
+ }
+ plugin_name.to_string()
+}
+
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
+ let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
- if plugin_name.starts_with("oh-my-opencode")
- && !plugin_name.starts_with("oh-my-opencode-slim")
- {
- // Adding standard OMO -> remove all Slim variants
- arr.retain(|v| {
- v.as_str()
- .map(|s| !s.starts_with("oh-my-opencode-slim"))
- .unwrap_or(true)
- });
- } else if plugin_name.starts_with("oh-my-opencode-slim") {
- // Adding Slim -> remove all standard OMO variants (but keep slim)
+ if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
arr.retain(|v| {
v.as_str()
.map(|s| {
- !s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
+ !matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
+ && !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
+ })
+ .unwrap_or(true)
+ });
+ } else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
+ {
+ arr.retain(|v| {
+ v.as_str()
+ .map(|s| {
+ !matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
+ && !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
}
- let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
+ let already_exists = arr
+ .iter()
+ .any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
if !already_exists {
- arr.push(Value::String(plugin_name.to_string()));
+ arr.push(Value::String(normalized_plugin_name));
}
}
None => {
- config["plugin"] = json!([plugin_name]);
+ config["plugin"] = json!([normalized_plugin_name]);
}
}
write_opencode_config(&config)
}
-pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
+pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
- .map(|s| {
- if !s.starts_with(prefix) {
- return true; // Keep: doesn't match prefix at all
- }
- let rest = &s[prefix.len()..];
- rest.starts_with('-')
- })
+ .map(|s| !matches_any_plugin_prefix(s, prefixes))
.unwrap_or(true)
});
diff --git a/src-tauri/src/proxy/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs
index cc4846eb7..1aa1d1b0e 100644
--- a/src-tauri/src/proxy/failover_switch.rs
+++ b/src-tauri/src/proxy/failover_switch.rs
@@ -2,15 +2,12 @@
//!
//! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发)
-//! - 数据库更新
//! - 托盘菜单更新
//! - 前端事件发射
-//! - Live 备份更新
use crate::database::Database;
use crate::error::AppError;
use std::collections::HashSet;
-use std::str::FromStr;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
@@ -98,30 +95,21 @@ impl FailoverSwitchManager {
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
- // 1. 更新数据库 is_current
- self.db.set_current_provider(app_type, provider_id)?;
+ let mut switched = false;
- // 2. 更新本地 settings(设备级)
- let app_type_enum = crate::app_config::AppType::from_str(app_type)
- .map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
- crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
-
- // 3. 更新托盘菜单和发射事件
if let Some(app) = app_handle {
- // 更新托盘菜单
if let Some(app_state) = app.try_state::() {
- // 更新 Live 备份(确保代理停止时恢复正确配置)
- if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
- if let Err(e) = app_state
- .proxy_service
- .update_live_backup_from_provider(app_type, &provider)
- .await
- {
- log::warn!("[FO-003] Live 备份更新失败: {e}");
- }
+ switched = app_state
+ .proxy_service
+ .hot_switch_provider(app_type, provider_id)
+ .await
+ .map_err(AppError::Message)?
+ .logical_target_changed;
+
+ if !switched {
+ return Ok(false);
}
- // 重建托盘菜单
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
@@ -142,6 +130,6 @@ impl FailoverSwitchManager {
}
}
- Ok(true)
+ Ok(switched)
}
}
diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs
index 984f04ceb..93d819ea1 100644
--- a/src-tauri/src/proxy/mod.rs
+++ b/src-tauri/src/proxy/mod.rs
@@ -24,6 +24,7 @@ pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub(crate) mod sse;
+pub(crate) mod switch_lock;
pub mod thinking_budget_rectifier;
pub mod thinking_optimizer;
pub mod thinking_rectifier;
diff --git a/src-tauri/src/proxy/switch_lock.rs b/src-tauri/src/proxy/switch_lock.rs
new file mode 100644
index 000000000..10a67ba65
--- /dev/null
+++ b/src-tauri/src/proxy/switch_lock.rs
@@ -0,0 +1,42 @@
+//! Per-app switch lock
+//!
+//! 确保同一应用同时只有一个供应商切换操作在执行,
+//! 防止并发切换导致 is_current 与 Live 备份不一致。
+
+use std::collections::HashMap;
+use std::sync::Arc;
+use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
+
+/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
+///
+/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
+#[derive(Clone, Default)]
+pub struct SwitchLockManager {
+ locks: Arc>>>>,
+}
+
+impl SwitchLockManager {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// 获取指定应用的切换锁。
+ ///
+ /// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
+ pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
+ let lock = {
+ let locks = self.locks.read().await;
+ if let Some(lock) = locks.get(app_type) {
+ lock.clone()
+ } else {
+ drop(locks);
+ let mut locks = self.locks.write().await;
+ locks
+ .entry(app_type.to_string())
+ .or_insert_with(|| Arc::new(Mutex::new(())))
+ .clone()
+ }
+ };
+ lock.lock_owned().await
+ }
+}
diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs
index caefbaed9..a91a35a5a 100644
--- a/src-tauri/src/services/omo.rs
+++ b/src-tauri/src/services/omo.rs
@@ -22,33 +22,41 @@ type OmoProfileData = (Option, Option, Option);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
- pub filename: &'static str,
+ pub preferred_filename: &'static str,
+ pub config_candidates: &'static [&'static str],
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
- pub plugin_prefix: &'static str,
+ pub plugin_prefixes: &'static [&'static str],
pub has_categories: bool,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
- filename: "oh-my-opencode.jsonc",
+ preferred_filename: "oh-my-openagent.jsonc",
+ config_candidates: &[
+ "oh-my-openagent.jsonc",
+ "oh-my-openagent.json",
+ "oh-my-opencode.jsonc",
+ "oh-my-opencode.json",
+ ],
category: "omo",
provider_prefix: "omo-",
- plugin_name: "oh-my-opencode@latest",
- plugin_prefix: "oh-my-opencode",
+ plugin_name: "oh-my-openagent@latest",
+ plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
has_categories: true,
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
- filename: "oh-my-opencode-slim.jsonc",
+ preferred_filename: "oh-my-opencode-slim.jsonc",
+ config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
- plugin_prefix: "oh-my-opencode-slim",
+ plugin_prefixes: &["oh-my-opencode-slim"],
has_categories: false,
label: "OMO Slim",
import_label: "Imported Slim",
@@ -61,22 +69,27 @@ pub struct OmoService;
impl OmoService {
// ── Path helpers ────────────────────────────────────────
+ fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec {
+ v.config_candidates
+ .iter()
+ .map(|name| base_dir.join(name))
+ .collect()
+ }
+
+ fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option {
+ Self::config_candidates(v, base_dir)
+ .into_iter()
+ .find(|path| path.exists())
+ }
+
fn config_path(v: &OmoVariant) -> PathBuf {
- get_opencode_dir().join(v.filename)
+ let base_dir = get_opencode_dir();
+ Self::find_existing_config_path(v, &base_dir)
+ .unwrap_or_else(|| base_dir.join(v.preferred_filename))
}
fn resolve_local_config_path(v: &OmoVariant) -> Result {
- let config_path = Self::config_path(v);
- if config_path.exists() {
- return Ok(config_path);
- }
-
- let json_path = config_path.with_extension("json");
- if json_path.exists() {
- return Ok(json_path);
- }
-
- Err(AppError::OmoConfigNotFound)
+ Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result, AppError> {
@@ -186,12 +199,18 @@ impl OmoService {
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
- let config_path = Self::config_path(v);
- if config_path.exists() {
- std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
- log::info!("{} config file deleted: {config_path:?}", v.label);
+ let base_dir = get_opencode_dir();
+ let mut deleted_paths = Vec::new();
+ for config_path in Self::config_candidates(v, &base_dir) {
+ if config_path.exists() {
+ std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
+ deleted_paths.push(config_path);
+ }
}
- crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
+ if !deleted_paths.is_empty() {
+ log::info!("{} config files deleted: {deleted_paths:?}", v.label);
+ }
+ crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
Ok(())
}
@@ -504,4 +523,38 @@ mod tests {
assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents"));
}
+
+ #[test]
+ fn test_find_existing_config_prefers_new_name_over_old() {
+ let dir = tempfile::tempdir().unwrap();
+ let old_path = dir.path().join("oh-my-opencode.jsonc");
+ let new_path = dir.path().join("oh-my-openagent.jsonc");
+
+ // Create both old and new files
+ std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
+ std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
+
+ let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
+ assert_eq!(
+ found.unwrap(),
+ new_path,
+ "When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
+ );
+ }
+
+ #[test]
+ fn test_find_existing_config_falls_back_to_old_name() {
+ let dir = tempfile::tempdir().unwrap();
+ let old_path = dir.path().join("oh-my-opencode.jsonc");
+
+ // Only old file exists
+ std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
+
+ let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
+ assert_eq!(
+ found.unwrap(),
+ old_path,
+ "When only the old config file exists, it should still be found"
+ );
+ }
}
diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs
index 26e9dfaa4..9f7e99d0e 100644
--- a/src-tauri/src/services/provider/mod.rs
+++ b/src-tauri/src/services/provider/mod.rs
@@ -1220,32 +1220,12 @@ impl ProviderService {
id
);
- // 获取新供应商的完整配置(用于更新备份)
- let provider = providers
- .get(id)
- .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
-
- // Update database is_current
- state.db.set_current_provider(app_type.as_str(), id)?;
-
- // Update local settings for consistency
- crate::settings::set_current_provider(&app_type, Some(id))?;
-
- // 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
futures::executor::block_on(
state
.proxy_service
- .update_live_backup_from_provider(app_type.as_str(), provider),
+ .hot_switch_provider(app_type.as_str(), id),
)
- .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
-
- // 关键修复:接管模式下切换供应商不会写回 Live 配置,
- // 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
- if matches!(app_type, AppType::Claude) {
- if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
- log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
- }
- }
+ .map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs
index ed5895bb7..74ca88312 100644
--- a/src-tauri/src/services/proxy.rs
+++ b/src-tauri/src/services/proxy.rs
@@ -7,6 +7,7 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
+use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
@@ -39,6 +40,12 @@ pub struct ProxyService {
server: Arc>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc>>,
+ switch_locks: SwitchLockManager,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct HotSwitchOutcome {
+ pub logical_target_changed: bool,
}
impl ProxyService {
@@ -47,6 +54,7 @@ impl ProxyService {
db,
server: Arc::new(RwLock::new(None)),
app_handle: Arc::new(RwLock::new(None)),
+ switch_locks: SwitchLockManager::new(),
}
}
@@ -1100,6 +1108,11 @@ impl ProxyService {
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
+ let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
+ self.restore_live_config_for_app_inner(app_type).await
+ }
+
+ async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
match app_type {
AppType::Claude => {
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
@@ -1159,6 +1172,15 @@ impl ProxyService {
async fn restore_live_config_for_app_with_fallback(
&self,
app_type: &AppType,
+ ) -> Result<(), String> {
+ let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
+ self.restore_live_config_for_app_with_fallback_inner(app_type)
+ .await
+ }
+
+ async fn restore_live_config_for_app_with_fallback_inner(
+ &self,
+ app_type: &AppType,
) -> Result<(), String> {
let app_type_str = app_type.as_str();
@@ -1487,6 +1509,17 @@ impl ProxyService {
&self,
app_type: &str,
provider: &Provider,
+ ) -> Result<(), String> {
+ let _guard = self.switch_locks.lock_for_app(app_type).await;
+ self.update_live_backup_from_provider_inner(app_type, provider)
+ .await
+ }
+
+ /// 仅供已持有 per-app 切换锁的调用方使用。
+ async fn update_live_backup_from_provider_inner(
+ &self,
+ app_type: &str,
+ provider: &Provider,
) -> Result<(), String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
@@ -1540,6 +1573,69 @@ impl ProxyService {
Ok(())
}
+ pub async fn hot_switch_provider(
+ &self,
+ app_type: &str,
+ provider_id: &str,
+ ) -> Result {
+ let _guard = self.switch_locks.lock_for_app(app_type).await;
+
+ let app_type_enum =
+ AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
+ let provider = self
+ .db
+ .get_provider_by_id(provider_id, app_type)
+ .map_err(|e| format!("读取供应商失败: {e}"))?
+ .ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
+
+ let logical_target_changed =
+ crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
+ .map_err(|e| format!("读取当前供应商失败: {e}"))?
+ .as_deref()
+ != Some(provider_id);
+
+ let has_backup = self
+ .db
+ .get_live_backup(app_type_enum.as_str())
+ .await
+ .map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
+ .is_some();
+ let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
+ let should_sync_backup = has_backup || live_taken_over;
+
+ self.db
+ .set_current_provider(app_type_enum.as_str(), provider_id)
+ .map_err(|e| format!("更新当前供应商失败: {e}"))?;
+ crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
+ .map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
+
+ if should_sync_backup {
+ self.update_live_backup_from_provider_inner(app_type, &provider)
+ .await?;
+
+ if matches!(app_type_enum, AppType::Claude) {
+ if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
+ log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
+ }
+ }
+ }
+
+ if let Some(server) = self.server.read().await.as_ref() {
+ server
+ .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
+ .await;
+ }
+
+ Ok(HotSwitchOutcome {
+ logical_target_changed,
+ })
+ }
+
+ #[cfg(test)]
+ async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
+ self.switch_locks.lock_for_app(app_type).await
+ }
+
fn preserve_codex_mcp_servers_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
@@ -1607,47 +1703,13 @@ impl ProxyService {
app_type: &str,
provider_id: &str,
) -> Result<(), String> {
- // 代理模式切换供应商(热切换):
- // - 更新 SSOT(数据库 is_current)
- // - 同步本地 settings(设备级 current_provider_*)
- // - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
- let app_type_enum =
- AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
+ let outcome = self.hot_switch_provider(app_type, provider_id).await?;
- self.db
- .set_current_provider(app_type_enum.as_str(), provider_id)
- .map_err(|e| format!("更新当前供应商失败: {e}"))?;
-
- // 同步本地 settings(设备级优先)
- crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
- .map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
-
- // 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
- let has_backup = self
- .db
- .get_live_backup(app_type_enum.as_str())
- .await
- .ok()
- .flatten()
- .is_some();
- let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
-
- if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
- // 同步更新 Live 备份(用于 stop_with_restore 恢复)
- if has_backup || live_taken_over {
- self.update_live_backup_from_provider(app_type, &provider)
- .await?;
- }
-
- // 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
- if let Some(server) = self.server.read().await.as_ref() {
- server
- .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
- .await;
- }
+ if outcome.logical_target_changed {
+ log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
+ } else {
+ log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
}
-
- log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
Ok(())
}
@@ -2193,6 +2255,185 @@ model = "gpt-5.1-codex"
assert_eq!(backup.original_config, expected);
}
+ #[tokio::test]
+ #[serial]
+ async fn hot_switch_provider_serializes_same_app_switches() {
+ use tokio::time::{sleep, Duration};
+
+ let _home = TempHome::new();
+ crate::settings::reload_settings().expect("reload settings");
+
+ let db = Arc::new(Database::memory().expect("init db"));
+ let service = ProxyService::new(db.clone());
+
+ let provider_a = Provider::with_id(
+ "a".to_string(),
+ "A".to_string(),
+ json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
+ None,
+ );
+ let provider_b = Provider::with_id(
+ "b".to_string(),
+ "B".to_string(),
+ json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
+ None,
+ );
+ let provider_c = Provider::with_id(
+ "c".to_string(),
+ "C".to_string(),
+ json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
+ None,
+ );
+
+ db.save_provider("claude", &provider_a)
+ .expect("save provider a");
+ db.save_provider("claude", &provider_b)
+ .expect("save provider b");
+ db.save_provider("claude", &provider_c)
+ .expect("save provider c");
+ db.set_current_provider("claude", "a")
+ .expect("set current provider");
+ crate::settings::set_current_provider(&AppType::Claude, Some("a"))
+ .expect("set local current provider");
+ db.save_live_backup("claude", "{\"env\":{}}")
+ .await
+ .expect("seed live backup");
+
+ let guard = service.lock_switch_for_test("claude").await;
+ let service_for_b = service.clone();
+ let service_for_c = service.clone();
+
+ let switch_b = tokio::spawn(async move {
+ service_for_b
+ .hot_switch_provider("claude", "b")
+ .await
+ .expect("switch to b")
+ });
+ sleep(Duration::from_millis(20)).await;
+ let switch_c = tokio::spawn(async move {
+ service_for_c
+ .hot_switch_provider("claude", "c")
+ .await
+ .expect("switch to c")
+ });
+
+ sleep(Duration::from_millis(20)).await;
+ drop(guard);
+
+ let outcome_b = switch_b.await.expect("join switch b");
+ let outcome_c = switch_c.await.expect("join switch c");
+ assert!(outcome_b.logical_target_changed);
+ assert!(outcome_c.logical_target_changed);
+
+ assert_eq!(
+ crate::settings::get_effective_current_provider(&db, &AppType::Claude)
+ .expect("effective current"),
+ Some("c".to_string())
+ );
+ assert_eq!(
+ crate::settings::get_current_provider(&AppType::Claude).as_deref(),
+ Some("c")
+ );
+ assert_eq!(
+ db.get_current_provider("claude").expect("db current"),
+ Some("c".to_string())
+ );
+
+ let backup = db
+ .get_live_backup("claude")
+ .await
+ .expect("get live backup")
+ .expect("backup exists");
+ let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
+ assert_eq!(backup.original_config, expected);
+ }
+
+ #[tokio::test]
+ #[serial]
+ async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
+ use tokio::time::{sleep, Duration};
+
+ let _home = TempHome::new();
+ crate::settings::reload_settings().expect("reload settings");
+
+ let db = Arc::new(Database::memory().expect("init db"));
+ let service = ProxyService::new(db.clone());
+
+ let provider_a = Provider::with_id(
+ "a".to_string(),
+ "A".to_string(),
+ json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
+ None,
+ );
+ let provider_b = Provider::with_id(
+ "b".to_string(),
+ "B".to_string(),
+ json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
+ None,
+ );
+
+ db.save_provider("claude", &provider_a)
+ .expect("save provider a");
+ db.save_provider("claude", &provider_b)
+ .expect("save provider b");
+ db.set_current_provider("claude", "a")
+ .expect("set current provider");
+ crate::settings::set_current_provider(&AppType::Claude, Some("a"))
+ .expect("set local current provider");
+ db.save_live_backup(
+ "claude",
+ &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
+ )
+ .await
+ .expect("seed live backup");
+ service
+ .write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
+ .expect("seed live file");
+
+ let guard = service.lock_switch_for_test("claude").await;
+ let service_for_switch = service.clone();
+ let service_for_restore = service.clone();
+
+ let switch_to_b = tokio::spawn(async move {
+ service_for_switch
+ .hot_switch_provider("claude", "b")
+ .await
+ .expect("switch to b")
+ });
+ sleep(Duration::from_millis(20)).await;
+ let restore = tokio::spawn(async move {
+ service_for_restore
+ .restore_live_config_for_app_with_fallback(&AppType::Claude)
+ .await
+ .expect("restore claude live")
+ });
+
+ sleep(Duration::from_millis(20)).await;
+ drop(guard);
+
+ let outcome = switch_to_b.await.expect("join switch");
+ restore.await.expect("join restore");
+ assert!(outcome.logical_target_changed);
+
+ assert_eq!(
+ crate::settings::get_effective_current_provider(&db, &AppType::Claude)
+ .expect("effective current"),
+ Some("b".to_string())
+ );
+
+ let backup = db
+ .get_live_backup("claude")
+ .await
+ .expect("get live backup")
+ .expect("backup exists");
+ let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
+ assert_eq!(backup.original_config, expected);
+ assert_eq!(
+ service.read_claude_live().expect("read live"),
+ provider_b.settings_config
+ );
+ }
+
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() {
diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs
index f0d0717e7..af2ff26c6 100644
--- a/src-tauri/src/services/webdav.rs
+++ b/src-tauri/src/services/webdav.rs
@@ -204,10 +204,11 @@ pub async fn ensure_remote_directories(
s if s == StatusCode::CREATED || s.is_success() => {
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
}
- // 405 commonly means "already exists" on many WebDAV servers
- StatusCode::METHOD_NOT_ALLOWED => {}
// Ambiguous — verify directory actually exists via PROPFIND
- s if s == StatusCode::CONFLICT || s.is_redirection() => {
+ s if s == StatusCode::METHOD_NOT_ALLOWED
+ || s == StatusCode::CONFLICT
+ || s.is_redirection() =>
+ {
if !propfind_exists(&client, &dir_url, auth).await? {
return Err(webdav_status_error("MKCOL", status, &dir_url));
}
diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs
index 498dc469a..747f169e5 100644
--- a/src-tauri/src/session_manager/mod.rs
+++ b/src-tauri/src/session_manager/mod.rs
@@ -1,7 +1,7 @@
pub mod providers;
pub mod terminal;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, openclaw, opencode};
@@ -36,6 +36,25 @@ pub struct SessionMessage {
pub ts: Option,
}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteSessionRequest {
+ pub provider_id: String,
+ pub session_id: String,
+ pub source_path: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteSessionOutcome {
+ pub provider_id: String,
+ pub session_id: String,
+ pub source_path: String,
+ pub success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
pub fn scan_sessions() -> Vec {
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
@@ -99,6 +118,16 @@ pub fn delete_session(
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
}
+pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec {
+ collect_delete_session_outcomes(requests, |request| {
+ delete_session(
+ &request.provider_id,
+ &request.session_id,
+ &request.source_path,
+ )
+ })
+}
+
fn delete_session_with_root(
provider_id: &str,
session_id: &str,
@@ -147,6 +176,41 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result(
+ requests: &[DeleteSessionRequest],
+ mut deleter: F,
+) -> Vec
+where
+ F: FnMut(&DeleteSessionRequest) -> Result,
+{
+ requests
+ .iter()
+ .map(|request| match deleter(request) {
+ Ok(true) => DeleteSessionOutcome {
+ provider_id: request.provider_id.clone(),
+ session_id: request.session_id.clone(),
+ source_path: request.source_path.clone(),
+ success: true,
+ error: None,
+ },
+ Ok(false) => DeleteSessionOutcome {
+ provider_id: request.provider_id.clone(),
+ session_id: request.session_id.clone(),
+ source_path: request.source_path.clone(),
+ success: false,
+ error: Some("Session was not deleted".to_string()),
+ },
+ Err(error) => DeleteSessionOutcome {
+ provider_id: request.provider_id.clone(),
+ session_id: request.session_id.clone(),
+ source_path: request.source_path.clone(),
+ success: false,
+ error: Some(error),
+ },
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -175,4 +239,44 @@ mod tests {
assert!(err.contains("session source not found"));
}
+
+ #[test]
+ fn batch_delete_collects_successes_and_failures_in_order() {
+ let requests = vec![
+ DeleteSessionRequest {
+ provider_id: "codex".to_string(),
+ session_id: "s1".to_string(),
+ source_path: "/tmp/s1".to_string(),
+ },
+ DeleteSessionRequest {
+ provider_id: "claude".to_string(),
+ session_id: "s2".to_string(),
+ source_path: "/tmp/s2".to_string(),
+ },
+ DeleteSessionRequest {
+ provider_id: "gemini".to_string(),
+ session_id: "s3".to_string(),
+ source_path: "/tmp/s3".to_string(),
+ },
+ ];
+
+ let outcomes = collect_delete_session_outcomes(&requests, |request| {
+ match request.session_id.as_str() {
+ "s1" => Ok(true),
+ "s2" => Err("boom".to_string()),
+ _ => Ok(false),
+ }
+ });
+
+ assert_eq!(outcomes.len(), 3);
+ assert!(outcomes[0].success);
+ assert_eq!(outcomes[0].error, None);
+ assert!(!outcomes[1].success);
+ assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
+ assert!(!outcomes[2].success);
+ assert_eq!(
+ outcomes[2].error.as_deref(),
+ Some("Session was not deleted")
+ );
+ }
}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 2358e622f..b44929dd1 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -584,17 +584,14 @@ pub fn get_current_provider(app_type: &AppType) -> Option {
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
- let mut settings = get_settings();
-
- match app_type {
- AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
- AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
- AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
- AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
- AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
- }
-
- update_settings(settings)
+ let id_owned = id.map(|s| s.to_string());
+ mutate_settings(|settings| match app_type {
+ AppType::Claude => settings.current_provider_claude = id_owned.clone(),
+ AppType::Codex => settings.current_provider_codex = id_owned.clone(),
+ AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
+ AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
+ AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
+ })
}
/// 获取有效的当前供应商 ID(验证存在性)
diff --git a/src/App.tsx b/src/App.tsx
index 16e2ef8af..0665c1889 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -683,7 +683,14 @@ function App() {
const handleOpenTerminal = async (provider: Provider) => {
try {
- await providersApi.openTerminal(provider.id, activeApp);
+ const selectedDir = await settingsApi.pickDirectory();
+ if (!selectedDir) {
+ return;
+ }
+
+ await providersApi.openTerminal(provider.id, activeApp, {
+ cwd: selectedDir,
+ });
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx
index d013611d6..ab7f3bf07 100644
--- a/src/components/providers/ProviderCard.tsx
+++ b/src/components/providers/ProviderCard.tsx
@@ -123,6 +123,7 @@ export function ProviderCard({
// OMO and OMO Slim share the same card behavior
const isAnyOmo = isOmo || isOmoSlim;
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
+ const isAdditiveMode = appId === "opencode" && !isAnyOmo;
const { data: health } = useProviderHealth(provider.id, appId);
@@ -209,9 +210,12 @@ export function ProviderCard({
: isCurrent;
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
+ const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
const shouldUseBlue =
(isAnyOmo && isActiveProvider) ||
- (!isAnyOmo && !isProxyTakeover && isActiveProvider);
+ (!isAnyOmo &&
+ !isProxyTakeover &&
+ (isActiveProvider || hasPersistentConfigHighlight));
return (
diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx
index 7a97b480b..d1e3f7bad 100644
--- a/src/components/providers/forms/ProviderPresetSelector.tsx
+++ b/src/components/providers/forms/ProviderPresetSelector.tsx
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
case "omo":
return t("providerForm.omoHint", {
defaultValue:
- "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
+ "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
});
default:
return t("providerPreset.hint", {
diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx
index 15de67e65..dc2841c33 100644
--- a/src/components/sessions/SessionItem.tsx
+++ b/src/components/sessions/SessionItem.tsx
@@ -1,5 +1,6 @@
import { ChevronRight, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
+import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
@@ -19,13 +20,21 @@ import {
interface SessionItemProps {
session: SessionMeta;
isSelected: boolean;
+ selectionMode: boolean;
+ isChecked: boolean;
+ isCheckDisabled?: boolean;
onSelect: (key: string) => void;
+ onToggleChecked: (checked: boolean) => void;
}
export function SessionItem({
session,
isSelected,
+ selectionMode,
+ isChecked,
+ isCheckDisabled = false,
onSelect,
+ onToggleChecked,
}: SessionItemProps) {
const { t } = useTranslation();
const title = formatSessionTitle(session);
@@ -33,46 +42,64 @@ export function SessionItem({
const sessionKey = getSessionKey(session);
return (
-
onSelect(sessionKey)}
+
-
-
-
-
-
-
-
-
- {getProviderLabel(session.providerId, t)}
-
-
-
{title}
-
-
+ {selectionMode && (
+
+ onToggleChecked(Boolean(checked))}
+ />
+
+ )}
+
onSelect(sessionKey)}
+ className="min-w-0 flex-1 text-left"
+ >
+
+
+
+
+
+
+
+
+ {getProviderLabel(session.providerId, t)}
+
+
+
{title}
+
+
-
-
-
- {lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
-
-
-
+
+
+
+ {lastActive
+ ? formatRelativeTime(lastActive, t)
+ : t("common.unknown")}
+
+
+
+
);
}
diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx
index ce925f574..c73f09ab5 100644
--- a/src/components/sessions/SessionManagerPage.tsx
+++ b/src/components/sessions/SessionManagerPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
+import { useQueryClient } from "@tanstack/react-query";
import {
Copy,
RefreshCw,
@@ -12,6 +13,7 @@ import {
Clock,
FolderOpen,
X,
+ CheckSquare,
} from "lucide-react";
import {
useDeleteSessionMutation,
@@ -63,6 +65,7 @@ type ProviderFilter =
export function SessionManagerPage({ appId }: { appId: string }) {
const { t } = useTranslation();
+ const queryClient = useQueryClient();
const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? [];
const detailRef = useRef(null);
@@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) {
);
const [tocDialogOpen, setTocDialogOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteTargets, setDeleteTargets] = useState(
+ null,
+ );
+ const [selectedSessionKeys, setSelectedSessionKeys] = useState>(
+ () => new Set(),
+ );
+ const [isBatchDeleting, setIsBatchDeleting] = useState(false);
+ const [selectionMode, setSelectionMode] = useState(false);
const searchInputRef = useRef(null);
const [search, setSearch] = useState("");
@@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) {
selectedSession?.sourcePath,
);
const deleteSessionMutation = useDeleteSessionMutation();
+ const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
+
+ useEffect(() => {
+ const validKeys = new Set(
+ sessions.map((session) => getSessionKey(session)),
+ );
+ setSelectedSessionKeys((current) => {
+ let changed = false;
+ const next = new Set();
+ current.forEach((key) => {
+ if (validKeys.has(key)) {
+ next.add(key);
+ } else {
+ changed = true;
+ }
+ });
+ return changed ? next : current;
+ });
+ }, [sessions]);
// 提取用户消息用于目录
const userMessagesToc = useMemo(() => {
@@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) {
};
const handleDeleteConfirm = async () => {
- if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
+ if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
return;
}
- setDeleteTarget(null);
- await deleteSessionMutation.mutateAsync({
- providerId: deleteTarget.providerId,
- sessionId: deleteTarget.sessionId,
- sourcePath: deleteTarget.sourcePath,
+ const targets = deleteTargets.filter((session) => session.sourcePath);
+ setDeleteTargets(null);
+
+ if (targets.length === 0) {
+ return;
+ }
+
+ if (targets.length === 1) {
+ const [target] = targets;
+ await deleteSessionMutation.mutateAsync({
+ providerId: target.providerId,
+ sessionId: target.sessionId,
+ sourcePath: target.sourcePath!,
+ });
+ setSelectedSessionKeys((current) => {
+ const next = new Set(current);
+ next.delete(getSessionKey(target));
+ return next;
+ });
+ return;
+ }
+
+ setIsBatchDeleting(true);
+ try {
+ const results = await sessionsApi.deleteMany(
+ targets.map((session) => ({
+ providerId: session.providerId,
+ sessionId: session.sessionId,
+ sourcePath: session.sourcePath!,
+ })),
+ );
+
+ const deletedKeys = results
+ .filter((result) => result.success)
+ .map(
+ (result) =>
+ `${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
+ );
+
+ const failedErrors = results
+ .filter((result) => !result.success)
+ .map((result) => result.error || t("common.unknown"));
+
+ if (deletedKeys.length > 0) {
+ const deletedKeySet = new Set(deletedKeys);
+ queryClient.setQueryData(["sessions"], (current) =>
+ (current ?? []).filter(
+ (session) => !deletedKeySet.has(getSessionKey(session)),
+ ),
+ );
+ }
+
+ results
+ .filter((result) => result.success)
+ .forEach((result) => {
+ queryClient.removeQueries({
+ queryKey: ["sessionMessages", result.providerId, result.sourcePath],
+ });
+ });
+
+ setSelectedSessionKeys((current) => {
+ const next = new Set(current);
+ deletedKeys.forEach((key) => next.delete(key));
+ return next;
+ });
+
+ await queryClient.invalidateQueries({ queryKey: ["sessions"] });
+
+ if (deletedKeys.length > 0) {
+ toast.success(
+ t("sessionManager.batchDeleteSuccess", {
+ defaultValue: "已删除 {{count}} 个会话",
+ count: deletedKeys.length,
+ }),
+ );
+ }
+
+ if (failedErrors.length > 0) {
+ toast.error(
+ t("sessionManager.batchDeleteFailed", {
+ defaultValue: "{{failed}} 个会话删除失败",
+ failed: failedErrors.length,
+ }),
+ {
+ description: failedErrors[0],
+ },
+ );
+ }
+ } catch (error) {
+ toast.error(
+ extractErrorMessage(error) ||
+ t("sessionManager.batchDeleteRequestFailed", {
+ defaultValue: "批量删除失败,请稍后重试",
+ }),
+ );
+ } finally {
+ setIsBatchDeleting(false);
+ }
+ };
+
+ const deletableFilteredSessions = useMemo(
+ () => filteredSessions.filter((session) => Boolean(session.sourcePath)),
+ [filteredSessions],
+ );
+
+ const selectedSessions = useMemo(
+ () =>
+ sessions.filter((session) =>
+ selectedSessionKeys.has(getSessionKey(session)),
+ ),
+ [sessions, selectedSessionKeys],
+ );
+
+ const selectedDeletableSessions = useMemo(
+ () => selectedSessions.filter((session) => Boolean(session.sourcePath)),
+ [selectedSessions],
+ );
+
+ useEffect(() => {
+ if (!selectionMode) return;
+
+ const visibleKeys = new Set(
+ deletableFilteredSessions.map((session) => getSessionKey(session)),
+ );
+
+ setSelectedSessionKeys((current) => {
+ let changed = false;
+ const next = new Set();
+
+ current.forEach((key) => {
+ if (visibleKeys.has(key)) {
+ next.add(key);
+ } else {
+ changed = true;
+ }
+ });
+
+ return changed ? next : current;
});
+ }, [deletableFilteredSessions, selectionMode]);
+
+ const allFilteredSelected =
+ deletableFilteredSessions.length > 0 &&
+ deletableFilteredSessions.every((session) =>
+ selectedSessionKeys.has(getSessionKey(session)),
+ );
+
+ const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
+ if (!session.sourcePath) return;
+ const key = getSessionKey(session);
+ setSelectedSessionKeys((current) => {
+ const next = new Set(current);
+ if (checked) {
+ next.add(key);
+ } else {
+ next.delete(key);
+ }
+ return next;
+ });
+ };
+
+ const handleToggleSelectAll = () => {
+ setSelectedSessionKeys((current) => {
+ const next = new Set(current);
+ if (allFilteredSelected) {
+ deletableFilteredSessions.forEach((session) =>
+ next.delete(getSessionKey(session)),
+ );
+ } else {
+ deletableFilteredSessions.forEach((session) =>
+ next.add(getSessionKey(session)),
+ );
+ }
+ return next;
+ });
+ };
+
+ const openBatchDeleteDialog = () => {
+ if (selectedDeletableSessions.length === 0) return;
+ setDeleteTargets(selectedDeletableSessions);
+ };
+
+ const exitSelectionMode = () => {
+ setSelectionMode(false);
+ setSelectedSessionKeys(new Set());
};
return (
@@ -219,174 +427,315 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{isSearchOpen ? (
-
-
-
setSearch(event.target.value)}
- placeholder={t("sessionManager.searchPlaceholder")}
- className="h-8 pl-8 pr-8 text-sm"
- autoFocus
- onKeyDown={(e) => {
- if (e.key === "Escape") {
+
+
+
+ setSearch(event.target.value)}
+ placeholder={t("sessionManager.searchPlaceholder")}
+ className="h-8 pl-8 pr-8 text-sm"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setIsSearchOpen(false);
+ setSearch("");
+ }
+ }}
+ onBlur={() => {
+ if (search.trim() === "") {
+ setIsSearchOpen(false);
+ }
+ }}
+ />
+ {
setIsSearchOpen(false);
setSearch("");
- }
- }}
- onBlur={() => {
- if (search.trim() === "") {
- setIsSearchOpen(false);
- }
- }}
- />
- {
- setIsSearchOpen(false);
- setSearch("");
- }}
- >
-
-
-
- ) : (
-
-
-
- {t("sessionManager.sessionList")}
-
-
- {filteredSessions.length}
-
+ }}
+ >
+
+
-
+ {selectionMode && (
{
- setIsSearchOpen(true);
- setTimeout(
- () => searchInputRef.current?.focus(),
- 0,
- );
- }}
+ className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
+ aria-label={t("sessionManager.exitBatchModeTooltip", {
+ defaultValue: "退出批量管理",
+ })}
+ onClick={exitSelectionMode}
>
-
+
- {t("sessionManager.searchSessions")}
+ {t("sessionManager.exitBatchModeTooltip", {
+ defaultValue: "退出批量管理",
+ })}
-
-
- setProviderFilter(value as ProviderFilter)
- }
- >
+ )}
+
+ ) : (
+
+
+
+
+ {t("sessionManager.sessionList")}
+
+
+ {filteredSessions.length}
+
+
+
+ {(selectionMode ||
+ deletableFilteredSessions.length > 0) && (
+
+
+ {
+ if (selectionMode) {
+ exitSelectionMode();
+ } else {
+ setSelectionMode(true);
+ }
+ }}
+ >
+
+
+
+
+ {selectionMode
+ ? t("sessionManager.exitBatchModeTooltip", {
+ defaultValue: "退出批量管理",
+ })
+ : t("sessionManager.manageBatchTooltip", {
+ defaultValue: "批量管理",
+ })}
+
+
+ )}
-
-
-
+ {
+ setIsSearchOpen(true);
+ setTimeout(
+ () => searchInputRef.current?.focus(),
+ 0,
+ );
+ }}
+ >
+
+
- {providerFilter === "all"
- ? t("sessionManager.providerFilterAll")
- : providerFilter}
+ {t("sessionManager.searchSessions")}
-
-
-
-
-
- {t("sessionManager.providerFilterAll")}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- void refetch()}
- >
-
-
-
- {t("common.refresh")}
-
+
+ setProviderFilter(value as ProviderFilter)
+ }
+ >
+
+
+
+
+
+
+
+ {providerFilter === "all"
+ ? t("sessionManager.providerFilterAll")
+ : providerFilter}
+
+
+
+
+
+
+
+ {t("sessionManager.providerFilterAll")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ void refetch()}
+ >
+
+
+
+ {t("common.refresh")}
+
+
+ {selectionMode && (
+
+
+
+ {t("sessionManager.selectedCount", {
+ defaultValue: "已选 {{count}} 项",
+ count: selectedDeletableSessions.length,
+ })}
+
+
+ {t("sessionManager.batchModeHint", {
+ defaultValue: "勾选要删除的会话",
+ })}
+
+
+
+
+ {deletableFilteredSessions.length > 0 && (
+
+ {allFilteredSelected
+ ? t("sessionManager.clearFilteredSelection", {
+ defaultValue: "取消全选",
+ })
+ : t("sessionManager.selectAllFiltered", {
+ defaultValue: "全选当前",
+ })}
+
+ )}
+ setSelectedSessionKeys(new Set())}
+ >
+ {t("sessionManager.clearSelection", {
+ defaultValue: "清空已选",
+ })}
+
+
+
+
+
+ {isBatchDeleting
+ ? t("sessionManager.batchDeleting", {
+ defaultValue: "删除中...",
+ })
+ : t("sessionManager.deleteSelected", {
+ defaultValue: "批量删除",
+ })}
+
+
+
+
+ )}
)}
@@ -416,7 +765,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
key={getSessionKey(session)}
session={session}
isSelected={isSelected}
+ selectionMode={selectionMode}
+ isChecked={selectedSessionKeys.has(
+ getSessionKey(session),
+ )}
+ isCheckDisabled={!session.sourcePath}
onSelect={setSelectedKey}
+ onToggleChecked={(checked) =>
+ toggleSessionChecked(session, checked)
+ }
/>
);
})}
@@ -548,15 +905,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
size="sm"
variant="destructive"
className="gap-1.5"
- onClick={() => setDeleteTarget(selectedSession)}
+ onClick={() =>
+ setDeleteTargets([selectedSession])
+ }
disabled={
- !selectedSession.sourcePath ||
- deleteSessionMutation.isPending
+ !selectedSession.sourcePath || isDeleting
}
>
- {deleteSessionMutation.isPending
+ {isDeleting
? t("sessionManager.deleting", {
defaultValue: "删除中...",
})
@@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
1
+ ? t("sessionManager.batchDeleteConfirmTitle", {
+ defaultValue: "批量删除会话",
+ })
+ : t("sessionManager.deleteConfirmTitle", {
+ defaultValue: "删除会话",
+ })
+ }
+ message={
+ deleteTargets && deleteTargets.length > 1
+ ? t("sessionManager.batchDeleteConfirmMessage", {
+ defaultValue:
+ "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
+ count: deleteTargets.length,
+ })
+ : deleteTargets?.[0]
+ ? t("sessionManager.deleteConfirmMessage", {
+ defaultValue:
+ "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
+ title: formatSessionTitle(deleteTargets[0]),
+ sessionId: deleteTargets[0].sessionId,
+ })
+ : ""
+ }
+ confirmText={
+ deleteTargets && deleteTargets.length > 1
+ ? t("sessionManager.batchDeleteConfirmAction", {
+ defaultValue: "删除所选会话",
+ })
+ : t("sessionManager.deleteConfirmAction", {
+ defaultValue: "删除会话",
})
- : ""
}
- confirmText={t("sessionManager.deleteConfirmAction", {
- defaultValue: "删除会话",
- })}
cancelText={t("common.cancel", { defaultValue: "取消" })}
variant="destructive"
onConfirm={() => void handleDeleteConfirm()}
onCancel={() => {
- if (!deleteSessionMutation.isPending) {
- setDeleteTarget(null);
+ if (!isDeleting) {
+ setDeleteTargets(null);
}
}}
/>
diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx
index 0161a9a73..0c072b737 100644
--- a/src/components/settings/WebdavSyncSection.tsx
+++ b/src/components/settings/WebdavSyncSection.tsx
@@ -98,6 +98,20 @@ function formatDbCompatVersion(version?: number | null): string | null {
return typeof version === "number" ? `db-v${version}` : null;
}
+function buildPasswordPreservationKey(values: {
+ baseUrl?: string | null;
+ username?: string | null;
+ remoteRoot?: string | null;
+ profile?: string | null;
+}) {
+ return JSON.stringify({
+ baseUrl: values.baseUrl ?? "",
+ username: values.username ?? "",
+ remoteRoot: values.remoteRoot ?? "cc-switch-sync",
+ profile: values.profile ?? "default",
+ });
+}
+
// ─── Types ──────────────────────────────────────────────────
type ActionState =
@@ -167,6 +181,10 @@ export function WebdavSyncSection({
const [passwordTouched, setPasswordTouched] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const justSavedTimerRef = useRef | null>(null);
+ const pendingPasswordPreservationRef = useRef<{
+ key: string;
+ password: string;
+ } | null>(null);
// Local form state — credentials are only persisted on explicit "Save".
const [form, setForm] = useState(() => ({
@@ -205,13 +223,36 @@ export function WebdavSyncSection({
// Sync form when config is loaded/updated from backend, but not while user is editing
useEffect(() => {
if (!config || dirty) return;
- setForm({
- baseUrl: config.baseUrl ?? "",
- username: config.username ?? "",
- password: config.password ?? "",
- remoteRoot: config.remoteRoot ?? "cc-switch-sync",
- profile: config.profile ?? "default",
- autoSync: config.autoSync ?? false,
+ setForm(() => {
+ const nextBaseUrl = config.baseUrl ?? "";
+ const nextUsername = config.username ?? "";
+ const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
+ const nextProfile = config.profile ?? "default";
+ const nextKey = buildPasswordPreservationKey({
+ baseUrl: nextBaseUrl,
+ username: nextUsername,
+ remoteRoot: nextRemoteRoot,
+ profile: nextProfile,
+ });
+ const shouldPreserveRedactedPassword =
+ !config.password &&
+ pendingPasswordPreservationRef.current?.key === nextKey &&
+ !!pendingPasswordPreservationRef.current.password;
+
+ const nextPassword = shouldPreserveRedactedPassword
+ ? pendingPasswordPreservationRef.current!.password
+ : (config.password ?? "");
+
+ pendingPasswordPreservationRef.current = null;
+
+ return {
+ baseUrl: nextBaseUrl,
+ username: nextUsername,
+ password: nextPassword,
+ remoteRoot: nextRemoteRoot,
+ profile: nextProfile,
+ autoSync: config.autoSync ?? false,
+ };
});
setPasswordTouched(false);
setPresetId(detectPreset(config.baseUrl ?? ""));
@@ -289,12 +330,13 @@ export function WebdavSyncSection({
enabled: true,
baseUrl,
username: form.username.trim(),
- password: form.password,
+ // 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
+ password: passwordTouched ? form.password : "",
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
profile: form.profile.trim() || "default",
autoSync: form.autoSync,
};
- }, [form]);
+ }, [form, passwordTouched]);
// ─── Handlers ───────────────────────────────────────────
@@ -326,6 +368,12 @@ export function WebdavSyncSection({
return;
}
setActionState("saving");
+ pendingPasswordPreservationRef.current = form.password
+ ? {
+ key: buildPasswordPreservationKey(settings),
+ password: form.password,
+ }
+ : null;
try {
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
setDirty(false);
@@ -339,6 +387,7 @@ export function WebdavSyncSection({
}, 2000);
await queryClient.invalidateQueries();
} catch (error) {
+ pendingPasswordPreservationRef.current = null;
toast.error(
t("settings.webdavSync.saveFailed", {
error: (error as Error)?.message ?? String(error),
@@ -362,7 +411,7 @@ export function WebdavSyncSection({
} finally {
setActionState("idle");
}
- }, [buildSettings, passwordTouched, queryClient, t]);
+ }, [buildSettings, form.password, passwordTouched, queryClient, t]);
/** Fetch remote info, then open upload confirmation dialog. */
const handleUploadClick = useCallback(async () => {
diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts
index 810de2760..18a830fca 100644
--- a/src/config/opencodeProviderPresets.ts
+++ b/src/config/opencodeProviderPresets.ts
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "Oh My OpenCode",
- websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
+ websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
settingsConfig: {
npm: "",
options: {},
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 57627f2f8..228b474e3 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -613,6 +613,16 @@
"searchSessions": "Search sessions",
"providerFilterAll": "All",
"sessionList": "Sessions",
+ "manageBatchTooltip": "Enter batch management",
+ "exitBatchModeTooltip": "Exit batch management",
+ "batchModeHint": "Select sessions to delete",
+ "selectForBatch": "Select session",
+ "selectedCount": "{{count}} selected",
+ "selectAllFiltered": "Select all",
+ "clearFilteredSelection": "Clear selection",
+ "clearSelection": "Clear",
+ "deleteSelected": "Delete",
+ "batchDeleting": "Deleting...",
"loadingSessions": "Loading sessions...",
"noSessions": "No sessions found",
"selectSession": "Select a session to view details",
@@ -641,6 +651,12 @@
"deleteConfirmAction": "Delete session",
"sessionDeleted": "Session deleted",
"deleteFailed": "Failed to delete session: {{error}}",
+ "batchDeleteConfirmTitle": "Delete selected sessions",
+ "batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
+ "batchDeleteConfirmAction": "Delete selected",
+ "batchDeleteSuccess": "Deleted {{count}} sessions",
+ "batchDeleteFailed": "{{failed}} sessions could not be deleted",
+ "batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
"loadingMessages": "Loading transcript...",
"emptySession": "No messages available",
"clickToCopyPath": "Click to copy path",
@@ -698,7 +714,7 @@
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
- "omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
+ "omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
"officialHint": "💡 Official provider uses browser login, no API Key needed",
"getApiKey": "Get API Key",
"partnerPromotion": {
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 7d7bd42b7..bf3189a0c 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -613,6 +613,16 @@
"searchSessions": "セッションを検索",
"providerFilterAll": "すべて",
"sessionList": "セッション一覧",
+ "manageBatchTooltip": "一括管理に入る",
+ "exitBatchModeTooltip": "一括管理を終了",
+ "batchModeHint": "削除するセッションを選択",
+ "selectForBatch": "セッションを選択",
+ "selectedCount": "{{count}} 件を選択中",
+ "selectAllFiltered": "一覧を全選択",
+ "clearFilteredSelection": "全選択を解除",
+ "clearSelection": "クリア",
+ "deleteSelected": "削除",
+ "batchDeleting": "削除中...",
"loadingSessions": "セッションを読み込み中...",
"noSessions": "セッションが見つかりません",
"selectSession": "セッションを選択してください",
@@ -641,6 +651,12 @@
"deleteConfirmAction": "セッションを削除",
"sessionDeleted": "セッションを削除しました",
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
+ "batchDeleteConfirmTitle": "選択したセッションを削除",
+ "batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
+ "batchDeleteConfirmAction": "選択した項目を削除",
+ "batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
+ "batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
+ "batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
"loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません",
"clickToCopyPath": "クリックしてパスをコピー",
@@ -698,7 +714,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
- "omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
+ "omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 0ec0cf0dd..88b6ce9ad 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -613,6 +613,16 @@
"searchSessions": "搜索会话",
"providerFilterAll": "全部",
"sessionList": "会话列表",
+ "manageBatchTooltip": "进入批量管理",
+ "exitBatchModeTooltip": "退出批量管理",
+ "batchModeHint": "勾选要删除的会话",
+ "selectForBatch": "选择会话",
+ "selectedCount": "已选 {{count}} 项",
+ "selectAllFiltered": "全选当前",
+ "clearFilteredSelection": "取消全选",
+ "clearSelection": "清空已选",
+ "deleteSelected": "批量删除",
+ "batchDeleting": "删除中...",
"loadingSessions": "加载会话中...",
"noSessions": "未发现会话",
"selectSession": "请选择会话查看详情",
@@ -641,6 +651,12 @@
"deleteConfirmAction": "删除会话",
"sessionDeleted": "会话已删除",
"deleteFailed": "删除会话失败: {{error}}",
+ "batchDeleteConfirmTitle": "批量删除会话",
+ "batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
+ "batchDeleteConfirmAction": "删除所选会话",
+ "batchDeleteSuccess": "已删除 {{count}} 个会话",
+ "batchDeleteFailed": "{{failed}} 个会话删除失败",
+ "batchDeleteRequestFailed": "批量删除失败,请稍后重试",
"loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容",
"clickToCopyPath": "点击复制路径",
@@ -698,7 +714,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
- "omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
+ "omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts
index 26e7d8fa1..89b6b7c7f 100644
--- a/src/lib/api/providers.ts
+++ b/src/lib/api/providers.ts
@@ -21,6 +21,10 @@ export interface SwitchResult {
warnings: string[];
}
+export interface OpenTerminalOptions {
+ cwd?: string;
+}
+
export const providersApi = {
async getAll(appId: AppId): Promise> {
return await invoke("get_providers", { app: appId });
@@ -95,8 +99,17 @@ export const providersApi = {
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
* 终端会使用该提供商特定的 API 配置,不影响全局设置
*/
- async openTerminal(providerId: string, appId: AppId): Promise {
- return await invoke("open_provider_terminal", { providerId, app: appId });
+ async openTerminal(
+ providerId: string,
+ appId: AppId,
+ options?: OpenTerminalOptions,
+ ): Promise {
+ const { cwd } = options ?? {};
+ return await invoke("open_provider_terminal", {
+ providerId,
+ app: appId,
+ cwd,
+ });
},
/**
diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts
index d9a4f61dc..0fbb10527 100644
--- a/src/lib/api/sessions.ts
+++ b/src/lib/api/sessions.ts
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
sourcePath: string;
}
+export interface DeleteSessionResult extends DeleteSessionOptions {
+ success: boolean;
+ error?: string;
+}
+
export const sessionsApi = {
async list(): Promise {
return await invoke("list_sessions");
@@ -28,6 +33,12 @@ export const sessionsApi = {
});
},
+ async deleteMany(
+ items: DeleteSessionOptions[],
+ ): Promise {
+ return await invoke("delete_sessions", { items });
+ },
+
async launchTerminal(options: {
command: string;
cwd?: string | null;
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
index 7102e4ba8..b5b65f595 100644
--- a/src/lib/api/settings.ts
+++ b/src/lib/api/settings.ts
@@ -47,6 +47,10 @@ export const settingsApi = {
await invoke("open_config_folder", { app: appId });
},
+ async pickDirectory(defaultPath?: string): Promise {
+ return await invoke("pick_directory", { defaultPath });
+ },
+
async selectConfigDirectory(defaultPath?: string): Promise {
return await invoke("pick_directory", { defaultPath });
},
diff --git a/src/types/omo.ts b/src/types/omo.ts
index 406b8c021..b2e106bd3 100644
--- a/src/types/omo.ts
+++ b/src/types/omo.ts
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
] as const;
export const OMO_DEFAULT_SCHEMA_URL =
- "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
+ "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
"disabled": false,
diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx
index b1301eeeb..a6e99115d 100644
--- a/tests/components/SessionManagerPage.test.tsx
+++ b/tests/components/SessionManagerPage.test.tsx
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
+ act,
fireEvent,
render,
screen,
@@ -8,6 +9,7 @@ import {
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
+import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
@@ -62,16 +64,19 @@ const renderPage = () => {
},
});
- return render(
-
-
- ,
- );
+ return {
+ client,
+ ...render(
+
+
+ ,
+ ),
+ };
};
const openSearch = () => {
- const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
- button.querySelector(".lucide-search"),
+ const searchButton = Array.from(screen.getAllByRole("button")).find(
+ (button) => button.querySelector(".lucide-search"),
);
if (!searchButton) {
@@ -81,10 +86,23 @@ const openSearch = () => {
fireEvent.click(searchButton);
};
+const closeSearch = () => {
+ const closeButton = Array.from(screen.getAllByRole("button")).find(
+ (button) => button.querySelector(".lucide-x"),
+ );
+
+ if (!closeButton) {
+ throw new Error("Search close button not found");
+ }
+
+ fireEvent.click(closeButton);
+};
+
describe("SessionManagerPage", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
+ Element.prototype.scrollIntoView = vi.fn();
const sessions: SessionMeta[] = [
{
@@ -178,11 +196,136 @@ describe("SessionManagerPage", () => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
);
- expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
+ expect(
+ screen.getByText("sessionManager.selectSession"),
+ ).toBeInTheDocument();
expect(
screen.queryByText("sessionManager.emptySession"),
).not.toBeInTheDocument();
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
});
+
+ it("restores batch delete controls when deleteMany rejects", async () => {
+ const deleteManySpy = vi
+ .spyOn(sessionsApi, "deleteMany")
+ .mockRejectedValueOnce(new Error("network error"));
+
+ renderPage();
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { name: "Alpha Session" }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
+ fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
+ fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
+
+ const dialog = screen.getByTestId("confirm-dialog");
+ fireEvent.click(
+ within(dialog).getByRole("button", { name: /删除所选会话/i }),
+ );
+
+ await waitFor(() =>
+ expect(toastErrorMock).toHaveBeenCalledWith("network error"),
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: /批量删除/i }),
+ ).not.toBeDisabled(),
+ );
+
+ deleteManySpy.mockRestore();
+ });
+
+ it("keeps the exit batch mode button visible when search hides all sessions", async () => {
+ renderPage();
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { name: "Alpha Session" }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
+ openSearch();
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: { value: "NoSuchSession" },
+ });
+
+ await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
+
+ expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
+ });
+
+ it("drops hidden selections when search narrows the result set", async () => {
+ renderPage();
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { name: "Alpha Session" }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
+ fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
+
+ expect(screen.getByText("已选 2 项")).toBeInTheDocument();
+
+ openSearch();
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: { value: "Alpha" },
+ });
+
+ await waitFor(() =>
+ expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
+ );
+
+ closeSearch();
+
+ await waitFor(() =>
+ expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
+ );
+ });
+
+ it("removes successfully deleted sessions from the UI before refetch completes", async () => {
+ const view = renderPage();
+ let resolveInvalidate!: () => void;
+ const invalidateSpy = vi
+ .spyOn(view.client, "invalidateQueries")
+ .mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveInvalidate = () => resolve(undefined);
+ }),
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", { name: "Alpha Session" }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
+ fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
+ fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
+
+ const dialog = screen.getByTestId("confirm-dialog");
+ fireEvent.click(
+ within(dialog).getByRole("button", { name: /删除所选会话/i }),
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
+ expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
+ });
+
+ await act(async () => {
+ resolveInvalidate();
+ });
+ invalidateSpy.mockRestore();
+ });
});
diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx
index 72e647bab..b6adc06f8 100644
--- a/tests/components/WebdavSyncSection.test.tsx
+++ b/tests/components/WebdavSyncSection.test.tsx
@@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) {
mutations: { retry: false },
},
});
- return render(
+ const view = render(
,
);
+ return { ...view, client };
}
describe("WebdavSyncSection", () => {
@@ -204,7 +205,7 @@ describe("WebdavSyncSection", () => {
expect.objectContaining({
baseUrl: "https://dav.example.com/dav/",
username: "alice",
- password: "secret",
+ password: "",
autoSync: false,
}),
false,
@@ -222,6 +223,111 @@ describe("WebdavSyncSection", () => {
);
});
+ it("preserves password only for the single post-save refresh", async () => {
+ const view = renderSection(baseConfig);
+
+ fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
+
+ await waitFor(() => {
+ expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
+ });
+
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(
+ (
+ screen.getByPlaceholderText(
+ "settings.webdavSync.passwordPlaceholder",
+ ) as HTMLInputElement
+ ).value,
+ ).toBe("secret");
+
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(
+ (
+ screen.getByPlaceholderText(
+ "settings.webdavSync.passwordPlaceholder",
+ ) as HTMLInputElement
+ ).value,
+ ).toBe("");
+ });
+
+ it("does not preserve password after a later external config refresh", async () => {
+ const view = renderSection(baseConfig);
+
+ fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
+
+ await waitFor(() => {
+ expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
+ });
+
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(
+ (
+ screen.getByPlaceholderText(
+ "settings.webdavSync.passwordPlaceholder",
+ ) as HTMLInputElement
+ ).value,
+ ).toBe("secret");
+
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(
+ (
+ screen.getByPlaceholderText(
+ "settings.webdavSync.passwordPlaceholder",
+ ) as HTMLInputElement
+ ).value,
+ ).toBe("");
+ });
+
+ it("does not submit a preserved password again when testing without touching it", async () => {
+ const view = renderSection(baseConfig);
+
+ fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
+
+ await waitFor(() => {
+ expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
+ });
+
+ view.rerender(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" }));
+
+ await waitFor(() => {
+ expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ password: "",
+ }),
+ true,
+ );
+ });
+ });
+
it("saves auto sync as true after toggle", async () => {
renderSection(baseConfig);
diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts
index 41212cb2c..3ae6c2d68 100644
--- a/tests/msw/handlers.ts
+++ b/tests/msw/handlers.ts
@@ -144,6 +144,29 @@ export const handlers = [
return success(deleteSession(providerId, sessionId, sourcePath));
}),
+ http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
+ const { items = [] } = await withJson<{
+ items?: {
+ providerId: string;
+ sessionId: string;
+ sourcePath: string;
+ }[];
+ }>(request);
+
+ return success(
+ items.map((item) => ({
+ providerId: item.providerId,
+ sessionId: item.sessionId,
+ sourcePath: item.sourcePath,
+ success: deleteSession(
+ item.providerId,
+ item.sessionId,
+ item.sourcePath,
+ ),
+ })),
+ );
+ }),
+
// MCP APIs
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);