Merge branch 'main' into feat/pricing-config-enhancement

This commit is contained in:
YoVinchen
2026-01-26 23:52:02 +08:00
24 changed files with 716 additions and 91 deletions
+26 -1
View File
@@ -1,3 +1,28 @@
fn main() {
tauri_build::build()
tauri_build::build();
// Windows: Embed Common Controls v6 manifest for test binaries
//
// When running `cargo test`, the generated test executables don't include
// the standard Tauri application manifest. Without Common Controls v6,
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
//
// This workaround:
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
// (Tauri already handles manifest embedding for the app binary)
#[cfg(target_os = "windows")]
{
let manifest_path = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
)
.join("common-controls.manifest");
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg={}", manifest_arg);
// Avoid duplicate manifest resources in binary builds.
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
println!("cargo:rerun-if-changed={}", manifest_path.display());
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
</assembly>
+19
View File
@@ -282,6 +282,25 @@ impl AppType {
AppType::OpenCode => "opencode",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
]
.into_iter()
}
}
impl FromStr for AppType {
+2 -9
View File
@@ -2,21 +2,14 @@
use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
+197 -33
View File
@@ -581,18 +581,19 @@ fn write_claude_config(
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 使用 Terminal.app 启动
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
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();
// Write the shell script to a temp file (no escaping needed!)
// Write the shell script to a temp file
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
@@ -611,7 +612,34 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Simple AppleScript - just execute the script file
// Try the preferred terminal first, fall back to Terminal.app if it fails
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "Terminal"
activate
@@ -627,12 +655,9 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"AppleScript 执行失败 (exit code: {:?}): {}",
"Terminal.app 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -641,13 +666,86 @@ end tell"#,
Ok(())
}
/// Linux: 尝试使用常见终端启动
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
end tell
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_open_app(
app_name: &str,
script_file: &std::path::Path,
use_e_flag: bool,
) -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg(app_name).arg("--args");
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
let output = cmd
.output()
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let terminals = [
let preferred = crate::settings::get_preferred_terminal();
// Default terminal list with their arguments
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
@@ -655,9 +753,10 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
// Create temp script file (same approach as macOS)
// Create temp script file
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();
@@ -679,25 +778,48 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Build terminal list: preferred terminal first (if specified), then defaults
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
// Find the preferred terminal's args from default list
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
let mut list = vec![(pref.as_str(), pref_args)];
// Add remaining terminals as fallbacks
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.iter().map(|s| *s).collect()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals {
// Check if terminal exists
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
for (terminal, args) in terminals_to_try {
// Check if terminal exists in common paths
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|| which_command(terminal);
if terminal_exists {
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.output();
.spawn();
match result {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
@@ -711,13 +833,25 @@ exec bash --norc --noprofile
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
/// Check if a command exists using `which`
#[cfg(target_os = "linux")]
fn which_command(cmd: &str) -> bool {
use std::process::Command;
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows: 根据用户首选终端启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
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('&', "^&");
@@ -733,23 +867,53 @@ del \"%~f0\" >nul 2>&1
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
// Try the preferred terminal first
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
}
result
}
/// 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> {
use std::process::Command;
let mut full_args = vec!["/C", "start"];
full_args.extend(args);
// Use output() to capture errors from the start command
// Use /K instead of /C to keep the window open after execution
let output = Command::new("cmd")
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
.args(&full_args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&bat_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"启动 Windows 终端失败 (exit code: {:?}): {}",
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
output.status.code(),
stderr
));
+12 -5
View File
@@ -6,7 +6,17 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
///
/// On Windows, respects the `HOME` environment variable (if set) to support
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
pub fn get_home_dir() -> PathBuf {
#[cfg(windows)]
if let Ok(home) = std::env::var("HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
@@ -71,10 +81,7 @@ pub fn get_app_config_dir() -> PathBuf {
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
get_home_dir().join(".cc-switch")
}
/// 获取应用配置文件路径
+1 -9
View File
@@ -1,18 +1,10 @@
use crate::config::write_text_file;
use crate::config::{get_home_dir, write_text_file};
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
+18
View File
@@ -745,6 +745,24 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
#[cfg(target_os = "windows")]
let _ = window.set_skip_taskbar(true);
#[cfg(target_os = "macos")]
tray::apply_tray_policy(app.handle(), false);
log::info!("静默启动模式:主窗口已隐藏");
} else {
// 正常启动模式:显示窗口
let _ = window.show();
log::info!("正常启动模式:主窗口已显示");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
+47 -13
View File
@@ -182,33 +182,67 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(())
}
/// Sync all providers to live configuration (for additive mode apps)
///
/// Writes all providers from the database to the live configuration file.
/// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
for provider in providers.values() {
if let Err(e) = write_live_snapshot(app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
provider.id
);
// Continue syncing other providers, don't abort
}
}
log::info!(
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
// Sync providers based on mode
for app_type in AppType::all() {
if app_type.is_additive_mode() {
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app_type in AppType::all() {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
+32 -1
View File
@@ -79,6 +79,9 @@ pub struct AppSettings {
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
#[serde(default)]
pub silent_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
@@ -114,6 +117,14 @@ pub struct AppSettings {
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_terminal: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -132,6 +143,7 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
silent_startup: false,
language: None,
visible_apps: None,
claude_config_dir: None,
@@ -143,6 +155,7 @@ impl Default for AppSettings {
current_provider_gemini: None,
current_provider_opencode: None,
skill_sync_method: SyncMethod::default(),
preferred_terminal: None,
}
}
}
@@ -150,7 +163,11 @@ impl Default for AppSettings {
impl AppSettings {
fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
Some(
crate::config::get_home_dir()
.join(".cc-switch")
.join("settings.json"),
)
}
fn normalize_paths(&mut self) {
@@ -402,3 +419,17 @@ pub fn get_skill_sync_method() -> SyncMethod {
})
.skill_sync_method
}
// ===== 终端设置管理函数 =====
/// 获取首选终端应用
pub fn get_preferred_terminal() -> Option<String> {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.preferred_terminal
.clone()
}
+10 -4
View File
@@ -971,12 +971,18 @@ fn export_sql_returns_error_for_invalid_path() {
let state = create_test_state().expect("create test state");
// Try to export to an invalid path (parent directory doesn't exist)
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
let invalid_parent = if cfg!(windows) {
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
} else {
PathBuf::from("/nonexistent/directory")
};
let invalid_path = invalid_parent.join("export.sql");
let err = state
.db
.export_sql(&invalid_path)
.expect_err("export to invalid path should fail");
let invalid_prefix = invalid_parent.to_string_lossy();
// The error can be either IoContext or Io depending on where it fails
match err {
@@ -988,8 +994,8 @@ fn export_sql_returns_error_for_invalid_path() {
}
AppError::Io { path, .. } => {
assert!(
path.starts_with("/nonexistent"),
"expected error for /nonexistent path, got: {path:?}"
path.starts_with(invalid_prefix.as_ref()),
"expected error for {invalid_parent:?}, got: {path:?}"
);
}
other => panic!("expected IoContext or Io error, got {other:?}"),
@@ -15,6 +15,7 @@ interface DirectorySettingsProps {
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
opencodeDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise<void>;
onResetDirectory: (app: AppId) => Promise<void>;
@@ -29,6 +30,7 @@ export function DirectorySettings({
claudeDir,
codexDir,
geminiDir,
opencodeDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
@@ -117,6 +119,17 @@ export function DirectorySettings({
onBrowse={() => onBrowseDirectory("gemini")}
onReset={() => onResetDirectory("gemini")}
/>
<DirectoryInput
label={t("settings.opencodeConfigDir")}
description={undefined}
value={opencodeDir}
resolvedValue={resolvedDirs.opencode}
placeholder={t("settings.browsePlaceholderOpencode")}
onChange={(val) => onDirectoryChange("opencode", val)}
onBrowse={() => onBrowseDirectory("opencode")}
onReset={() => onResetDirectory("opencode")}
/>
</section>
</>
);
+8
View File
@@ -36,6 +36,7 @@ import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
@@ -256,6 +257,12 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method })
}
/>
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
handleAutoSave({ preferredTerminal: terminal })
}
/>
</motion.div>
) : null}
</TabsContent>
@@ -300,6 +307,7 @@ export function SettingsPage({
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
opencodeDir={settings.opencodeConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
@@ -0,0 +1,111 @@
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isMac, isWindows, isLinux } from "@/lib/platform";
// Terminal options per platform
const MACOS_TERMINALS = [
{ value: "terminal", labelKey: "settings.terminal.options.macos.terminal" },
{ value: "iterm2", labelKey: "settings.terminal.options.macos.iterm2" },
{ value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" },
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
] as const;
const WINDOWS_TERMINALS = [
{ value: "cmd", labelKey: "settings.terminal.options.windows.cmd" },
{
value: "powershell",
labelKey: "settings.terminal.options.windows.powershell",
},
{ value: "wt", labelKey: "settings.terminal.options.windows.wt" },
] as const;
const LINUX_TERMINALS = [
{
value: "gnome-terminal",
labelKey: "settings.terminal.options.linux.gnomeTerminal",
},
{ value: "konsole", labelKey: "settings.terminal.options.linux.konsole" },
{
value: "xfce4-terminal",
labelKey: "settings.terminal.options.linux.xfce4Terminal",
},
{ value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" },
{ value: "kitty", labelKey: "settings.terminal.options.linux.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" },
] as const;
// Get terminals for the current platform
function getTerminalOptions() {
if (isMac()) {
return MACOS_TERMINALS;
}
if (isWindows()) {
return WINDOWS_TERMINALS;
}
if (isLinux()) {
return LINUX_TERMINALS;
}
// Fallback to macOS options
return MACOS_TERMINALS;
}
// Get default terminal for the current platform
function getDefaultTerminal(): string {
if (isMac()) {
return "terminal";
}
if (isWindows()) {
return "cmd";
}
if (isLinux()) {
return "gnome-terminal";
}
return "terminal";
}
export interface TerminalSettingsProps {
value?: string;
onChange: (value: string) => void;
}
export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {
const { t } = useTranslation();
const terminals = getTerminalOptions();
const defaultTerminal = getDefaultTerminal();
// Use value or default
const currentValue = value || defaultTerminal;
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.terminal.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.terminal.description")}
</p>
</header>
<Select value={currentValue} onValueChange={onChange}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{terminals.map((terminal) => (
<SelectItem key={terminal.value} value={terminal.value}>
{t(terminal.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("settings.terminal.fallbackHint")}
</p>
</section>
);
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power } from "lucide-react";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
interface WindowSettingsProps {
@@ -27,6 +27,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
icon={<EyeOff className="h-4 w-4 text-green-500" />}
title={t("settings.silentStartup")}
description={t("settings.silentStartupDescription")}
checked={!!settings.silentStartup}
onCheckedChange={(value) => onChange({ silentStartup: value })}
/>
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
title={t("settings.enableClaudePluginIntegration")}
+52 -8
View File
@@ -5,13 +5,14 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
gemini: string;
opencode: string;
}
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -39,7 +40,13 @@ const computeDefaultConfigDir = async (
try {
const home = await homeDir();
const folder =
app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
app === "claude"
? ".claude"
: app === "codex"
? ".codex"
: app === "gemini"
? ".gemini"
: ".config/opencode";
return await join(home, folder);
} catch (error) {
console.error(
@@ -70,6 +77,7 @@ export interface UseDirectorySettingsResult {
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
) => void;
}
@@ -96,6 +104,7 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
opencode: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -104,6 +113,7 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
opencode: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -119,19 +129,23 @@ export function useDirectorySettings({
claudeDir,
codexDir,
geminiDir,
opencodeDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
defaultOpencodeDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("opencode"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("opencode"),
]);
if (!active) return;
@@ -143,6 +157,7 @@ export function useDirectorySettings({
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "",
opencode: defaultOpencodeDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -153,6 +168,7 @@ export function useDirectorySettings({
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini,
opencode: opencodeDir || defaultsRef.current.opencode,
});
} catch (error) {
console.error(
@@ -183,7 +199,9 @@ export function useDirectorySettings({
? { claudeConfigDir: sanitized }
: key === "codex"
? { codexConfigDir: sanitized }
: { geminiConfigDir: sanitized },
: key === "gemini"
? { geminiConfigDir: sanitized }
: { opencodeConfigDir: sanitized },
);
}
@@ -205,7 +223,13 @@ export function useDirectorySettings({
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
updateDirectoryState(
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode",
value,
);
},
@@ -215,13 +239,21 @@ export function useDirectorySettings({
const browseDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: key === "codex"
? (settings?.codexConfigDir ?? resolvedDirs.codex)
: (settings?.geminiConfigDir ?? resolvedDirs.gemini);
: key === "gemini"
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
: (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -263,7 +295,13 @@ export function useDirectorySettings({
const resetDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
@@ -292,7 +330,12 @@ export function useDirectorySettings({
}, [updateDirectoryState]);
const resetAllDirectories = useCallback(
(claudeDir?: string, codexDir?: string, geminiDir?: string) => {
(
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
@@ -300,6 +343,7 @@ export function useDirectorySettings({
claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini,
opencode: opencodeDir ?? defaultsRef.current.opencode,
});
},
[],
+18 -2
View File
@@ -109,6 +109,7 @@ export function useSettings(): UseSettingsResult {
sanitizeDir(data?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir),
sanitizeDir(data?.geminiConfigDir),
sanitizeDir(data?.opencodeConfigDir),
);
setRequiresRestart(false);
}, [
@@ -131,12 +132,16 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language,
};
@@ -238,16 +243,21 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language,
};
@@ -344,11 +354,17 @@ export function useSettings(): UseSettingsResult {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将当前使用的供应商写回对应应用的 live 配置
// 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
if (
claudeDirChanged ||
codexDirChanged ||
geminiDirChanged ||
opencodeDirChanged
) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(
+6
View File
@@ -83,9 +83,12 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
language: normalizedLanguage,
};
@@ -138,9 +141,12 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
language: normalizedLanguage,
};
+32
View File
@@ -265,6 +265,8 @@
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
"silentStartup": "Silent Startup",
"silentStartupDescription": "Start in background mode without showing main window",
"autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
@@ -287,6 +289,33 @@
"copy": "Copy Files",
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
},
"terminal": {
"title": "Preferred Terminal",
"description": "Choose which terminal app to use when clicking the terminal button",
"fallbackHint": "If the selected terminal is unavailable, the system default will be used",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "Command Prompt",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
"appConfigDir": "CC Switch Configuration Directory",
@@ -298,9 +327,12 @@
"codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory",
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
"opencodeConfigDir": "OpenCode Configuration Directory",
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
+32
View File
@@ -265,6 +265,8 @@
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"silentStartup": "サイレント起動",
"silentStartupDescription": "起動時にメインウィンドウを表示せず、トレイのみで起動",
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
@@ -287,6 +289,33 @@
"copy": "ファイルコピー",
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
},
"terminal": {
"title": "優先ターミナル",
"description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択",
"fallbackHint": "選択したターミナルが利用できない場合、システムのデフォルトが使用されます",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "コマンドプロンプト",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
@@ -298,9 +327,12 @@
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
"browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認",
+32
View File
@@ -265,6 +265,8 @@
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
"silentStartup": "静默启动",
"silentStartupDescription": "程序启动时不显示主窗口,仅在系统托盘运行",
"autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
@@ -287,6 +289,33 @@
"copy": "文件复制",
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
},
"terminal": {
"title": "首选终端",
"description": "选择点击终端按钮时使用的终端应用",
"fallbackHint": "如果选择的终端不可用,将自动使用系统默认终端",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "命令提示符",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
"appConfigDir": "CC Switch 配置目录",
@@ -298,9 +327,12 @@
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录",
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
"opencodeConfigDir": "OpenCode 配置目录",
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
+9
View File
@@ -166,6 +166,8 @@ export interface Settings {
skipClaudeOnboarding?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 静默启动(程序启动时不显示主窗口)
silentStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
@@ -193,6 +195,13 @@ export interface Settings {
// ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod;
// ===== 终端设置 =====
// 首选终端应用(可选,默认使用系统默认终端)
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
// Windows: "cmd" | "powershell" | "wt"
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
preferredTerminal?: string;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
+16 -5
View File
@@ -64,9 +64,12 @@ describe("useDirectorySettings", () => {
);
getAppConfigDirOverrideMock.mockResolvedValue(null);
getConfigDirMock.mockImplementation(async (app: string) =>
app === "claude" ? "/remote/claude" : "/remote/codex",
);
getConfigDirMock.mockImplementation(async (app: string) => {
if (app === "claude") return "/remote/claude";
if (app === "codex") return "/remote/codex";
if (app === "gemini") return "/remote/gemini";
return "/remote/opencode";
});
selectConfigDirectoryMock.mockReset();
});
@@ -84,7 +87,8 @@ describe("useDirectorySettings", () => {
appConfig: "/override/app",
claude: "/remote/claude",
codex: "/remote/codex",
gemini: "/remote/codex", // Gemini 使用 codex 作为默认
gemini: "/remote/gemini",
opencode: "/remote/opencode",
});
});
@@ -214,10 +218,17 @@ describe("useDirectorySettings", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
act(() => {
result.current.resetAllDirectories("/server/claude", "/server/codex");
result.current.resetAllDirectories(
"/server/claude",
"/server/codex",
"/server/gemini",
"/server/opencode",
);
});
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
});
});
+1
View File
@@ -381,6 +381,7 @@ describe("useSettings hook", () => {
"/server/claude",
undefined,
undefined, // geminiConfigDir
undefined, // opencodeConfigDir
);
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
});