mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
1266 lines
49 KiB
TypeScript
1266 lines
49 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Download,
|
||
Copy,
|
||
ExternalLink,
|
||
Github,
|
||
Globe,
|
||
Info,
|
||
Loader2,
|
||
RefreshCw,
|
||
Terminal,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
ArrowUpCircle,
|
||
ChevronDown,
|
||
Stethoscope,
|
||
} 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";
|
||
import { settingsApi } from "@/lib/api";
|
||
import type {
|
||
ToolInstallation,
|
||
ToolInstallationReport,
|
||
} from "@/lib/api/settings";
|
||
import { useUpdate } from "@/contexts/UpdateContext";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { motion } from "framer-motion";
|
||
import appIcon from "@/assets/icons/app-icon.png";
|
||
import { APP_ICON_MAP } from "@/config/appConfig";
|
||
import type { AppId } from "@/lib/api/types";
|
||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||
import { isWindows } from "@/lib/platform";
|
||
import { isUpdateAvailable } from "@/lib/version";
|
||
import { ToolUpgradeConfirmDialog } from "./ToolUpgradeConfirmDialog";
|
||
import { ToolInstallRow } from "./ToolInstallRow";
|
||
|
||
interface AboutSectionProps {
|
||
isPortable: boolean;
|
||
}
|
||
|
||
interface ToolVersion {
|
||
name: string;
|
||
version: string | null;
|
||
latest_version: string | null;
|
||
error: string | null;
|
||
// 后端已定位到可执行文件但 --version 报错(装了却跑不起来)。直接读此字段,
|
||
// 不要靠匹配 error 文案反推——避免前端与后端字符串硬耦合。
|
||
installed_but_broken: boolean;
|
||
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
|
||
wsl_distro: string | null;
|
||
}
|
||
|
||
const TOOL_NAMES = [
|
||
"claude",
|
||
"codex",
|
||
"gemini",
|
||
"opencode",
|
||
"openclaw",
|
||
"hermes",
|
||
] as const;
|
||
type ToolName = (typeof TOOL_NAMES)[number];
|
||
type ToolLifecycleAction = "install" | "update";
|
||
|
||
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 posixScriptInstallCommand = (url: string) =>
|
||
`bash -c 'tmp=$(mktemp) && curl -fsSL ${url} -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'`;
|
||
|
||
const HERMES_WINDOWS_INSTALL_SCRIPT =
|
||
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
|
||
|
||
const powershellEncodedCommand = (script: string): string => {
|
||
let binary = "";
|
||
for (let i = 0; i < script.length; i += 1) {
|
||
const code = script.charCodeAt(i);
|
||
binary += String.fromCharCode(code & 0xff, code >> 8);
|
||
}
|
||
return btoa(binary);
|
||
};
|
||
|
||
const HERMES_WINDOWS_INSTALL_COMMAND = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${powershellEncodedCommand(
|
||
HERMES_WINDOWS_INSTALL_SCRIPT,
|
||
)}`;
|
||
|
||
const POSIX_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
|
||
${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthropic-ai/claude-code@latest
|
||
# Codex
|
||
npm i -g @openai/codex@latest
|
||
# Gemini CLI
|
||
npm i -g @google/gemini-cli@latest
|
||
# OpenCode
|
||
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
|
||
# OpenClaw
|
||
npm i -g openclaw@latest
|
||
# Hermes
|
||
${posixScriptInstallCommand("https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh")}`;
|
||
|
||
const WINDOWS_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
|
||
npm i -g @anthropic-ai/claude-code@latest
|
||
# Codex
|
||
npm i -g @openai/codex@latest
|
||
# Gemini CLI
|
||
npm i -g @google/gemini-cli@latest
|
||
# OpenCode
|
||
npm i -g opencode-ai@latest
|
||
# OpenClaw
|
||
npm i -g openclaw@latest
|
||
# Hermes
|
||
${HERMES_WINDOWS_INSTALL_COMMAND}`;
|
||
|
||
const ONE_CLICK_INSTALL_COMMANDS = isWindows()
|
||
? WINDOWS_ONE_CLICK_INSTALL_COMMANDS
|
||
: POSIX_ONE_CLICK_INSTALL_COMMANDS;
|
||
|
||
const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
||
claude: "Claude Code",
|
||
codex: "Codex",
|
||
gemini: "Gemini CLI",
|
||
opencode: "OpenCode",
|
||
openclaw: "OpenClaw",
|
||
hermes: "Hermes",
|
||
};
|
||
|
||
// 后端返回的 tool 是 string;这里收敛唯一的 ToolName 断言与兜底,供升级确认
|
||
// 对话框按工具名展示(避免在 JSX 里内联 cast、且每次渲染都新建闭包)。
|
||
function toolDisplayName(tool: string): string {
|
||
return TOOL_DISPLAY_NAMES[tool as ToolName] ?? tool;
|
||
}
|
||
|
||
const TOOL_APP_IDS: Record<ToolName, AppId> = {
|
||
claude: "claude",
|
||
codex: "codex",
|
||
gemini: "gemini",
|
||
opencode: "opencode",
|
||
openclaw: "openclaw",
|
||
hermes: "hermes",
|
||
};
|
||
|
||
// 工具版本探测代价高:每个工具一次 `--version` 子进程 + 一次 npm/github/pypi 网络请求。
|
||
// 设置页用 Radix Tabs,非激活 Tab 会被卸载——每次切回「关于」都重挂 AboutSection,若都
|
||
// 全量重查纯属浪费。用「模块级」缓存(生命周期 = JS 模块 = 应用会话,不随组件卸载销毁)
|
||
// 跨重挂存活:重挂时若缓存仍新鲜(距上次全量加载 < TTL)直接复用、跳过探测;超期或用户
|
||
// 手动「刷新」才强制重查。at = 最近一次「全量加载」完成时刻;单工具刷新(切 shell / 升级
|
||
// 后)只更新数据、不重置 at,避免一次局部刷新把整体 TTL 续命。
|
||
const TOOL_VERSIONS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 分钟
|
||
let toolVersionsCache: { data: ToolVersion[]; at: number } | null = null;
|
||
// 应用自身版本(getVersion,本地毫秒级、无网络)也缓存一份,纯为重挂时免去 loading 闪烁。
|
||
let appVersionCache: string | null = null;
|
||
|
||
// 把探测结果按 name 合并进已有列表:替换同名项、追加新项;空列表时直接采用新结果。
|
||
// 组件 state 与模块缓存共用同一套合并语义(单工具与全量探测都经此函数)。
|
||
function mergeToolVersions(
|
||
prev: ToolVersion[],
|
||
updated: ToolVersion[],
|
||
): ToolVersion[] {
|
||
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;
|
||
}
|
||
|
||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||
// ... (use hooks as before) ...
|
||
const { t } = useTranslation();
|
||
// 惰性初始化自模块缓存:重挂时首帧即渲染上次的值,避免 loading 闪烁;首次挂载缓存
|
||
// 为空则回退到原始初值(null / loading)。
|
||
const [version, setVersion] = useState<string | null>(() => appVersionCache);
|
||
const [isLoadingVersion, setIsLoadingVersion] = useState(
|
||
() => appVersionCache === null,
|
||
);
|
||
const [isDownloading, setIsDownloading] = useState(false);
|
||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>(
|
||
() => toolVersionsCache?.data ?? [],
|
||
);
|
||
// 有缓存(哪怕已超期)就先展示旧值、初始不 loading;超期时由挂载副作用触发后台
|
||
// 重查(stale-while-revalidate)。无缓存(首次)才从 loading 起步。
|
||
const [isLoadingTools, setIsLoadingTools] = useState(
|
||
() => toolVersionsCache === null,
|
||
);
|
||
const [toolActions, setToolActions] = useState<
|
||
Partial<Record<ToolName, ToolLifecycleAction>>
|
||
>({});
|
||
const [batchAction, setBatchAction] = useState<ToolLifecycleAction | null>(
|
||
null,
|
||
);
|
||
const [showInstallCommands, setShowInstallCommands] = useState(false);
|
||
|
||
const { hasUpdate, updateInfo, checkUpdate, resetDismiss, isChecking } =
|
||
useUpdate();
|
||
|
||
const [wslShellByTool, setWslShellByTool] = useState<
|
||
Record<string, WslShellPreference>
|
||
>({});
|
||
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||
// 多处安装冲突诊断结果:按工具存储,有冲突的工具会在其卡片下方展示。
|
||
// 来源两路:顶部「诊断安装冲突」按钮一次性扫全部,或升级后版本未变时自动补诊。
|
||
const [toolDiagnostics, setToolDiagnostics] = useState<
|
||
Partial<Record<ToolName, ToolInstallation[]>>
|
||
>({});
|
||
const [isDiagnosingAll, setIsDiagnosingAll] = useState(false);
|
||
// 升级前探测到「多处安装需确认」时暂存:toolNames=本次要升级的全部工具,
|
||
// plans=其中需要确认的(≥2 处)那些。用户确认后对 toolNames 整体执行升级。
|
||
const [pendingUpgrade, setPendingUpgrade] = useState<{
|
||
toolNames: ToolName[];
|
||
plans: ToolInstallationReport[];
|
||
} | null>(null);
|
||
// 升级 preflight(probe 阶段)的 in-flight 工具集合。
|
||
// probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize),
|
||
// 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击
|
||
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / 官方 installer,写冲突)。
|
||
// 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。
|
||
// 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight,
|
||
// 精确反映到各自卡片按钮的 disabled。
|
||
const [preflightTools, setPreflightTools] = useState<Set<ToolName>>(
|
||
() => new Set(),
|
||
);
|
||
|
||
const toolVersionByName = useMemo(() => {
|
||
return new Map(toolVersions.map((tool) => [tool.name, tool]));
|
||
}, [toolVersions]);
|
||
|
||
const updatableToolNames = useMemo(
|
||
() =>
|
||
TOOL_NAMES.filter((toolName) => {
|
||
const tool = toolVersionByName.get(toolName);
|
||
return isUpdateAvailable(tool?.version, tool?.latest_version);
|
||
}),
|
||
[toolVersionByName],
|
||
);
|
||
|
||
const refreshToolVersions = useCallback(
|
||
async (
|
||
toolNames: ToolName[],
|
||
wslOverrides?: Record<string, WslShellPreference>,
|
||
): Promise<ToolVersion[]> => {
|
||
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) => mergeToolVersions(prev, updated));
|
||
// 同步进模块缓存,供切 Tab 重挂时复用。时间戳沿用上次「全量加载」的(单工具
|
||
// 刷新不算全量、不重置 TTL);缓存为空时以 at=0 起步——0 是「尚未完成全量加载」
|
||
// 的过期哨兵,确保探测中途切走/切回时,残缺缓存被判过期而触发重查,而非把半套
|
||
// 数据当成完整结果复用。真实时间戳只由 loadAllToolVersions 的 finally 盖上。
|
||
toolVersionsCache = {
|
||
data: mergeToolVersions(toolVersionsCache?.data ?? [], updated),
|
||
at: toolVersionsCache?.at ?? 0,
|
||
};
|
||
|
||
// 返回刷新结果,调用方可据此判断版本是否真的探到(避免读 state 撞 stale closure)。
|
||
return updated;
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to refresh tools", error);
|
||
return [];
|
||
} finally {
|
||
setLoadingTools((prev) => {
|
||
const next = { ...prev };
|
||
for (const name of toolNames) next[name] = false;
|
||
return next;
|
||
});
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const loadAllToolVersions = useCallback(
|
||
async (options?: { force?: boolean }) => {
|
||
const force = options?.force ?? false;
|
||
// 命中新鲜缓存:切回「关于」Tab 触发的重挂直接复用上次结果,跳过 6 个 `--version`
|
||
// 子进程 + 6 个 latest 版本网络请求。手动「刷新」传 force 绕过缓存强制重查。
|
||
if (
|
||
!force &&
|
||
toolVersionsCache &&
|
||
Date.now() - toolVersionsCache.at < TOOL_VERSIONS_CACHE_TTL_MS
|
||
) {
|
||
setToolVersions(toolVersionsCache.data);
|
||
setIsLoadingTools(false);
|
||
return;
|
||
}
|
||
setIsLoadingTools(true);
|
||
try {
|
||
// 逐工具并发探测:每个工具一完成就合并进 toolVersions(并写模块缓存)、清掉自己
|
||
// 的 loadingTools 标志,对应卡片随即独立刷新——而非等全部探测完才一次性显示(后端
|
||
// 原本对 6 个工具串行 await,总耗时累加;并发后压成「最慢的那一个」)。refreshTool-
|
||
// Versions 已内建按 name 合并 + per-tool loading + try/catch 兜底(单工具失败返回 []
|
||
// 不拖累其余),故 Promise.all 永不 reject。Respect current shell/flag overrides.
|
||
await Promise.all(
|
||
TOOL_NAMES.map((toolName) =>
|
||
refreshToolVersions([toolName], wslShellByTool),
|
||
),
|
||
);
|
||
} finally {
|
||
// 全量探测结束:把缓存时间戳刷新为现在,标记「刚完成一次全量加载」、重置 TTL。
|
||
if (toolVersionsCache) {
|
||
toolVersionsCache = { ...toolVersionsCache, at: Date.now() };
|
||
}
|
||
setIsLoadingTools(false);
|
||
}
|
||
},
|
||
[wslShellByTool, refreshToolVersions],
|
||
);
|
||
|
||
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;
|
||
|
||
// 本软件自身版本走本地调用(getVersion,无网络,毫秒级),与工具版本探测彼此独立。
|
||
// 之前两者被塞进同一个 Promise.all,导致 setVersion / setIsLoadingVersion 被压在
|
||
// 「全部工具检查完成」之后——图标下方的版本徽标因此要干等 6 个工具全检完才显示。
|
||
// 拆成两条独立链路:应用版本一拿到就立刻显示,工具探测各自渐进刷新,互不阻塞。
|
||
const loadAppVersion = async () => {
|
||
try {
|
||
const appVersion = await getVersion();
|
||
appVersionCache = appVersion;
|
||
if (active) {
|
||
setVersion(appVersion);
|
||
}
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to load app version", error);
|
||
if (active) {
|
||
setVersion(null);
|
||
}
|
||
} finally {
|
||
if (active) {
|
||
setIsLoadingVersion(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void loadAppVersion();
|
||
void loadAllToolVersions();
|
||
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) ...
|
||
|
||
const handleOpenReleaseNotes = useCallback(async () => {
|
||
try {
|
||
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
|
||
const displayVersion = targetVersion.startsWith("v")
|
||
? targetVersion
|
||
: targetVersion
|
||
? `v${targetVersion}`
|
||
: "";
|
||
|
||
if (!displayVersion) {
|
||
await settingsApi.openExternal(
|
||
"https://github.com/farion1231/cc-switch/releases",
|
||
);
|
||
return;
|
||
}
|
||
|
||
await settingsApi.openExternal(
|
||
`https://github.com/farion1231/cc-switch/releases/tag/${displayVersion}`,
|
||
);
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to open release notes", error);
|
||
toast.error(t("settings.openReleaseNotesFailed"));
|
||
}
|
||
}, [t, updateInfo?.availableVersion, version]);
|
||
|
||
const handleCheckUpdate = useCallback(async () => {
|
||
if (hasUpdate) {
|
||
if (isPortable) {
|
||
try {
|
||
await settingsApi.checkUpdates();
|
||
} catch (error) {
|
||
console.error("[AboutSection] Portable update failed", error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setIsDownloading(true);
|
||
try {
|
||
resetDismiss();
|
||
const installed = await settingsApi.installUpdateAndRestart();
|
||
if (!installed) {
|
||
toast.success(t("settings.upToDate"), { closeButton: true });
|
||
}
|
||
} catch (error) {
|
||
console.error("[AboutSection] Update failed", error);
|
||
toast.error(t("settings.updateFailed"), {
|
||
description: extractErrorMessage(error) || undefined,
|
||
closeButton: true,
|
||
});
|
||
try {
|
||
await settingsApi.checkUpdates();
|
||
} catch (fallbackError) {
|
||
console.error(
|
||
"[AboutSection] Failed to open fallback updater",
|
||
fallbackError,
|
||
);
|
||
}
|
||
} finally {
|
||
setIsDownloading(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const available = await checkUpdate();
|
||
if (!available) {
|
||
toast.success(t("settings.upToDate"), { closeButton: true });
|
||
}
|
||
} catch (error) {
|
||
console.error("[AboutSection] Check update failed", error);
|
||
toast.error(t("settings.checkUpdateFailed"));
|
||
}
|
||
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t]);
|
||
|
||
const handleCopyInstallCommands = useCallback(async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
|
||
toast.success(t("settings.installCommandsCopied"), { closeButton: true });
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to copy install commands", error);
|
||
toast.error(t("settings.installCommandsCopyFailed"));
|
||
}
|
||
}, [t]);
|
||
|
||
// 升级后自动补诊单个工具:静默后台执行。有冲突写入结果;无冲突则清掉该工具可能残留
|
||
// 的过期冲突展示(外部卸载/修复后冲突可能已消失,不清会一直显示旧列表)。不弹 toast、
|
||
// 不报错打扰——与用户主动点的全量诊断区别对待。
|
||
const diagnoseToolSilently = useCallback(async (toolName: ToolName) => {
|
||
try {
|
||
const [report] = await settingsApi.probeToolInstallations([toolName]);
|
||
setToolDiagnostics((prev) => {
|
||
if (report?.is_conflict) {
|
||
return { ...prev, [toolName]: report.installs };
|
||
}
|
||
// 无冲突:清掉残留;无旧结果则返回同引用,避免无谓 re-render。
|
||
if (!(toolName in prev)) return prev;
|
||
const next = { ...prev };
|
||
delete next[toolName];
|
||
return next;
|
||
});
|
||
} catch (error) {
|
||
console.error(
|
||
`[AboutSection] Auto-diagnose failed for ${toolName}`,
|
||
error,
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
// 顶部按钮:一次性诊断全部 6 个工具,有冲突的写入各自卡片,
|
||
// 全部无冲突时给一条 info toast。后端逐工具枚举所有安装并判定分歧。
|
||
const handleDiagnoseAll = useCallback(async () => {
|
||
setIsDiagnosingAll(true);
|
||
try {
|
||
const reports = await settingsApi.probeToolInstallations([...TOOL_NAMES]);
|
||
const next: Partial<Record<ToolName, ToolInstallation[]>> = {};
|
||
let conflicts = 0;
|
||
for (const report of reports) {
|
||
if (report.is_conflict) {
|
||
next[report.tool as ToolName] = report.installs;
|
||
conflicts += 1;
|
||
}
|
||
}
|
||
setToolDiagnostics(next);
|
||
if (conflicts === 0) {
|
||
toast.info(t("settings.toolDiagnoseNoConflict"), { closeButton: true });
|
||
}
|
||
} catch (error) {
|
||
console.error("[AboutSection] Diagnose all failed", error);
|
||
toast.error(t("settings.toolDiagnoseFailed"), {
|
||
description: extractErrorMessage(error) || undefined,
|
||
closeButton: true,
|
||
});
|
||
} finally {
|
||
setIsDiagnosingAll(false);
|
||
}
|
||
}, [t]);
|
||
|
||
// 实际执行安装/升级的串行循环(已通过任何必要的确认后才调用)。
|
||
const executeRun = useCallback(
|
||
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
|
||
const isBatch = toolNames.length > 1;
|
||
if (isBatch) {
|
||
setBatchAction(action);
|
||
}
|
||
|
||
// 逐工具串行执行:每个工具独立成败、独立刷新版本,一个失败不会连坐
|
||
// 后续工具(后端把整批拼成单脚本 + set -e,会在首个失败处中止整批)。
|
||
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
|
||
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
|
||
const failures: {
|
||
toolName: ToolName;
|
||
detail: string;
|
||
soft: boolean;
|
||
kind?: "notRunnable" | "versionUnchanged";
|
||
}[] = [];
|
||
let succeeded = 0;
|
||
|
||
for (const toolName of toolNames) {
|
||
setToolActions((prev) => ({ ...prev, [toolName]: action }));
|
||
try {
|
||
const previousTool = toolVersionByName.get(toolName);
|
||
const previousVersion = previousTool?.version ?? null;
|
||
const previousLatestVersion = previousTool?.latest_version ?? null;
|
||
|
||
await settingsApi.runToolLifecycleAction(
|
||
[toolName],
|
||
action,
|
||
wslShellByTool,
|
||
);
|
||
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
|
||
const refreshed = await refreshToolVersions(
|
||
[toolName],
|
||
wslShellByTool,
|
||
);
|
||
const tool = refreshed.find((t) => t.name === toolName);
|
||
if (tool?.version) {
|
||
const latestVersion = tool.latest_version ?? previousLatestVersion;
|
||
const versionUnchangedAfterUpdate =
|
||
action === "update" &&
|
||
Boolean(previousVersion) &&
|
||
tool.version === previousVersion &&
|
||
isUpdateAvailable(tool.version, latestVersion);
|
||
|
||
if (versionUnchangedAfterUpdate) {
|
||
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
|
||
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
|
||
failures.push({
|
||
toolName,
|
||
detail: t("settings.toolActionVersionUnchanged", {
|
||
version: tool.version,
|
||
latest: latestVersion ?? t("common.unknown"),
|
||
}),
|
||
soft: true,
|
||
kind: "versionUnchanged",
|
||
});
|
||
void diagnoseToolSilently(toolName);
|
||
} else {
|
||
succeeded += 1;
|
||
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
|
||
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
|
||
if (action === "update") {
|
||
void diagnoseToolSilently(toolName);
|
||
}
|
||
}
|
||
} else {
|
||
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
|
||
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
|
||
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
|
||
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
|
||
failures.push({
|
||
toolName,
|
||
detail,
|
||
soft: true,
|
||
kind: "notRunnable",
|
||
});
|
||
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
|
||
void diagnoseToolSilently(toolName);
|
||
}
|
||
} catch (error) {
|
||
console.error(
|
||
`[AboutSection] Failed to run tool action for ${toolName}`,
|
||
error,
|
||
);
|
||
const detail = extractErrorMessage(error) || String(error);
|
||
failures.push({ toolName, detail, soft: false });
|
||
} finally {
|
||
setToolActions((prev) => {
|
||
const next = { ...prev };
|
||
delete next[toolName];
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
|
||
if (isBatch) {
|
||
setBatchAction(null);
|
||
}
|
||
|
||
const actionLabel =
|
||
action === "install"
|
||
? t("settings.toolInstall")
|
||
: t("settings.toolUpdate");
|
||
|
||
if (failures.length === 0) {
|
||
toast.success(
|
||
t("settings.toolActionDone", {
|
||
count: succeeded,
|
||
action: actionLabel,
|
||
}),
|
||
{ closeButton: true },
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 批量场景每个失败只摘取错误末行(最相关),单工具场景给出完整详情。
|
||
const lastLine = (text: string) => {
|
||
const lines = text.trim().split("\n").filter(Boolean);
|
||
return lines[lines.length - 1] ?? text;
|
||
};
|
||
const failureDescription = isBatch
|
||
? failures
|
||
.map(
|
||
(f) => `${TOOL_DISPLAY_NAMES[f.toolName]}: ${lastLine(f.detail)}`,
|
||
)
|
||
.join("\n")
|
||
: failures[0]?.detail;
|
||
|
||
const hardFailures = failures.filter((f) => !f.soft);
|
||
const allSoftVersionUnchanged =
|
||
failures.length > 0 &&
|
||
failures.every((f) => f.soft && f.kind === "versionUnchanged");
|
||
|
||
if (succeeded === 0 && hardFailures.length === 0) {
|
||
// 命令均成功执行、但结果需要用户介入(版本没变 / 装上却跑不起来)
|
||
// → 降级为 warning 并解释原因。
|
||
toast.warning(
|
||
allSoftVersionUnchanged
|
||
? t("settings.toolActionVersionUnchangedTitle")
|
||
: t("settings.toolActionInstalledNotRunnable"),
|
||
{
|
||
description: failureDescription || undefined,
|
||
closeButton: true,
|
||
},
|
||
);
|
||
} else if (succeeded === 0) {
|
||
toast.error(t("settings.toolActionFailed"), {
|
||
description: failureDescription || undefined,
|
||
closeButton: true,
|
||
});
|
||
} else {
|
||
// 部分成功:用 warning 汇总成败数量,详情列出失败的工具。
|
||
toast.warning(
|
||
t("settings.toolActionPartial", {
|
||
succeeded,
|
||
failed: failures.length,
|
||
action: actionLabel,
|
||
}),
|
||
{ description: failureDescription || undefined, closeButton: true },
|
||
);
|
||
}
|
||
},
|
||
[
|
||
t,
|
||
wslShellByTool,
|
||
toolVersionByName,
|
||
refreshToolVersions,
|
||
diagnoseToolSilently,
|
||
],
|
||
);
|
||
|
||
// 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁;
|
||
// 这层锁覆盖三个否则会漏掉的窗口:
|
||
// ① update 的 probeToolInstallations 阶段(1-3 秒跨进程,executeRun 之前);
|
||
// ② executeRun 内部 setToolActions 落到 React commit 前的几个 microtask;
|
||
// ③ install 直接进 executeRun 的同一段 microtask 窗口。
|
||
// 早退检查避免同一工具在 toolActions / preflight 已登记时被重复入栈——批量场景里
|
||
// 只要有一个工具被锁,整批不开新一轮,因为后端 set -e 串行的语义假设是「一次性
|
||
// 单脚本」,跨两次 IPC 并发会破坏它。
|
||
const handleRunToolAction = useCallback(
|
||
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
|
||
if (toolNames.length === 0) return;
|
||
if (
|
||
toolNames.some(
|
||
(name) => preflightTools.has(name) || toolActions[name] !== undefined,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
// 入栈 preflight,按钮立刻 disabled。new Set(prev) 是不可变更新(直接 mutate
|
||
// 原 Set 会让 React 复用引用、跳过 re-render);finally 块负责异常路径解锁。
|
||
setPreflightTools((prev) => {
|
||
const next = new Set(prev);
|
||
toolNames.forEach((name) => next.add(name));
|
||
return next;
|
||
});
|
||
try {
|
||
if (action === "install") {
|
||
await executeRun(toolNames, action);
|
||
return;
|
||
}
|
||
let reports: ToolInstallationReport[];
|
||
try {
|
||
reports = await settingsApi.probeToolInstallations(toolNames);
|
||
} catch (error) {
|
||
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。
|
||
console.error("[AboutSection] probeToolInstallations failed", error);
|
||
await executeRun(toolNames, action);
|
||
return;
|
||
}
|
||
const needConfirm = reports.filter((r) => r.needs_confirmation);
|
||
if (needConfirm.length === 0) {
|
||
await executeRun(toolNames, action);
|
||
return;
|
||
}
|
||
setPendingUpgrade({ toolNames, plans: needConfirm });
|
||
} finally {
|
||
setPreflightTools((prev) => {
|
||
const next = new Set(prev);
|
||
toolNames.forEach((name) => next.delete(name));
|
||
return next;
|
||
});
|
||
}
|
||
},
|
||
[executeRun, preflightTools, toolActions],
|
||
);
|
||
|
||
const handleConfirmUpgrade = useCallback(() => {
|
||
if (pendingUpgrade) {
|
||
void executeRun(pendingUpgrade.toolNames, "update");
|
||
}
|
||
setPendingUpgrade(null);
|
||
}, [pendingUpgrade, executeRun]);
|
||
|
||
const handleCancelUpgrade = useCallback(() => setPendingUpgrade(null), []);
|
||
|
||
const displayVersion = version ?? t("common.unknown");
|
||
|
||
// 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮,
|
||
// 避免并发触发多个 npm/pip 全局写入造成冲突。
|
||
// preflightTools 覆盖升级前的 probe 阶段——那段在 executeRun 之前、toolActions
|
||
// 还没置位,如果不算进 busy 会留出 1-3 秒的并发触发窗口。
|
||
const isAnyBusy =
|
||
Boolean(batchAction) ||
|
||
Object.keys(toolActions).length > 0 ||
|
||
preflightTools.size > 0;
|
||
|
||
return (
|
||
<motion.section
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
className="space-y-6"
|
||
>
|
||
<header className="space-y-1">
|
||
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("settings.aboutHint")}
|
||
</p>
|
||
</header>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.98 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.3, delay: 0.1 }}
|
||
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
|
||
>
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex items-center gap-8">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
|
||
<h4 className="text-lg font-semibold text-foreground">
|
||
CC Switch
|
||
</h4>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline" className="gap-1.5 bg-background/80">
|
||
<span className="text-muted-foreground">
|
||
{t("common.version")}
|
||
</span>
|
||
{isLoadingVersion ? (
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
) : (
|
||
<span className="font-medium">{`v${displayVersion}`}</span>
|
||
)}
|
||
</Badge>
|
||
{isPortable && (
|
||
<Badge variant="secondary" className="gap-1.5">
|
||
<Info className="h-3 w-3" />
|
||
{t("settings.portableMode")}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => settingsApi.openExternal("https://ccswitch.io")}
|
||
className="h-8 gap-1.5 text-xs"
|
||
>
|
||
<Globe className="h-3.5 w-3.5" />
|
||
{t("settings.officialWebsite")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
settingsApi.openExternal(
|
||
"https://github.com/farion1231/cc-switch",
|
||
)
|
||
}
|
||
className="h-8 gap-1.5 text-xs"
|
||
>
|
||
<Github className="h-3.5 w-3.5" />
|
||
{t("settings.github")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleOpenReleaseNotes}
|
||
className="h-8 gap-1.5 text-xs"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
{t("settings.releaseNotes")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={handleCheckUpdate}
|
||
disabled={isChecking || isDownloading}
|
||
className="h-8 gap-1.5 text-xs"
|
||
>
|
||
{isDownloading ? (
|
||
<>
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
{t("settings.updating")}
|
||
</>
|
||
) : hasUpdate ? (
|
||
<>
|
||
<Download className="h-3.5 w-3.5" />
|
||
{t("settings.updateTo", {
|
||
version: updateInfo?.availableVersion ?? "",
|
||
})}
|
||
</>
|
||
) : isChecking ? (
|
||
<>
|
||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||
{t("settings.checking")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
{t("settings.checkForUpdates")}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{hasUpdate && updateInfo && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
|
||
>
|
||
<p className="font-medium text-primary mb-1">
|
||
{t("settings.updateAvailable", {
|
||
version: updateInfo.availableVersion,
|
||
})}
|
||
</p>
|
||
{updateInfo.notes && (
|
||
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
|
||
{updateInfo.notes}
|
||
</p>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex flex-col gap-2 px-1 sm:flex-row sm:items-center sm:justify-between">
|
||
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 gap-1.5 text-xs"
|
||
onClick={() => handleDiagnoseAll()}
|
||
disabled={isLoadingTools || isAnyBusy || isDiagnosingAll}
|
||
>
|
||
{isDiagnosingAll ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Stethoscope className="h-3.5 w-3.5" />
|
||
)}
|
||
{isDiagnosingAll
|
||
? t("settings.toolDiagnosing")
|
||
: t("settings.toolDiagnose")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 gap-1.5 text-xs"
|
||
onClick={() => loadAllToolVersions({ force: true })}
|
||
disabled={isLoadingTools || isAnyBusy}
|
||
>
|
||
<RefreshCw
|
||
className={
|
||
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
||
}
|
||
/>
|
||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-7 gap-1.5 text-xs"
|
||
onClick={() => handleRunToolAction(updatableToolNames, "update")}
|
||
disabled={
|
||
isLoadingTools || isAnyBusy || updatableToolNames.length === 0
|
||
}
|
||
>
|
||
{batchAction === "update" ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{t("settings.updateAllTools", {
|
||
count: updatableToolNames.length,
|
||
})}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 px-1 sm:grid-cols-2 xl:grid-cols-3">
|
||
{TOOL_NAMES.map((toolName, index) => {
|
||
const tool = toolVersionByName.get(toolName);
|
||
const appConfig = APP_ICON_MAP[TOOL_APP_IDS[toolName]];
|
||
const displayName = TOOL_DISPLAY_NAMES[toolName];
|
||
// 单卡片 loading 用「结果是否已到」而非「整批是否结束」驱动,实现渐进式刷新:
|
||
// - loadingTools[t]:本工具探测在途(首次加载或单工具刷新);
|
||
// - isLoadingTools && !has(t):整批进行中且该工具尚未返回——覆盖首帧/刷新时
|
||
// 未完成卡片的 loading 外观。某工具结果一落进 toolVersions,has(t) 即为 true,
|
||
// 该卡片立刻脱离 loading(哪怕全局 isLoadingTools 还为 true),其它卡片不受影响。
|
||
const isToolVersionLoading =
|
||
Boolean(loadingTools[toolName]) ||
|
||
(isLoadingTools && !toolVersionByName.has(toolName));
|
||
const isOutdated = isUpdateAvailable(
|
||
tool?.version,
|
||
tool?.latest_version,
|
||
);
|
||
// 已安装却跑不起来(如 Node 版本不达标):用它区分卡片文案与按钮,避免把
|
||
// "装了跑不起来"误判成"未安装"而给出无用的安装按钮(重装同一版本解决不了)。
|
||
const installedButBroken = Boolean(tool?.installed_but_broken);
|
||
// loading 和 broken 都没有可执行动作;其余按是否已装/是否过期选择。
|
||
const action: ToolLifecycleAction | null =
|
||
isToolVersionLoading || installedButBroken
|
||
? null
|
||
: !tool?.version
|
||
? "install"
|
||
: isOutdated
|
||
? "update"
|
||
: null;
|
||
const runningAction = toolActions[toolName];
|
||
const title = tool?.version || tool?.error || t("common.unknown");
|
||
const conflicts = toolDiagnostics[toolName];
|
||
|
||
return (
|
||
<motion.div
|
||
key={toolName}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3, delay: 0.15 + index * 0.04 }}
|
||
className="flex min-h-[150px] flex-col gap-3 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
|
||
{appConfig?.icon ?? <Terminal className="h-4 w-4" />}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium">
|
||
{displayName}
|
||
</div>
|
||
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
|
||
<span
|
||
className={`mt-1 inline-flex w-fit 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)}
|
||
{tool.wsl_distro ? ` · ${tool.wsl_distro}` : ""}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{isToolVersionLoading ? (
|
||
<Loader2 className="mt-1 h-4 w-4 animate-spin text-muted-foreground" />
|
||
) : tool?.version ? (
|
||
isOutdated ? (
|
||
<span className="mt-1 shrink-0 rounded-full border border-yellow-500/20 bg-yellow-500/10 px-1.5 py-0.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||
{t("settings.updateAvailableShort")}
|
||
</span>
|
||
) : (
|
||
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
|
||
)
|
||
) : (
|
||
<AlertCircle className="mt-1 h-4 w-4 shrink-0 text-yellow-500" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-1.5 text-xs">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-muted-foreground">
|
||
{t("settings.currentVersion")}
|
||
</span>
|
||
<span
|
||
className="min-w-0 truncate font-mono text-foreground"
|
||
title={title}
|
||
>
|
||
{isToolVersionLoading
|
||
? t("common.loading")
|
||
: tool?.version
|
||
? tool.version
|
||
: installedButBroken
|
||
? t("settings.installedNotRunnable")
|
||
: t("common.notInstalled")}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-muted-foreground">
|
||
{t("settings.latestVersion")}
|
||
</span>
|
||
<span className="min-w-0 truncate font-mono text-foreground">
|
||
{isToolVersionLoading
|
||
? t("common.loading")
|
||
: tool?.latest_version || t("common.unknown")}
|
||
</span>
|
||
</div>
|
||
{!isToolVersionLoading && !tool?.version && tool?.error && (
|
||
<div className="truncate text-[11px] text-muted-foreground">
|
||
{tool.error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{tool?.env_type === "wsl" && (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Select
|
||
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||
onValueChange={(v) => handleToolShellChange(toolName, v)}
|
||
disabled={isToolVersionLoading || isAnyBusy}
|
||
>
|
||
<SelectTrigger className="h-7 w-[82px] 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>
|
||
<Select
|
||
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
|
||
onValueChange={(v) =>
|
||
handleToolShellFlagChange(toolName, v)
|
||
}
|
||
disabled={isToolVersionLoading || isAnyBusy}
|
||
>
|
||
<SelectTrigger className="h-7 w-[82px] 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>
|
||
)}
|
||
|
||
{/* 多处安装冲突诊断结果:仅在懒触发后有数据时渲染。 */}
|
||
{conflicts && conflicts.length > 0 && (
|
||
<div className="space-y-1.5 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-2.5">
|
||
<div className="text-[11px] font-medium text-yellow-600 dark:text-yellow-400">
|
||
{t("settings.toolConflictTitle")}
|
||
</div>
|
||
<p className="text-[10px] leading-snug text-muted-foreground">
|
||
{t("settings.toolConflictHint")}
|
||
</p>
|
||
<ul className="space-y-1.5">
|
||
{conflicts.map((inst) => (
|
||
<li key={inst.path}>
|
||
<ToolInstallRow inst={inst} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-auto flex items-center justify-end">
|
||
{isToolVersionLoading ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("common.loading")}
|
||
</span>
|
||
) : installedButBroken ? (
|
||
// 已安装但跑不起来:重装无济于事,不给按钮,给一句指向环境的提示。
|
||
<span className="text-xs text-yellow-600 dark:text-yellow-400">
|
||
{t("settings.toolCheckEnv")}
|
||
</span>
|
||
) : action ? (
|
||
<Button
|
||
size="sm"
|
||
variant={action === "install" ? "outline" : "default"}
|
||
className="h-7 gap-1.5 text-xs"
|
||
onClick={() => handleRunToolAction([toolName], action)}
|
||
disabled={isToolVersionLoading || isAnyBusy}
|
||
>
|
||
{runningAction ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : action === "install" ? (
|
||
<Download className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{/* loading 时文案保持不变、仅图标切换为 spinner,
|
||
按钮宽度恒定,避免"升级"→"升级中…"导致的抖动。 */}
|
||
{action === "install"
|
||
? t("settings.toolInstall")
|
||
: t("settings.toolUpdate")}
|
||
</Button>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("settings.toolReady")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3, delay: 0.3 }}
|
||
className="space-y-3"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowInstallCommands((v) => !v)}
|
||
aria-expanded={showInstallCommands}
|
||
className="flex w-full items-center gap-1.5 px-1 text-sm font-medium text-foreground transition-colors hover:text-primary"
|
||
>
|
||
<ChevronDown
|
||
className={`h-3.5 w-3.5 transition-transform ${
|
||
showInstallCommands ? "" : "-rotate-90"
|
||
}`}
|
||
/>
|
||
{t("settings.manualInstallCommands")}
|
||
</button>
|
||
{showInstallCommands && (
|
||
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("settings.oneClickInstallHint")}
|
||
</p>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handleCopyInstallCommands}
|
||
className="h-7 gap-1.5 text-xs"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
{t("common.copy")}
|
||
</Button>
|
||
</div>
|
||
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
||
{ONE_CLICK_INSTALL_COMMANDS}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
|
||
<ToolUpgradeConfirmDialog
|
||
isOpen={pendingUpgrade !== null}
|
||
plans={pendingUpgrade?.plans ?? []}
|
||
displayName={toolDisplayName}
|
||
onConfirm={handleConfirmUpgrade}
|
||
onCancel={handleCancelUpgrade}
|
||
/>
|
||
</motion.section>
|
||
);
|
||
}
|