Add directory picker before launching Claude terminal

This commit is contained in:
YoVinchen
2026-03-30 08:42:49 +08:00
parent 67e074c0a7
commit 02fd639dfc
4 changed files with 147 additions and 14 deletions

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,37 @@ fn extract_env_vars_from_config(
env_vars
}
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, 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");
}
}

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: "终端已打开",

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,
});
},
/**

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 });
},