feat(skills): curated GitHub catalog redesign (#3016)
* feat(skills): remove ClawHub catalog integration Drop the ClawHub registry as a skills catalog source across web server, shared UI, VS Code, docs, and locales. The catalog now serves git-based sources only: the curated Anthropic repo and user-defined repositories. Also removes the now-unused adm-zip dependency. * feat(skills): redesign catalog around curated GitHub repositories Replace the single-source dropdown with a card grid of curated GitHub repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus user-defined sources. Source cards show skill counts, GitHub stars, and last-updated time; a global search covers all loaded sources. Server: curated sources gain GitHub repo metadata (stars, pushed_at) fetched best-effort with a 3-hour in-memory and on-disk cache; scans run through a concurrency-limited, deduplicated cache with 3-hour TTL persisted across restarts. Refresh still bypasses the cache. Shared UI: source cards, global search with clear button, per-skill GitHub links, install/installed states. VS Code curated list updated to match. All new copy translated across 12 locales. * fix(skills): address catalog review findings - GitHub metadata fetch timeout drops to 1.5s (under the catalog client's 3s deadline) and failed lookups cache briefly (5 min) so repeated catalog loads do not re-hit a failing API. - Disk cache files are written with owner-only permissions (0o600); rename preserves the mode. - loadSource deduplicates concurrent in-flight requests per source and the shared isLoadingSource flag now clears only when the last active source load finishes.
This commit is contained in:
committed by
GitHub
parent
90d8868bfc
commit
1ed3f1f575
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
directoryOverride?: string | null;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
// Build selection with clawdhub metadata if present
|
||||
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
|
||||
skillDir: request.skillDir,
|
||||
};
|
||||
if (item?.clawdhub) {
|
||||
selection.clawdhub = {
|
||||
slug: item.clawdhub.slug,
|
||||
version: item.clawdhub.version,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await installSkills({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: [selection],
|
||||
selections: [{ skillDir: request.skillDir }],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,24 +14,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
|
||||
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
|
||||
showModeTabs?: boolean;
|
||||
}
|
||||
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
const getRepoUrl = (source: string): string | null => {
|
||||
const trimmed = source.trim();
|
||||
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return `https://github.com/${trimmed}`;
|
||||
};
|
||||
|
||||
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
|
||||
const repoUrl = getRepoUrl(item.repoSource);
|
||||
if (!repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
|
||||
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
|
||||
};
|
||||
|
||||
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
|
||||
|
||||
const formatStars = (stars: number): string => {
|
||||
const locale = getCurrentIntlLocale();
|
||||
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
|
||||
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
|
||||
}
|
||||
return cachedStarsFormatter.formatter.format(stars);
|
||||
};
|
||||
|
||||
type RelativeTimeKey =
|
||||
| 'common.relative.justNow'
|
||||
| 'common.relative.minutesAgoShort'
|
||||
| 'common.relative.hoursAgoShort'
|
||||
| 'common.relative.daysAgoShort'
|
||||
| 'common.relative.weeksAgoShort'
|
||||
| 'common.relative.yearsAgoShort';
|
||||
|
||||
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
|
||||
const timestamp = Date.parse(isoDate);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60_000) {
|
||||
return { key: 'common.relative.justNow', count: 0 };
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return { key: 'common.relative.minutesAgoShort', count: minutes };
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return { key: 'common.relative.hoursAgoShort', count: hours };
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
return { key: 'common.relative.daysAgoShort', count: days };
|
||||
}
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 52) {
|
||||
return { key: 'common.relative.weeksAgoShort', count: weeks };
|
||||
}
|
||||
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
|
||||
};
|
||||
|
||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
try {
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const SourceCard: React.FC<{
|
||||
source: SkillsCatalogSource;
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
skillsCount: number | null;
|
||||
onSelect: () => void;
|
||||
t: ReturnType<typeof useI18n>['t'];
|
||||
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
|
||||
const stars = source.stars ?? null;
|
||||
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive
|
||||
? 'border-primary'
|
||||
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
|
||||
{isLoading ? (
|
||||
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
skillsCount !== null && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
|
||||
<span className="flex items-center gap-3 mt-1">
|
||||
{stars !== null && (
|
||||
<span
|
||||
className="typography-micro text-muted-foreground flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
|
||||
>
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{formatStars(stars)}
|
||||
</span>
|
||||
)}
|
||||
{updated && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{updated.key === 'common.relative.justNow'
|
||||
? t(updated.key)
|
||||
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
loadSource,
|
||||
loadMoreClawdHub,
|
||||
isLoadingCatalog,
|
||||
isLoadingSource,
|
||||
isLoadingMore,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore(useShallow((s) => ({
|
||||
sources: s.sources,
|
||||
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource: s.setSelectedSource,
|
||||
loadCatalog: s.loadCatalog,
|
||||
loadSource: s.loadSource,
|
||||
loadMoreClawdHub: s.loadMoreClawdHub,
|
||||
isLoadingCatalog: s.isLoadingCatalog,
|
||||
isLoadingSource: s.isLoadingSource,
|
||||
isLoadingMore: s.isLoadingMore,
|
||||
loadedSourceIds: s.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
|
||||
lastCatalogError: s.lastCatalogError,
|
||||
})));
|
||||
|
||||
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
||||
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
||||
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
// Load every source in the background so global search covers all of them.
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
|
||||
if (unloaded.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!loadedSourceIds[selectedSourceId]) {
|
||||
void loadSource(selectedSourceId);
|
||||
let cancelled = false;
|
||||
const loadRest = async () => {
|
||||
for (const src of unloaded) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
await loadSource(src.id);
|
||||
}
|
||||
};
|
||||
void loadRest();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, loadedSourceIds, loadSource]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
|
||||
return;
|
||||
}
|
||||
void loadSource(selectedSourceId);
|
||||
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [itemsBySource, selectedSourceId]);
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
const matches = (item: SkillsCatalogItem) =>
|
||||
item.skillName.toLowerCase().includes(q)
|
||||
|| (item.description || '').toLowerCase().includes(q)
|
||||
|| (item.frontmatterName || '').toLowerCase().includes(q);
|
||||
|
||||
if (isSearching) {
|
||||
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
|
||||
}
|
||||
if (!selectedSourceId) {
|
||||
return [];
|
||||
}
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
|
||||
|
||||
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||
|
||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
||||
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
|
||||
const hasMoreClawdHub = Boolean(
|
||||
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
|
||||
);
|
||||
|
||||
const removeSelectedCatalog = async () => {
|
||||
if (!selectedSourceId || !isCustomSource) {
|
||||
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
};
|
||||
|
||||
const listTitle = isSearching
|
||||
? t('settings.skills.catalog.page.list.searchTitle')
|
||||
: (selectedSource?.label ?? '');
|
||||
|
||||
// The selected source has no items yet and a load is in flight — show the
|
||||
// loading state instead of a stale list from the previously selected source.
|
||||
const isSelectedSourceLoading = !isSearching
|
||||
&& selectedSourceId !== null
|
||||
&& !loadedSourceIds[selectedSourceId]
|
||||
&& (isLoadingSource || isLoadingCatalog);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="typography-meta text-muted-foreground mb-4">
|
||||
{t('settings.skills.catalog.page.subtitle')}
|
||||
</p>
|
||||
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="mb-5">
|
||||
<div className="relative max-w-md">
|
||||
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
|
||||
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('settings.skills.catalog.page.search.clear')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.skills.catalog.page.section.sourceRepository')}
|
||||
title={t('settings.skills.catalog.page.section.sources')}
|
||||
divider={false}
|
||||
settingsItem="skills.catalog.source"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<Select
|
||||
value={selectedSourceId || ''}
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
|
||||
{selectedSource?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
<SelectItem key={src.id} value={src.id}>
|
||||
{src.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
source={src}
|
||||
isActive={src.id === selectedSourceId}
|
||||
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
|
||||
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
|
||||
onSelect={() => setSelectedSource(src.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
{isCustomSource && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
data-settings-item="skills.catalog.add-catalog"
|
||||
size="xs"
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="py-1.5">
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="h-7 pl-8 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground mt-1 block">
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
<button
|
||||
type="button"
|
||||
data-settings-item="skills.catalog.add-catalog"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="typography-ui-label text-muted-foreground block">
|
||||
{t('settings.skills.catalog.page.source.addOwnTitle')}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
|
||||
{t('settings.skills.catalog.page.source.addOwnDescription')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{lastCatalogError && (
|
||||
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
)}
|
||||
|
||||
<SettingsSection>
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
|
||||
{listTitle}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 shrink-0">
|
||||
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId && !isSearching) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
|
||||
</Button>
|
||||
{isCustomSource && !isSearching && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
const skillUrl = getSkillUrl(item);
|
||||
|
||||
return (
|
||||
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
|
||||
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
|
||||
{item.clawdhub && (
|
||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
||||
{item.clawdhub.owner && (
|
||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="download" className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
|
||||
{skillUrl ? (
|
||||
<a
|
||||
href={skillUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono hover:underline truncate inline-flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="github" className="h-3 w-3 shrink-0" />
|
||||
{item.repoSource}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono truncate">{item.repoSource}</span>
|
||||
)}
|
||||
{item.skillDir && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{item.skillDir}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
|
||||
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{skillUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{installed ? (
|
||||
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user