From 9620a8c76905d0a512f474e49d499a3e2ffb3e51 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 30 Mar 2026 00:43:55 +0800 Subject: [PATCH] feat(terminal): add Tabby terminal support on macOS, Windows, and Linux Add Tabby (https://github.com/Eugeny/tabby) as a preferred terminal option across all three platforms: - macOS: launch via `open -na Tabby --args run` with login shell - Windows: search multiple executable candidates (PATH, LOCALAPPDATA, ProgramFiles) and launch via `tabby run cmd /K` - Linux: add `tabby` and `tabby-terminal` to the auto-detection list Also includes: - Session manager integration with login-shell command wrapping - Terminal selector UI entries for all platforms - i18n labels (zh/en/ja) - Unit tests for Tabby arg building (with/without cwd) Closes https://github.com/farion1231/cc-switch/issues/1708 --- src-tauri/src/commands/misc.rs | 87 +++++++++++++++++++ src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/lightweight.rs | 2 +- src-tauri/src/session_manager/terminal/mod.rs | 70 +++++++++++++++ src-tauri/src/settings.rs | 6 +- src-tauri/src/tray.rs | 7 +- src/components/settings/TerminalSettings.tsx | 3 + src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh.json | 9 +- src/types.ts | 6 +- 11 files changed, 190 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index e07f1482f..c7713e1aa 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -888,6 +888,7 @@ exec bash --norc --noprofile "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), + "tabby" => launch_macos_tabby(&script_file), _ => launch_macos_terminal_app(&script_file), // "terminal" or default }; @@ -1005,6 +1006,39 @@ fn launch_macos_open_app( Ok(()) } +/// macOS: Tabby +#[cfg(target_os = "macos")] +fn launch_macos_tabby(script_file: &std::path::Path) -> Result<(), String> { + use std::process::Command; + + let output = Command::new("open") + .args(build_macos_tabby_args(script_file)) + .output() + .map_err(|e| format!("启动 Tabby 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "Tabby 启动失败 (exit code: {:?}): {}", + output.status.code(), + stderr + )); + } + + Ok(()) +} + +fn build_macos_tabby_args(script_file: &std::path::Path) -> Vec { + vec![ + "-na".to_string(), + "Tabby".to_string(), + "--args".to_string(), + "run".to_string(), + "bash".to_string(), + script_file.to_string_lossy().into_owned(), + ] +} + /// Linux: 根据用户首选终端启动 #[cfg(target_os = "linux")] fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { @@ -1023,6 +1057,8 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { ("alacritty", vec!["-e"]), ("kitty", vec!["-e"]), ("ghostty", vec!["-e"]), + ("tabby", vec!["run"]), + ("tabby-terminal", vec!["run"]), ]; // Create temp script file @@ -1148,6 +1184,7 @@ del \"%~f0\" >nul 2>&1 "PowerShell", ), "wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"), + "tabby" => run_windows_tabby_command(&bat_path), _ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default }; @@ -1164,6 +1201,56 @@ del \"%~f0\" >nul 2>&1 result } +#[cfg(target_os = "windows")] +fn run_windows_tabby_command(bat_path: &str) -> Result<(), String> { + use std::process::Command; + + let mut last_error = String::from("未找到可用的 Tabby 可执行文件"); + + for candidate in windows_tabby_executable_candidates() { + let result = Command::new(&candidate) + .args(["run", "cmd", "/K", bat_path]) + .creation_flags(CREATE_NO_WINDOW) + .spawn(); + + match result { + Ok(_) => return Ok(()), + Err(e) => { + last_error = format!("执行 {} 失败: {}", candidate.display(), e); + } + } + } + + Err(last_error) +} + +#[cfg(target_os = "windows")] +fn windows_tabby_executable_candidates() -> Vec { + let mut candidates = Vec::new(); + + for candidate in ["tabby", "tabby.exe", "Tabby", "Tabby.exe"] { + push_unique_path(&mut candidates, std::path::PathBuf::from(candidate)); + } + + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + let tabby_dir = std::path::PathBuf::from(local_app_data) + .join("Programs") + .join("Tabby"); + push_unique_path(&mut candidates, tabby_dir.join("Tabby.exe")); + push_unique_path(&mut candidates, tabby_dir.join("tabby.exe")); + } + + for env_key in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(base_dir) = std::env::var_os(env_key) { + let tabby_dir = std::path::PathBuf::from(base_dir).join("Tabby"); + push_unique_path(&mut candidates, tabby_dir.join("Tabby.exe")); + push_unique_path(&mut candidates, tabby_dir.join("tabby.exe")); + } + } + + candidates +} + /// 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> { 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/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index ab13466ba..fbf762087 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -21,6 +21,7 @@ pub fn launch_terminal( "kitty" => launch_kitty(command, cwd), "wezterm" => launch_wezterm(command, cwd), "alacritty" => launch_alacritty(command, cwd), + "tabby" => launch_tabby(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), } @@ -211,6 +212,35 @@ fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { } } +fn launch_tabby(command: &str, cwd: Option<&str>) -> Result<(), String> { + let status = Command::new("open") + .args(build_tabby_args(command, cwd)) + .status() + .map_err(|e| format!("Failed to launch Tabby: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Tabby.".to_string()) + } +} + +fn build_tabby_args(command: &str, cwd: Option<&str>) -> Vec { + let full_command = build_shell_command(command, cwd); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + + vec![ + "-na".to_string(), + "Tabby".to_string(), + "--args".to_string(), + "run".to_string(), + shell, + "-l".to_string(), + "-c".to_string(), + full_command, + ] +} + fn launch_custom( command: &str, cwd: Option<&str>, @@ -305,4 +335,44 @@ mod tests { "raw:echo foo\\\\\\\\bar\\npwd\\n" ); } + + #[test] + fn tabby_uses_login_shell_for_resume_commands() { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let args = build_tabby_args("claude --resume abc-123", Some("/tmp/project dir")); + + assert_eq!( + args, + vec![ + "-na", + "Tabby", + "--args", + "run", + &shell, + "-l", + "-c", + "cd \"/tmp/project dir\" && claude --resume abc-123", + ] + ); + } + + #[test] + fn tabby_keeps_command_when_no_cwd_is_provided() { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let args = build_tabby_args("claude --resume abc-123", None); + + assert_eq!( + args, + vec![ + "-na", + "Tabby", + "--args", + "run", + &shell, + "-l", + "-c", + "claude --resume abc-123", + ] + ); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2358e622f..ac1b2f0af 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -264,9 +264,9 @@ pub struct AppSettings { // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) - /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" - /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) - /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" + /// - macOS: "terminal" | "iterm2" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "tabby" + /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) | "tabby" + /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" | "tabby" #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_terminal: Option, } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f5b068157..2702cb822 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>) diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index c1d62a158..cd4db30fe 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -16,6 +16,7 @@ const MACOS_TERMINALS = [ { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, + { value: "tabby", labelKey: "settings.terminal.options.macos.tabby" }, ] as const; const WINDOWS_TERMINALS = [ @@ -25,6 +26,7 @@ const WINDOWS_TERMINALS = [ labelKey: "settings.terminal.options.windows.powershell", }, { value: "wt", labelKey: "settings.terminal.options.windows.wt" }, + { value: "tabby", labelKey: "settings.terminal.options.windows.tabby" }, ] as const; const LINUX_TERMINALS = [ @@ -40,6 +42,7 @@ const LINUX_TERMINALS = [ { value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" }, { value: "kitty", labelKey: "settings.terminal.options.linux.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" }, + { value: "tabby", labelKey: "settings.terminal.options.linux.tabby" }, ] as const; // Get terminals for the current platform diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ca639040d..b69150afa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -496,12 +496,14 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "tabby": "Tabby" }, "windows": { "cmd": "Command Prompt", "powershell": "PowerShell", - "wt": "Windows Terminal" + "wt": "Windows Terminal", + "tabby": "Tabby" }, "linux": { "gnomeTerminal": "GNOME Terminal", @@ -509,7 +511,8 @@ "xfce4Terminal": "Xfce4 Terminal", "alacritty": "Alacritty", "kitty": "Kitty", - "ghostty": "Ghostty" + "ghostty": "Ghostty", + "tabby": "Tabby" } } }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 76f4808a9..0ab199569 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -496,12 +496,14 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "tabby": "Tabby" }, "windows": { "cmd": "コマンドプロンプト", "powershell": "PowerShell", - "wt": "Windows Terminal" + "wt": "Windows Terminal", + "tabby": "Tabby" }, "linux": { "gnomeTerminal": "GNOME Terminal", @@ -509,7 +511,8 @@ "xfce4Terminal": "Xfce4 Terminal", "alacritty": "Alacritty", "kitty": "Kitty", - "ghostty": "Ghostty" + "ghostty": "Ghostty", + "tabby": "Tabby" } } }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1b80ae690..519053ffd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -496,12 +496,14 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "tabby": "Tabby" }, "windows": { "cmd": "命令提示符", "powershell": "PowerShell", - "wt": "Windows Terminal" + "wt": "Windows Terminal", + "tabby": "Tabby" }, "linux": { "gnomeTerminal": "GNOME Terminal", @@ -509,7 +511,8 @@ "xfce4Terminal": "Xfce4 Terminal", "alacritty": "Alacritty", "kitty": "Kitty", - "ghostty": "Ghostty" + "ghostty": "Ghostty", + "tabby": "Tabby" } } }, diff --git a/src/types.ts b/src/types.ts index e6a7f358a..8954fb63a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -303,9 +303,9 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" - // Windows: "cmd" | "powershell" | "wt" - // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" + // macOS: "terminal" | "iterm2" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "tabby" + // Windows: "cmd" | "powershell" | "wt" | "tabby" + // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" | "tabby" preferredTerminal?: string; }