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>
|
||||
|
||||
|
||||
@@ -1247,7 +1247,7 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
|
||||
type SkillsCatalogSourceId = string;
|
||||
|
||||
type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
||||
type SkillsCatalogSourceType = 'github';
|
||||
|
||||
export interface SkillsCatalogSource {
|
||||
id: SkillsCatalogSourceId;
|
||||
@@ -1256,6 +1256,10 @@ export interface SkillsCatalogSource {
|
||||
source: string;
|
||||
defaultSubpath?: string;
|
||||
sourceType?: SkillsCatalogSourceType;
|
||||
/** GitHub repository star count (null when unavailable) */
|
||||
stars?: number | null;
|
||||
/** GitHub repository last-push timestamp, ISO (null when unavailable) */
|
||||
repoUpdatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface SkillsCatalogItemInstalledBadge {
|
||||
@@ -1264,18 +1268,6 @@ interface SkillsCatalogItemInstalledBadge {
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
interface ClawdHubSkillMetadata {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
versionsCount?: number;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItem {
|
||||
sourceId: SkillsCatalogSourceId;
|
||||
repoSource: string;
|
||||
@@ -1288,22 +1280,18 @@ export interface SkillsCatalogItem {
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
installed?: SkillsCatalogItemInstalledBadge;
|
||||
/** ClawdHub-specific metadata (present only for ClawdHub sources) */
|
||||
clawdhub?: ClawdHubSkillMetadata;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogResponse {
|
||||
ok: boolean;
|
||||
sources?: SkillsCatalogSource[];
|
||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
export interface SkillsCatalogSourceResponse {
|
||||
ok: boolean;
|
||||
items?: SkillsCatalogItem[];
|
||||
nextCursor?: string | null;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
@@ -1328,11 +1316,6 @@ export interface SkillsRepoScanResponse {
|
||||
|
||||
interface SkillsInstallSelection {
|
||||
skillDir: string;
|
||||
/** ClawdHub-specific metadata for installation */
|
||||
clawdhub?: {
|
||||
slug: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillsInstallRequest {
|
||||
|
||||
@@ -845,16 +845,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuell',
|
||||
'settings.skills.catalog.page.mode.external': 'Extern',
|
||||
'settings.skills.catalog.page.title': 'Fähigkeitskatalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.',
|
||||
'settings.skills.catalog.page.section.sources': 'Quellen',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…',
|
||||
'settings.skills.catalog.page.search.clear': 'Suche löschen',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Sterne: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...',
|
||||
'settings.skills.catalog.page.loading.more': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden',
|
||||
@@ -862,7 +872,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installiert ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unbekannt',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'von',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -897,16 +897,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manual',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': 'Skills Catalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.',
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Clear search',
|
||||
'settings.skills.catalog.page.source.skillsCount': '{count} skills',
|
||||
'settings.skills.catalog.page.source.stars': '{count} stars',
|
||||
'settings.skills.catalog.page.source.updated': 'Updated {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Search results',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Source Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Loading...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Loading skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Loading...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) found',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found',
|
||||
@@ -914,7 +924,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installed ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unknown',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'by',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -865,16 +865,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fuentes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Borrar búsqueda',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrellas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Actualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Cargando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Cargando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Cargando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades",
|
||||
@@ -882,7 +892,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconocido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Claves de paso",
|
||||
|
||||
@@ -783,16 +783,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuel',
|
||||
'settings.skills.catalog.page.mode.external': 'Externe',
|
||||
'settings.skills.catalog.page.title': 'Catalogue de skills',
|
||||
'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.",
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Effacer la recherche',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Étoiles : {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Mis à jour {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills",
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Chargement...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Chargement des skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Chargement...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé',
|
||||
@@ -800,7 +810,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installé ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'inconnu',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'par',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
|
||||
'settings.openchamber.passkeys.title': 'Mots-clés',
|
||||
|
||||
@@ -898,16 +898,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'スキルカタログ',
|
||||
'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。',
|
||||
'settings.skills.catalog.page.section.sources': 'ソース',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…',
|
||||
'settings.skills.catalog.page.search.clear': '検索をクリア',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'スター: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示',
|
||||
'settings.skills.catalog.page.list.searchTitle': '検索結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '更新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む',
|
||||
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
|
||||
'settings.skills.catalog.page.loading.more': '読み込み中...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません',
|
||||
@@ -915,7 +925,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
|
||||
'settings.skills.catalog.page.badge.unknown': '不明',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '提供',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
|
||||
'settings.openchamber.passkeys.title': 'パスキー',
|
||||
|
||||
@@ -865,16 +865,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '수동',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': '스킬 카탈로그',
|
||||
'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.',
|
||||
'settings.skills.catalog.page.section.sources': '소스',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…',
|
||||
'settings.skills.catalog.page.search.clear': '검색 지우기',
|
||||
'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개',
|
||||
'settings.skills.catalog.page.source.stars': '스타: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '업데이트: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기',
|
||||
'settings.skills.catalog.page.list.searchTitle': '검색 결과',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기',
|
||||
'settings.skills.catalog.page.loading.catalog': '로딩 중...',
|
||||
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
|
||||
'settings.skills.catalog.page.loading.more': '로딩 중...',
|
||||
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다',
|
||||
@@ -882,7 +892,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '설치됨({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
|
||||
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '작성자',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1825,21 +1825,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności',
|
||||
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
|
||||
'settings.skills.catalog.page.badge.unknown': 'nieznane',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'autor:',
|
||||
'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
|
||||
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.more': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...',
|
||||
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
|
||||
'settings.skills.catalog.page.mode.manual': 'Ręczny',
|
||||
@@ -1847,6 +1844,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
|
||||
'settings.skills.catalog.page.title': 'Katalog umiejętności',
|
||||
'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.',
|
||||
'settings.skills.catalog.page.section.sources': 'Źródła',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…',
|
||||
'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania',
|
||||
'settings.skills.catalog.shared.actions.install': 'Zainstaluj',
|
||||
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
|
||||
'settings.skills.catalog.shared.actions.scan': 'Skanuj',
|
||||
|
||||
@@ -865,16 +865,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fontes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Limpar pesquisa',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrelas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Atualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositório de origem",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Carregando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Carregando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Carregando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada",
|
||||
@@ -882,7 +892,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconhecido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Chaves de acesso",
|
||||
|
||||
@@ -865,16 +865,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Вручну",
|
||||
"settings.skills.catalog.page.mode.external": "зовнішній",
|
||||
"settings.skills.catalog.page.title": "Каталог навичок",
|
||||
'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.',
|
||||
'settings.skills.catalog.page.section.sources': 'Джерела',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…',
|
||||
'settings.skills.catalog.page.search.clear': 'Очистити пошук',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Зірок: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Оновлено {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички",
|
||||
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
|
||||
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
|
||||
"settings.skills.catalog.page.loading.more": "Завантаження...",
|
||||
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено",
|
||||
@@ -882,7 +892,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "встановлено ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
|
||||
"settings.skills.catalog.page.badge.unknown": "невідомий",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "за",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
|
||||
"settings.openchamber.passkeys.title": "Ключі доступу",
|
||||
|
||||
@@ -865,16 +865,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手动',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': '技能目录',
|
||||
'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。',
|
||||
'settings.skills.catalog.page.section.sources': '来源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜索',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能数:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星标:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新于 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜索结果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '来源仓库',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能',
|
||||
'settings.skills.catalog.page.loading.catalog': '加载中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
|
||||
'settings.skills.catalog.page.loading.more': '加载中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能',
|
||||
@@ -882,7 +892,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安装({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目录',
|
||||
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -862,16 +862,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'Skills 目錄',
|
||||
'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。',
|
||||
'settings.skills.catalog.page.section.sources': '來源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜尋',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能數:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星標:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新於 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜尋結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': '載入中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在載入 skills...',
|
||||
'settings.skills.catalog.page.loading.more': '載入中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills',
|
||||
@@ -879,7 +889,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安裝({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
|
||||
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useProjectsStore', () => ({
|
||||
useProjectsStore: {
|
||||
getState: () => ({
|
||||
getActiveProject: () => null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: async () => new Response('{}', { status: 500 }),
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useSkillsStore', () => ({
|
||||
invalidateSkillsLoadCache: () => undefined,
|
||||
refreshSkillsAfterOpenCodeRestart: async () => undefined,
|
||||
useSkillsStore: {
|
||||
getState: () => ({}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/configUpdate', () => ({
|
||||
startConfigUpdate: () => undefined,
|
||||
finishConfigUpdate: () => undefined,
|
||||
updateConfigUpdateMessage: () => undefined,
|
||||
}));
|
||||
|
||||
const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore');
|
||||
|
||||
describe('skills catalog ClawHub label', () => {
|
||||
beforeEach(() => {
|
||||
useSkillsCatalogStore.setState({
|
||||
sources: useSkillsCatalogStore.getState().sources,
|
||||
});
|
||||
});
|
||||
|
||||
test('fallback sources label ClawHub correctly', () => {
|
||||
const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub');
|
||||
expect(clawhub).toBeDefined();
|
||||
expect(clawhub?.label).toBe('ClawHub');
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
sourceType: 'clawdhub',
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: "OpenAI's curated skills",
|
||||
source: 'openai/skills',
|
||||
defaultSubpath: 'skills/.curated',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
description: "Cursor's plugin skills",
|
||||
source: 'cursor/plugins',
|
||||
defaultSubpath: 'pstack/skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'mattpocock',
|
||||
label: 'Matt Pocock',
|
||||
description: 'Matt Pocock skills collection',
|
||||
source: 'mattpocock/skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
|
||||
const skillsCatalogLastLoadedAt = new Map<string, number>();
|
||||
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
const sourceLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
let activeSourceLoads = 0;
|
||||
|
||||
const getSkillsCatalogCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
|
||||
@@ -71,13 +89,10 @@ export interface SkillsCatalogState {
|
||||
sources: SkillsCatalogSource[];
|
||||
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
||||
selectedSourceId: string | null;
|
||||
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
|
||||
loadedSourceIds: Record<string, boolean>;
|
||||
clawdhubHasMoreBySource: Record<string, boolean>;
|
||||
|
||||
isLoadingCatalog: boolean;
|
||||
isLoadingSource: boolean;
|
||||
isLoadingMore: boolean;
|
||||
isScanning: boolean;
|
||||
isInstalling: boolean;
|
||||
|
||||
@@ -91,7 +106,6 @@ export interface SkillsCatalogState {
|
||||
|
||||
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadMoreClawdHub: () => Promise<boolean>;
|
||||
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
||||
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
|
||||
}
|
||||
@@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
sources: FALLBACK_SOURCES,
|
||||
itemsBySource: {},
|
||||
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
|
||||
pageInfoBySource: {},
|
||||
loadedSourceIds: {},
|
||||
clawdhubHasMoreBySource: {},
|
||||
|
||||
isLoadingCatalog: false,
|
||||
isLoadingSource: false,
|
||||
isLoadingMore: false,
|
||||
isScanning: false,
|
||||
isInstalling: false,
|
||||
|
||||
@@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
pageInfoBySource: get().pageInfoBySource,
|
||||
loadedSourceIds: get().loadedSourceIds,
|
||||
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
|
||||
};
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
@@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
|
||||
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
||||
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
||||
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
|
||||
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
||||
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
@@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
set({
|
||||
sources,
|
||||
itemsBySource,
|
||||
pageInfoBySource,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
selectedSourceId,
|
||||
});
|
||||
|
||||
@@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
set({
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
pageInfoBySource: previous.pageInfoBySource,
|
||||
loadedSourceIds: previous.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
|
||||
@@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent loads of the same source: the background
|
||||
// loader effect can restart while a request for this source is
|
||||
// already in flight.
|
||||
if (!options?.refresh) {
|
||||
const inFlight = sourceLoadInFlight.get(sourceId);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
activeSourceLoads += 1;
|
||||
set({ isLoadingSource: true, lastCatalogError: null });
|
||||
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
||||
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
|
||||
}));
|
||||
return true;
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
||||
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
set({
|
||||
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = payload?.items || [];
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({
|
||||
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
||||
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = payload?.items || [];
|
||||
const nextCursor = payload?.nextCursor ?? null;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[sourceId]: items.length > 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({
|
||||
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoadingSource: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreClawdHub: async () => {
|
||||
const selectedSourceId = get().selectedSourceId;
|
||||
if (!selectedSourceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageInfo = get().pageInfoBySource[selectedSourceId];
|
||||
const cursor = pageInfo?.nextCursor || null;
|
||||
|
||||
set({ isLoadingMore: true });
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
|
||||
if (currentDirectory) {
|
||||
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
|
||||
}
|
||||
if (cursor) {
|
||||
parts.push(`cursor=${encodeURIComponent(cursor)}`);
|
||||
}
|
||||
const queryParams = `?${parts.join('&')}`;
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCursor = payload.nextCursor ?? null;
|
||||
const currentItems = get().itemsBySource[selectedSourceId] || [];
|
||||
const items = payload.items || [];
|
||||
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
|
||||
let newCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const key = `${item.sourceId}:${item.skillDir}`;
|
||||
if (!merged.has(key)) {
|
||||
newCount += 1;
|
||||
} finally {
|
||||
activeSourceLoads -= 1;
|
||||
if (activeSourceLoads === 0) {
|
||||
set({ isLoadingSource: false });
|
||||
}
|
||||
merged.set(key, item);
|
||||
}
|
||||
})();
|
||||
|
||||
const noMore = items.length === 0 || newCount === 0;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: {
|
||||
...state.itemsBySource,
|
||||
[selectedSourceId]: Array.from(merged.values()),
|
||||
},
|
||||
pageInfoBySource: {
|
||||
...state.pageInfoBySource,
|
||||
[selectedSourceId]: { nextCursor },
|
||||
},
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[selectedSourceId]: !noMore,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
sourceLoadInFlight.set(sourceId, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
set({ isLoadingMore: false });
|
||||
if (sourceLoadInFlight.get(sourceId) === request) {
|
||||
sourceLoadInFlight.delete(sourceId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user