diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index 36bf17e66..7de242c06 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -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章新增功能验收。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 1d24341e8..cbac3c6d1 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -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. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index 3b36eb8f6..d88e17d30 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -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` 已包含该功能。 diff --git a/plugins/dsh/src/client/catalog-error.ts b/plugins/dsh/src/client/catalog-error.ts index 76d808d40..1e4abcf6e 100644 --- a/plugins/dsh/src/client/catalog-error.ts +++ b/plugins/dsh/src/client/catalog-error.ts @@ -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; diff --git a/plugins/dsh/src/client/catalog-page.tsx b/plugins/dsh/src/client/catalog-page.tsx index bff181fad..1d8c4fa8c 100644 --- a/plugins/dsh/src/client/catalog-page.tsx +++ b/plugins/dsh/src/client/catalog-page.tsx @@ -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(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(null); const lastCard = useRef(null); const lastSelected = useRef(selected); + const favoriteFilter = useRef(null); + const previousFavoriteOnly = useRef(favoriteOnly); + const favoriteRequest = useRef(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 { + 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['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

{message}

; } 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
{upgradeCandidate && installed &&
SkillHub/{selected ? '技能详情' : '技能广场'}
- {(installation || installed) && }
+ {(installation || installed) && } {sourceOpen &&

当前来源:{context.data?.registryUrl || '尚未配置来源'}

} {(compatibility.isError || compatibility.data?.dependencyBaseline === 'unverified') &&
@@ -183,8 +249,10 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa

{sourceMatches ? detail.data?.title || selected.displayName || selected.slug : selected.displayName || selected.slug}

@{selected.namespace}/{selected.slug} · {selected.latestVersion}

+
{sourceMatches && favoriteAction({ ...selected, displayName: detail.data?.title || selected.displayName, summary: detail.data?.summary ?? selected.summary })}
+ enabled={sourceMatches && !detail.isError && !!detail.data && detail.data.namespace === selected.namespace && detail.data.slug === selected.slug} />
+ {favorites.error &&

{favorites.error}

}

{sourceMatches ? detail.data?.summary ?? selected.summary : selected.summary}

{!sourceMatches ? error('来源已变化,请返回技能广场重新选择。', back) : detail.isPending ? @@ -211,25 +279,45 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa :

请先打开一个 DSH 会话,再查看该用户和项目的安装记录。

} {!context.data.configured ?

连接 SkillHub

{entries.configuration ? '在 DSH 插件页面的 SkillHub 设置中配置技能来源。' : '来源配置暂不可用,已安装技能仍可管理。'}

: <> -
{ event.preventDefault(); search(draft.trim()); }}> -
{ navigation.draft = event.target.value; setDraft(event.target.value); }} />
- + { event.preventDefault(); if (favoriteOnly) searchFavorites(favoriteDraft.trim()); else search(draft.trim()); }}> +
{ + if (favoriteOnly) { navigation.favoriteDraft = event.target.value; setFavoriteDraft(event.target.value); } + else { navigation.draft = event.target.value; setDraft(event.target.value); } + }} />
+
- {(label || !!labels.data?.items.length) &&
+ {!favoriteOnly && (label || !!labels.data?.items.length) &&
标签 {labels.data?.items.map(item => )} {label && !labels.data?.items.some(item => item.slug === label) && }
} - {labels.isError &&

标签暂时无法读取。{label && }

} -

{pages ? `共匹配 ${pages[0]!.total} 项,当前显示 ${items.length} 项。` : '技能目录'}

- {!limited && } + {!favoriteOnly && labels.isError &&

标签暂时无法读取。{label && }

} +
+

{favoriteOnly ? favorites.ready ? `${visibleFavorites.length} 项收藏` : favorites.loading ? '正在读取收藏…' : '本机收藏' : pages ? `共匹配 ${pages[0]!.total} 项,当前显示 ${items.length} 项。` : '技能目录'}

+ {!favoriteOnly && !limited && }
+ {favorites.error &&

{favorites.error}

} + {favoriteOpening?.revision === revision &&

正在读取所选技能…

} + {favoriteOpenError && favoriteOpenError.revision === revision &&

{favoriteOpenError.message}

} {installed && inventory.isPending && !inventory.data &&

正在核对本地安装状态,完成后可安装或管理技能。

} {installed && !inventory.isFetching && (inventory.isError || !inventory.data || !inventory.data.complete || inventory.data.unavailable.length > 0) &&

部分本地安装状态暂时无法核实,安装和管理操作暂不可用。

} - {limited &&

当前来源仅支持首批搜索,不支持分页与排序。可输入更具体的关键词缩小范围。

} - {listing.isPending ? : listing.isError && !pages ? error(listing.error.message, () => { void listing.refetch(); }) : <> + {!favoriteOnly && limited &&

当前来源仅支持首批搜索,不支持分页与排序。可输入更具体的关键词缩小范围。

} + {favoriteOnly ? favorites.loading ? : !favorites.ready ? null : visibleFavorites.length === 0 + ?

{favorites.items.length ? '没有匹配的收藏。' : '当前来源还没有收藏。'}

{!favorites.items.length && }
+ :
{visibleFavorites.map(item => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug) + ? {skillInstallationSummary(inventory.data.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug))} : undefined} + actions={ 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); }} />)}
+ : listing.isPending ? : listing.isError && !pages ? error(listing.error.message, () => { void listing.refetch(); }) : <> {items.length === 0 ?

{listing.hasNextPage ? '此批没有可展示的已发布技能,可继续加载。' : '没有找到匹配的技能,请尝试其他关键词。'}

- :
{items.map(item => {items.map(item => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug) ? {skillInstallationSummary(inventory.data.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug))} : undefined} diff --git a/plugins/dsh/src/client/favorite-button.tsx b/plugins/dsh/src/client/favorite-button.tsx new file mode 100644 index 000000000..a0e276027 --- /dev/null +++ b/plugins/dsh/src/client/favorite-button.tsx @@ -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 ; +} diff --git a/plugins/dsh/src/client/favorites.ts b/plugins/dsh/src/client/favorites.ts new file mode 100644 index 000000000..7400d5c02 --- /dev/null +++ b/plugins/dsh/src/client/favorites.ts @@ -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) => 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 ?? '' }; +} diff --git a/plugins/dsh/src/client/native-control-styles.tsx b/plugins/dsh/src/client/native-control-styles.tsx index b538a696f..55e111583 100644 --- a/plugins/dsh/src/client/native-control-styles.tsx +++ b/plugins/dsh/src/client/native-control-styles.tsx @@ -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%} diff --git a/plugins/dsh/src/client/skill-card.tsx b/plugins/dsh/src/client/skill-card.tsx index 308a36890..74e26ef19 100644 --- a/plugins/dsh/src/client/skill-card.tsx +++ b/plugins/dsh/src/client/skill-card.tsx @@ -34,17 +34,18 @@ export function SkillMetrics({ item }: { item: SkillPresentation }) {
; } export const SkillCard = forwardRef; status?: ReactNode; - actions?: ReactNode; accessibleLabel?: string; descriptionId?: string; -}>(({ item, version, onOpen, status, actions, accessibleLabel, descriptionId }, ref) => + item: SkillPresentation; version?: string; onOpen: MouseEventHandler; status?: ReactNode; + actions?: ReactNode; favoriteAction?: ReactNode; accessibleLabel?: string; descriptionId?: string; +}>(({ item, version, onOpen, status, actions, favoriteAction, accessibleLabel, descriptionId }, ref) => ', 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()).toBe('');