diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index e07f1482..aa77fde0 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,37 @@ fn extract_env_vars_from_config( env_vars } +fn resolve_launch_cwd(cwd: Option) -> Result, String> { + let Some(raw_path) = cwd + .map(|value| value.trim().to_string()) + .filter(|value| !value.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())); + } + + 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 +838,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 +880,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 +889,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 +1039,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 +1061,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 +1153,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 cd_command = build_windows_cd_command(cwd); let content = format!( "@echo off +{cd_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, + cd_command = cd_command, ); std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; @@ -1164,6 +1205,43 @@ 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(target_os = "windows")] +fn build_windows_cd_command(cwd: Option<&Path>) -> String { + cwd.map(|dir| { + format!( + "cd /d \"{}\" || exit /b 1\r\n", + escape_windows_batch_value(&dir.to_string_lossy()) + ) + }) + .unwrap_or_default() +} + +#[cfg(target_os = "windows")] +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 +1416,35 @@ 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"); + } } diff --git a/src/App.tsx b/src/App.tsx index b39b0146..02b7a5b4 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 47a95063..551b0595 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 7102e4ba..b5b65f59 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 }); },