diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index e07f1482f..4e491b5f2 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -6,7 +6,7 @@ use crate::services::ProviderService; use once_cell::sync::Lazy; use regex::Regex; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use tauri::AppHandle; use tauri::State; @@ -720,8 +720,10 @@ pub async fn open_provider_terminal( state: State<'_, crate::store::AppState>, app: String, #[allow(non_snake_case)] providerId: String, + cwd: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + let launch_cwd = resolve_launch_cwd(cwd)?; // 获取提供商配置 let providers = ProviderService::list(state.inner(), app_type.clone()) @@ -736,7 +738,8 @@ pub async fn open_provider_terminal( let env_vars = extract_env_vars_from_config(config, &app_type); // 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名 - launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?; + launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref()) + .map_err(|e| format!("启动终端失败: {e}"))?; Ok(true) } @@ -791,11 +794,49 @@ fn extract_env_vars_from_config( env_vars } +fn resolve_launch_cwd(cwd: Option) -> Result, String> { + let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + + if raw_path.contains('\n') || raw_path.contains('\r') { + return Err("目录路径包含非法换行符".to_string()); + } + + let path = Path::new(&raw_path); + if !path.exists() { + return Err(format!("目录不存在: {raw_path}")); + } + + let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?; + if !resolved.is_dir() { + return Err(format!("选择的路径不是文件夹: {}", resolved.display())); + } + + // Strip Windows extended-length prefix that canonicalize produces, + // as it can break batch scripts and other shell commands. + // Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths. + #[cfg(target_os = "windows")] + let resolved = { + let s = resolved.to_string_lossy(); + if let Some(unc) = s.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{unc}")) + } else if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + resolved + } + }; + + Ok(Some(resolved)) +} + /// 创建临时配置文件并启动 claude 终端 /// 使用 --settings 参数传入提供商特定的 API 配置 fn launch_terminal_with_env( env_vars: Vec<(String, String)>, provider_id: &str, + cwd: Option<&Path>, ) -> Result<(), String> { let temp_dir = std::env::temp_dir(); let config_file = temp_dir.join(format!( @@ -809,19 +850,19 @@ fn launch_terminal_with_env( #[cfg(target_os = "macos")] { - launch_macos_terminal(&config_file)?; + launch_macos_terminal(&config_file, cwd)?; Ok(()) } #[cfg(target_os = "linux")] { - launch_linux_terminal(&config_file)?; + launch_linux_terminal(&config_file, cwd)?; Ok(()) } #[cfg(target_os = "windows")] { - launch_windows_terminal(&temp_dir, &config_file)?; + launch_windows_terminal(&temp_dir, &config_file, cwd)?; return Ok(()); } @@ -851,7 +892,7 @@ fn write_claude_config( /// macOS: 根据用户首选终端启动 #[cfg(target_os = "macos")] -fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> { +fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let preferred = crate::settings::get_preferred_terminal(); @@ -860,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> { let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); + let cd_command = build_shell_cd_command(cwd); // Write the shell script to a temp file let script_content = format!( r#"#!/bin/bash trap 'rm -f "{config_path}" "{script_file}"' EXIT +{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" claude --settings "{config_path}" exec bash --norc --noprofile "#, config_path = config_path, - script_file = script_file.display() + script_file = script_file.display(), + cd_command = cd_command, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -1007,7 +1051,7 @@ fn launch_macos_open_app( /// Linux: 根据用户首选终端启动 #[cfg(target_os = "linux")] -fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { +fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; use std::process::Command; @@ -1029,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); + let cd_command = build_shell_cd_command(cwd); let script_content = format!( r#"#!/bin/bash trap 'rm -f "{config_path}" "{script_file}"' EXIT +{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" claude --settings "{config_path}" exec bash --norc --noprofile "#, config_path = config_path, - script_file = script_file.display() + script_file = script_file.display(), + cd_command = cd_command, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -1118,22 +1165,28 @@ fn which_command(cmd: &str) -> bool { fn launch_windows_terminal( temp_dir: &std::path::Path, config_file: &std::path::Path, + cwd: Option<&Path>, ) -> Result<(), String> { let preferred = crate::settings::get_preferred_terminal(); let terminal = preferred.as_deref().unwrap_or("cmd"); let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); - let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&"); + let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy()); + let cwd_command = build_windows_cwd_command(cwd); let content = format!( "@echo off +{cwd_command} echo Using provider-specific claude config: echo {} claude --settings \"{}\" del \"{}\" >nul 2>&1 del \"%~f0\" >nul 2>&1 ", - config_path_for_batch, config_path_for_batch, config_path_for_batch + config_path_for_batch, + config_path_for_batch, + config_path_for_batch, + cwd_command = cwd_command, ); std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; @@ -1164,6 +1217,55 @@ del \"%~f0\" >nul 2>&1 result } +fn build_shell_cd_command(cwd: Option<&Path>) -> String { + cwd.map(|dir| { + format!( + "cd {} || exit 1\n", + shell_single_quote(&dir.to_string_lossy()) + ) + }) + .unwrap_or_default() +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn is_windows_unc_path(path: &str) -> bool { + path.starts_with(r"\\") +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn build_windows_cwd_command_str(path: &str) -> String { + let escaped = escape_windows_batch_value(path); + + if is_windows_unc_path(path) { + // `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first. + format!("pushd \"{escaped}\" || exit /b 1\r\n") + } else { + format!("cd /d \"{escaped}\" || exit /b 1\r\n") + } +} + +#[cfg(target_os = "windows")] +fn build_windows_cwd_command(cwd: Option<&Path>) -> String { + cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy())) + .unwrap_or_default() +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn escape_windows_batch_value(value: &str) -> String { + value + .replace('^', "^^") + .replace('%', "%%") + .replace('&', "^&") + .replace('|', "^|") + .replace('<', "^<") + .replace('>', "^>") + .replace('(', "^(") + .replace(')', "^)") +} /// Windows: Run a start command with common error handling #[cfg(target_os = "windows")] fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> { @@ -1338,4 +1440,62 @@ mod tests { ] ); } + + #[test] + fn resolve_launch_cwd_accepts_existing_directory() { + let resolved = + resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned())) + .expect("temp dir should resolve") + .expect("temp dir should be present"); + + assert!(resolved.is_dir()); + } + + #[test] + fn resolve_launch_cwd_rejects_missing_directory() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}")); + + let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned())) + .expect_err("missing directory should fail"); + + assert!(error.contains("目录不存在")); + } + + #[test] + fn build_shell_cd_command_quotes_spaces_and_single_quotes() { + let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien"))); + + assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n"); + } + + #[test] + fn build_windows_cwd_command_str_uses_cd_for_drive_paths() { + let command = build_windows_cwd_command_str(r"C:\work\repo"); + + assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n"); + } + + #[test] + fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() { + let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo"); + + assert_eq!( + command, + "pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n" + ); + } + + #[test] + fn build_windows_cwd_command_str_escapes_batch_metacharacters() { + let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)"); + + assert_eq!( + command, + "pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n" + ); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 09bb312b3..04bb6da3a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -22,10 +22,10 @@ pub mod skill; mod stream_check; mod sync_support; +mod lightweight; mod usage; mod webdav_sync; mod workspace; -mod lightweight; pub use auth::*; pub use config::*; @@ -48,7 +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::*; -pub use lightweight::*; diff --git a/src-tauri/src/lightweight.rs b/src-tauri/src/lightweight.rs index cd0875710..c16af79ef 100644 --- a/src-tauri/src/lightweight.rs +++ b/src-tauri/src/lightweight.rs @@ -87,4 +87,4 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { pub fn is_lightweight_mode() -> bool { LIGHTWEIGHT_MODE.load(Ordering::Acquire) -} \ No newline at end of file +} diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 4ad941f09..ea9274ff8 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -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 = chunks diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f5b068157..6ac3d2046 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -393,11 +393,10 @@ pub fn create_tray_menu( true, crate::lightweight::is_lightweight_mode(), None::<&str>, - ).map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?; + ) + .map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?; - menu_builder = menu_builder - .item(&lightweight_item) - .separator(); + menu_builder = menu_builder.item(&lightweight_item).separator(); // 退出菜单(分隔符已在上面的 section 循环中添加) let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) @@ -472,10 +471,8 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { 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}"); - } + } else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) { + log::error!("进入轻量模式失败: {e}"); } } "quit" => { diff --git a/src/App.tsx b/src/App.tsx index b39b01460..02b7a5b4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: "终端已打开", diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 47a950630..551b05951 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -21,6 +21,10 @@ export interface SwitchResult { warnings: string[]; } +export interface OpenTerminalOptions { + cwd?: string; +} + export const providersApi = { async getAll(appId: AppId): Promise> { return await invoke("get_providers", { app: appId }); @@ -83,8 +87,17 @@ export const providersApi = { * 任何提供商都可以打开终端,不受是否为当前激活提供商的限制 * 终端会使用该提供商特定的 API 配置,不影响全局设置 */ - async openTerminal(providerId: string, appId: AppId): Promise { - return await invoke("open_provider_terminal", { providerId, app: appId }); + async openTerminal( + providerId: string, + appId: AppId, + options?: OpenTerminalOptions, + ): Promise { + const { cwd } = options ?? {}; + return await invoke("open_provider_terminal", { + providerId, + app: appId, + cwd, + }); }, /** diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 7102e4ba8..b5b65f595 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -47,6 +47,10 @@ export const settingsApi = { await invoke("open_config_folder", { app: appId }); }, + async pickDirectory(defaultPath?: string): Promise { + return await invoke("pick_directory", { defaultPath }); + }, + async selectConfigDirectory(defaultPath?: string): Promise { return await invoke("pick_directory", { defaultPath }); },