Design pattern & component consistency governance across 120 files: ESLint governance (eslint.config.mjs): - Enforce ui/ components over native HTML controls (warn) - Ban Lucide imports (error), require Phosphor via ui/icon.tsx (warn) - Component file size limit 500 lines (warn, skip blanks+comments) - Patterns layer isolation (error: no hooks/lib imports) - Exclude 资料/ from lint scope Icon unification (ui/icon.tsx): - Unified barrel export with 86+ Phosphor icons - Decoupled types/index.ts from icon library (generic IconComponent type) - Separated command data (lib/constants/commands.ts) from icons (command-icons.ts) Color token system (globals.css): - Added semantic status tokens: success/warning/error/info with muted/foreground/border variants - Migrated 39 files from raw green/red/orange/amber/blue to semantic tokens - grep-based lint:colors script covers green/emerald/red/orange/yellow/amber/blue Component splits (all under 500 lines): - MessageInput 1659→444: MessageInputParts, SlashCommandPopover, 7 hooks, 2 libs - ChatView ~850→419: useStreamSubscription, useAssistantTrigger - ProviderManager 1081→406: provider-presets, PresetConnectDialog, useProviderModels - AppShell 527→470: useUpdateChecker - AssistantWorkspaceSection 908→499: WorkspaceTabPanels/ConfirmDialogs/StatusCards - ChatListPanel 677→484: SessionListItem, ProjectGroupHeader, chat-list-utils Other: - Migrated 80 native HTML controls to ui/ components across 38 files - Fixed 22 hook dependency / set-state-in-effect issues - Cleaned 7 dead code warnings - Rebuilt better-sqlite3 for ARM architecture - Added handover doc: docs/handover/ui-governance.md Lint status: 0 errors 0 warnings on touched files; typecheck pass; 374/374 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
import type { TranslationKey } from "@/i18n";
|
|
import type { CliToolDefinition, CliToolRuntimeInfo } from "@/types";
|
|
import { CliToolCard } from "./CliToolCard";
|
|
import { CliToolDetailDialog } from "./CliToolDetailDialog";
|
|
import { CliToolExtraDetailDialog } from "./CliToolExtraDetailDialog";
|
|
import { CliToolInstallDialog } from "./CliToolInstallDialog";
|
|
import { CliToolBatchDescribeDialog } from "./CliToolBatchDescribeDialog";
|
|
import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@/components/ui/icon";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EXTRA_WELL_KNOWN_BINS } from "@/lib/cli-tools-catalog";
|
|
|
|
type AutoDescCache = Record<string, { zh: string; en: string }>;
|
|
|
|
export function CliToolsManager() {
|
|
const { t, locale } = useTranslation();
|
|
const [catalog, setCatalog] = useState<CliToolDefinition[]>([]);
|
|
const [runtimeInfos, setRuntimeInfos] = useState<CliToolRuntimeInfo[]>([]);
|
|
const [extraDetected, setExtraDetected] = useState<CliToolRuntimeInfo[]>([]);
|
|
const [platform, setPlatform] = useState<string>('');
|
|
const [hasBrew, setHasBrew] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
const [autoDescriptions, setAutoDescriptions] = useState<AutoDescCache>({});
|
|
|
|
// Dialog state
|
|
const [detailTool, setDetailTool] = useState<{ tool: CliToolDefinition; canInstall: boolean } | null>(null);
|
|
const [extraDetailTool, setExtraDetailTool] = useState<{ displayName: string; runtimeInfo: CliToolRuntimeInfo } | null>(null);
|
|
const [installTool, setInstallTool] = useState<{ tool: CliToolDefinition; method: string } | null>(null);
|
|
const [batchDescribeOpen, setBatchDescribeOpen] = useState(false);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [catalogRes, installedRes] = await Promise.all([
|
|
fetch('/api/cli-tools/catalog'),
|
|
fetch('/api/cli-tools/installed'),
|
|
]);
|
|
const catalogData = await catalogRes.json();
|
|
const installedData = await installedRes.json();
|
|
setCatalog(catalogData.tools || []);
|
|
setRuntimeInfos(installedData.tools || []);
|
|
setExtraDetected(installedData.extra || []);
|
|
setPlatform(installedData.platform || '');
|
|
setHasBrew(installedData.hasBrew !== false);
|
|
} catch (err) {
|
|
console.error('Failed to fetch CLI tools data:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
// Load cached auto-descriptions from localStorage
|
|
try {
|
|
const cached = localStorage.getItem('cli-tools-auto-desc');
|
|
if (cached) setAutoDescriptions(JSON.parse(cached));
|
|
} catch { /* ignore */ }
|
|
}, [fetchData]);
|
|
|
|
const getRuntimeInfo = (toolId: string): CliToolRuntimeInfo | undefined => {
|
|
return runtimeInfos.find(r => r.id === toolId);
|
|
};
|
|
|
|
const installedCatalogTools = catalog.filter(t => {
|
|
const info = getRuntimeInfo(t.id);
|
|
return info && info.status !== 'not_installed';
|
|
});
|
|
|
|
const recommendedTools = catalog.filter(t => {
|
|
const info = getRuntimeInfo(t.id);
|
|
return !info || info.status === 'not_installed';
|
|
});
|
|
|
|
// Only extra-detected tool IDs for batch describe (catalog tools already have descriptions)
|
|
const extraToolIds = extraDetected.map(e => e.id);
|
|
|
|
const handleInstall = (tool: CliToolDefinition, method: string) => {
|
|
setInstallTool({ tool, method });
|
|
};
|
|
|
|
const handleInstallComplete = () => {
|
|
setInstallTool(null);
|
|
fetchData();
|
|
};
|
|
|
|
const handleBatchDescribeComplete = (results: AutoDescCache) => {
|
|
const merged = { ...autoDescriptions, ...results };
|
|
setAutoDescriptions(merged);
|
|
localStorage.setItem('cli-tools-auto-desc', JSON.stringify(merged));
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<SpinnerGap size={24} className="animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 overflow-y-auto">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">{t('cliTools.title')}</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">{t('cliTools.description')}</p>
|
|
</div>
|
|
|
|
{/* Installed — catalog tools + extra system-detected tools merged */}
|
|
{(installedCatalogTools.length > 0 || extraDetected.length > 0) && (
|
|
<section>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-sm font-medium text-muted-foreground">{t('cliTools.installed')}</h2>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs gap-1.5"
|
|
onClick={() => setBatchDescribeOpen(true)}
|
|
>
|
|
<Sparkle size={14} />
|
|
{t('cliTools.batchDescribe')}
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
{/* Catalog tools that are installed */}
|
|
{installedCatalogTools.map(tool => (
|
|
<CliToolCard
|
|
key={tool.id}
|
|
tool={tool}
|
|
runtimeInfo={getRuntimeInfo(tool.id)!}
|
|
variant="installed"
|
|
autoDescription={autoDescriptions[tool.id]}
|
|
onDetail={() => setDetailTool({ tool, canInstall: false })}
|
|
locale={locale}
|
|
platform={platform}
|
|
/>
|
|
))}
|
|
{/* Extra system-detected tools (not in catalog) */}
|
|
{extraDetected.map(info => {
|
|
const entry = EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === info.id);
|
|
const displayName = entry?.[1] ?? info.id;
|
|
const desc = autoDescriptions[info.id];
|
|
return (
|
|
<div
|
|
key={info.id}
|
|
className="flex items-center gap-3 rounded-lg border border-border/40 px-3 py-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
|
onClick={() => setExtraDetailTool({ displayName, runtimeInfo: info })}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-sm truncate">{displayName}</h3>
|
|
{info.version && (
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
v{info.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
{desc
|
|
? (locale === 'zh' ? desc.zh : desc.en)
|
|
: t('cliTools.noDescription' as TranslationKey)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Recommended (not installed) */}
|
|
<section>
|
|
<h2 className="text-sm font-medium text-muted-foreground mb-3">{t('cliTools.recommended')}</h2>
|
|
|
|
{/* Brew not installed warning */}
|
|
{!hasBrew && (platform === 'darwin' || platform === 'linux') && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-status-warning-border bg-status-warning-muted px-3 py-2.5 mb-3">
|
|
<Warning size={16} className="text-status-warning-foreground shrink-0 mt-0.5" />
|
|
<div className="text-xs text-muted-foreground">
|
|
<p className="font-medium text-foreground mb-1">{t('cliTools.brewNotInstalled')}</p>
|
|
<p>{t('cliTools.brewInstallGuide')}</p>
|
|
<code className="block mt-1.5 bg-muted/50 rounded px-2 py-1 text-[11px] font-mono select-all">
|
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
{recommendedTools.map(tool => (
|
|
<CliToolCard
|
|
key={tool.id}
|
|
tool={tool}
|
|
runtimeInfo={getRuntimeInfo(tool.id)}
|
|
variant="recommended"
|
|
onDetail={() => setDetailTool({ tool, canInstall: true })}
|
|
onInstall={handleInstall}
|
|
locale={locale}
|
|
platform={platform}
|
|
/>
|
|
))}
|
|
</div>
|
|
{recommendedTools.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">{t('cliTools.allInstalled')}</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Docs link */}
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground pt-2">
|
|
<ArrowSquareOut size={12} />
|
|
<a
|
|
href={locale === 'zh' ? 'https://www.codepilot.sh/zh/docs' : 'https://www.codepilot.sh/docs'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:text-foreground hover:underline transition-colors"
|
|
>
|
|
{t('cliTools.viewDocs')}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Detail dialog */}
|
|
{detailTool && (
|
|
<CliToolDetailDialog
|
|
open={!!detailTool}
|
|
onOpenChange={(open) => !open && setDetailTool(null)}
|
|
tool={detailTool.tool}
|
|
locale={locale}
|
|
onInstall={detailTool.canInstall ? handleInstall : undefined}
|
|
platform={platform}
|
|
/>
|
|
)}
|
|
|
|
{/* Extra tool detail dialog */}
|
|
{extraDetailTool && (
|
|
<CliToolExtraDetailDialog
|
|
open={!!extraDetailTool}
|
|
onOpenChange={(open) => !open && setExtraDetailTool(null)}
|
|
displayName={extraDetailTool.displayName}
|
|
runtimeInfo={extraDetailTool.runtimeInfo}
|
|
autoDescription={autoDescriptions[extraDetailTool.runtimeInfo.id]}
|
|
locale={locale}
|
|
/>
|
|
)}
|
|
|
|
{/* Install dialog */}
|
|
{installTool && (
|
|
<CliToolInstallDialog
|
|
open={!!installTool}
|
|
onOpenChange={(open) => !open && setInstallTool(null)}
|
|
tool={installTool.tool}
|
|
method={installTool.method}
|
|
onComplete={handleInstallComplete}
|
|
/>
|
|
)}
|
|
|
|
{/* Batch AI describe dialog */}
|
|
<CliToolBatchDescribeDialog
|
|
open={batchDescribeOpen}
|
|
onOpenChange={setBatchDescribeOpen}
|
|
toolIds={extraToolIds}
|
|
existingDescriptions={autoDescriptions}
|
|
onComplete={handleBatchDescribeComplete}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|