mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
014c82d28b
Rework the About tool-upgrade flow so upgrades target the install the
command line is actually using, instead of running bare `npm i -g` and
landing on PATH's first npm — which used to move upgrades to the wrong
location or fail outright when the resolved bin had no sibling npm.
Anchored upgrade command (update path, non-Windows):
- claude native installer (~/.local/share/claude/versions/) -> `claude update`
- Homebrew formula (canonical path under /Cellar/) -> `brew upgrade <formula>`
- volta / bun -> `volta install` / `bun add -g`
- Node managers with sibling npm (nvm/fnm/mise/homebrew-npm) -> that
directory's npm with `i -g <pkg>@latest`
- system / unknown sources (e.g. opencode install.sh under ~/.opencode/bin,
~/go/bin) -> fall back to the bare command, surfaced as anchored=false
so the UI shows an honest "default entry not determinable" hint instead
of pretending the upgrade is targeted.
Multi-install confirmation:
- When the probe finds >=2 installs, pop ToolUpgradeConfirmDialog showing
each install (source badge + path + version + Default badge) and the
resolved command before executing. Top-level text only asserts what is
universally true ("won't update all of them; see each tool below");
per-card text adapts to anchored vs unanchored.
- Pending upgrade state stores the full upgrade set; the dialog only
shows the subset that needs confirmation, so non-conflicting tools in
a batch still get upgraded after confirm.
Unified probe command:
- Merge the previous diagnose_tool_installations and plan_tool_upgrades
into a single probe_tool_installations returning installs + is_conflict
+ needs_confirmation + command + anchored. Diagnose button, post-upgrade
auto-diagnose, and pre-upgrade confirmation all share one IPC. The
diagnose button's previous Promise.all of 6 IPCs is now a single call.
resolve_path_default robustness:
- The previous lines().next() was poisoned by interactive .zshrc output
(welcome banners, p10k instant prompts, etc.), causing default-install
detection to fail and dropping multi-install scenarios into the
unanchored fallback even when one install was clearly the native one.
- Replaced with first_abs_path_line that scans for the first
absolute-path line and ignores noise, mirroring how try_get_version
already tolerates banner noise via a regex.
Diagnostic staleness fix:
- diagnoseToolSilently now clears stale diagnostics in the else branch
when the conflict no longer holds (previously only wrote on conflict,
never cleared, so externally resolving a conflict left the card stuck
on a stale list until the user clicked Diagnose).
- Auto-diagnose now triggers after any successful update, not only when
the version didn't change, so the card reflects the latest conflict
state even when an anchored upgrade succeeds while another install
remains.
Other:
- Shared ToolInstallRow between the conflict list and the confirmation
dialog removes the duplicated per-install row JSX.
- Drop the new shell_quote_path helper in favor of reusing the file's
existing POSIX-correct shell_single_quote, gated on whitespace.
- Collapse displayName lookup into a stable module-level helper to remove
the `as ToolName` cast and avoid recreating the closure each render.
- 17 backend unit tests covering anchored command generation across each
source, brew formula extraction, is_conflicting thresholds, default
install resolution, and the welcome-banner scenario.
- i18n (zh/en/ja): new keys for confirmation dialog
title/hint/will-run/confirm-button and the unanchored hint.
1166 lines
43 KiB
TypeScript
1166 lines
43 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Download,
|
||
Copy,
|
||
ExternalLink,
|
||
Github,
|
||
Globe,
|
||
Info,
|
||
Loader2,
|
||
RefreshCw,
|
||
Terminal,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
ArrowUpCircle,
|
||
ChevronDown,
|
||
Stethoscope,
|
||
Trash2,
|
||
} 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 { relaunchApp } from "@/lib/updater";
|
||
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 { 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 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
|
||
python3 -m pip install --upgrade "hermes-agent[web]"`;
|
||
|
||
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",
|
||
};
|
||
|
||
// 各工具的全局包名:npm 系用 npm 包名,hermes 例外(pip 包 hermes-agent)。
|
||
const TOOL_NPM_PACKAGES: Record<Exclude<ToolName, "hermes">, string> = {
|
||
claude: "@anthropic-ai/claude-code",
|
||
codex: "@openai/codex",
|
||
gemini: "@google/gemini-cli",
|
||
opencode: "opencode-ai",
|
||
openclaw: "openclaw",
|
||
};
|
||
|
||
// 取路径的目录部分(兼容 / 与 \ 分隔符);无分隔符返回空串。
|
||
function dirOfPath(p: string): string {
|
||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||
return i > 0 ? p.slice(0, i) : "";
|
||
}
|
||
|
||
// 路径是否为 Windows 风格(含反斜杠或盘符前缀)。
|
||
function isWindowsPath(p: string): boolean {
|
||
return p.includes("\\") || /^[a-zA-Z]:/.test(p);
|
||
}
|
||
|
||
// 含空格时加引号,避免命令被 shell 拆断。
|
||
function shellQuote(s: string): string {
|
||
return s.includes(" ") ? `"${s}"` : s;
|
||
}
|
||
|
||
// 为「某一处具体安装」生成卸载建议命令(只读、供复制,绝不代执行)。
|
||
// 关键:用该处同目录的 npm 精确作用于这一处的 node 安装,避免裸 `npm rm -g`
|
||
// 误删了当前激活(可能是想保留的默认)那处。volta/bun/pip 走各自的卸载器。
|
||
function buildUninstallCommand(
|
||
toolName: ToolName,
|
||
inst: ToolInstallation,
|
||
): string {
|
||
if (toolName === "hermes") {
|
||
return "python3 -m pip uninstall hermes-agent";
|
||
}
|
||
const pkg = TOOL_NPM_PACKAGES[toolName];
|
||
if (inst.source === "volta") {
|
||
return `volta uninstall ${pkg}`;
|
||
}
|
||
if (inst.source === "bun") {
|
||
return `bun rm -g ${pkg}`;
|
||
}
|
||
// nvm / fnm / mise / homebrew / system / scoop 上的 node 全局包:统一 npm rm -g,
|
||
// 但锚定到该处同目录的 npm 以删对版本。
|
||
const dir = dirOfPath(inst.path);
|
||
const win = isWindowsPath(inst.path);
|
||
const npmBin = win ? "npm.cmd" : "npm";
|
||
const npm = dir ? shellQuote(`${dir}${win ? "\\" : "/"}${npmBin}`) : "npm";
|
||
return `${npm} rm -g ${pkg}`;
|
||
}
|
||
|
||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||
// ... (use hooks as before) ...
|
||
const { t } = useTranslation();
|
||
const [version, setVersion] = useState<string | null>(null);
|
||
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
|
||
const [isDownloading, setIsDownloading] = useState(false);
|
||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
||
const [isLoadingTools, setIsLoadingTools] = useState(true);
|
||
const [toolActions, setToolActions] = useState<
|
||
Partial<Record<ToolName, ToolLifecycleAction>>
|
||
>({});
|
||
const [batchAction, setBatchAction] = useState<ToolLifecycleAction | null>(
|
||
null,
|
||
);
|
||
const [showInstallCommands, setShowInstallCommands] = useState(false);
|
||
|
||
const {
|
||
hasUpdate,
|
||
updateInfo,
|
||
updateHandle,
|
||
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);
|
||
|
||
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 Boolean(
|
||
tool?.version &&
|
||
tool.latest_version &&
|
||
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) => {
|
||
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;
|
||
});
|
||
|
||
// 返回刷新结果,调用方可据此判断版本是否真的探到(避免读 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 () => {
|
||
setIsLoadingTools(true);
|
||
try {
|
||
// 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] = await Promise.all([
|
||
getVersion(),
|
||
loadAllToolVersions(),
|
||
]);
|
||
|
||
if (active) {
|
||
setVersion(appVersion);
|
||
}
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to load info", error);
|
||
if (active) {
|
||
setVersion(null);
|
||
}
|
||
} finally {
|
||
if (active) {
|
||
setIsLoadingVersion(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void load();
|
||
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 && updateHandle) {
|
||
if (isPortable) {
|
||
try {
|
||
await settingsApi.checkUpdates();
|
||
} catch (error) {
|
||
console.error("[AboutSection] Portable update failed", error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setIsDownloading(true);
|
||
try {
|
||
resetDismiss();
|
||
await updateHandle.downloadAndInstall();
|
||
await relaunchApp();
|
||
} catch (error) {
|
||
console.error("[AboutSection] Update failed", error);
|
||
toast.error(t("settings.updateFailed"));
|
||
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, updateHandle]);
|
||
|
||
// 复制单条卸载命令到剪贴板(只复制,不执行)。
|
||
const handleCopyCommand = useCallback(
|
||
async (command: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(command);
|
||
toast.success(t("settings.toolUninstallCopied"), { closeButton: true });
|
||
} catch (error) {
|
||
console.error("[AboutSection] Failed to copy command", error);
|
||
toast.error(t("settings.installCommandsCopyFailed"));
|
||
}
|
||
},
|
||
[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 }[] =
|
||
[];
|
||
let succeeded = 0;
|
||
|
||
for (const toolName of toolNames) {
|
||
setToolActions((prev) => ({ ...prev, [toolName]: action }));
|
||
try {
|
||
await settingsApi.runToolLifecycleAction(
|
||
[toolName],
|
||
action,
|
||
wslShellByTool,
|
||
);
|
||
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
|
||
const refreshed = await refreshToolVersions(
|
||
[toolName],
|
||
wslShellByTool,
|
||
);
|
||
const tool = refreshed.find((t) => t.name === toolName);
|
||
if (tool?.version) {
|
||
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 });
|
||
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
|
||
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);
|
||
|
||
if (succeeded === 0 && hardFailures.length === 0) {
|
||
// 命令均成功执行、但工具都探不到版本(装上却跑不起来)→ 降级为 warning 并解释原因。
|
||
toast.warning(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, refreshToolVersions, diagnoseToolSilently],
|
||
);
|
||
|
||
// 升级前先探测安装分布:检测到多处安装(命令行只命中其中一处)时弹确认,
|
||
// 让用户在「升级只动默认那处、其余不动」这件事上知情。install 无锚点,直接执行。
|
||
const handleRunToolAction = useCallback(
|
||
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
|
||
if (toolNames.length === 0) return;
|
||
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 });
|
||
},
|
||
[executeRun],
|
||
);
|
||
|
||
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 全局写入造成冲突。
|
||
const isAnyBusy = Boolean(batchAction) || Object.keys(toolActions).length > 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="space-y-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 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()}
|
||
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];
|
||
const isToolVersionLoading =
|
||
isLoadingTools || Boolean(loadingTools[toolName]);
|
||
const isOutdated = Boolean(
|
||
tool?.version &&
|
||
tool.latest_version &&
|
||
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={
|
||
isLoadingTools || loadingTools[toolName] || 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={
|
||
isLoadingTools || loadingTools[toolName] || 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) => {
|
||
const uninstallCmd = buildUninstallCommand(
|
||
toolName,
|
||
inst,
|
||
);
|
||
return (
|
||
<li key={inst.path} className="space-y-1">
|
||
<ToolInstallRow inst={inst} />
|
||
{/* 卸载建议命令:仅供复制,绝不代执行。前置红色垃圾桶图标
|
||
明示这是卸载(破坏性)命令,避免误以为是普通信息。 */}
|
||
<div className="flex items-center gap-1.5">
|
||
<code className="min-w-0 flex-1 truncate rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||
{uninstallCmd}
|
||
</code>
|
||
<Trash2 className="h-3 w-3 shrink-0 text-red-500" />
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopyCommand(uninstallCmd)}
|
||
title={t("settings.toolUninstallCopyHint")}
|
||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground"
|
||
>
|
||
<Copy className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|