程序员阿江(Relakkes) e1c2f54f14 fix: Improve settings extension browsing
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
2026-05-19 16:07:07 +08:00

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>
)
}