diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 44def074..5fb7f544 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -7692,19 +7692,11 @@ pub mod texts { } } - pub fn tui_sessions_toast_terminal_launched() -> &'static str { - if is_chinese() { - "已打开终端恢复会话。" - } else { - "Terminal launched for session resume." - } - } - pub fn tui_sessions_toast_resume_fallback(err: &str) -> String { if is_chinese() { - format!("无法自动打开终端,已显示恢复命令:{err}") + format!("无法恢复会话,已显示恢复命令:{err}") } else { - format!("Could not open a terminal; showing the resume command instead: {err}") + format!("Could not resume session; showing the resume command instead: {err}") } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 457eb9ce..0e2e6dc2 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -222,36 +222,20 @@ pub(crate) fn handle_action( Ok(()) } Action::SessionResume { command, cwd } => { - let preferred_terminal = crate::settings::get_preferred_terminal(); - let target = session_terminal_target(preferred_terminal.as_deref()); - let launch_result = ctx.terminal.with_terminal_restored(|| { - crate::session_manager::terminal::launch_terminal( - &target, - &command, - cwd.as_deref(), - None, - ) - .map_err(AppError::Message) + let launch_result = ctx.terminal.with_terminal_restored_for_handoff(|| { + exec_session_resume(&command, cwd.as_deref()) }); - match launch_result { - Ok(()) => { - ctx.app.push_toast( - texts::tui_sessions_toast_terminal_launched(), - ToastKind::Success, - ); - } - Err(err) => { - ctx.app.overlay = Overlay::TextView(TextViewState { - title: texts::tui_sessions_resume_command().to_string(), - lines: command.lines().map(|line| line.to_string()).collect(), - scroll: 0, - action: None, - }); - ctx.app.push_toast( - texts::tui_sessions_toast_resume_fallback(&err.to_string()), - ToastKind::Warning, - ); - } + if let Err(err) = launch_result { + ctx.app.overlay = Overlay::TextView(TextViewState { + title: texts::tui_sessions_resume_command().to_string(), + lines: command.lines().map(|line| line.to_string()).collect(), + scroll: 0, + action: None, + }); + ctx.app.push_toast( + texts::tui_sessions_toast_resume_fallback(&err.to_string()), + ToastKind::Warning, + ); } Ok(()) } @@ -541,15 +525,46 @@ pub(crate) fn handle_action( } } -fn session_terminal_target(preferred_terminal: Option<&str>) -> String { - match preferred_terminal - .map(str::trim) - .filter(|value| !value.is_empty()) - { - Some("iterm2") => "iterm".to_string(), - Some(target) => target.to_string(), - None => "terminal".to_string(), +#[cfg(unix)] +fn build_session_resume_command( + shell: &std::ffi::OsStr, + command: &str, + cwd: Option<&str>, +) -> std::process::Command { + let mut process = std::process::Command::new(shell); + process.arg("-c").arg(command); + if let Some(cwd) = cwd.filter(|value| !value.trim().is_empty()) { + process.current_dir(cwd); + } + process +} + +#[cfg(unix)] +fn exec_session_resume(command: &str, cwd: Option<&str>) -> Result<(), AppError> { + use std::os::unix::process::CommandExt; + + if command.trim().is_empty() { + return Err(AppError::Message("Resume command is empty".to_string())); } + + let shell = std::env::var_os("SHELL") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "/bin/sh".into()); + let exec_err = build_session_resume_command(&shell, command, cwd).exec(); + Err(AppError::localized( + "sessions.resume_exec_failed", + format!("恢复会话失败: {exec_err}"), + format!("Failed to resume session: {exec_err}"), + )) +} + +#[cfg(not(unix))] +fn exec_session_resume(_command: &str, _cwd: Option<&str>) -> Result<(), AppError> { + Err(AppError::localized( + "sessions.resume_unsupported_platform", + "当前平台暂不支持在当前终端恢复会话。".to_string(), + "Resuming a session in the current terminal is not supported on this platform.".to_string(), + )) } #[cfg(test)] @@ -641,11 +656,35 @@ mod tests { } #[test] - fn session_terminal_target_matches_upstream_setting_names() { - assert_eq!(session_terminal_target(None), "terminal"); - assert_eq!(session_terminal_target(Some("")), "terminal"); - assert_eq!(session_terminal_target(Some("iterm2")), "iterm"); - assert_eq!(session_terminal_target(Some("ghostty")), "ghostty"); + #[cfg(unix)] + fn session_resume_command_uses_user_shell_and_session_cwd() { + let command = build_session_resume_command( + std::ffi::OsStr::new("/bin/zsh"), + "claude --resume session-1", + Some("/workspace"), + ); + + assert_eq!(command.get_program(), std::path::Path::new("/bin/zsh")); + assert_eq!( + command.get_args().collect::>(), + vec!["-c", "claude --resume session-1"] + ); + assert_eq!( + command.get_current_dir(), + Some(std::path::Path::new("/workspace")) + ); + } + + #[test] + #[cfg(unix)] + fn session_resume_command_ignores_blank_cwd() { + let command = build_session_resume_command( + std::ffi::OsStr::new("/bin/bash"), + "codex resume session-1", + Some(" "), + ); + + assert_eq!(command.get_current_dir(), None); } fn write_invalid_legacy_config(home: &Path) { diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 71ebb5db..0b527d46 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -1,5 +1,4 @@ pub mod providers; -pub mod terminal; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs deleted file mode 100644 index 420eff9b..00000000 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ /dev/null @@ -1,382 +0,0 @@ -use std::process::Command; - -pub fn launch_terminal( - target: &str, - command: &str, - cwd: Option<&str>, - custom_config: Option<&str>, -) -> Result<(), String> { - if command.trim().is_empty() { - return Err("Resume command is empty".to_string()); - } - - if !cfg!(target_os = "macos") { - return Err("Terminal resume is only supported on macOS".to_string()); - } - - match target { - "terminal" => launch_macos_terminal(command, cwd), - "iTerm" | "iterm" => launch_iterm(command, cwd), - "ghostty" => launch_ghostty(command, cwd), - "kitty" => launch_kitty(command, cwd), - "wezterm" => launch_wezterm(command, cwd), - "kaku" => launch_kaku(command, cwd), - "alacritty" => launch_alacritty(command, cwd), - #[cfg(unix)] - "warp" => launch_warp(command, cwd), - "custom" => launch_custom(command, cwd, custom_config), - _ => Err(format!("Unsupported terminal target: {target}")), - } -} - -fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> { - let full_command = build_shell_command(command, cwd); - let escaped = escape_osascript(&full_command); - let script = format!( - r#"tell application "Terminal" - activate - do script "{escaped}" -end tell"# - ); - - let status = Command::new("osascript") - .arg("-e") - .arg(script) - .status() - .map_err(|e| format!("Failed to launch Terminal: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Terminal command execution failed".to_string()) - } -} - -fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> { - let full_command = build_shell_command(command, cwd); - let escaped = escape_osascript(&full_command); - // iTerm2 AppleScript to create a new window and execute command - let script = format!( - r#"tell application "iTerm" - activate - create window with default profile - tell current session of current window - write text "{escaped}" - end tell -end tell"# - ); - - let status = Command::new("osascript") - .arg("-e") - .arg(script) - .status() - .map_err(|e| format!("Failed to launch iTerm: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("iTerm command execution failed".to_string()) - } -} - -fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - - let mut args = vec![ - "-na".to_string(), - "Ghostty".to_string(), - "--args".to_string(), - "--quit-after-last-window-closed=true".to_string(), - ]; - - if let Some(dir) = cwd { - if !dir.trim().is_empty() { - args.push(format!("--working-directory={dir}")); - } - } - - args.push("-e".to_string()); - args.push(shell); - args.push("-l".to_string()); - args.push("-c".to_string()); - args.push(command.to_string()); - - let status = Command::new("open") - .args(&args) - .status() - .map_err(|e| format!("Failed to launch Ghostty: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Ghostty. Make sure it is installed.".to_string()) - } -} - -fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { - let full_command = build_shell_command(command, cwd); - - // 获取用户默认 shell - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - - let status = Command::new("open") - .arg("-na") - .arg("kitty") - .arg("--args") - .arg("-e") - .arg(&shell) - .arg("-l") - .arg("-c") - .arg(&full_command) - .status() - .map_err(|e| format!("Failed to launch Kitty: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Kitty. Make sure it is installed.".to_string()) - } -} - -fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { - // wezterm start --cwd ... -- command - // To invoke via `open`, we use `open -na "WezTerm" --args start ...` - let args = build_wezterm_compatible_args("WezTerm", command, cwd); - - let status = Command::new("open") - .args(args.iter().map(String::as_str)) - .status() - .map_err(|e| format!("Failed to launch WezTerm: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch WezTerm.".to_string()) - } -} - -fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> { - // Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint. - let args = build_wezterm_compatible_args("Kaku", command, cwd); - - let status = Command::new("open") - .args(args.iter().map(String::as_str)) - .status() - .map_err(|e| format!("Failed to launch Kaku: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Kaku.".to_string()) - } -} - -fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell) -} - -fn build_wezterm_compatible_args_with_shell( - app_name: &str, - command: &str, - cwd: Option<&str>, - shell: &str, -) -> Vec { - let full_command = build_shell_command(command, None); - let mut args = vec![ - "-na".to_string(), - app_name.to_string(), - "--args".to_string(), - "start".to_string(), - ]; - - if let Some(dir) = cwd { - args.push("--cwd".to_string()); - args.push(dir.to_string()); - } - - // Invoke shell to run the command string (to handle pipes, etc) - args.push("--".to_string()); - args.push(shell.to_string()); - args.push("-c".to_string()); - args.push(full_command); - args -} - -#[cfg(unix)] -fn launch_warp(command: &str, cwd: Option<&str>) -> Result<(), String> { - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - - let cwd = cwd.ok_or("Failed to resume session without cwd")?; - - let mut script_file = tempfile::Builder::new() - .disable_cleanup(true) - .permissions(std::fs::Permissions::from_mode(0o755)) - .tempfile_in(cwd) - .map_err(|e| format!("Failed to create temporary script file for launching Warp: {e}"))?; - - writeln!( - &mut script_file, - r#"#!/usr/bin/env sh - - rm -- "$0" - - exec {command} - "#, - ) - .map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?; - - let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap(); - warp_url - .query_pairs_mut() - .append_pair("path", &script_file.path().to_string_lossy()); - let warp_url = warp_url.to_string(); - - let status = Command::new("open") - .args(["-a", "Warp", &warp_url]) - .status() - .map_err(|e| format!("Failed to launch Warp: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Warp.".to_string()) - } -} - -fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { - // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command - let full_command = build_shell_command(command, None); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - - let mut args = vec!["-na", "Alacritty", "--args"]; - - if let Some(dir) = cwd { - args.push("--working-directory"); - args.push(dir); - } - - args.push("-e"); - args.push(&shell); - args.push("-c"); - args.push(&full_command); - - let status = Command::new("open") - .args(&args) - .status() - .map_err(|e| format!("Failed to launch Alacritty: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Alacritty.".to_string()) - } -} - -fn launch_custom( - command: &str, - cwd: Option<&str>, - custom_config: Option<&str>, -) -> Result<(), String> { - let template = custom_config.ok_or("No custom terminal config provided")?; - - if template.trim().is_empty() { - return Err("Custom terminal command template is empty".to_string()); - } - - let cmd_str = command; - let dir_str = cwd.unwrap_or("."); - - let final_cmd_line = template - .replace("{command}", cmd_str) - .replace("{cwd}", dir_str); - - // Execute via sh -c - let status = Command::new("sh") - .arg("-c") - .arg(&final_cmd_line) - .status() - .map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Custom terminal execution returned error code".to_string()) - } -} - -fn build_shell_command(command: &str, cwd: Option<&str>) -> String { - match cwd { - Some(dir) if !dir.trim().is_empty() => { - format!("cd {} && {}", shell_escape(dir), command) - } - _ => command.to_string(), - } -} - -fn shell_escape(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") -} - -fn escape_osascript(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() { - assert_eq!( - build_shell_command("claude --resume abc-123", None), - "claude --resume abc-123" - ); - } - - #[test] - fn wezterm_compatible_terminals_use_start_and_cwd_arguments() { - let args = build_wezterm_compatible_args_with_shell( - "Kaku", - "claude --resume abc-123", - Some("/tmp/project dir"), - "/bin/zsh", - ); - - assert_eq!( - args, - vec![ - "-na".to_string(), - "Kaku".to_string(), - "--args".to_string(), - "start".to_string(), - "--cwd".to_string(), - "/tmp/project dir".to_string(), - "--".to_string(), - "/bin/zsh".to_string(), - "-c".to_string(), - "claude --resume abc-123".to_string(), - ] - ); - } - - #[test] - fn ghostty_uses_working_directory_arg_for_cwd() { - // cwd should be passed as --working-directory, not embedded in the shell command string - // This avoids shell expansion of special characters in directory paths - let cwd = "/tmp/project dir"; - let command = "claude --resume abc-123"; - - // Verify build_shell_command does NOT include cwd when used in ghostty context - // (ghostty passes cwd via --working-directory flag instead) - assert_eq!( - build_shell_command(command, None), - "claude --resume abc-123" - ); - - // Verify shell_escape works correctly for paths with spaces - assert_eq!(shell_escape(cwd), "\"/tmp/project dir\""); - } -} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b974b574..88fbacb7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -437,8 +437,9 @@ pub struct AppSettings { pub webdav_sync: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub backup_retain_count: Option, - /// 首选终端应用,用于会话恢复。 + /// 已废弃,仅用于保留现有配置值。 #[serde(default, skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] pub preferred_terminal: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub local_migrations: Option, @@ -556,13 +557,6 @@ impl AppSettings { if let Some(webdav) = self.webdav_sync.as_mut() { webdav.normalize(); } - - self.preferred_terminal = self - .preferred_terminal - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); } fn normalize_loaded(&mut self) { @@ -743,13 +737,6 @@ pub fn mark_codex_provider_template_migrated( }) } -pub fn get_preferred_terminal() -> Option { - settings_store() - .read() - .ok() - .and_then(|settings| settings.preferred_terminal.clone()) -} - pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> { let mut settings = get_settings(); let current = settings @@ -1020,3 +1007,22 @@ pub fn set_skip_claude_onboarding(enabled: bool) -> Result<(), AppError> { settings.skip_claude_onboarding = enabled; update_settings(settings) } + +#[cfg(test)] +mod tests { + use super::AppSettings; + + #[test] + fn deprecated_preferred_terminal_round_trips_without_normalization() { + let original = " custom-terminal "; + let mut settings: AppSettings = serde_json::from_value(serde_json::json!({ + "preferredTerminal": original + })) + .expect("deserialize settings"); + + settings.normalize_loaded(); + + let serialized = serde_json::to_value(settings).expect("serialize settings"); + assert_eq!(serialized["preferredTerminal"], original); + } +}