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! + +ChefShop +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 +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 辅助编程真正成为开发者的生产力工具。点击这里注册! + +ChefShop +感谢 厨师长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 ( - +
+ + + {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); + } + }} + /> + -
- ) : ( -
-
- - {t("sessionManager.sessionList")} - - - {filteredSessions.length} - + }} + > + +
-
+ {selectionMode && ( - {t("sessionManager.searchSessions")} + {t("sessionManager.exitBatchModeTooltip", { + defaultValue: "退出批量管理", + })} - - - - - - - {t("common.refresh")} - + + + + + + + {t("common.refresh")} + +
+ {selectionMode && ( +
+
+ + {t("sessionManager.selectedCount", { + defaultValue: "已选 {{count}} 项", + count: selectedDeletableSessions.length, + })} + + + {t("sessionManager.batchModeHint", { + defaultValue: "勾选要删除的会话", + })} + +
+
+
+ {deletableFilteredSessions.length > 0 && ( + + )} + +
+ +
+
+ )}
)} @@ -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);