mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Settings needed a faster way to find local skills, and the plugin overview was wasting space by forcing summary cards into a tall right rail. Keep skill discovery client-side over the already-loaded list and flatten plugin metrics into a compact header so release notes can point issue #513 at the exact user-facing fix. Fixes #513 Constraint: Settings skills data is already loaded locally through /api/skills Rejected: Add server-side skill search | unnecessary API surface for a small local filter Confidence: high Scope-risk: narrow Directive: Keep Settings extension browsing read-only unless an install/edit flow is explicitly added Tested: bun run check:desktop Related: #513
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
import { useEffect, useMemo } from 'react'
|
|
import { usePluginStore } from '../../stores/pluginStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import { Button } from '../shared/Button'
|
|
import type { PluginSummary } from '../../types/plugin'
|
|
|
|
type PluginBucket = 'attention' | 'enabled' | 'disabled'
|
|
|
|
export function PluginList() {
|
|
const {
|
|
plugins,
|
|
marketplaces,
|
|
summary,
|
|
lastReloadSummary,
|
|
isLoading,
|
|
isApplying,
|
|
error,
|
|
fetchPlugins,
|
|
fetchPluginDetail,
|
|
reloadPlugins,
|
|
} = usePluginStore()
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
|
const addToast = useUIStore((s) => s.addToast)
|
|
const t = useTranslation()
|
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
|
const currentWorkDir = activeSession?.workDir || undefined
|
|
|
|
useEffect(() => {
|
|
void fetchPlugins(currentWorkDir)
|
|
}, [fetchPlugins, currentWorkDir])
|
|
|
|
const grouped = useMemo(() => {
|
|
const buckets: Record<PluginBucket, PluginSummary[]> = {
|
|
attention: [],
|
|
enabled: [],
|
|
disabled: [],
|
|
}
|
|
|
|
for (const plugin of plugins) {
|
|
if (plugin.hasErrors) {
|
|
buckets.attention.push(plugin)
|
|
} else if (plugin.enabled) {
|
|
buckets.enabled.push(plugin)
|
|
} else {
|
|
buckets.disabled.push(plugin)
|
|
}
|
|
}
|
|
|
|
return buckets
|
|
}, [plugins])
|
|
|
|
const handleReload = async () => {
|
|
try {
|
|
const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
|
|
addToast({
|
|
type: reloadSummary.errors > 0 ? 'warning' : 'success',
|
|
message: t('settings.plugins.reloadToast', {
|
|
enabled: String(reloadSummary.enabled),
|
|
skills: String(reloadSummary.skills),
|
|
errors: String(reloadSummary.errors),
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
addToast({
|
|
type: 'error',
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
|
|
}
|
|
|
|
if (plugins.length === 0) {
|
|
return (
|
|
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
|
|
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
|
|
extension
|
|
</span>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.plugins.empty')}
|
|
</p>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.plugins.emptyHint')}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 min-w-0">
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
|
<div className="flex flex-col gap-4 px-5 py-5 min-w-0">
|
|
<div className="flex flex-col gap-4 min-w-0 xl:flex-row xl:items-start xl:justify-between">
|
|
<div className="min-w-0 max-w-4xl">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
|
{t('settings.plugins.browserEyebrow')}
|
|
</div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
|
extension
|
|
</span>
|
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.plugins.browserTitle')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{t('settings.plugins.browserDescription')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 xl:justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="min-h-9 flex-1 sm:flex-none"
|
|
onClick={() => void fetchPlugins(currentWorkDir)}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
|
{t('settings.plugins.refresh')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="min-h-9 flex-1 sm:flex-none"
|
|
onClick={handleReload}
|
|
loading={isApplying}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">sync</span>
|
|
{t('settings.plugins.apply')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid min-w-0 grid-cols-2 gap-2 md:grid-cols-4">
|
|
<SummaryCard
|
|
label={t('settings.plugins.summary.total')}
|
|
value={String(summary?.total ?? plugins.length)}
|
|
icon="extension"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.plugins.summary.enabled')}
|
|
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
|
|
icon="check_circle"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.plugins.summary.attention')}
|
|
value={String(grouped.attention.length)}
|
|
icon="warning"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.plugins.summary.marketplaces')}
|
|
value={String(summary?.marketplaceCount ?? marketplaces.length)}
|
|
icon="storefront"
|
|
/>
|
|
</div>
|
|
|
|
{lastReloadSummary && (
|
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
|
{t('settings.plugins.lastReload', {
|
|
enabled: String(lastReloadSummary.enabled),
|
|
skills: String(lastReloadSummary.skills),
|
|
errors: String(lastReloadSummary.errors),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{marketplaces.length > 0 && (
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.plugins.marketplacesTitle')}
|
|
</h4>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.plugins.marketplacesHint')}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{marketplaces.map((marketplace) => (
|
|
<div
|
|
key={marketplace.name}
|
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{marketplace.name}
|
|
</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
|
marketplace.autoUpdate
|
|
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
|
|
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
|
|
}`}>
|
|
{marketplace.autoUpdate
|
|
? t('settings.plugins.marketplaceAutoUpdateOn')
|
|
: t('settings.plugins.marketplaceAutoUpdateOff')}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
|
{marketplace.source}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<span>{t('settings.plugins.marketplaceInstalledCount', { count: String(marketplace.installedCount) })}</span>
|
|
{marketplace.lastUpdated && (
|
|
<span>{t('settings.plugins.marketplaceUpdatedAt', { value: new Date(marketplace.lastUpdated).toLocaleString() })}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{renderGroup('attention', grouped.attention, fetchPluginDetail, currentWorkDir, t)}
|
|
{renderGroup('enabled', grouped.enabled, fetchPluginDetail, currentWorkDir, t)}
|
|
{renderGroup('disabled', grouped.disabled, fetchPluginDetail, currentWorkDir, t)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderGroup(
|
|
bucket: PluginBucket,
|
|
items: PluginSummary[],
|
|
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>,
|
|
cwd: string | undefined,
|
|
t: ReturnType<typeof useTranslation>,
|
|
) {
|
|
if (items.length === 0) return null
|
|
|
|
const titleKey =
|
|
bucket === 'attention'
|
|
? 'settings.plugins.group.attention'
|
|
: bucket === 'enabled'
|
|
? 'settings.plugins.group.enabled'
|
|
: 'settings.plugins.group.disabled'
|
|
|
|
return (
|
|
<section
|
|
key={bucket}
|
|
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<div className="min-w-0">
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{t(titleKey)}
|
|
</h4>
|
|
<p className="text-xs leading-5 text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.plugins.groupHint', { count: String(items.length) })}
|
|
</p>
|
|
</div>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">{items.length}</span>
|
|
</div>
|
|
<div className="flex flex-col p-2">
|
|
{items.map((plugin) => (
|
|
<button
|
|
key={plugin.id}
|
|
onClick={() => void fetchPluginDetail(plugin.id, cwd)}
|
|
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
|
{plugin.hasErrors ? 'warning' : plugin.enabled ? 'extension' : 'extension_off'}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
|
|
{plugin.name}
|
|
</span>
|
|
<StatusPill plugin={plugin} />
|
|
<ScopePill scope={plugin.scope} />
|
|
{plugin.version && (
|
|
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
v{plugin.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
|
{plugin.description || t('settings.plugins.noDescription')}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<span>{plugin.marketplace}</span>
|
|
{plugin.componentCounts.skills > 0 && (
|
|
<span>{t('settings.plugins.capability.skills', { count: String(plugin.componentCounts.skills) })}</span>
|
|
)}
|
|
{plugin.componentCounts.agents > 0 && (
|
|
<span>{t('settings.plugins.capability.agents', { count: String(plugin.componentCounts.agents) })}</span>
|
|
)}
|
|
{plugin.componentCounts.mcpServers > 0 && (
|
|
<span>{t('settings.plugins.capability.mcpServers', { count: String(plugin.componentCounts.mcpServers) })}</span>
|
|
)}
|
|
{plugin.errors.length > 0 && (
|
|
<span className="text-[var(--color-error)]">
|
|
{t('settings.plugins.errorCount', { count: String(plugin.errors.length) })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
|
|
chevron_right
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
icon: string
|
|
}) {
|
|
return (
|
|
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
|
<div className="flex min-w-0 items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
|
<span className="min-w-0 truncate text-[10px] leading-4">
|
|
{label}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1.5 truncate text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatusPill({ plugin }: { plugin: PluginSummary }) {
|
|
const t = useTranslation()
|
|
|
|
if (plugin.hasErrors) {
|
|
return (
|
|
<span className="rounded-full bg-[var(--color-error)]/12 px-2 py-0.5 text-[10px] font-medium text-[var(--color-error)]">
|
|
{t('settings.plugins.status.attention')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
|
plugin.enabled
|
|
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
|
|
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
|
|
}`}>
|
|
{plugin.enabled
|
|
? t('settings.plugins.status.enabled')
|
|
: t('settings.plugins.status.disabled')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function ScopePill({ scope }: { scope: PluginSummary['scope'] }) {
|
|
const t = useTranslation()
|
|
return (
|
|
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
{t(`settings.plugins.scope.${scope}`)}
|
|
</span>
|
|
)
|
|
}
|