Compare commits

..

11 Commits

Author SHA1 Message Date
YoVinchen 60e0263cc0 fix(config): update StepFun API endpoint domain from .ai to .com 2026-04-01 16:45:59 +08:00
Bengbengbalabalabeng 72f3610ac0 docs(readme): use dynamic badges (#1806) 2026-04-01 16:09:19 +08:00
Jason 854266e3c4 docs: add ChefShop AI sponsor to all README versions
Add ChefShop AI as a new sponsor with logo and descriptions in Chinese, English, and Japanese README files.
2026-03-31 23:07:51 +08:00
Jason af8f907467 fix(proxy): serialize per-app provider switches to prevent state corruption
Concurrent failover switches for the same app could cause is_current,
local settings, and Live backup to point at different providers.

- Add SwitchLockManager with per-app mutexes (different apps still parallel)
- Unify scattered switch logic into ProxyService::hot_switch_provider
- Fix TOCTOU in set_current_provider via mutate_settings
- Add logical_target_changed to skip redundant UI refreshes
- Add tests for serialization and restore-waits-for-switch scenarios
2026-03-31 22:46:42 +08:00
Dex Miller 6a083cdd1c fix(omo): adapt to oh-my-openagent rename with backward compatibility (#1746)
* fix(omo): adapt to oh-my-openagent rename with backward compatibility

Closes https://github.com/farion1231/cc-switch/issues/1733

* fix(omo): prioritize oh-my-openagent config over legacy oh-my-opencode
2026-03-31 16:32:49 +08:00
Dex Miller c68476d0ea fix(provider): show persistent config highlight for additive-mode providers (#1747)
* fix(provider): show persistent config highlight for additive-mode providers

Closes https://github.com/farion1231/cc-switch/issues/1692

* fix(provider): limit persistent config highlight to opencode only
2026-03-31 16:21:04 +08:00
Dex Miller 96a0a37331 feat(terminal): add directory picker before launching Claude terminal (#1752)
* Add directory picker before launching Claude terminal

* fix(terminal): preserve cwd path and strip Windows verbatim prefix

- Stop trimming non-empty paths so directories with leading/trailing
  spaces on Unix are handled correctly
- Strip \\?\ extended-length prefix from canonicalized paths on Windows
  to prevent batch script cd failures

* fix(terminal): restore UNC paths when stripping Windows verbatim prefix

Handle \\?\UNC\server\share form separately from regular \\?\ prefix,
converting it back to \\server\share so network/WSL directory paths
remain valid in batch cd commands.

* fix(terminal): use pushd for UNC paths in Windows batch launcher

`cmd.exe` cannot set a UNC path (e.g. `\\wsl$\...`) as the current
directory via `cd /d`; it errors with "CMD does not support UNC paths
as current directories". Switch to `pushd` which temporarily maps the
UNC share to a drive letter.

Rename `build_windows_cd_command` → `build_windows_cwd_command` to
reflect the broader semantics. Extract `build_windows_cwd_command_str`
and `is_windows_unc_path` helpers for testability, and add unit tests
covering drive paths, UNC paths, and batch metacharacter escaping.

Also fix minor style issues: sort mod declarations alphabetically,
add missing EOF newline in lightweight.rs, add explicit type annotation
in streaming_responses test, and reformat tray menu builder chain.
2026-03-31 15:10:08 +08:00
Alexlangl 8e2ffbc845 feat: add bulk delete for session manager (#1693)
* feat: add bulk delete for session manager

* fix: address batch delete review issues

* fix: keep session list in sync after batch delete
2026-03-30 22:15:57 +08:00
Alexlangl 4f7ea76347 fix: preserve WebDAV password display and validate MKCOL 405 (#1685)
* fix: preserve WebDAV password display and validate MKCOL 405

* fix: scope WebDAV password preservation to post-save refresh
2026-03-30 22:13:21 +08:00
Dex Miller 67e074c0a7 refactor(proxy): transparent header forwarding via hyper client (#1714)
* style(frontend): reformat provider forms, constants and hooks

Apply prettier formatting across 5 frontend files. No logic changes.

Changed files:
- AddProviderDialog.tsx: reformat generic type annotation and callback
- ClaudeFormFields.tsx: consolidate multi-line useState and Collapsible props
- CodexConfigSections.tsx: expand single-line React imports to multi-line,
  collapse removeCodexTopLevelField() call
- constants.ts: merge TemplateType into single line
- useSkills.ts: expand single-line TanStack Query imports to multi-line,
  reformat uninstallSkill mutationFn chain

* deps(proxy): add hyper ecosystem crates and manual decompression libs

reqwest internally normalizes all header names to lowercase and does not
preserve insertion order, causing proxied requests to differ from the
original client requests. To achieve transparent header forwarding with
original casing and order, introduce lower-level hyper HTTP client libs.

New dependencies:
- hyper-util 0.1: TokioExecutor + legacy Client with
  preserve_header_case support for HTTP/1.1
- hyper-rustls 0.27: rustls-based TLS connector for hyper
- http 1 / http-body 1 / http-body-util 0.1: HTTP type crates for
  hyper 1.x request/response construction
- flate2 1: manual gzip/deflate decompression (replaces reqwest auto)
- brotli 7: manual brotli decompression

Changed dependencies:
- serde_json: enable preserve_order feature to keep JSON field order
- reqwest: drop gzip feature to prevent reqwest from overriding the
  client's original accept-encoding header

* refactor(proxy): use hyper client for header-case preserving forwarding

Previously the proxy used reqwest for all upstream requests. reqwest
normalizes header names to lowercase and reorders them internally,
making proxied requests distinguishable from direct CLI requests.
Some upstream providers are sensitive to these differences.

This commit replaces reqwest with a hyper-based HTTP client on the
default (non-proxy) path, achieving wire-level header fidelity:

Server layer (server.rs):
- Replace axum::serve with a manual hyper HTTP/1.1 accept loop
- Enable preserve_header_case(true) so incoming header casing is
  captured in a HeaderCaseMap extension on each request
- Bridge hyper requests to axum Router via tower::Service

New hyper client module (hyper_client.rs):
- Lazy-initialized hyper-util Client with preserve_header_case
- ProxyResponse enum wrapping both hyper::Response and reqwest::Response
  behind a unified interface (status, headers, bytes, bytes_stream)
- send_request() builds requests with ordered HeaderMap + case map

Request handlers (handlers.rs):
- Switch from (HeaderMap, Json<Value>) extractors to raw
  axum::extract::Request to preserve Extensions (containing the
  HeaderCaseMap from the accept loop)
- Pass extensions through the forwarding chain

Forwarder (forwarder.rs):
- Remove HEADER_BLACKLIST array; replace with ordered header iteration
  that preserves original header sequence and casing
- Build ordered_headers by iterating client headers, skipping only
  auth/host/content-length, and inserting auth headers at the original
  authorization position to maintain order
- Handle anthropic-beta (ensure claude-code-20250219 tag) and
  anthropic-version (passthrough or default) inline during iteration
- Remove should_force_identity_encoding() — accept-encoding is now
  transparently forwarded to upstream
- Use hyper client by default; fall back to reqwest only when an
  HTTP/SOCKS5 proxy tunnel is configured

Provider adapters (adapter.rs, claude.rs, codex.rs, gemini.rs):
- Replace add_auth_headers(RequestBuilder) -> RequestBuilder with
  get_auth_headers(AuthInfo) -> Vec<(HeaderName, HeaderValue)>
- Adapters now return header pairs instead of mutating a reqwest builder
- Claude adapter: merge Anthropic/ClaudeAuth/Bearer into single branch;
  move Copilot fingerprint headers into get_auth_headers

Response processing (response_processor.rs):
- Add manual decompression (gzip/deflate/brotli via flate2 + brotli)
  for non-streaming responses, since reqwest auto-decompression is now
  disabled to allow accept-encoding passthrough
- Add compressed-SSE warning log for streaming responses
- Accept ProxyResponse instead of reqwest::Response

HTTP client (http_client.rs):
- Disable reqwest auto-decompression (.no_gzip/.no_brotli/.no_deflate)
  on both global and per-provider clients

Streaming adapters (streaming.rs, streaming_responses.rs):
- Generalize stream error type from reqwest::Error to generic E: Error

Misc:
- log_codes.rs: add SRV-005 (ACCEPT_ERR) and SRV-006 (CONN_ERR)
- stream_check.rs: reformat copilot header lines
- transform.rs: fix trailing whitespace alignment

* fix(lint): resolve 35 clippy warnings across Rust codebase

Fix all clippy warnings reported by `cargo clippy --lib`:

- codex_config.rs: fix doc_overindented_list_items (3 spaces -> 2)
- commands/copilot.rs: inline format args in 2 log::error! calls
- commands/provider.rs: inline format args in 3 map_err closures
- proxy/hyper_client.rs: inline format arg in log::debug! call
- proxy/providers/copilot_auth.rs: inline format args in 16 locations
  (log macros, format! in headers, error constructors)
- proxy/thinking_optimizer.rs: inline format args in 2 log::info! calls
- services/skill.rs: inline format args in log::debug! call
- services/webdav_sync.rs: inline format args in 6 format! calls
  (version compat messages, download limit messages)
- services/webdav_sync/archive.rs: inline format args in 2 format! calls
- session_manager/providers/opencode.rs: inline format args in
  source_path format!

All fixes use the clippy::uninlined_format_args suggestion pattern:
  format!("msg: {}", var)  ->  format!("msg: {var}")

* deps(proxy): add raw HTTP write and native TLS cert dependencies

Add crates required for the raw TCP/TLS write path that bypasses
hyper's header encoder to preserve original header name casing:

- httparse: parse raw TCP peek bytes to capture header casings
- tokio-rustls + rustls: direct TLS connections for raw write path
- webpki-roots: Mozilla CA bundle baseline
- rustls-native-certs: load system keychain CAs (trusts proxy MITM
  certificates from Clash, mitmproxy, etc.)

* fix(proxy): address code review feedback on response handling

Fixes from PR #1714 code review:

- Extract `read_decoded_body()` and `strip_entity_headers_for_rebuilt_body()`
  in response_processor to properly clean content-encoding/content-length
  headers after decompression
- Reuse `read_decoded_body()` in handlers.rs for Claude transform path,
  ensuring compressed responses are decoded before format conversion
- Make `build_proxy_url_from_config()` public so forwarder can pass proxy
  URL to the hyper raw write path
- Add `has_system_proxy_env()` utility with test coverage
- Add 50ms backoff after accept() failures in server.rs to prevent
  tight-loop CPU spin on transient socket errors

* feat(proxy): implement raw TCP/TLS write with HTTP CONNECT tunnel

Rewrite hyper_client with a two-tier strategy for header case preservation:

Primary path (raw write):
- Peek raw TCP bytes in server.rs to capture OriginalHeaderCases before
  hyper lowercases them
- Build raw HTTP/1.1 request bytes with exact original header name casing
- Write directly to TLS stream, then use WriteFilter to let hyper parse
  the response while discarding its duplicate request writes
- Support HTTP CONNECT tunneling through upstream proxies, so header case
  is preserved even when a proxy (Clash, V2Ray) is configured

Fallback path (hyper-util Client):
- Used when OriginalHeaderCases is empty or raw write fails
- Configured with title_case_headers(true) for best-effort casing

TLS improvements:
- Load native system certificates alongside webpki roots so proxy MITM
  CAs (installed in system keychain) are trusted through CONNECT tunnels

Key types added:
- OriginalHeaderCases: maps lowercase name → original wire-casing bytes
- WriteFilter<S>: AsyncRead+AsyncWrite wrapper that discards writes
- connect_via_proxy(): HTTP CONNECT tunnel establishment
- ExtensionDebugMarker: diagnostic marker for extension chain debugging

* refactor(proxy): route requests through hyper with proxy-aware forwarding

Rework forwarder request dispatch to always prefer the hyper raw write
path (header case preservation) over reqwest:

Request routing:
- HTTP/HTTPS proxy: hyper raw write through CONNECT tunnel (case preserved)
- SOCKS5 proxy: reqwest fallback (CONNECT not supported for SOCKS5)
- No proxy: hyper raw write direct connection

Header handling improvements:
- Replace host header in-place at original position instead of
  skip-and-append, preserving client's header ordering
- Preserve client's original accept-encoding for transparent passthrough;
  only force identity encoding when transform path needs decompression
- Add should_force_identity_encoding() to centralize the decision
- Remove hardcoded 'br, gzip, deflate' override that masked client values

Proxy URL resolution (priority order):
1. Provider-specific proxy config (if enabled)
2. Global proxy URL configured in CC Switch
3. Direct connection (no proxy)

* chore(proxy): remove dead code, redundant tests and debug scaffolding

- Inline should_force_identity_encoding() (was just `needs_transform`)
  and delete its 5 test cases
- Remove ExtensionDebugMarker diagnostic type
- Remove unused has_system_proxy_env() and its test
- Remove strip_entity_headers test
- Simplify hyper path: remove redundant is_socks_proxy ternary
- Update hyper_client module doc to reflect CONNECT tunnel support

* fix(proxy): block direct-connect fallback and complete CONNECT tunnel support

* feat(hooks): improve proxy requirement warnings with specific reasons

- Remove redundant OpenAI format hint toast messages
- Add detailed reason detection for proxy requirements (OpenAI Chat, OpenAI Responses, full URL mode)
- Update i18n files with new reason-specific keys

* style(*): format code with prettier

- Remove extra whitespace in http_client.rs
- Fix formatting issues in useProviderActions.ts

* fix(proxy): post-merge fixes for forward return type and clippy warnings

- Restore forward() return type to (ProxyResponse, Option<String>)
  to pass claude_api_format through to callers
- Inline format args in log::warn! macro (clippy::uninlined_format_args)
- Suppress too_many_arguments for check_claude_stream

* refactor(proxy): preserve original header wire order and add non-streaming body timeout

- Rewrite build_raw_request to emit headers in original
  client-sent sequence instead of hash-map order
- Remove unused OriginalHeaderCases::get_all method
- Add body_timeout to read_decoded_body to prevent
  requests hanging when upstream stalls after headers
2026-03-29 20:26:15 +08:00
ruokeqx b1c7fe5563 feat: add lightweight mode (#1739) 2026-03-29 18:35:23 +08:00
41 changed files with 2023 additions and 414 deletions
+7 -2
View File
@@ -4,10 +4,10 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>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 <a href="https://ctok.ai">here</a> to register!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>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 <a href="https://chefshop.ai">here</a> to purchase!</td>
</tr>
</table>
</details>
+7 -2
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
</tr>
</table>
</details>
+7 -2
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -101,6 +101,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
</tr>
</table>
</details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+14
View File
@@ -0,0 +1,14 @@
#[tauri::command]
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::enter_lightweight_mode(&app)
}
#[tauri::command]
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::exit_lightweight_mode(&app)
}
#[tauri::command]
pub fn is_lightweight_mode() -> bool {
crate::lightweight::is_lightweight_mode()
}
+171 -11
View File
@@ -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<String>,
) -> Result<bool, String> {
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<String>) -> Result<Option<PathBuf>, 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"
);
}
}
+2
View File
@@ -22,6 +22,7 @@ pub mod skill;
mod stream_check;
mod sync_support;
mod lightweight;
mod usage;
mod webdav_sync;
mod workspace;
@@ -47,6 +48,7 @@ pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use lightweight::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
@@ -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<session_manager::DeleteSessionRequest>,
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
.await
.map_err(|e| format!("Failed to delete sessions: {e}"))
}
+29
View File
@@ -12,6 +12,7 @@ mod error;
mod gemini_config;
mod gemini_mcp;
mod init_status;
mod lightweight;
mod mcp;
mod openclaw_config;
mod opencode_config;
@@ -204,6 +205,12 @@ pub fn run() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
}
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false;
for arg in &args {
@@ -615,6 +622,12 @@ pub fn run() {
let urls = event.urls();
log::info!("Received {} URL(s)", urls.len());
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
@@ -1008,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
@@ -1085,6 +1099,10 @@ pub fn run() {
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
// lightweight mode (for testing or low-resource environments)
commands::enter_lightweight_mode,
commands::exit_lightweight_mode,
commands::is_lightweight_mode,
]);
let app = builder
@@ -1135,6 +1153,10 @@ pub fn run() {
let _ = window.show();
let _ = window.set_focus();
tray::apply_tray_policy(app_handle, true);
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
@@ -1144,6 +1166,13 @@ pub fn run() {
log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
{
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
+90
View File
@@ -0,0 +1,90 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager;
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(true);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, false);
}
if let Some(window) = app.get_webview_window("main") {
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
}
// else: already in lightweight mode or window not found, just set the flag
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("进入轻量模式");
Ok(())
}
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
use tauri::WebviewWindowBuilder;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
return Ok(());
}
let window_config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.ok_or("主窗口配置未找到")?;
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(false);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
Ok(())
}
pub fn is_lightweight_mode() -> bool {
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
}
+47 -23
View File
@@ -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)
});
+11 -23
View File
@@ -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::<crate::store::AppState>() {
// 更新 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)
}
}
+1
View File
@@ -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;
@@ -974,7 +974,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let events: Vec<Value> = chunks
+42
View File
@@ -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<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
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
}
}
+77 -24
View File
@@ -21,33 +21,41 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── 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",
@@ -60,22 +68,27 @@ pub struct OmoService;
impl OmoService {
// ── Path helpers ────────────────────────────────────────
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
v.config_candidates
.iter()
.map(|name| base_dir.join(name))
.collect()
}
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
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<PathBuf, AppError> {
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<Map<String, Value>, AppError> {
@@ -123,12 +136,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(())
}
@@ -451,4 +470,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"
);
}
}
+2 -22
View File
@@ -480,32 +480,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
+280 -39
View File
@@ -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<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
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<HotSwitchOutcome, String> {
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() {
+4 -3
View File
@@ -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));
}
+105 -1
View File
@@ -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<i64>,
}
#[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<String>,
}
pub fn scan_sessions() -> Vec<SessionMeta> {
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<DeleteSessionOutcome> {
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<PathBuf, Strin
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
}
fn collect_delete_session_outcomes<F>(
requests: &[DeleteSessionRequest],
mut deleter: F,
) -> Vec<DeleteSessionOutcome>
where
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
{
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")
);
}
}
+8 -11
View File
@@ -584,17 +584,14 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `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(验证存在性)
+43
View File
@@ -14,6 +14,7 @@ use crate::store::AppState;
pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub lightweight_mode: &'static str,
pub quit: &'static str,
pub _auto_label: &'static str,
}
@@ -24,6 +25,7 @@ impl TrayTexts {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
lightweight_mode: "Lightweight Mode",
quit: "Quit",
_auto_label: "Auto (Failover)",
},
@@ -31,12 +33,14 @@ impl TrayTexts {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
lightweight_mode: "軽量モード",
quit: "終了",
_auto_label: "自動 (フェイルオーバー)",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
lightweight_mode: "轻量模式",
quit: "退出",
_auto_label: "自动 (故障转移)",
},
@@ -382,6 +386,18 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator();
}
let lightweight_item = CheckMenuItem::with_id(
app,
"lightweight_mode",
tray_texts.lightweight_mode,
true,
crate::lightweight::is_lightweight_mode(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
menu_builder = menu_builder.item(&lightweight_item).separator();
// 退出菜单(分隔符已在上面的 section 循环中添加)
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
@@ -393,6 +409,20 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
use crate::store::AppState;
if let Some(state) = app.try_state::<AppState>() {
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("刷新托盘菜单失败: {e}");
}
}
}
}
}
#[cfg(target_os = "macos")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy;
@@ -430,6 +460,19 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
{
apply_tray_policy(app, true);
}
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
"lightweight_mode" => {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式失败: {e}");
}
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
log::error!("进入轻量模式失败: {e}");
}
}
"quit" => {
+8 -1
View File
@@ -648,7 +648,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: "终端已打开",
+11 -4
View File
@@ -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 (
<div
@@ -224,7 +228,8 @@ export function ProviderCard({
shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
!isActiveProvider && "hover:shadow-sm",
!(isActiveProvider || hasPersistentConfigHighlight) &&
"hover:shadow-sm",
dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
)}
@@ -234,8 +239,10 @@ export function ProviderCard({
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10",
!isActiveProvider && "from-primary/10",
isActiveProvider ? "opacity-100" : "opacity-0",
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
isActiveProvider || hasPersistentConfigHighlight
? "opacity-100"
: "opacity-0",
)}
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -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", {
+61 -34
View File
@@ -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 (
<button
type="button"
onClick={() => onSelect(sessionKey)}
<div
className={cn(
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
"flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
isSelected
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/60 border border-transparent",
)}
>
<div className="flex items-center gap-2 mb-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0">
<ProviderIcon
icon={getProviderIconName(session.providerId)}
name={session.providerId}
size={18}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{getProviderLabel(session.providerId, t)}
</TooltipContent>
</Tooltip>
<span className="text-sm font-medium truncate flex-1">{title}</span>
<ChevronRight
className={cn(
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
isSelected && "text-primary rotate-90",
)}
/>
</div>
{selectionMode && (
<div className="shrink-0 pt-0.5">
<Checkbox
checked={isChecked}
disabled={isCheckDisabled}
aria-label={t("sessionManager.selectForBatch", {
defaultValue: "选择会话",
})}
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
/>
</div>
)}
<button
type="button"
onClick={() => onSelect(sessionKey)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2 mb-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0">
<ProviderIcon
icon={getProviderIconName(session.providerId)}
name={session.providerId}
size={18}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{getProviderLabel(session.providerId, t)}
</TooltipContent>
</Tooltip>
<span className="text-sm font-medium truncate flex-1">{title}</span>
<ChevronRight
className={cn(
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
isSelected && "text-primary rotate-90",
)}
/>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="size-3" />
<span>
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
</span>
</div>
</button>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="size-3" />
<span>
{lastActive
? formatRelativeTime(lastActive, t)
: t("common.unknown")}
</span>
</div>
</button>
</div>
);
}
+551 -175
View File
@@ -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<HTMLDivElement | null>(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<SessionMeta | null>(null);
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
null,
);
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
() => new Set(),
);
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const searchInputRef = useRef<HTMLInputElement | null>(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<string>();
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<SessionMeta[]>(["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<string>();
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 }) {
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
<CardHeader className="py-2 px-3 border-b">
{isSearchOpen ? (
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("sessionManager.searchPlaceholder")}
className="h-8 pl-8 pr-8 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(event) => 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);
}
}}
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={() => {
setIsSearchOpen(false);
setSearch("");
}
}}
onBlur={() => {
if (search.trim() === "") {
setIsSearchOpen(false);
}
}}
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={() => {
setIsSearchOpen(false);
setSearch("");
}}
>
<X className="size-3" />
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
{t("sessionManager.sessionList")}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{filteredSessions.length}
</Badge>
}}
>
<X className="size-3" />
</Button>
</div>
<div className="flex items-center gap-1">
{selectionMode && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
variant="secondary"
size="icon"
className="size-7"
onClick={() => {
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}
>
<Search className="size-3.5" />
<CheckSquare className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("sessionManager.searchSessions")}
{t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})}
</TooltipContent>
</Tooltip>
<Select
value={providerFilter}
onValueChange={(value) =>
setProviderFilter(value as ProviderFilter)
}
>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CardTitle className="text-sm font-medium whitespace-nowrap">
{t("sessionManager.sessionList")}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{filteredSessions.length}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
{(selectionMode ||
deletableFilteredSessions.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={selectionMode ? "secondary" : "ghost"}
size="icon"
className={
selectionMode
? "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"
: "size-7"
}
aria-label={
selectionMode
? t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})
: t("sessionManager.manageBatchTooltip", {
defaultValue: "批量管理",
})
}
onClick={() => {
if (selectionMode) {
exitSelectionMode();
} else {
setSelectionMode(true);
}
}}
>
<CheckSquare className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{selectionMode
? t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})
: t("sessionManager.manageBatchTooltip", {
defaultValue: "批量管理",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
<ProviderIcon
icon={
providerFilter === "all"
? "apps"
: getProviderIconName(providerFilter)
}
name={providerFilter}
size={14}
/>
</SelectTrigger>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => {
setIsSearchOpen(true);
setTimeout(
() => searchInputRef.current?.focus(),
0,
);
}}
>
<Search className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{providerFilter === "all"
? t("sessionManager.providerFilterAll")
: providerFilter}
{t("sessionManager.searchSessions")}
</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<ProviderIcon icon="apps" name="all" size={14} />
<span>
{t("sessionManager.providerFilterAll")}
</span>
</div>
</SelectItem>
<SelectItem value="codex">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openai"
name="codex"
size={14}
/>
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
icon="claude"
name="claude"
size={14}
/>
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<ProviderIcon
icon="gemini"
name="gemini"
size={14}
/>
<span>Gemini CLI</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void refetch()}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
<Select
value={providerFilter}
onValueChange={(value) =>
setProviderFilter(value as ProviderFilter)
}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
<ProviderIcon
icon={
providerFilter === "all"
? "apps"
: getProviderIconName(providerFilter)
}
name={providerFilter}
size={14}
/>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{providerFilter === "all"
? t("sessionManager.providerFilterAll")
: providerFilter}
</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<ProviderIcon
icon="apps"
name="all"
size={14}
/>
<span>
{t("sessionManager.providerFilterAll")}
</span>
</div>
</SelectItem>
<SelectItem value="codex">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openai"
name="codex"
size={14}
/>
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
icon="claude"
name="claude"
size={14}
/>
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<ProviderIcon
icon="gemini"
name="gemini"
size={14}
/>
<span>Gemini CLI</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void refetch()}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</div>
</div>
{selectionMode && (
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{t("sessionManager.selectedCount", {
defaultValue: "已选 {{count}} 项",
count: selectedDeletableSessions.length,
})}
</Badge>
<span className="truncate">
{t("sessionManager.batchModeHint", {
defaultValue: "勾选要删除的会话",
})}
</span>
</div>
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
<div className="flex flex-wrap items-center gap-2">
{deletableFilteredSessions.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs whitespace-nowrap"
onClick={handleToggleSelectAll}
>
{allFilteredSelected
? t("sessionManager.clearFilteredSelection", {
defaultValue: "取消全选",
})
: t("sessionManager.selectAllFiltered", {
defaultValue: "全选当前",
})}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs whitespace-nowrap"
onClick={() => setSelectedSessionKeys(new Set())}
>
{t("sessionManager.clearSelection", {
defaultValue: "清空已选",
})}
</Button>
</div>
<Button
variant="destructive"
size="sm"
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
onClick={openBatchDeleteDialog}
disabled={
isDeleting ||
selectedDeletableSessions.length === 0
}
>
<Trash2 className="size-3.5" />
<span className="text-xs">
{isBatchDeleting
? t("sessionManager.batchDeleting", {
defaultValue: "删除中...",
})
: t("sessionManager.deleteSelected", {
defaultValue: "批量删除",
})}
</span>
</Button>
</div>
</div>
)}
</div>
)}
</CardHeader>
@@ -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
}
>
<Trash2 className="size-3.5" />
<span className="hidden sm:inline">
{deleteSessionMutation.isPending
{isDeleting
? t("sessionManager.deleting", {
defaultValue: "删除中...",
})
@@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</div>
</div>
<ConfirmDialog
isOpen={Boolean(deleteTarget)}
title={t("sessionManager.deleteConfirmTitle", {
defaultValue: "删除会话",
})}
message={
deleteTarget
? t("sessionManager.deleteConfirmMessage", {
defaultValue:
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
title: formatSessionTitle(deleteTarget),
sessionId: deleteTarget.sessionId,
isOpen={Boolean(deleteTargets)}
title={
deleteTargets && deleteTargets.length > 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);
}
}}
/>
+59 -10
View File
@@ -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<ReturnType<typeof setTimeout> | 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 () => {
+2 -2
View File
@@ -192,7 +192,7 @@ export const providerPresets: ProviderPreset[] = [
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.stepfun.ai/v1",
ANTHROPIC_BASE_URL: "https://api.stepfun.com/v1",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "step-3.5-flash",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash",
@@ -201,7 +201,7 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
endpointCandidates: ["https://api.stepfun.ai/v1"],
endpointCandidates: ["https://api.stepfun.com/v1"],
apiFormat: "openai_chat",
icon: "stepfun",
iconColor: "#005AFF",
+3 -3
View File
@@ -298,7 +298,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
websiteUrl: "https://platform.stepfun.ai",
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
baseUrl: "https://api.stepfun.ai/v1",
baseUrl: "https://api.stepfun.com/v1",
apiKey: "",
api: "openai-completions",
models: [
@@ -315,8 +315,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
templateValues: {
baseUrl: {
label: "Base URL",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
editorValue: "",
},
apiKey: {
+4 -4
View File
@@ -489,7 +489,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
npm: "@ai-sdk/openai-compatible",
name: "StepFun",
options: {
baseURL: "https://api.stepfun.ai/v1",
baseURL: "https://api.stepfun.com/v1",
apiKey: "",
setCacheKey: true,
},
@@ -503,8 +503,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
editorValue: "",
},
apiKey: {
@@ -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: {},
+17 -1
View File
@@ -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": {
+17 -1
View File
@@ -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": {
+17 -1
View File
@@ -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": {
+15 -2
View File
@@ -21,6 +21,10 @@ export interface SwitchResult {
warnings: string[];
}
export interface OpenTerminalOptions {
cwd?: string;
}
export const providersApi = {
async getAll(appId: AppId): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app: appId });
@@ -83,8 +87,17 @@ export const providersApi = {
*
* 使 API
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
async openTerminal(
providerId: string,
appId: AppId,
options?: OpenTerminalOptions,
): Promise<boolean> {
const { cwd } = options ?? {};
return await invoke("open_provider_terminal", {
providerId,
app: appId,
cwd,
});
},
/**
+11
View File
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
sourcePath: string;
}
export interface DeleteSessionResult extends DeleteSessionOptions {
success: boolean;
error?: string;
}
export const sessionsApi = {
async list(): Promise<SessionMeta[]> {
return await invoke("list_sessions");
@@ -28,6 +33,12 @@ export const sessionsApi = {
});
},
async deleteMany(
items: DeleteSessionOptions[],
): Promise<DeleteSessionResult[]> {
return await invoke("delete_sessions", { items });
},
async launchTerminal(options: {
command: string;
cwd?: string | null;
+4
View File
@@ -47,6 +47,10 @@ export const settingsApi = {
await invoke("open_config_folder", { app: appId });
},
async pickDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath });
},
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath });
},
+1 -1
View File
@@ -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,
+151 -8
View File
@@ -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(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
);
return {
client,
...render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
),
};
};
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();
});
});
+108 -2
View File
@@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) {
mutations: { retry: false },
},
});
return render(
const view = render(
<QueryClientProvider client={client}>
<WebdavSyncSection config={config} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("secret");
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("secret");
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection
config={{ ...baseConfig, username: "bob", password: "" }}
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
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);
+23
View File
@@ -129,6 +129,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);