mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(dsh): add local favorites controls and filtering
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
1cce623b57
commit
1b3a336bca
16 changed files with 429 additions and 35 deletions
|
|
@ -480,9 +480,9 @@ Chromium 145.0.7632.6连接真实RC5 Host与本地Registry夹具,验证目录/
|
|||
## 54. 本地收藏与当前来源筛选(#1064)
|
||||
|
||||
- [x] 54.1 核对现有卡片、来源、生成Remote与官方持久化原语,明确最小交互、身份、快照和跨Host写入边界,设计见favorites-design.md。
|
||||
- [ ] 54.2 独立收藏store与Host RPC:来源隔离、完整读取、明确状态幂等写、官方锁/原子写、数据校验与稳定错误;不改inventory或既有安装门禁。
|
||||
- [ ] 54.3 复用卡片/详情书签和浏览页收藏筛选,完整本地搜索、最新版本打开、同步/迟到/失败状态与键盘焦点。
|
||||
- [ ] 54.4 以独立磁盘/HTTP/跨进程Oracle验证正常、边界、错误、并发和来源切换,执行构建、类型、lint、测试及包/生成契约检查。
|
||||
- [x] 54.2 独立收藏store与Host RPC:来源隔离、完整读取、明确状态幂等写、官方锁/原子写、数据校验与稳定错误;不改inventory或既有安装门禁。
|
||||
- [x] 54.3 复用卡片/详情书签和浏览页收藏筛选,完整本地搜索、最新版本打开、同步/迟到/失败状态与键盘焦点。
|
||||
- [x] 54.4 以独立磁盘/HTTP/跨进程Oracle验证正常、边界、错误、并发和来源切换,执行构建、类型、lint、测试及包/生成契约检查。
|
||||
- [ ] 54.5 三RC真实Host偏好/RPC回归、RC5浏览器/重启/分页/主题/390px验收、独立评审及人工预览;发布后置。
|
||||
|
||||
第53章后续CI已在PR #1063完成:30项通过、7项条件跳过,包含19个DSH组合;用户人工确认后已合并,临时预览与模型进程已关闭。它不替代第54章新增功能验收。
|
||||
|
|
|
|||
|
|
@ -269,3 +269,9 @@ Discarding changes restores files from the currently installed release: modified
|
|||
Persistent backups live under `skillhub/backups` in DSH Home, outside skill directories. Limits are 100 MiB per backup, 1 GiB in total and 20 backups. Reaching a limit rejects the new backup; backups are not automatically deleted. Failed backups never authorize overwriting, and edits after backup require a fresh check. Local files are compared and backed up on the Host, never uploaded to the Registry.
|
||||
|
||||
Manual update actions on conversation cards also open the change preview. Model tools retain their existing confirmation semantics for unmodified installations and cannot authorize backup-and-overwrite; local modifications still require handling in the page.
|
||||
|
||||
## Local favorites
|
||||
|
||||
The bookmark on market cards and details saves a local favorite. The browse view's “仅看收藏” filter reads the complete current-source list and searches it locally, independently of loaded market pages. Favorites require no login, install nothing, and do not change the website's star count. Opening an item resolves its current downloadable version; offline or missing items stay saved without automatically installing an old version.
|
||||
|
||||
Favorites use `$DSH_HOME/skillhub/favorites.json`, with DSH_HOME resolved by the Host's path rules. Profiles and projects sharing that home share favorites; Registry sources and different DSH_HOME values remain isolated. Removing a favorite does not uninstall a skill, and switching or removing a source does not delete its favorites. Read/write failures are reported without silently clearing the list or overwriting unknown formats. This describes the source candidate, not the published npm `0.6.1` package.
|
||||
|
|
|
|||
|
|
@ -272,3 +272,9 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh
|
|||
持久备份保存在 DSH Home 的 `skillhub/backups` 目录,与技能目录分离;单份上限 100 MiB、总量 1 GiB、最多 20 份,达到上限拒绝新备份,首期不自动删除。备份失败不覆盖,备份后又有本地修改也需重新处理。本地文件仅在 Host 比较和备份,不上传 Registry。
|
||||
|
||||
对话卡片上的人工更新确认同样进入变化预览。模型工具仍保留原有无本地修改时的升级确认语义,不具备备份覆盖授权;发现本地修改时继续拒绝,需由用户进入页面处理。
|
||||
|
||||
## 本地收藏
|
||||
|
||||
市场卡片与详情中的书签按钮用于本机收藏;浏览页的“仅看收藏”读取当前来源的完整收藏列表,可直接搜索,不依赖已加载的市场分页。收藏不安装技能、不改变网站收藏数,也不需要登录。点击收藏项时重新解析当前可下载版本;离线或下架不删除收藏,也不自动安装旧版本。
|
||||
|
||||
收藏保存在 `$DSH_HOME/skillhub/favorites.json`(DSH_HOME按宿主路径规则解析),同一DSH_HOME下的Profile和项目共享,来源之间隔离。取消收藏不会卸载,切换或移除来源不会删除其收藏;不同DSH_HOME不自动同步。文件异常或保存失败会提示重试,不静默清空;未知格式不覆盖。此处描述源码候选能力,不表示公开npm `0.6.1` 已包含该功能。
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
export function catalogErrorMessage(error: { code: string; details?: unknown }): string {
|
||||
if (error.code === 'skillhub/context-stale') return '来源已变化,请刷新后重试。';
|
||||
if (error.code === 'skillhub/source-not-configured') return '请先在插件配置中设置 SkillHub 地址。';
|
||||
if (error.code === 'skillhub/favorites-error') {
|
||||
const reason = error.details && typeof error.details === 'object' && 'reason' in error.details ? error.details.reason : undefined;
|
||||
if (reason === 'INVALID_STORE') return '本地收藏文件无法读取,原有内容已保留。';
|
||||
if (reason === 'LIMIT_REACHED') return '本机收藏已达上限,请先取消部分收藏。';
|
||||
if (reason === 'INVALID_INPUT') return '此技能暂时无法收藏,请刷新后重试。';
|
||||
return '本地收藏暂时不可用,请重试。';
|
||||
}
|
||||
if (error.code !== 'skillhub/registry-error') return '请求失败,请稍后重试。';
|
||||
const reason = error.details && typeof error.details === 'object' && 'reason' in error.details
|
||||
? error.details.reason : undefined;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import { CardSkeletons, CatalogSkeleton, DetailSkeleton } from './loading-layout
|
|||
import { UiAvailabilityNotice, type UiAvailability } from './ui-availability.js';
|
||||
import { NativeControlStyles } from './native-control-styles.js';
|
||||
import { catalogErrorMessage } from './catalog-error.js';
|
||||
import { FavoriteButton } from './favorite-button.js';
|
||||
import { favoriteIdentity, filterFavorites, resolvedFavoriteItem } from './favorites.js';
|
||||
import { useFavorites } from './use-favorites.js';
|
||||
import { QueryClient, QueryClientProvider, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
|
||||
|
|
@ -22,7 +25,7 @@ import type { InstallationServices } from './install-control.js';
|
|||
import { SelectField, Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from './ui.js';
|
||||
import { Icon, SkillAvatar, SkillCard, SkillMetrics } from './skill-card.js';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type { CatalogPageResult, SkillList, InstalledSkillList } from '../host/types.js';
|
||||
import type { CatalogPageResult, SkillList, InstalledSkillList, FavoriteSkill } from '../host/types.js';
|
||||
import { skillInstallationSummary } from './skill-primary-actions.js';
|
||||
import '../../lib/typert.remote-client.js';
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ export interface CatalogNavigation {
|
|||
installedOrigin?: { sessionId: string; sourceRevision: string };
|
||||
tab: 'browse' | 'installed'; query: string; draft: string; sort?: Sort;
|
||||
labelSelection?: { sourceRevision: string; slug: string };
|
||||
favoriteOnly?: boolean; favoriteQuery?: string; favoriteDraft?: string; favoriteScrollTop?: number;
|
||||
selected: Item | null; sourceRevision: string | null; scrollTop: number;
|
||||
}
|
||||
export const createCatalogNavigation = (): CatalogNavigation => ({
|
||||
|
|
@ -61,6 +65,11 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
const [installedOrigin, setInstalledOrigin] = useState(navigation.installedOrigin);
|
||||
const [draft, setDraft] = useState(navigation.draft);
|
||||
const [query, setQuery] = useState(navigation.query);
|
||||
const [favoriteOnly, setFavoriteOnly] = useState(navigation.favoriteOnly ?? false);
|
||||
const [favoriteQuery, setFavoriteQuery] = useState(navigation.favoriteQuery ?? '');
|
||||
const [favoriteDraft, setFavoriteDraft] = useState(navigation.favoriteDraft ?? '');
|
||||
const [favoriteOpening, setFavoriteOpening] = useState<{ revision: string; key: string } | null>(null);
|
||||
const [favoriteOpenError, setFavoriteOpenError] = useState<{ revision: string; item: FavoriteSkill; intent: 'detail' | 'install'; message: string } | null>(null);
|
||||
const [sort, setSort] = useState<Sort>(navigation.sort ?? 'newest');
|
||||
const [labelSelection, setLabelSelection] = useState(navigation.labelSelection);
|
||||
const [selected, setSelected] = useState(navigation.selected);
|
||||
|
|
@ -68,15 +77,23 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const lastCard = useRef<HTMLButtonElement | null>(null);
|
||||
const lastSelected = useRef(selected);
|
||||
const favoriteFilter = useRef<HTMLButtonElement>(null);
|
||||
const previousFavoriteOnly = useRef(favoriteOnly);
|
||||
const favoriteRequest = useRef<AbortController | null>(null);
|
||||
const context = useQuery({ queryKey: ['skillhub-context'], queryFn: async () => unwrap(await remote.getContext()), retry: false });
|
||||
const compatibility = useQuery({ queryKey: ['skillhub-compatibility'], queryFn: async () => unwrap(await remote.getCompatibility()), retry: false });
|
||||
const revision = context.data?.sourceRevision;
|
||||
const latestRevision = useRef(revision); latestRevision.current = revision;
|
||||
const favorites = useFavorites(remote, revision, !!context.data?.configured && tab === 'browse');
|
||||
const favoriteItems = useRef(favorites.items); favoriteItems.current = favorites.items;
|
||||
useLayoutEffect(() => () => { favoriteRequest.current?.abort(); favoriteRequest.current = null; }, [revision]);
|
||||
const previousRevision = useRef(revision);
|
||||
useLayoutEffect(() => {
|
||||
if (previousRevision.current && revision && previousRevision.current !== revision) {
|
||||
setInstallCandidate(null); setUpgradeCandidate(null); setRemovalCandidate(null);
|
||||
navigation.selected = null; setSelected(null);
|
||||
navigation.installedOrigin = undefined; setInstalledOrigin(undefined);
|
||||
setFavoriteOpening(null); setFavoriteOpenError(null);
|
||||
}
|
||||
previousRevision.current = revision;
|
||||
}, [revision, navigation]);
|
||||
|
|
@ -85,7 +102,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
enabled: !!installed && !!revision && !!context.data?.configured && tab === 'browse', retry: false,
|
||||
queryFn: async ({ signal }) => unwrap(await installed!.list(revision!, signal)) });
|
||||
const label = labelSelection && labelSelection.sourceRevision === revision ? labelSelection.slug : '';
|
||||
const labels = useQuery({ ...readCacheOptions, queryKey: ['skillhub-labels', revision], enabled: !!context.data?.configured, retry: false,
|
||||
const labels = useQuery({ ...readCacheOptions, queryKey: ['skillhub-labels', revision], enabled: !!context.data?.configured && !favoriteOnly && tab === 'browse', retry: false,
|
||||
queryFn: async ({ signal }) => unwrap(await remote.listLabels(revision!, signal)) });
|
||||
useLayoutEffect(() => {
|
||||
if (revision && labelSelection && labelSelection.sourceRevision !== revision) {
|
||||
|
|
@ -97,7 +114,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
const sourceMatches = !selected || revision === navigation.sourceRevision;
|
||||
const listing = useInfiniteQuery({
|
||||
...readCacheOptions,
|
||||
queryKey: ['skillhub-catalog', revision, query, sort, label], enabled: !!context.data?.configured && tab === 'browse',
|
||||
queryKey: ['skillhub-catalog', revision, query, sort, label], enabled: !!context.data?.configured && tab === 'browse' && !favoriteOnly,
|
||||
initialPageParam: 0, retry: false,
|
||||
queryFn: async ({ signal, pageParam }) => unwrap(await (label ? remote.listCatalogByLabel(revision!, query, pageParam, sort, label, signal)
|
||||
: remote.listCatalog(revision!, query, pageParam, sort, signal))),
|
||||
|
|
@ -106,6 +123,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
const pages = listing.data?.pages;
|
||||
const items = Array.from(new Map((pages?.flatMap(page => page.items) ?? []).map(item => [`${item.namespace}/${item.slug}`, item])).values());
|
||||
const limited = pages?.[0]?.mode === 'limited';
|
||||
const visibleFavorites = filterFavorites(favorites.items, favoriteQuery);
|
||||
const detail = useQuery({
|
||||
...readCacheOptions,
|
||||
queryKey: ['skillhub-detail', revision, selected?.namespace, selected?.slug, selected?.latestVersion],
|
||||
|
|
@ -114,18 +132,57 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
});
|
||||
useLayoutEffect(() => {
|
||||
if (tab !== 'browse') return;
|
||||
if (scroller.current) scroller.current.scrollTop = selected ? 0 : navigation.scrollTop;
|
||||
const filterChanged = previousFavoriteOnly.current !== favoriteOnly; previousFavoriteOnly.current = favoriteOnly;
|
||||
if (scroller.current) scroller.current.scrollTop = selected ? 0 : favoriteOnly ? navigation.favoriteScrollTop ?? 0 : navigation.scrollTop;
|
||||
if (selected) heading.current?.focus();
|
||||
else lastCard.current?.focus({ preventScroll: true });
|
||||
}, [selected, navigation, tab]);
|
||||
else if (!filterChanged) lastCard.current?.focus({ preventScroll: true });
|
||||
}, [selected, navigation, tab, favoriteOnly]);
|
||||
function back() { navigation.selected = null; setSelected(null); }
|
||||
function rememberScroll() {
|
||||
const top = scroller.current?.scrollTop ?? 0;
|
||||
if (favoriteOnly) navigation.favoriteScrollTop = top;
|
||||
else navigation.scrollTop = top;
|
||||
}
|
||||
function cancelFavoriteOpen() {
|
||||
favoriteRequest.current?.abort(); favoriteRequest.current = null;
|
||||
setFavoriteOpening(null); setFavoriteOpenError(null);
|
||||
}
|
||||
function openSkill(item: Item, button?: HTMLButtonElement) {
|
||||
cancelFavoriteOpen();
|
||||
lastSelected.current = item;
|
||||
if (button) lastCard.current = button;
|
||||
navigation.scrollTop = scroller.current?.scrollTop ?? 0;
|
||||
rememberScroll();
|
||||
navigation.selected = item; navigation.sourceRevision = revision!;
|
||||
setSelected(item);
|
||||
}
|
||||
async function openFavorite(item: FavoriteSkill, intent: 'detail' | 'install', button?: HTMLButtonElement) {
|
||||
if (!revision) return;
|
||||
const captured = revision; cancelFavoriteOpen();
|
||||
const controller = new AbortController(); favoriteRequest.current = controller;
|
||||
setFavoriteOpening({ revision: captured, key: favoriteIdentity(item) });
|
||||
try {
|
||||
const resolved = unwrap(await remote.resolveFavoriteVersion(captured, item.namespace, item.slug, controller.signal));
|
||||
if (controller.signal.aborted || favoriteRequest.current !== controller || latestRevision.current !== captured) return;
|
||||
if (!favoriteItems.current.some(saved => favoriteIdentity(saved) === favoriteIdentity(item))) return;
|
||||
const current = resolvedFavoriteItem(item, resolved);
|
||||
if (intent === 'install') setInstallCandidate({ item: current, revision: captured });
|
||||
else openSkill(current, button);
|
||||
} catch (failure) {
|
||||
if (!controller.signal.aborted && favoriteRequest.current === controller && latestRevision.current === captured) {
|
||||
setFavoriteOpenError({ revision: captured, item, intent, message: failure instanceof Error ? failure.message : '技能暂时无法读取,请重试。' });
|
||||
}
|
||||
} finally {
|
||||
if (favoriteRequest.current === controller) { favoriteRequest.current = null; setFavoriteOpening(null); }
|
||||
}
|
||||
}
|
||||
function favoriteAction(item: FavoriteSkill) {
|
||||
const pressed = favorites.items.some(saved => favoriteIdentity(saved) === favoriteIdentity(item));
|
||||
return <FavoriteButton item={item} pressed={pressed} disabled={!favorites.ready || favorites.saving} busy={favorites.loading || favorites.saving}
|
||||
onToggle={() => {
|
||||
if (pressed && favoriteOpening?.key === favoriteIdentity(item)) cancelFavoriteOpen();
|
||||
void favorites.save(item, !pressed).then(saved => { if (saved && favoriteOnly && pressed) favoriteFilter.current?.focus({ preventScroll: true }); });
|
||||
}} />;
|
||||
}
|
||||
function manageSkill(item: NonNullable<typeof inventory.data>['items'][number], intent: 'remove' | 'upgrade') {
|
||||
if (intent === 'remove') { if (revision && context.data) setRemovalCandidate({ item, revision, source: item.registry ?? context.data.registryUrl }); return; }
|
||||
if (revision && context.data) setUpgradeCandidate({ item, revision, source: item.registry ?? context.data.registryUrl });
|
||||
|
|
@ -134,7 +191,8 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
return <div role="alert" className="astron-sh-error"><p>{message}</p><Button onClick={retry}>重试</Button></div>;
|
||||
}
|
||||
function changeTab(value: string) {
|
||||
if (tab === 'browse') navigation.scrollTop = scroller.current?.scrollTop ?? 0;
|
||||
cancelFavoriteOpen();
|
||||
if (tab === 'browse') rememberScroll();
|
||||
navigation.installedOrigin = undefined; setInstalledOrigin(undefined);
|
||||
navigation.tab = value as typeof tab; setTab(value as typeof tab);
|
||||
}
|
||||
|
|
@ -154,6 +212,14 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
navigation.query = next; navigation.scrollTop = 0; setQuery(next);
|
||||
if (next === query) void listing.refetch();
|
||||
}
|
||||
function searchFavorites(next: string) {
|
||||
navigation.favoriteQuery = next; navigation.favoriteScrollTop = 0; setFavoriteQuery(next);
|
||||
if (scroller.current) scroller.current.scrollTop = 0;
|
||||
}
|
||||
function toggleFavorites() {
|
||||
cancelFavoriteOpen(); rememberScroll();
|
||||
navigation.favoriteOnly = !favoriteOnly; setFavoriteOnly(!favoriteOnly);
|
||||
}
|
||||
return <section ref={scroller} className="skillhub-controls astron-sh-catalog" aria-label="SkillHub 技能广场">
|
||||
<NativeControlStyles /><UiAvailabilityNotice availability={availability} />
|
||||
{upgradeCandidate && installed && <UpgradeControl key={`${installed.sessionId}:${upgradeCandidate.revision}:${upgradeCandidate.item.itemKey}`} presentation="dialog" eligible
|
||||
|
|
@ -171,7 +237,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
|
||||
<header className="astron-sh-catalog-header"><div className="astron-sh-location"><Icon name="grid" /><strong>SkillHub</strong><span>/</span><span>{selected ? '技能详情' : '技能广场'}</span></div>
|
||||
<div className="astron-sh-toolbar-actions"><Button variant="ghost" className="astron-sh-icon-button" aria-label="查看当前来源" title={context.data?.registryUrl || '尚未配置来源'} onClick={() => setSourceOpen(value => !value)}><Icon name="info" /></Button>
|
||||
{(installation || installed) && <Button variant="ghost" className="astron-sh-icon-button" aria-label="返回会话" title="关闭 SkillHub,返回会话" onClick={() => { if (!selected && tab === 'browse') navigation.scrollTop = scroller.current?.scrollTop ?? 0; onReturn(); }}><Icon name="close" /></Button>}</div>
|
||||
{(installation || installed) && <Button variant="ghost" className="astron-sh-icon-button" aria-label="返回会话" title="关闭 SkillHub,返回会话" onClick={() => { cancelFavoriteOpen(); if (!selected && tab === 'browse') rememberScroll(); onReturn(); }}><Icon name="close" /></Button>}</div>
|
||||
</header>
|
||||
{sourceOpen && <p className="astron-sh-source-info" role="status">当前来源:{context.data?.registryUrl || '尚未配置来源'}</p>}
|
||||
{(compatibility.isError || compatibility.data?.dependencyBaseline === 'unverified') && <div role="alert" className="astron-sh-error">
|
||||
|
|
@ -183,8 +249,10 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
<div className="astron-sh-detail-heading"><SkillAvatar name={sourceMatches ? detail.data?.title || selected.displayName : selected.displayName} slug={selected.slug} namespace={selected.namespace} />
|
||||
<div><h2 ref={heading} tabIndex={-1}>{sourceMatches ? detail.data?.title || selected.displayName || selected.slug : selected.displayName || selected.slug}</h2>
|
||||
<p>@{selected.namespace}/{selected.slug} · {selected.latestVersion}</p></div>
|
||||
<div className="astron-sh-detail-actions">{sourceMatches && favoriteAction({ ...selected, displayName: detail.data?.title || selected.displayName, summary: detail.data?.summary ?? selected.summary })}
|
||||
<SkillWebsiteLink webUrl={detail.data?.webUrl}
|
||||
enabled={sourceMatches && !detail.isError && !!detail.data && detail.data.namespace === selected.namespace && detail.data.slug === selected.slug} /></div>
|
||||
enabled={sourceMatches && !detail.isError && !!detail.data && detail.data.namespace === selected.namespace && detail.data.slug === selected.slug} /></div></div>
|
||||
{favorites.error && <div role="alert" className="astron-sh-favorite-feedback"><p>{favorites.error}</p><Button size="sm" variant="ghost" disabled={favorites.fetching || favorites.saving} onClick={favorites.retry}>重新读取收藏</Button></div>}
|
||||
<p className="astron-sh-detail-summary">{sourceMatches ? detail.data?.summary ?? selected.summary : selected.summary}</p>
|
||||
{!sourceMatches ? error('来源已变化,请返回技能广场重新选择。', back)
|
||||
: detail.isPending ? <DetailSkeleton />
|
||||
|
|
@ -211,25 +279,45 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
|
|||
: <div className="astron-sh-empty"><p>请先打开一个 DSH 会话,再查看该用户和项目的安装记录。</p><Button onClick={onReturn}>前往会话</Button></div>
|
||||
}</TabsContent><TabsContent value="browse">
|
||||
{!context.data.configured ? <div className="astron-sh-empty"><h2>连接 SkillHub</h2><p>{entries.configuration ? '在 DSH 插件页面的 SkillHub 设置中配置技能来源。' : '来源配置暂不可用,已安装技能仍可管理。'}</p><Button onClick={() => { void context.refetch(); }}>刷新连接</Button></div> : <>
|
||||
<form className="astron-sh-search-form" onSubmit={event => { event.preventDefault(); search(draft.trim()); }}>
|
||||
<div className="astron-sh-search-field"><Icon name="search" /><Input aria-label="搜索技能" placeholder="搜索技能名称或用途" value={draft} onChange={event => { navigation.draft = event.target.value; setDraft(event.target.value); }} /></div>
|
||||
<Button type="submit">搜索</Button><Button onClick={() => { navigation.draft = ''; setDraft(''); search(''); }}>清空</Button>
|
||||
<form className="astron-sh-search-form" onSubmit={event => { event.preventDefault(); if (favoriteOnly) searchFavorites(favoriteDraft.trim()); else search(draft.trim()); }}>
|
||||
<div className="astron-sh-search-field"><Icon name="search" /><Input aria-label={favoriteOnly ? '搜索本机收藏' : '搜索技能'} placeholder={favoriteOnly ? '搜索收藏的技能' : '搜索技能名称或用途'} value={favoriteOnly ? favoriteDraft : draft} onChange={event => {
|
||||
if (favoriteOnly) { navigation.favoriteDraft = event.target.value; setFavoriteDraft(event.target.value); }
|
||||
else { navigation.draft = event.target.value; setDraft(event.target.value); }
|
||||
}} /></div>
|
||||
<Button type="submit">搜索</Button><Button onClick={() => {
|
||||
if (favoriteOnly) { navigation.favoriteDraft = ''; setFavoriteDraft(''); searchFavorites(''); }
|
||||
else { navigation.draft = ''; setDraft(''); search(''); }
|
||||
}}>清空</Button>
|
||||
</form>
|
||||
{(label || !!labels.data?.items.length) && <div className="astron-sh-label-filter" role="group" aria-label="技能标签">
|
||||
{!favoriteOnly && (label || !!labels.data?.items.length) && <div className="astron-sh-label-filter" role="group" aria-label="技能标签">
|
||||
<span>标签</span><Button size="sm" variant="ghost" aria-pressed={!label} onClick={() => changeLabel('')}>全部</Button>
|
||||
{labels.data?.items.map(item => <Button key={item.slug} size="sm" variant="ghost" aria-pressed={label === item.slug} onClick={() => changeLabel(item.slug)}>{catalogLabel(item)}</Button>)}
|
||||
{label && !labels.data?.items.some(item => item.slug === label) && <Button size="sm" variant="ghost" aria-pressed="true" onClick={() => changeLabel('')}>{label} · 清除</Button>}
|
||||
</div>}
|
||||
{labels.isError && <p className="astron-sh-label-note" role="status">标签暂时无法读取。<Button size="sm" variant="ghost" disabled={labels.isFetching} onClick={() => { void labels.refetch(); }}>重试标签</Button>{label && <Button size="sm" variant="ghost" onClick={() => changeLabel('')}>清除标签筛选</Button>}</p>}
|
||||
<div className="astron-sh-result-bar"><p role="status">{pages ? `共匹配 ${pages[0]!.total} 项,当前显示 ${items.length} 项。` : '技能目录'}</p>
|
||||
{!limited && <label>排序<SelectField label="技能排序" value={sort} onValueChange={next => { const value = next as Sort; resetListing(query, value); navigation.sort = value; setSort(value); }} options={[{ value: 'newest', label: '最新发布' }, { value: 'downloads', label: '下载量' }, { value: 'rating', label: '评分' }]} /></label>}
|
||||
{!favoriteOnly && labels.isError && <p className="astron-sh-label-note" role="status">标签暂时无法读取。<Button size="sm" variant="ghost" disabled={labels.isFetching} onClick={() => { void labels.refetch(); }}>重试标签</Button>{label && <Button size="sm" variant="ghost" onClick={() => changeLabel('')}>清除标签筛选</Button>}</p>}
|
||||
<div className="astron-sh-result-bar"><div className="astron-sh-result-summary"><Button ref={favoriteFilter} size="sm" variant={favoriteOnly ? 'outline' : 'ghost'} className="astron-sh-favorite-filter" aria-pressed={favoriteOnly} onClick={toggleFavorites}><Icon name="bookmark" />仅看收藏</Button>
|
||||
<p role="status">{favoriteOnly ? favorites.ready ? `${visibleFavorites.length} 项收藏` : favorites.loading ? '正在读取收藏…' : '本机收藏' : pages ? `共匹配 ${pages[0]!.total} 项,当前显示 ${items.length} 项。` : '技能目录'}</p></div>
|
||||
{!favoriteOnly && !limited && <label>排序<SelectField label="技能排序" value={sort} onValueChange={next => { const value = next as Sort; resetListing(query, value); navigation.sort = value; setSort(value); }} options={[{ value: 'newest', label: '最新发布' }, { value: 'downloads', label: '下载量' }, { value: 'rating', label: '评分' }]} /></label>}
|
||||
</div>
|
||||
{favorites.error && <div role="alert" className="astron-sh-favorite-feedback"><p>{favorites.error}</p><Button size="sm" variant="ghost" disabled={favorites.fetching || favorites.saving} onClick={favorites.retry}>重新读取收藏</Button></div>}
|
||||
{favoriteOpening?.revision === revision && <p role="status" className="astron-sh-label-note">正在读取所选技能…</p>}
|
||||
{favoriteOpenError && favoriteOpenError.revision === revision && <div role="alert" className="astron-sh-favorite-feedback"><p>{favoriteOpenError.message}</p><Button size="sm" variant="ghost" onClick={() => { void openFavorite(favoriteOpenError.item, favoriteOpenError.intent); }}>重试</Button><Button size="sm" variant="ghost" onClick={() => setFavoriteOpenError(null)}>关闭</Button></div>}
|
||||
{installed && inventory.isPending && !inventory.data && <p className="astron-sh-label-note" role="status">正在核对本地安装状态,完成后可安装或管理技能。</p>}
|
||||
{installed && !inventory.isFetching && (inventory.isError || !inventory.data || !inventory.data.complete || inventory.data.unavailable.length > 0) && <p className="astron-sh-label-note" role="status">部分本地安装状态暂时无法核实,安装和管理操作暂不可用。<Button size="sm" variant="ghost" onClick={() => { void inventory.refetch(); }}>重新核对</Button></p>}
|
||||
{limited && <p role="status" className="astron-sh-source">当前来源仅支持首批搜索,不支持分页与排序。可输入更具体的关键词缩小范围。</p>}
|
||||
{listing.isPending ? <CardSkeletons /> : listing.isError && !pages ? error(listing.error.message, () => { void listing.refetch(); }) : <>
|
||||
{!favoriteOnly && limited && <p role="status" className="astron-sh-source">当前来源仅支持首批搜索,不支持分页与排序。可输入更具体的关键词缩小范围。</p>}
|
||||
{favoriteOnly ? favorites.loading ? <CardSkeletons /> : !favorites.ready ? null : visibleFavorites.length === 0
|
||||
? <div className="astron-sh-empty"><p>{favorites.items.length ? '没有匹配的收藏。' : '当前来源还没有收藏。'}</p>{!favorites.items.length && <Button variant="outline" onClick={toggleFavorites}>浏览技能</Button>}</div>
|
||||
: <div className="astron-sh-card-grid">{visibleFavorites.map(item => <SkillCard key={favoriteIdentity(item)} item={item} favoriteAction={favoriteAction(item)}
|
||||
status={inventory.data && !inventory.isError && inventory.data.items.some(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug)
|
||||
? <span>{skillInstallationSummary(inventory.data.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug))}</span> : undefined}
|
||||
actions={<MarketCardActions records={inventory.data?.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug) ?? []}
|
||||
services={installed} sourceRevision={revision!} verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length}
|
||||
onInstall={() => { void openFavorite(item, 'install'); }} onManage={() => { void openFavorite(item, 'detail'); }} />}
|
||||
ref={node => { if (lastSelected.current?.namespace === item.namespace && lastSelected.current?.slug === item.slug) lastCard.current = node; }}
|
||||
onOpen={event => { void openFavorite(item, 'detail', event.currentTarget); }} />)}</div>
|
||||
: listing.isPending ? <CardSkeletons /> : listing.isError && !pages ? error(listing.error.message, () => { void listing.refetch(); }) : <>
|
||||
{items.length === 0 ? <p className="astron-sh-empty">{listing.hasNextPage ? '此批没有可展示的已发布技能,可继续加载。' : '没有找到匹配的技能,请尝试其他关键词。'}</p>
|
||||
: <div className="astron-sh-card-grid">{items.map(item => <SkillCard key={`${item.namespace}/${item.slug}`} item={item} version={item.latestVersion}
|
||||
: <div className="astron-sh-card-grid">{items.map(item => <SkillCard key={`${item.namespace}/${item.slug}`} item={item} version={item.latestVersion} favoriteAction={favoriteAction(item)}
|
||||
status={inventory.data && !inventory.isError
|
||||
&& inventory.data.items.some(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug)
|
||||
? <span>{skillInstallationSummary(inventory.data.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug))}</span> : undefined}
|
||||
|
|
|
|||
12
plugins/dsh/src/client/favorite-button.tsx
Normal file
12
plugins/dsh/src/client/favorite-button.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { FavoriteSkill } from '../host/types.js';
|
||||
import { Icon } from './skill-card.js';
|
||||
import { Button } from './ui.js';
|
||||
|
||||
export function FavoriteButton({ item, pressed, disabled, busy, onToggle }: {
|
||||
item: FavoriteSkill; pressed: boolean; disabled: boolean; busy: boolean; onToggle(): void;
|
||||
}) {
|
||||
const label = `${pressed ? '取消收藏' : '收藏'} ${item.displayName || item.slug} @${item.namespace}/${item.slug}`;
|
||||
return <Button variant="ghost" size="icon" className="astron-sh-icon-button astron-sh-favorite-button"
|
||||
aria-label={label} aria-pressed={pressed} aria-busy={busy || undefined} title={pressed ? '取消本机收藏' : '收藏到本机'} disabled={disabled}
|
||||
onClick={event => { event.stopPropagation(); onToggle(); }}><Icon name="bookmark" /></Button>;
|
||||
}
|
||||
27
plugins/dsh/src/client/favorites.ts
Normal file
27
plugins/dsh/src/client/favorites.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { FavoriteSkill, FavoriteVersion, SkillList } from '../host/types.js';
|
||||
|
||||
export const favoritesKey = (sourceRevision: string | undefined) => ['skillhub-favorites', sourceRevision] as const;
|
||||
export const favoriteIdentity = (item: Pick<FavoriteSkill, 'namespace' | 'slug'>) => JSON.stringify([item.namespace, item.slug]);
|
||||
|
||||
/** Persist public display text only, never a version, installation token or metrics. */
|
||||
export function favoriteSnapshot(item: FavoriteSkill): FavoriteSkill {
|
||||
return {
|
||||
namespace: item.namespace, slug: item.slug,
|
||||
...(item.displayName ? { displayName: item.displayName.slice(0, 256) } : {}),
|
||||
...(item.summary ? { summary: item.summary.slice(0, 1024) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterFavorites(items: FavoriteSkill[], query: string): FavoriteSkill[] {
|
||||
const text = query.trim().normalize('NFKC').toLocaleLowerCase();
|
||||
return text ? items.filter(item => [item.namespace, item.slug, item.displayName, item.summary]
|
||||
.some(value => value?.normalize('NFKC').toLocaleLowerCase().includes(text))) : items;
|
||||
}
|
||||
|
||||
/** A saved snapshot becomes a catalog selection only after a current-source resolve. */
|
||||
export function resolvedFavoriteItem(item: FavoriteSkill, version: FavoriteVersion): SkillList['items'][number] {
|
||||
if (version.namespace !== item.namespace || version.slug !== item.slug || !version.version.trim()) {
|
||||
throw new Error('技能版本暂时无法读取,请重试。');
|
||||
}
|
||||
return { ...favoriteSnapshot(item), namespace: version.namespace, slug: version.slug, latestVersion: version.version, summary: item.summary ?? '' };
|
||||
}
|
||||
|
|
@ -180,6 +180,8 @@ export function NativeControlStyles() {
|
|||
.skillhub-controls .astron-sh-catalog-header{min-height:54px;margin-bottom:20px;border-bottom:1px solid var(--astron-sh-border);padding-bottom:12px;gap:12px}
|
||||
.skillhub-controls .astron-sh-location{display:flex;align-items:center;gap:12px;font-size:13px}.skillhub-controls .astron-sh-location strong{font-size:15px;font-weight:600}.skillhub-controls .astron-sh-location>span{color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-location>svg{color:var(--astron-sh-accent)}
|
||||
.skillhub-controls .astron-sh-toolbar-actions{display:flex;gap:6px}.skillhub-controls .astron-sh-icon-button{height:32px;min-height:32px;width:32px;padding:6px}.skillhub-controls .astron-sh-source-info{padding:10px 14px;border-radius:7px;background:var(--astron-sh-soft);font-size:12px;color:var(--astron-sh-muted);overflow-wrap:anywhere}
|
||||
.skillhub-controls .astron-sh-skill-heading>div{flex:1}.skillhub-controls .astron-sh-favorite-button{flex-shrink:0;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-skill-heading>.astron-sh-favorite-button{position:relative;z-index:2;align-self:flex-start;margin-left:auto}
|
||||
.skillhub-controls .astron-sh-favorite-button[aria-pressed=true]{color:var(--astron-sh-accent);background:var(--astron-sh-soft)}.skillhub-controls .astron-sh-favorite-button[aria-pressed=true] svg{fill:currentColor}.skillhub-controls .astron-sh-favorite-filter{display:flex;align-items:center;gap:6px;flex-shrink:0}.skillhub-controls .astron-sh-favorite-filter[aria-pressed=true]{color:var(--astron-sh-accent);border-color:var(--astron-sh-accent);background:var(--astron-sh-soft)}.skillhub-controls .astron-sh-favorite-filter svg{width:14px;height:14px}.skillhub-controls .astron-sh-result-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.skillhub-controls .astron-sh-favorite-feedback{margin:0 0 14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.skillhub-controls .astron-sh-favorite-feedback p{margin:0}.skillhub-controls .astron-sh-detail-actions{display:flex;align-items:center;gap:8px;margin-left:auto}
|
||||
.skillhub-controls .astron-sh-skill-card{height:auto;min-height:210px}.skillhub-controls .astron-sh-avatar{width:38px;height:38px;border-radius:9px}.skillhub-controls .astron-sh-avatar>span{font-size:19px}.skillhub-controls .astron-sh-skill-heading{padding:15px 16px 10px;gap:11px}.skillhub-controls .astron-sh-skill-heading h3{font-size:15px;line-height:20px;font-weight:600}.skillhub-controls .astron-sh-skill-heading p{font-size:12px;line-height:18px;margin-top:3px}.skillhub-controls .astron-sh-skill-body{padding:0 16px 10px}.skillhub-controls .astron-sh-skill-summary{font-size:13px;line-height:20px;min-height:40px;max-height:40px}.skillhub-controls .astron-sh-skill-meta{margin-top:8px;font-size:12px;line-height:18px}.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{padding:7px 15px;min-height:43px;height:auto}.skillhub-controls .astron-sh-metrics{font-size:12px;gap:11px}.skillhub-controls .astron-sh-metrics svg{width:12px;height:12px;color:color-mix(in srgb,var(--astron-sh-accent) 60%,var(--astron-sh-muted))}.skillhub-controls .astron-sh-card-open{color:var(--astron-sh-accent);font-size:12px}.skillhub-controls .astron-sh-detail-heading .astron-sh-avatar{width:46px;height:46px}
|
||||
.skillhub-controls .astron-sh-select-root{display:inline-flex;min-width:0}.skillhub-controls .astron-sh-select-trigger{width:100%;min-width:120px;display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;height:38px;border:1px solid var(--astron-sh-border);border-radius:7px;background:var(--astron-sh-bg);padding:8px 11px;text-align:start}.skillhub-controls .astron-sh-select-trigger>svg{width:14px;height:14px;color:var(--astron-sh-muted)}
|
||||
.skillhub-controls .astron-sh-select-content{z-index:1100;max-height:var(--radix-select-content-available-height);min-width:var(--radix-select-trigger-width);overflow:hidden;border:1px solid var(--astron-sh-border);border-radius:8px;background:var(--astron-sh-bg);color:var(--astron-sh-fg);box-shadow:0 7px 24px #0002;padding:4px;font-size:13px}.skillhub-controls .astron-sh-select-item{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:8px 10px;border-radius:5px;cursor:default;outline:none}.skillhub-controls .astron-sh-select-item[data-disabled]{opacity:.45;cursor:not-allowed}.skillhub-controls .astron-sh-select-item[data-highlighted]{background:var(--astron-sh-soft);color:var(--astron-sh-accent)}.skillhub-controls .astron-sh-select-item[data-state=checked]{color:var(--astron-sh-accent);font-weight:500}.skillhub-controls .astron-sh-select-scroll{display:flex;justify-content:center;height:20px;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-scope-field>.astron-sh-select-root{width:100%}
|
||||
|
|
|
|||
|
|
@ -34,17 +34,18 @@ export function SkillMetrics({ item }: { item: SkillPresentation }) {
|
|||
</div>;
|
||||
}
|
||||
export const SkillCard = forwardRef<HTMLButtonElement, {
|
||||
item: SkillPresentation; version: string; onOpen: MouseEventHandler<HTMLButtonElement>; status?: ReactNode;
|
||||
actions?: ReactNode; accessibleLabel?: string; descriptionId?: string;
|
||||
}>(({ item, version, onOpen, status, actions, accessibleLabel, descriptionId }, ref) =>
|
||||
item: SkillPresentation; version?: string; onOpen: MouseEventHandler<HTMLButtonElement>; status?: ReactNode;
|
||||
actions?: ReactNode; favoriteAction?: ReactNode; accessibleLabel?: string; descriptionId?: string;
|
||||
}>(({ item, version, onOpen, status, actions, favoriteAction, accessibleLabel, descriptionId }, ref) =>
|
||||
<Card className="astron-sh-skill-card">
|
||||
<button ref={ref} type="button" className="astron-sh-card-open" onClick={onOpen}
|
||||
aria-label={accessibleLabel || `查看 ${item.displayName || item.slug} @${item.namespace}/${item.slug} ${version} 详情`} aria-describedby={descriptionId} />
|
||||
aria-label={accessibleLabel || `查看 ${item.displayName || item.slug} @${item.namespace}/${item.slug}${version ? ` ${version}` : ''} 详情`} aria-describedby={descriptionId} />
|
||||
<CardHeader className="astron-sh-skill-heading"><SkillAvatar name={item.displayName} slug={item.slug} namespace={item.namespace} />
|
||||
<div><CardTitle>{item.displayName || item.slug}</CardTitle><CardDescription title={`@${item.namespace}/${item.slug}`}>@{item.namespace}/{item.slug}</CardDescription></div>
|
||||
{favoriteAction}
|
||||
</CardHeader>
|
||||
<CardContent className="astron-sh-skill-body"><p id={descriptionId} className="astron-sh-skill-summary" title={item.summary}>{item.summary || '暂无摘要'}</p>
|
||||
<div className="astron-sh-skill-meta"><span title={version}>v{version}</span>{status}</div></CardContent>
|
||||
{(version || status) && <div className="astron-sh-skill-meta">{version && <span title={version}>v{version}</span>}{status}</div>}</CardContent>
|
||||
<CardFooter><SkillMetrics item={item} /><div className="astron-sh-card-actions">{actions}</div></CardFooter>
|
||||
</Card>);
|
||||
SkillCard.displayName = 'SkillCard';
|
||||
|
|
|
|||
63
plugins/dsh/src/client/use-favorites.ts
Normal file
63
plugins/dsh/src/client/use-favorites.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
|
||||
import type { FavoriteList, FavoriteSkill } from '../host/types.js';
|
||||
import { catalogErrorMessage } from './catalog-error.js';
|
||||
import { favoriteSnapshot, favoritesKey } from './favorites.js';
|
||||
import '../../lib/typert.remote-client.js';
|
||||
|
||||
type Remote = Pick<TypertRemoteNamespaceMap['skillhub'], 'listFavorites' | 'setFavorite'>;
|
||||
interface FavoriteMutation { sourceRevision: string; item: FavoriteSkill; favorite: boolean; signal: AbortSignal }
|
||||
|
||||
function unwrap<T>(result: { ok: true; value: T } | { ok: false; error: { code: string; details?: unknown } }): T {
|
||||
if (result.ok) return result.value;
|
||||
throw new Error(catalogErrorMessage(result.error));
|
||||
}
|
||||
|
||||
/** Share serialization across observers of the same source, including a detail/card pair. */
|
||||
export function favoriteMutationOptions(remote: Pick<Remote, 'setFavorite'>, client: QueryClient, sourceRevision: string | undefined): UseMutationOptions<FavoriteList, Error, FavoriteMutation> {
|
||||
return {
|
||||
scope: { id: JSON.stringify(favoritesKey(sourceRevision)) }, retry: false,
|
||||
mutationFn: async input => {
|
||||
input.signal.throwIfAborted();
|
||||
return unwrap(await remote.setFavorite(input.sourceRevision, input.item, input.favorite, input.signal));
|
||||
},
|
||||
onMutate: input => client.cancelQueries({ queryKey: favoritesKey(input.sourceRevision), exact: true }),
|
||||
onSuccess: (value, input) => { client.setQueryData(favoritesKey(input.sourceRevision), value); },
|
||||
onSettled: (_data, _error, input) => client.invalidateQueries({ queryKey: favoritesKey(input.sourceRevision), exact: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useFavorites(remote: Remote, sourceRevision: string | undefined, enabled: boolean) {
|
||||
const client = useQueryClient();
|
||||
const controllers = useRef(new Set<AbortController>());
|
||||
useLayoutEffect(() => () => {
|
||||
for (const controller of controllers.current) controller.abort();
|
||||
controllers.current.clear();
|
||||
}, [sourceRevision]);
|
||||
const query = useQuery({ queryKey: favoritesKey(sourceRevision), enabled: enabled && !!sourceRevision,
|
||||
retry: false, staleTime: 0,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!sourceRevision) throw new Error('请先连接技能来源。');
|
||||
return unwrap(await remote.listFavorites(sourceRevision, signal));
|
||||
},
|
||||
});
|
||||
const mutation = useMutation(favoriteMutationOptions(remote, client, sourceRevision));
|
||||
const currentMutation = mutation.variables?.sourceRevision === sourceRevision;
|
||||
const saving = currentMutation && mutation.isPending;
|
||||
const message = currentMutation && mutation.isError ? mutation.error.message : query.isError ? query.error.message : undefined;
|
||||
return {
|
||||
items: query.data?.items ?? [], loading: query.isPending, fetching: query.isFetching,
|
||||
ready: !!query.data && !query.isError, saving, error: message,
|
||||
retry: () => { mutation.reset(); void query.refetch(); },
|
||||
save: async (item: FavoriteSkill, favorite: boolean): Promise<boolean> => {
|
||||
if (!sourceRevision || !query.data || query.isError) return false;
|
||||
const controller = new AbortController(); controllers.current.add(controller);
|
||||
try {
|
||||
await mutation.mutateAsync({ sourceRevision, item: favoriteSnapshot(item), favorite, signal: controller.signal });
|
||||
return !controller.signal.aborted;
|
||||
} catch { return false; }
|
||||
finally { controllers.current.delete(controller); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -27,6 +27,19 @@ test('unknown or malformed errors use safe fallback messages', () => {
|
|||
expect(catalogErrorMessage({ code: 'skillhub/source-not-configured' })).toContain('设置 SkillHub 地址');
|
||||
});
|
||||
|
||||
test('local favorites errors explain recovery without exposing file paths or upstream diagnostics', () => {
|
||||
const cases = [
|
||||
['INVALID_STORE', '本地收藏文件无法读取,原有内容已保留。'],
|
||||
['LIMIT_REACHED', '本机收藏已达上限,请先取消部分收藏。'],
|
||||
['INVALID_INPUT', '此技能暂时无法收藏,请刷新后重试。'],
|
||||
['UNAVAILABLE', '本地收藏暂时不可用,请重试。'],
|
||||
] as const;
|
||||
for (const [reason, expected] of cases) {
|
||||
expect(catalogErrorMessage({ code: 'skillhub/favorites-error', details: { reason, message: '/private/favorites.json' } })).toBe(expected);
|
||||
}
|
||||
expect(catalogErrorMessage({ code: 'skillhub/favorites-error', details: { reason: 'FUTURE' } })).toBe('本地收藏暂时不可用,请重试。');
|
||||
});
|
||||
|
||||
test('rate limit hints only interpolate a validated wait duration', () => {
|
||||
expect(catalogErrorMessage({ code: 'skillhub/registry-error', details: { reason: 'RATE_LIMITED', retryAfterSeconds: 30 } }))
|
||||
.toContain('至少 30 秒后手动重试');
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@ import { expect, test } from 'bun:test';
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { assertHostDependencies, hostDependencyBaseline, supportedDshVersions } from '../src/host/compatibility.js';
|
||||
|
||||
const favoritesSdk = '@deepseek-ai/dsh-atomic-write';
|
||||
|
||||
test('Host dependency guard matches exact peer versions and installed development artifacts', async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
expect(manifest.peerDependenciesMeta).toEqual({ '@deepseek-ai/dsh-user-questions': { optional: true } });
|
||||
expect(manifest.peerDependencies['@deepseek-ai/dsh-user-questions']).toBe(supportedDshVersions.join(' || '));
|
||||
expect(Object.fromEntries(Object.entries(manifest.peerDependencies).filter(([name]) => !manifest.peerDependenciesMeta?.[name]?.optional))).toEqual(Object.fromEntries(Object.keys(hostDependencyBaseline).map(name => [name, name === '@deepseek-ai/cordis' ? '4.0.4 || 4.0.5' : supportedDshVersions.join(' || ')])));
|
||||
expect(Object.fromEntries(Object.entries(manifest.peerDependencies).filter(([name]) => !manifest.peerDependenciesMeta?.[name]?.optional && name !== favoritesSdk))).toEqual(Object.fromEntries(Object.keys(hostDependencyBaseline).map(name => [name, name === '@deepseek-ai/cordis' ? '4.0.4 || 4.0.5' : supportedDshVersions.join(' || ')])));
|
||||
expect(() => assertHostDependencies()).not.toThrow();
|
||||
});
|
||||
|
||||
test('independent favorites SDK declares exact supported peers and the installed development baseline', async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
// Preferences use a separately declared Host SDK, not an added inventory-write gate.
|
||||
expect(Object.hasOwn(hostDependencyBaseline, favoritesSdk)).toBe(false);
|
||||
expect(manifest.peerDependencies[favoritesSdk]).toBe('0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5');
|
||||
expect(manifest.peerDependenciesMeta?.[favoritesSdk]).toBeUndefined();
|
||||
expect(manifest.devDependencies[favoritesSdk]).toBe('0.2.1-rc.2');
|
||||
const installed = JSON.parse(await readFile(new URL(import.meta.resolve('@deepseek-ai/dsh-atomic-write/package.json')), 'utf8'));
|
||||
expect(installed).toMatchObject({ name: favoritesSdk, version: '0.2.1-rc.2' });
|
||||
});
|
||||
|
||||
test('every mismatched, missing or malformed Host package fails closed without exposing loader diagnostics', () => {
|
||||
for (const name of Object.keys(hostDependencyBaseline)) {
|
||||
for (const invalid of [null, {}, { name, version: '0.2.1-alpha.2' }, { name: 'other', version: '0.2.0-rc.2' }]) {
|
||||
|
|
|
|||
96
plugins/dsh/test/favorites-client.test.ts
Normal file
96
plugins/dsh/test/favorites-client.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { expect, test } from 'bun:test';
|
||||
import { MutationObserver, QueryClient, isCancelledError } from '@tanstack/react-query';
|
||||
import type { FavoriteList, FavoriteSkill } from '../src/host/types.js';
|
||||
import { favoriteIdentity, favoriteSnapshot, favoritesKey, filterFavorites, resolvedFavoriteItem } from '../src/client/favorites.js';
|
||||
import { favoriteMutationOptions } from '../src/client/use-favorites.js';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
const first: FavoriteSkill = { namespace: 'team', slug: 'writer', displayName: '文档助手', summary: 'Write notes' };
|
||||
const second: FavoriteSkill = { namespace: 'another', slug: 'writer', displayName: 'Another Writer' };
|
||||
|
||||
test('favorite snapshots are bounded public identity and text, never a version, token or market count', () => {
|
||||
const item = { ...first, displayName: '长'.repeat(300), summary: '文'.repeat(1100), latestVersion: '1.2.0', detailToken: 'private', starCount: 42 };
|
||||
const snapshot = favoriteSnapshot(item);
|
||||
expect(snapshot).toEqual({ namespace: 'team', slug: 'writer', displayName: '长'.repeat(256), summary: '文'.repeat(1024) });
|
||||
expect(item.starCount).toBe(42);
|
||||
expect(favoriteIdentity(first)).not.toBe(favoriteIdentity(second));
|
||||
expect(favoriteIdentity({ namespace: 'a/b', slug: 'c' })).not.toBe(favoriteIdentity({ namespace: 'a', slug: 'b/c' }));
|
||||
});
|
||||
|
||||
test('favorite search covers the complete local list independently of catalog pages', () => {
|
||||
const items = Array.from({ length: 30 }, (_, index) => ({ namespace: 'demo', slug: `skill-${index}`, displayName: index === 29 ? '跨页收藏' : `Skill ${index}` }));
|
||||
expect(filterFavorites(items, '跨页')).toEqual([items[29]!]);
|
||||
expect(filterFavorites([first, second], 'ANOTHER')).toEqual([second]);
|
||||
expect(filterFavorites([first], 'write notes')).toEqual([first]);
|
||||
expect(filterFavorites(items, ' ')).toBe(items);
|
||||
});
|
||||
|
||||
test('only a fresh matching version resolution can create an installable catalog selection', () => {
|
||||
expect(resolvedFavoriteItem(first, { namespace: 'team', slug: 'writer', version: '2.0.0' })).toMatchObject({ latestVersion: '2.0.0', summary: 'Write notes' });
|
||||
for (const version of [{ namespace: 'other', slug: 'writer', version: '2' }, { namespace: 'team', slug: 'other', version: '2' }, { namespace: 'team', slug: 'writer', version: ' ' }]) {
|
||||
expect(() => resolvedFavoriteItem(first, version)).toThrow('技能版本暂时无法读取');
|
||||
}
|
||||
});
|
||||
|
||||
test('saving cancels an older list read before committing the confirmed snapshot into its own source cache', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const stale = deferred<FavoriteList>(); const saved: FavoriteList = { items: [first] };
|
||||
const reading = client.fetchQuery({ queryKey: favoritesKey('source-a'), queryFn: () => stale.promise }).catch(error => error);
|
||||
const mutation = new MutationObserver(client, favoriteMutationOptions({ setFavorite: async () => ({ ok: true, value: saved }) }, client, 'source-a'));
|
||||
try {
|
||||
client.setQueryData(favoritesKey('source-b'), { items: [second] });
|
||||
await mutation.mutate({ sourceRevision: 'source-a', item: first, favorite: true, signal: new AbortController().signal });
|
||||
stale.resolve({ items: [] });
|
||||
expect(isCancelledError(await reading)).toBe(true);
|
||||
expect(client.getQueryData<FavoriteList>(favoritesKey('source-a'))).toEqual(saved);
|
||||
expect(client.getQueryData<FavoriteList>(favoritesKey('source-b'))).toEqual({ items: [second] });
|
||||
} finally { client.clear(); }
|
||||
});
|
||||
|
||||
test('same-source saves serialize across observers and a failed write preserves the previous cache', async () => {
|
||||
const client = new QueryClient(); const entered = deferred<void>(); const release = deferred<void>();
|
||||
let calls = 0;
|
||||
const remote = { setFavorite: async () => {
|
||||
calls++;
|
||||
if (calls === 1) { entered.resolve(); await release.promise; return { ok: true as const, value: { items: [first] } }; }
|
||||
return { ok: true as const, value: { items: [first, second] } };
|
||||
} };
|
||||
const options = favoriteMutationOptions(remote, client, 'source');
|
||||
const a = new MutationObserver(client, options), b = new MutationObserver(client, options);
|
||||
try {
|
||||
const firstSave = a.mutate({ sourceRevision: 'source', item: first, favorite: true, signal: new AbortController().signal });
|
||||
await entered.promise;
|
||||
const secondSave = b.mutate({ sourceRevision: 'source', item: second, favorite: true, signal: new AbortController().signal });
|
||||
await Promise.resolve(); expect(calls).toBe(1);
|
||||
release.resolve(); await Promise.all([firstSave, secondSave]);
|
||||
expect(client.getQueryData<FavoriteList>(favoritesKey('source'))).toEqual({ items: [first, second] });
|
||||
const failing = new MutationObserver(client, favoriteMutationOptions({ setFavorite: async () => { throw new Error('unavailable'); } }, client, 'source'));
|
||||
await expect(failing.mutate({ sourceRevision: 'source', item: first, favorite: false, signal: new AbortController().signal })).rejects.toThrow('unavailable');
|
||||
expect(client.getQueryData<FavoriteList>(favoritesKey('source'))).toEqual({ items: [first, second] });
|
||||
} finally { client.clear(); }
|
||||
});
|
||||
|
||||
test('a queued cancelled save never transports or changes another source', async () => {
|
||||
const client = new QueryClient(); let calls = 0;
|
||||
const entered = deferred<void>(), release = deferred<void>();
|
||||
const controller = new AbortController();
|
||||
const options = favoriteMutationOptions({ setFavorite: async () => {
|
||||
calls++; entered.resolve(); await release.promise;
|
||||
return { ok: true, value: { items: [first] } };
|
||||
} }, client, 'old-source');
|
||||
const firstMutation = new MutationObserver(client, options), queued = new MutationObserver(client, options);
|
||||
try {
|
||||
client.setQueryData(favoritesKey('new-source'), { items: [second] });
|
||||
const pending = firstMutation.mutate({ sourceRevision: 'old-source', item: first, favorite: true, signal: new AbortController().signal });
|
||||
await entered.promise;
|
||||
const cancelled = queued.mutate({ sourceRevision: 'old-source', item: second, favorite: true, signal: controller.signal }).then(() => null, (error: unknown) => error);
|
||||
controller.abort(); release.resolve(); await pending;
|
||||
expect(await cancelled).toMatchObject({ name: 'AbortError' });
|
||||
expect(calls).toBe(1); expect(client.getQueryData<FavoriteList>(favoritesKey('old-source'))).toEqual({ items: [first] });
|
||||
expect(client.getQueryData<FavoriteList>(favoritesKey('new-source'))).toEqual({ items: [second] });
|
||||
} finally { client.clear(); }
|
||||
});
|
||||
|
|
@ -41,6 +41,8 @@ describe('native local favorites service', () => {
|
|||
await expect(ctx.skillhub.listFavorites(renewedA, signal)).rejects.toMatchObject({ code: 'skillhub/favorites-error', details: { reason: 'INVALID_STORE', retryable: false } });
|
||||
await expect(ctx.skillhub.setFavorite(renewedA, { ...item, slug: '' }, true, signal)).rejects.toMatchObject({ code: 'skillhub/favorites-error', details: { reason: 'INVALID_INPUT' } });
|
||||
expect(await readFile(file, 'utf8')).toBe('{damaged-private-path');
|
||||
expect(ctx.skillhub.getStatus()).toBe('SkillHub: connected');
|
||||
expect(ctx.skillhub.getContext()).toMatchObject({ configured: true, sourceRevision: renewedA });
|
||||
} finally { await fiber.dispose(); await server.stop(true); }
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,29 @@ describe('local favorites preferences', () => {
|
|||
expect(await readFile(file)).toEqual(before);
|
||||
}));
|
||||
|
||||
test('a live lock timeout preserves bytes, then a released lock permits a later retry', () => fixture(async (_root, store, file) => {
|
||||
await store.set(source, item, true); const before = await readFile(file);
|
||||
const held = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const lock = withFileLock(file, async () => { held.resolve(); await release.promise; }); await held.promise;
|
||||
try {
|
||||
await expect(store.set(source, { ...item, slug: 'after-lock' }, true)).rejects.toMatchObject({ reason: 'UNAVAILABLE', message: 'UNAVAILABLE' });
|
||||
expect(await readFile(file)).toEqual(before);
|
||||
} finally { release.resolve(); await lock; }
|
||||
await store.set(source, { ...item, slug: 'after-lock' }, true);
|
||||
expect(JSON.parse(await readFile(file, 'utf8')).items.map((entry: { slug: string }) => entry.slug).sort()).toEqual(['after-lock', 'writer']);
|
||||
}), 10000);
|
||||
|
||||
test('cancellation while waiting for a lock rejects before publishing', () => fixture(async (_root, store, file) => {
|
||||
await store.set(source, item, true); const before = await readFile(file);
|
||||
const held = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const lock = withFileLock(file, async () => { held.resolve(); await release.promise; }); await held.promise;
|
||||
const abort = new AbortController();
|
||||
const outcome = store.set(source, { ...item, slug: 'cancelled' }, true, () => abort.signal.throwIfAborted()).then(() => null, (error: unknown) => error);
|
||||
abort.abort(); release.resolve(); await lock;
|
||||
expect(await outcome).toBe(abort.signal.reason);
|
||||
expect(await readFile(file)).toEqual(before);
|
||||
}));
|
||||
|
||||
test('non-file or unavailable storage exposes stable reasons without raw diagnostics', () => fixture(async (root, store, file) => {
|
||||
await mkdir(join(root, 'skillhub')); await mkdir(file);
|
||||
await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE', message: 'INVALID_STORE' });
|
||||
|
|
@ -106,8 +129,25 @@ describe('local favorites preferences', () => {
|
|||
await writeFile(join(root, 'store.mjs'), await build.outputs[0]!.text());
|
||||
await symlink(fileURLToPath(new URL('../node_modules', import.meta.url)), join(root, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const runner = join(root, 'writer.mjs');
|
||||
await writeFile(runner, `import {FavoritesStore} from './store.mjs';
|
||||
process.send({type:'ready'});
|
||||
await writeFile(runner, `import fs from 'node:fs/promises';
|
||||
import {syncBuiltinESMExports} from 'node:module';
|
||||
import {join} from 'node:path';
|
||||
const lock=join(process.argv[2],'skillhub/favorites.json.lock');
|
||||
const originalWrite=fs.writeFile;
|
||||
let observed=false;
|
||||
// Observe the SDK's real exclusive-create failure, never replace lock behavior.
|
||||
fs.writeFile=async function(filename,data,options){
|
||||
try {return await originalWrite.call(this,filename,data,options);}
|
||||
catch(error){
|
||||
if(!observed&&String(filename)===lock&&options?.flag==='wx'){
|
||||
const contention=error.code==='EEXIST'||(error.code==='EPERM'&&await fs.lstat(lock).then(value=>value.isFile(),()=>false));
|
||||
if(contention){observed=true;process.send({type:'contended',code:error.code});}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const {FavoritesStore}=await import('./store.mjs');
|
||||
try {const value=await new FavoritesStore(process.argv[2]).set(process.argv[3],{namespace:'team',slug:process.argv[4]},true);process.send({type:'saved',count:value.items.length});process.disconnect();} catch(error){process.send({type:'failed',reason:error.reason??error.name});process.disconnect();process.exitCode=1;}`);
|
||||
const children: ReturnType<typeof spawn>[] = [], counts: number[] = [], completions: Promise<void>[] = [], ready: Promise<void>[] = [];
|
||||
try {
|
||||
|
|
@ -115,13 +155,21 @@ try {const value=await new FavoritesStore(process.argv[2]).set(process.argv[3],{
|
|||
for (const slug of ['first', 'second']) {
|
||||
const child = spawn('node', [runner, root, source, slug], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); children.push(child);
|
||||
ready.push(new Promise<void>((resolve, reject) => {
|
||||
child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'ready') resolve(); });
|
||||
child.once('error', reject); child.once('exit', code => { if (code !== 0) reject(new Error(`Writer exited ${code}`)); });
|
||||
let contended = false;
|
||||
const deadline = setTimeout(() => reject(new Error('Writer did not attempt the held lock')), 5000);
|
||||
child.on('message', message => {
|
||||
if (message && typeof message === 'object' && 'type' in message && message.type === 'contended'
|
||||
&& 'code' in message && (message.code === 'EEXIST' || message.code === 'EPERM')) { contended = true; clearTimeout(deadline); resolve(); }
|
||||
});
|
||||
child.once('error', error => { clearTimeout(deadline); reject(error); });
|
||||
child.once('exit', code => { clearTimeout(deadline); if (!contended) reject(new Error(`Writer exited ${code} before attempting the held lock`)); });
|
||||
}));
|
||||
completions.push(new Promise<void>((resolve, reject) => {
|
||||
const completion = new Promise<void>((resolve, reject) => {
|
||||
child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'saved' && 'count' in message && typeof message.count === 'number') counts.push(message.count); });
|
||||
child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`)));
|
||||
}));
|
||||
});
|
||||
void completion.catch(() => {});
|
||||
completions.push(completion);
|
||||
}
|
||||
await Promise.all(ready);
|
||||
expect(JSON.parse(await readFile(file, 'utf8')).items.map((entry: { slug: string }) => entry.slug)).toEqual(['writer']);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SkillCard } from '../src/client/skill-card.js';
|
|||
import { MarketCardActions } from '../src/client/market-card-actions.js';
|
||||
import { InstallationStatus } from '../src/client/installation-status.js';
|
||||
import { catalogLabel } from '../src/client/catalog-label.js';
|
||||
import { FavoriteButton } from '../src/client/favorite-button.js';
|
||||
|
||||
const item = { namespace: 'demo', slug: 'document', displayName: '文档助手' };
|
||||
describe('market presentation and action boundaries', () => {
|
||||
|
|
@ -26,6 +27,15 @@ describe('market presentation and action boundaries', () => {
|
|||
expect(render(false)).toContain('disabled=""');
|
||||
expect(render(true)).not.toContain('disabled=""');
|
||||
});
|
||||
test('favorite control is separate from the detail trigger and snapshots have no invented version or metrics', () => {
|
||||
const html = renderToStaticMarkup(<SkillCard item={item} onOpen={() => {}}
|
||||
favoriteAction={<FavoriteButton item={item} pressed disabled={false} busy={false} onToggle={() => {}} />} />);
|
||||
const detailEnd = html.indexOf('</button>', html.indexOf('class="astron-sh-card-open"'));
|
||||
expect(html.indexOf('aria-label="取消收藏 文档助手 @demo/document"')).toBeGreaterThan(detailEnd);
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
expect(html).toContain('aria-label="查看 文档助手 @demo/document 详情"');
|
||||
expect(html).not.toContain('vundefined'); expect(html).not.toContain('收藏数');
|
||||
});
|
||||
test('hides only complete empty source records, preserving unsafe status', () => {
|
||||
const base = { checkedAt: '2026-10-20T00:00:00Z', complete: true, unavailable: [], records: [] };
|
||||
expect(renderToStaticMarkup(<InstallationStatus hideEmpty installation={base} />)).toBe('');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue