mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
feat: more granular local environment checks (#870)
* feat: more granular local environment checks * refactor: improve PR #870 with i18n, shadcn Select, and testable helpers - Extract is_valid_shell, is_valid_shell_flag, default_flag_for_shell to module-level #[cfg(windows)] functions for testability - Add unit tests for extracted helper functions - Replace native <select> with shadcn/ui Select components - Extract env badge ternary to ENV_BADGE_CONFIG Record lookup - Add i18n keys for env badges and WSL selectors (zh/en/ja) - Unify initial useEffect load path with loadAllToolVersions() * fix: prevent useEffect re-firing on wslShellByTool changes The useEffect that loads initial tool versions depended on loadAllToolVersions, which in turn depended on wslShellByTool. This caused a full re-fetch of all 4 tools every time the user changed a WSL shell or flag, racing with the single-tool refresh. Fix: use empty deps [] since this is a mount-only effect. The refresh button and shell/flag handlers cover subsequent updates. --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
+239
-42
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
@@ -85,50 +86,122 @@ pub struct ToolVersion {
|
||||
version: Option<String>,
|
||||
latest_version: Option<String>, // 新增字段:最新版本
|
||||
error: Option<String>,
|
||||
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
|
||||
env_type: String,
|
||||
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells)
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WslShellPreferenceInput {
|
||||
#[serde(default)]
|
||||
pub wsl_shell: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wsl_shell_flag: Option<String>,
|
||||
}
|
||||
|
||||
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
|
||||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
("wsl".to_string(), Some(distro))
|
||||
} else {
|
||||
("windows".to_string(), None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("macos".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("linux".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("unknown".to_string(), None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||
pub async fn get_tool_versions(
|
||||
tools: Option<Vec<String>>,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
) -> Result<Vec<ToolVersion>, String> {
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取单个工具的版本信息(内部实现)
|
||||
async fn get_single_tool_version_impl(
|
||||
tool: &str,
|
||||
wsl_shell: Option<&str>,
|
||||
wsl_shell_flag: Option<&str>,
|
||||
) -> ToolVersion {
|
||||
debug_assert!(
|
||||
VALID_TOOLS.contains(&tool),
|
||||
"unexpected tool name in get_single_tool_version_impl: {tool}"
|
||||
);
|
||||
|
||||
// 判断该工具的运行环境 & WSL distro(如有)
|
||||
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
// 1. 获取本地版本
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||||
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||||
} else {
|
||||
let direct_result = try_get_version(tool);
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
} else {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
} else {
|
||||
// 扫描常见的 npm 全局安装路径
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
results.push(ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
});
|
||||
ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
env_type,
|
||||
wsl_distro,
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Helper function to fetch latest version from npm registry
|
||||
@@ -242,8 +315,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// Validate that the given shell name is one of the allowed shells.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
fn is_valid_shell(shell: &str) -> bool {
|
||||
matches!(
|
||||
shell.rsplit('/').next().unwrap_or(shell),
|
||||
"sh" | "bash" | "zsh" | "fish" | "dash"
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate that the given shell flag is one of the allowed flags.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_shell_flag(flag: &str) -> bool {
|
||||
matches!(flag, "-c" | "-lc" | "-lic")
|
||||
}
|
||||
|
||||
/// Return the default invocation flag for the given shell.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
"fish" => "-lc",
|
||||
_ => "-lic",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(
|
||||
tool: &str,
|
||||
distro: &str,
|
||||
force_shell: Option<&str>,
|
||||
force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
@@ -257,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
// 构建 Shell 脚本检测逻辑
|
||||
let (shell, flag, cmd) = if let Some(shell) = force_shell {
|
||||
// Defensive validation: never allow an arbitrary executable name here.
|
||||
if !is_valid_shell(shell) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
|
||||
}
|
||||
let shell = shell.rsplit('/').next().unwrap_or(shell);
|
||||
let flag = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
flag
|
||||
} else {
|
||||
default_flag_for_shell(shell)
|
||||
};
|
||||
|
||||
(shell.to_string(), flag, format!("{tool} --version"))
|
||||
} else {
|
||||
let cmd = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
|
||||
} else {
|
||||
// 兜底:自动尝试 -lic, -lc, -c
|
||||
format!(
|
||||
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
|
||||
)
|
||||
};
|
||||
|
||||
("sh".to_string(), "-c", cmd)
|
||||
};
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
@@ -306,7 +441,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
fn try_get_version_wsl(
|
||||
_tool: &str,
|
||||
_distro: &str,
|
||||
_force_shell: Option<&str>,
|
||||
_force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
@@ -1061,6 +1201,63 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wsl_helpers {
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell() {
|
||||
assert!(is_valid_shell("bash"));
|
||||
assert!(is_valid_shell("zsh"));
|
||||
assert!(is_valid_shell("sh"));
|
||||
assert!(is_valid_shell("fish"));
|
||||
assert!(is_valid_shell("dash"));
|
||||
assert!(is_valid_shell("/usr/bin/bash"));
|
||||
assert!(is_valid_shell("/bin/zsh"));
|
||||
assert!(!is_valid_shell("powershell"));
|
||||
assert!(!is_valid_shell("cmd"));
|
||||
assert!(!is_valid_shell(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell_flag() {
|
||||
assert!(is_valid_shell_flag("-c"));
|
||||
assert!(is_valid_shell_flag("-lc"));
|
||||
assert!(is_valid_shell_flag("-lic"));
|
||||
assert!(!is_valid_shell_flag("-x"));
|
||||
assert!(!is_valid_shell_flag(""));
|
||||
assert!(!is_valid_shell_flag("--login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_flag_for_shell() {
|
||||
assert_eq!(default_flag_for_shell("sh"), "-c");
|
||||
assert_eq!(default_flag_for_shell("dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("fish"), "-lc");
|
||||
assert_eq!(default_flag_for_shell("bash"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("zsh"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_wsl_distro_name() {
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu"));
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
|
||||
assert!(is_valid_wsl_distro_name("my_distro"));
|
||||
assert!(!is_valid_wsl_distro_name(""));
|
||||
assert!(!is_valid_wsl_distro_name("distro with spaces"));
|
||||
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
@@ -30,8 +37,48 @@ interface ToolVersion {
|
||||
version: string | null;
|
||||
latest_version: string | null;
|
||||
error: string | null;
|
||||
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
|
||||
wsl_distro: string | null;
|
||||
}
|
||||
|
||||
const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
type ToolName = (typeof TOOL_NAMES)[number];
|
||||
|
||||
type WslShellPreference = {
|
||||
wslShell?: string | null;
|
||||
wslShellFlag?: string | null;
|
||||
};
|
||||
|
||||
const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const;
|
||||
// UI-friendly order: login shell first.
|
||||
const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const;
|
||||
|
||||
const ENV_BADGE_CONFIG: Record<
|
||||
string,
|
||||
{ labelKey: string; className: string }
|
||||
> = {
|
||||
wsl: {
|
||||
labelKey: "settings.envBadge.wsl",
|
||||
className:
|
||||
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
},
|
||||
windows: {
|
||||
labelKey: "settings.envBadge.windows",
|
||||
className:
|
||||
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
},
|
||||
macos: {
|
||||
labelKey: "settings.envBadge.macos",
|
||||
className:
|
||||
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20",
|
||||
},
|
||||
linux: {
|
||||
labelKey: "settings.envBadge.linux",
|
||||
className:
|
||||
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
# Codex
|
||||
@@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
isChecking,
|
||||
} = useUpdate();
|
||||
|
||||
const loadToolVersions = useCallback(async () => {
|
||||
const [wslShellByTool, setWslShellByTool] = useState<
|
||||
Record<string, WslShellPreference>
|
||||
>({});
|
||||
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const refreshToolVersions = useCallback(
|
||||
async (
|
||||
toolNames: ToolName[],
|
||||
wslOverrides?: Record<string, WslShellPreference>,
|
||||
) => {
|
||||
if (toolNames.length === 0) return;
|
||||
|
||||
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
|
||||
setLoadingTools((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const name of toolNames) next[name] = true;
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await settingsApi.getToolVersions(
|
||||
toolNames,
|
||||
wslOverrides,
|
||||
);
|
||||
|
||||
setToolVersions((prev) => {
|
||||
if (prev.length === 0) return updated;
|
||||
const byName = new Map(updated.map((t) => [t.name, t]));
|
||||
const merged = prev.map((t) => byName.get(t.name) ?? t);
|
||||
const existing = new Set(prev.map((t) => t.name));
|
||||
for (const u of updated) {
|
||||
if (!existing.has(u.name)) merged.push(u);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to refresh tools", error);
|
||||
} finally {
|
||||
setLoadingTools((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const name of toolNames) next[name] = false;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadAllToolVersions = useCallback(async () => {
|
||||
setIsLoadingTools(true);
|
||||
try {
|
||||
const tools = await settingsApi.getToolVersions();
|
||||
setToolVersions(tools);
|
||||
// Respect current UI overrides (shell / flag) when doing a full refresh.
|
||||
const versions = await settingsApi.getToolVersions(
|
||||
[...TOOL_NAMES],
|
||||
wslShellByTool,
|
||||
);
|
||||
setToolVersions(versions);
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to load tool versions", error);
|
||||
} finally {
|
||||
setIsLoadingTools(false);
|
||||
}
|
||||
}, []);
|
||||
}, [wslShellByTool]);
|
||||
|
||||
const handleToolShellChange = async (toolName: ToolName, value: string) => {
|
||||
const wslShell = value === "auto" ? null : value;
|
||||
const nextPref: WslShellPreference = {
|
||||
...(wslShellByTool[toolName] ?? {}),
|
||||
wslShell,
|
||||
};
|
||||
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||
};
|
||||
|
||||
const handleToolShellFlagChange = async (
|
||||
toolName: ToolName,
|
||||
value: string,
|
||||
) => {
|
||||
const wslShellFlag = value === "auto" ? null : value;
|
||||
const nextPref: WslShellPreference = {
|
||||
...(wslShellByTool[toolName] ?? {}),
|
||||
wslShellFlag,
|
||||
};
|
||||
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [appVersion, tools] = await Promise.all([
|
||||
const [appVersion] = await Promise.all([
|
||||
getVersion(),
|
||||
settingsApi.getToolVersions(),
|
||||
loadAllToolVersions(),
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
setVersion(appVersion);
|
||||
setToolVersions(tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to load info", error);
|
||||
@@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingVersion(false);
|
||||
setIsLoadingTools(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// Mount-only: loadAllToolVersions is intentionally excluded to avoid
|
||||
// re-fetching all tools whenever wslShellByTool changes. Single-tool
|
||||
// refreshes are handled by refreshToolVersions in the shell/flag handlers.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
||||
@@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={loadToolVersions}
|
||||
onClick={() => loadAllToolVersions()}
|
||||
disabled={isLoadingTools}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -317,8 +441,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
|
||||
{TOOL_NAMES.map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
// Special case for OpenCode (capital C), others use capitalize
|
||||
const displayName =
|
||||
@@ -340,8 +465,64 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
{/* Environment Badge */}
|
||||
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
|
||||
>
|
||||
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{/* WSL Shell Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_OPTIONS.map((shell) => (
|
||||
<SelectItem key={shell} value={shell}>
|
||||
{shell}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* WSL Shell Flag Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellFlagChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
|
||||
<SelectItem key={flag} value={flag}>
|
||||
{flag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingTools ? (
|
||||
{isLoadingTools || loadingTools[toolName] ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : tool?.version ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting..."
|
||||
"deleting": "Deleting...",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -452,6 +453,14 @@
|
||||
"oneClickInstall": "One-click Install",
|
||||
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "Local environment check",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "Flag",
|
||||
"installCommandsCopied": "Install commands copied",
|
||||
"installCommandsCopyFailed": "Copy failed, please copy manually.",
|
||||
"importFailedError": "Import config failed: {{message}}",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "検索",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中..."
|
||||
"deleting": "削除中...",
|
||||
"auto": "自動"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "API Key を入力",
|
||||
@@ -452,6 +453,14 @@
|
||||
"oneClickInstall": "ワンクリックインストール",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール",
|
||||
"localEnvCheck": "ローカル環境チェック",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "フラグ",
|
||||
"installCommandsCopied": "インストールコマンドをコピーしました",
|
||||
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
|
||||
"importFailedError": "設定のインポートに失敗しました: {{message}}",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"search": "查询",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中..."
|
||||
"deleting": "删除中...",
|
||||
"auto": "自动"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -452,6 +453,14 @@
|
||||
"oneClickInstall": "一键安装",
|
||||
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "本地环境检查",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "标志",
|
||||
"installCommandsCopied": "安装命令已复制",
|
||||
"installCommandsCopyFailed": "复制失败,请手动复制。",
|
||||
"importFailedError": "导入配置失败:{{message}}",
|
||||
|
||||
+10
-2
@@ -169,15 +169,23 @@ export const settingsApi = {
|
||||
return await invoke("get_auto_launch_status");
|
||||
},
|
||||
|
||||
async getToolVersions(): Promise<
|
||||
async getToolVersions(
|
||||
tools?: string[],
|
||||
wslShellByTool?: Record<
|
||||
string,
|
||||
{ wslShell?: string | null; wslShellFlag?: string | null }
|
||||
>,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
version: string | null;
|
||||
latest_version: string | null;
|
||||
error: string | null;
|
||||
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
|
||||
wsl_distro: string | null;
|
||||
}>
|
||||
> {
|
||||
return await invoke("get_tool_versions");
|
||||
return await invoke("get_tool_versions", { tools, wslShellByTool });
|
||||
},
|
||||
|
||||
async getRectifierConfig(): Promise<RectifierConfig> {
|
||||
|
||||
Reference in New Issue
Block a user