fix(dsh): simplify installed cards and restore skill summaries (#1032)

* feat(dsh): add efficient installed skill update overview

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): count snapshot reads across platforms

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): allow filesystem fixture time on Windows

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(dsh): clarify installed skill cards and metadata

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(dsh): compact installed cards and group secondary actions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-29 15:24:24 +08:00 • committed by GitHub
parent 6952629eed
commit d09bb7d20c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 324 additions and 42 deletions

View file

@ -4,7 +4,7 @@
用户主动检查当前来源、当前工作区可见的受管理安装。无进入页面自动扫描、后台轮询、批量执行、自动覆盖或新增Registry/SaaS协议。日常仍按技能聚合;实际检查/更新以精确itemKey为单位。
## 交互
已安装页提供检查更新与进度n/m;结果渐进呈现,不逐项弹通知,不阻断浏览。汇总可更新技能数与检查失败项,支持全部/可更新/检查失败筛选和失败重试,关键词可叠加。已最新无卡片冗余文字;可更新显示目标版本摘要或多位置有更新,查看更新进入现有详情右侧,保持使用技能主动作。位置结果独立:同技能只计一个可更新技能,但不同位置版本与确认分开。blocked显示需处理,不算可执行更新;unavailable/错误算检查失败,不伪报已最新。
已安装页提供检查更新与进度n/m;结果渐进呈现,不逐项弹通知,不阻断浏览。汇总可更新技能数与检查失败项,支持全部/可更新/检查失败筛选和失败重试,关键词可叠加。已最新无卡片冗余文字;卡片右上角最多展示两个状态标签:安装状态仅在已停用或待核验时展示;更新标签按检查失败、需处理、可更新优先级展示一项,单位置可更新保留目标版本,多位置统一显示“可更新”,其余细节进入详情查看。正常已安装不重复标记。卡片头部组合头像、名称、命名空间与右侧状态;无状态时不预留独立标签行。保持紧凑间距和同排按钮对齐,窄屏下状态与长名称不能重叠。点击卡片(支持键盘)进入现有详情右侧,移除重复的管理安装、详情与查看更新按钮;仅可用技能保留使用技能主动作,点击使用不触发详情跳转。返回列表恢复焦点、滚动和有效筛选。位置结果独立:同技能只计一个可更新技能,但不同位置版本与确认分开。blocked显示需处理,不算可执行更新;unavailable/错误算检查失败,不伪报已最新。
## 技术边界
统一检查使用DSH私有只读检查轮次;保留原单项checkUpdate和精确位置prepareUpgrade事务,不另造批量写入API。客户端有界并发只读检查;重入禁用,取消/离开/切来源/会话/工作区时中止在途并丢弃迟到结果。结果仅适用于同一来源会话及同一安装引用。库存变更、更新/卸载/启停导致旧itemKey失效时清除对应结果。详情返回保留仍有效结果与筛选;详情更新执行始终重新核验,不把检查结果当授权。错误/空库存/不完整库存明确表达,不能全局显示全部已最新。
@ -21,3 +21,8 @@
轮次引用只由Host生成并绑定真实会话/工作区/来源/文件服务/运行时,客户端不提供路径。数量、生命周期和并发有界,结束、取消、过期和销毁释放资源;单个位置等待取消不误杀同技能其他位置共享请求。旧轮次结果不得流入新上下文,检查快照不能作为写入授权。
性能验收覆盖10/100/500处安装,分别记录库存读取、目标核验、远端请求次数和耗时;以读取次数和工作量接近线性为主要回归断言,不使用机器相关固定耗时阈值。真实DSH验证新私有协议、渐进结果、取消和原逐项更新确认。
## 已安装卡片的信息与展示来源
已安装卡片采用紧凑横向布局:左侧头像,中间真实标题、一行简介及弱化的安装版本/命名空间,右侧必要状态与操作。桌面两列、窄屏一列,极窄卡片操作自然换行,不挤压标题。安装位置版本不同时展示多版本摘要,不把最新市场版本冒充本地版本。保留使用技能主操作与整卡详情入口;更多菜单仅提供管理安装和检查更新。管理安装进入既有详情右侧,检查更新复用只读轮次并限定当前技能的精确安装引用,多位置分别检查,不执行更新。菜单与使用操作不冒泡打开详情,关闭菜单恢复触发器焦点,进入详情后返回恢复卡片焦点。日常卡片不展示启停、卸载或检查可用性。最多两个状态标签,可更新使用柔和蓝色、已停用中性灰、失败/需处理琥珀色,明暗主题均可读且不只靠颜色表达。窄卡状态自然换行,不挤掉标题。无摘要时展示简短缺省,不编造用途。
列表展示信息从可信受管理安装位置的 SKILL.md 或 SKILL.md.disabled 读取,返回可选 displayName/summary。固定文件、普通文件、有界读取、拒绝符号链接和异常路径;YAML解析仅抽取有界字符串,不执行标签或内容。失败仅省略展示字段,不改变安装状态、完整性判定或来源。展示字段不进入安装引用、授权和更新快照。只在列表读取,保持一次库存读取,无逐卡网络详情请求、全目录摘要哈希或影响检查更新的快照热路径;会话、来源、根目录变化仍在返回前核验。

View file

@ -138,7 +138,7 @@ Each visibility setting accepts `auto`, `show` or `hide`. Auto detects actual an
- Browse supports search, server sorting, real labels and explicit **Load more**, normally 12 results per page. A first-page 404/405 can fall back to bounded legacy search, with an explicit pagination/sorting limitation. A failed label filter never silently returns unfiltered results.
- Details show fixed-version documentation and optional tabs. Select a downloadable version before confirming installation. Existing-target protection remains; skill downgrades are not supported.
- Installed skills are grouped by identity and support keyword filtering. Open “管理安装” for independent location rows and precise confirmed operations; missing directories retain stale-record cleanup.
- Installed skills are grouped by identity and support keyword filtering. Compact horizontal cards use two columns on desktop and one on narrow screens. Click a card to open details; usable skills keep the primary use action. The more menu opens installation management or checks only this skill’s installation locations without applying updates. Cards show locally installed names, one-line summaries and actual installed versions, marking differing copies as multiple versions. Up to two semantic badges show disabled/unverified installation and update status (blue for updates, gray for disabled, amber for warnings), without a redundant installed label. Details provide independent location rows and precise confirmed operations; missing directories retain stale-record cleanup.
- `astron_skillhub_search` only searches. Result cards provide a detail action; the drawer offers explicit installation confirmation.
- An explicit request to install a uniquely selected result does not require opening the drawer first. `astron_skillhub_install` verifies the search-time version and content snapshot. It accepts a `selectionRef` or a full coordinate, exclusively, and a `user`/`project` scope. Expired or ambiguous references are rejected without switching to current latest.

View file

@ -138,7 +138,7 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。
- **浏览技能**:搜索、服务端排序、真实标签筛选、点击加载更多。新目录接口每批 12 项;旧来源首批 404/405 可回退受限搜索,明确提示不支持分页和排序。选中标签失败不会退回未过滤结果。
- **技能详情**:查看文档、版本和可选模块;可“查看此版本”后确认安装指定的可下载版本。已有其他版本时保留覆盖保护,不执行降级。
- **已安装**:按技能聚合并支持关键词过滤。默认提供使用与管理安装;详情右侧按位置显示版本、状态和操作,升级、启停与卸载均只影响确认的位置。目录缺失时仍可清理失效记录。
- **已安装**:按技能聚合并支持关键词过滤。紧凑横向卡片在桌面显示两列、窄屏一列,点击卡片进入详情。可用技能保留“使用技能”主按钮,更多菜单提供“管理安装”和只检查当前技能各安装位置的“检查更新”,不自动执行更新。展示本地技能文件中的名称、一行摘要与实际安装版本;不同位置版本不同时明确标注多个版本。右上角最多显示安装异常/停用与更新状态两个标签(可更新为浅蓝、停用为灰、异常为琥珀色),正常已安装不重复标记。详情右侧按位置显示版本、状态和操作,升级、启停与卸载均只影响确认的位置。目录缺失时仍可清理失效记录。
- **对话搜索**:`astron_skillhub_search` 只搜索,不安装或执行技能。结果提供明确详情动作;详情抽屉可确认安装。
- **对话安装**:用户明确要求安装唯一搜索结果时,不必先打开详情。`astron_skillhub_install` 核验搜索时的精确版本和快照;接受 `selectionRef` 或完整坐标,二者互斥,范围为 `user`/`project`。过期或歧义引用拒绝执行,不改装当前 latest。

View file

@ -16,6 +16,7 @@
"class-variance-authority": "0.7.1",
"react-markdown": "10.1.0",
"rehype-sanitize": "6.0.0",
"yaml": "2.9.1",
"zod": "4.4.3",
},
"devDependencies": {

View file

@ -100,6 +100,7 @@
"class-variance-authority": "0.7.1",
"react-markdown": "10.1.0",
"rehype-sanitize": "6.0.0",
"yaml": "2.9.1",
"zod": "4.4.3"
},
"devDependencies": {

View file

@ -1,3 +1,4 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { checkInstalledUpdates, updateFailed, type UpdateResult } from './update-overview.js';
import { SkillWebsiteLink } from './skill-website-link.js';
import { groupManagementItems, InstallationManagement, type ManagementViewServices } from './management-tool-view.js';
@ -14,7 +15,7 @@ import { DetailContent } from './detail-content.js';
import { RemovalHistory, type RemovalServices } from './removal-control.js';
import { InstalledStyles } from './installed-styles.js';
import type { InstalledNavigation } from './installed-navigation.js';
import { Button, Card, CardHeader, CardContent, CardFooter } from './ui.js';
import { Badge, Button, Card, CardHeader, CardTitle, CardDescription } from './ui.js';
import { SkillAvatar } from './skill-card.js';
export interface InstalledServices extends UpgradeServices, RemovalServices {
@ -30,6 +31,27 @@ export interface InstalledServices extends UpgradeServices, RemovalServices {
checkUpdate(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise<RemoteResult<InstalledUpdateCheck>>;
}
type Item = InstalledSkillList['items'][number];
export function installedCardBadges(items: Item[], results: Record<string, UpdateResult>, verified: boolean) {
const badges: { label: string; tone: 'neutral' | 'warning' | 'info' }[] = [];
const summary = verified ? skillInstallationSummary(items) : '安装待核验';
if (summary !== '已安装') badges.push({ label: summary, tone: summary === '已停用' ? 'neutral' : 'warning' });
if (items.some(item => updateFailed(results[item.itemKey]))) badges.push({ label: '检查失败', tone: 'warning' });
else if (items.some(item => results[item.itemKey]?.state === 'blocked')) badges.push({ label: '更新需处理', tone: 'warning' });
else if (items.some(item => results[item.itemKey]?.state === 'upgradable')) {
const result = results[items[0]!.itemKey];
badges.push({ label: items.length > 1 ? '可更新' : `可更新至 ${result && 'candidateVersion' in result ? result.candidateVersion ?? '新版本' : '新版本'}`, tone: 'info' });
}
return badges;
}
export function installedCardVersions(items: Item[]) {
const versions = [...new Set(items.map(item => item.version))];
return { label: versions.length === 1 ? `v${versions[0]}` : `多个版本 · ${versions.map(version => `v${version}`).join(' / ')}`,
title: items.map(item => `${item.scope === 'user' ? '所有项目' : '仅此项目'} · v${item.version}`).join(';') };
}
export function installedUpdateKeys(inventory: Item[], results: Record<string, UpdateResult>, retry: boolean, group?: Item[]) {
return inventory.filter(item => !group || group.some(target => target.itemKey === item.itemKey))
.map(item => item.itemKey).filter(key => !retry || updateFailed(results[key]));
}
const noContext = { getSnapshot: () => 0, subscribe: () => () => {} };
export function InstalledSkills({ services, sourceRevision, source, remote, modes, scrollContainer, navigation, onInstall }: {
services: InstalledServices; sourceRevision: string; source: string; remote: TypertRemoteNamespaceMap['skillhub']; modes: SkillHubConfig['detailTabs'];
@ -44,6 +66,8 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
const heading = useRef<HTMLHeadingElement>(null);
const refreshButton = useRef<HTMLButtonElement>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
const triggerKey = useRef<string | null>(null);
const menuNavigating = useRef(false);
const listScroll = useRef(navigation.listScroll);
useLayoutEffect(() => { Object.assign(navigation, { scope: 'all', keyword, selected, intent: 'detail', listScroll: listScroll.current }); });
useLayoutEffect(() => {
@ -69,7 +93,7 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
const verified = !!query.data && !query.isError && query.data.complete && !query.data.unavailable.length;
const records = (identity: Item) => query.data?.items.filter(item => item.namespace === identity.namespace && item.slug === identity.slug) ?? [identity];
const actions = (items: Item[], label?: string) => <SkillPrimaryActions items={items} revision={sourceRevision} verified={verified}
select={services.management?.selectSkillUse} useSkill={services.skillUse} onManage={!label ? () => { const identity = items[0]; if (identity) { listScroll.current = scrollContainer.current?.scrollTop ?? 0; setSelected(identity); } } : undefined} label={label} summaryVisible={!label} />;
select={services.management?.selectSkillUse} useSkill={services.skillUse} label={label} summaryVisible={!label} />;
const [results, setResults] = useState<Record<string, UpdateResult>>({});
const [filter, setFilter] = useState('all');
@ -88,9 +112,9 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
useLayoutEffect(() => {
if (selected) { scan.current?.abort(); scan.current = null; setProgress(previous => ({ ...previous, running: false })); }
}, [selected]);
const startCheck = (retry = false) => {
const startCheck = (retry = false, group?: Item[]) => {
if (scan.current || query.isFetching || query.isError) return;
const keys = (query.data?.items ?? []).map(item => item.itemKey).filter(key => !retry || updateFailed(results[key]));
const keys = installedUpdateKeys(query.data?.items ?? [], results, retry, group);
if (!keys.length) return;
const controller = new AbortController(); scan.current = controller;
const scanGeneration = context.getSnapshot();
@ -153,7 +177,7 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
</article><aside className="astron-sh-detail-aside">{services.management && <InstallationManagement identity={selected} revision={sourceRevision} services={services.management} updateChecks={Object.fromEntries(Object.entries(results).filter((entry): entry is [string, InstalledUpdateCheck] => entry[1].state !== 'failed'))} useSkill={services.skillUse} onInstall={onInstall ? () => onInstall(selected) : undefined} />}</aside></div>
</section>;
const term = keyword.trim().toLocaleLowerCase();
const groups = allGroups.filter(items => filter === 'all' || items.some(item => filter === 'updates' ? results[item.itemKey]?.state === 'upgradable' : updateFailed(results[item.itemKey]))).filter(items => !term || items.some(item => `@${item.namespace}/${item.slug} ${item.version}`.toLocaleLowerCase().includes(term)));
const groups = allGroups.filter(items => filter === 'all' || items.some(item => filter === 'updates' ? results[item.itemKey]?.state === 'upgradable' : updateFailed(results[item.itemKey]))).filter(items => !term || items.some(item => `@${item.namespace}/${item.slug} ${item.displayName ?? ''} ${item.version}`.toLocaleLowerCase().includes(term)));
return <section className="skillhub-installed skillhub-controls" aria-label="已安装技能">
<InstalledStyles />
<div className="skillhub-installed-toolbar"><label className="skillhub-installed-search">
@ -175,17 +199,42 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode
{!groups.length ? <p className="skillhub-installed-empty">{term || filter !== 'all' ? '没有匹配的技能。' : verified ? '当前来源暂无已安装技能。' : '暂未读取到安装记录。'}</p>
: <div className="astron-sh-card-grid" aria-label="已安装技能列表">{groups.map(items => {
const identity = items[0]!;
return <Card className="astron-sh-installed-card" key={JSON.stringify([identity.namespace, identity.slug])}>
<CardHeader className="astron-sh-skill-heading"><SkillAvatar slug={identity.slug} namespace={identity.namespace} /><div><h3>{identity.slug}</h3><span>@{identity.namespace}/{identity.slug}</span></div></CardHeader>
<CardContent><span>{skillInstallationSummary(items)}</span>
{items.some(item => results[item.itemKey]?.state === 'upgradable') && <p>{items.length > 1 ? '多位置安装有更新' : `可更新至 ${(() => { const result = results[identity.itemKey]; return result && 'candidateVersion' in result ? result.candidateVersion ?? '新版本' : '新版本'; })()}`}</p>}
{items.some(item => results[item.itemKey]?.state === 'blocked') && <p>更新需处理,请查看安装状态。</p>}
{items.some(item => updateFailed(results[item.itemKey])) && <p>部分安装位置检查失败,可重试。</p>}
</CardContent>
<CardFooter className="skillhub-installed-actions">{actions(items)}<Button variant="ghost" onClick={event => {
// This record selects documentation only; use always resolves the actual winner on the Host.
listScroll.current = scrollContainer.current?.scrollTop ?? 0; trigger.current = event.currentTarget; setSelected(identity);
}}>{items.some(item => results[item.itemKey]?.state === 'upgradable') ? '查看更新' : '详情'}</Button></CardFooter>
const key = JSON.stringify([identity.namespace, identity.slug]);
const presentation = items.find(item => item.summary) ?? identity;
const displayName = presentation.displayName || identity.slug;
const versions = installedCardVersions(items);
const usable = verified && services.management?.selectSkillUse && items.some(item => item.state === 'present' && item.installationState !== 'disabled');
return <Card className="astron-sh-installed-card" key={key}>
<button type="button" className="astron-sh-card-open" aria-label={`查看 ${displayName} @${identity.namespace}/${identity.slug} 详情`}
ref={node => { if (triggerKey.current === key) trigger.current = node; }} onClick={event => {
// This record selects documentation only; use resolves the actual winner on the Host.
listScroll.current = scrollContainer.current?.scrollTop ?? 0;
triggerKey.current = key; trigger.current = event.currentTarget; setSelected(identity);
}} />
<SkillAvatar name={displayName} slug={identity.slug} namespace={identity.namespace} />
<CardHeader className="skillhub-installed-identity">
<CardTitle title={displayName}>{displayName}</CardTitle>
<p className="skillhub-installed-description" title={presentation.summary}>{presentation.summary || '暂无摘要'}</p>
<CardDescription className="skillhub-installed-version" title={`@${identity.namespace}/${identity.slug} · ${versions.title}`}>{versions.label} · @{identity.namespace}/{identity.slug}</CardDescription>
</CardHeader>
<div className="skillhub-installed-side">
<div className="skillhub-installed-badges">{installedCardBadges(items, results, verified).map(badge =>
<Badge key={badge.label} variant={badge.tone}>{badge.label}</Badge>)}</div>
<div className="skillhub-installed-actions astron-sh-card-actions">
{usable && actions(items)}
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild><Button size="sm" variant="ghost" aria-label={`${displayName} 更多操作`}>···</Button></DropdownMenu.Trigger>
<DropdownMenu.Portal><DropdownMenu.Content className="skillhub-controls astron-sh-position-menu" align="end" sideOffset={4} collisionPadding={8}
onCloseAutoFocus={event => { if (menuNavigating.current) { event.preventDefault(); menuNavigating.current = false; } }}>
<DropdownMenu.Item onSelect={() => {
listScroll.current = scrollContainer.current?.scrollTop ?? 0;
menuNavigating.current = true; triggerKey.current = key; trigger.current = null; setSelected(identity);
}}>管理安装</DropdownMenu.Item>
<DropdownMenu.Item disabled={progress.running || query.isFetching || query.isError} onSelect={() => startCheck(false, items)}>检查更新</DropdownMenu.Item>
</DropdownMenu.Content></DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
</Card>;
})}</div>}
</>}

View file

@ -13,13 +13,25 @@ export function InstalledStyles() {
.skillhub-installed .skillhub-installed-filters { display:flex;gap:4px; }
.skillhub-installed .skillhub-installed-filters button { margin:0; }
.skillhub-installed .skillhub-installed-note { font-size:13px;color:var(--astron-sh-muted);margin-block:16px; }
.skillhub-installed .astron-sh-installed-card { display:flex;flex-direction:column;min-width:0; }
.skillhub-installed .astron-sh-installed-card .astron-sh-card-content { padding:0 18px 18px;font-size:13px; }
.skillhub-installed .astron-sh-installed-card .astron-sh-card-content p { margin:4px 0; }
.skillhub-installed .skillhub-installed-actions { display:flex;flex-wrap:wrap;gap:8px;padding:14px 18px;border-top:1px solid var(--astron-sh-border);margin-top:auto; }
.skillhub-installed .skillhub-installed-actions button { font-size:12px;padding:6px 8px; }
.skillhub-installed .skillhub-installed-actions button + button { margin-inline-start:0; }
.skillhub-installed .skillhub-installed-coordinate { display:block;font-size:12px;line-height:18px;color:var(--astron-sh-muted);margin-top:4px;overflow-wrap:anywhere; }
.skillhub-installed .astron-sh-card-grid { grid-template-columns:repeat(2,minmax(0,1fr));gap:12px; }
.skillhub-installed .astron-sh-installed-card { display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:start;gap:12px;padding:16px;position:relative;min-width:0; }
.skillhub-installed .skillhub-installed-identity { padding:0;min-width:0; }
.skillhub-installed .skillhub-installed-identity h3 { font-size:15px;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.skillhub-installed .skillhub-installed-description { margin:5px 0 0;font-size:13px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.skillhub-installed .skillhub-installed-version { margin:5px 0 0;font-size:12px;line-height:18px;color:var(--astron-sh-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.skillhub-installed .skillhub-installed-side { display:flex;flex-direction:column;align-items:flex-end;justify-content:space-between;align-self:stretch;gap:10px;max-width:140px; }
.skillhub-installed .skillhub-installed-badges { display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px;max-width:140px; }
.skillhub-installed .skillhub-installed-badges .astron-sh-badge { text-align:right; }
.skillhub-installed .skillhub-installed-actions { margin-top:auto;flex-wrap:nowrap;gap:4px; }
.skillhub-installed .skillhub-installed-actions button { margin:0;font-size:12px;min-height:30px;height:auto;padding:4px 8px; }
.skillhub-installed .skillhub-installed-actions .astron-sh-primary-actions { margin:0; }
@container (max-width:800px) { .skillhub-installed .astron-sh-card-grid { grid-template-columns:1fr; } }
@container (max-width:380px) {
.skillhub-installed .astron-sh-installed-card { grid-template-columns:32px minmax(0,1fr);gap:10px;padding:12px; }
.skillhub-installed .astron-sh-installed-card>.astron-sh-avatar { width:32px;height:32px; }
.skillhub-installed .skillhub-installed-side { grid-column:2;flex-direction:row;align-items:center;max-width:none; }
.skillhub-installed .skillhub-installed-actions { margin-top:0;margin-left:auto; }
}
.skillhub-installed .skillhub-installed-empty { padding:32px 16px;text-align:center;border:1px dashed var(--astron-sh-border);border-radius:12px; }
.skillhub-installed .astron-sh-version-update { font-size:13px;line-height:20px; }
.skillhub-installed .astron-sh-version-update .astron-sh-card-header { padding:14px 16px 8px; }

View file

@ -44,6 +44,10 @@ export function NativeControlStyles() {
.skillhub-controls .astron-sh-button-default:not(:disabled):hover{background:color-mix(in srgb,var(--astron-sh-accent) 88%,var(--astron-sh-fg))}
.skillhub-controls .astron-sh-button-ghost{border-color:transparent;background:transparent;box-shadow:none}
.skillhub-controls .astron-sh-button-destructive,.skillhub-controls .skillhub-danger{color:light-dark(#b42318,#ffb4ab)}
.skillhub-controls .astron-sh-badge{display:inline-flex;align-items:center;max-width:100%;border:1px solid transparent;border-radius:5px;padding:1px 7px;font-size:11px;line-height:18px;font-weight:500;overflow-wrap:anywhere}
.skillhub-controls .astron-sh-badge-info{color:light-dark(#175b98,#9bd0ff);background:light-dark(#eaf4ff,#112e48);border-color:light-dark(#bfdbfe,#24517a)}
.skillhub-controls .astron-sh-badge-neutral{color:light-dark(#525866,#c5c8d0);background:light-dark(#f1f2f4,#2a2c31);border-color:light-dark(#dce0e6,#42464e)}
.skillhub-controls .astron-sh-badge-warning{color:light-dark(#945414,#f0c27c);background:light-dark(#fff3df,#3e3020);border-color:light-dark(#edd7ab,#635033)}
.skillhub-controls .astron-sh-input{height:40px;min-width:0;background:var(--astron-sh-soft)}
.skillhub-controls .astron-sh-catalog-header{display:flex;align-items:start;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.skillhub-controls .astron-sh-catalog-header h1{margin:0 0 6px}.skillhub-controls .astron-sh-source{font-size:12px;color:var(--astron-sh-muted);overflow-wrap:anywhere;margin:0}

View file

@ -23,6 +23,14 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ classN
return <Component ref={ref} type={type} className={classes(buttonVariants({ variant, size }), className)} {...props} />;
});
Button.displayName = 'Button';
/** shadcn/ui Badge composition with scoped semantic colors for light and dark DSH themes. */
const badgeVariants = cva('astron-sh-badge', {
variants: { variant: { info: 'astron-sh-badge-info', neutral: 'astron-sh-badge-neutral', warning: 'astron-sh-badge-warning' } },
defaultVariants: { variant: 'neutral' },
});
export function Badge({ className, variant, ...props }: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={classes(badgeVariants({ variant }), className)} {...props} />;
}
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) =>
<input ref={ref} className={classes('astron-sh-input', className)} {...props} />);
Input.displayName = 'Input';

View file

@ -44,6 +44,7 @@ import type { NativeRecoveryResult } from './generated/native-contract.js';
import type { InstallationTargetList } from './types.js';
import type { LocalRuntimeStatus } from './local-runtime.js';
import type { InstalledSkillList, InstalledSkillDetail, InstalledUpdateCheck, SkillUsePreparation, SkillUseSelection } from './types.js';
import { readInstalledPresentations } from './installed-presentation.js';
import { installationFailureCode } from './installation-failure.js';
import type { InstalledProjectionItem } from './generated/native-contract.js';
@ -312,6 +313,7 @@ export class SkillHubService extends TypertRemoteService {
const outcome = await runtime.listInstalled(source.registryUrl, roots).then(
result => ({ result, error: undefined }), error => ({ result: undefined, error }),
);
const presentation = outcome.result ? await readInstalledPresentations(outcome.result.items, roots, combined) : [];
combined.throwIfAborted();
this.assertSource(sourceRevision);
if (agent.session.header.cwd !== cwd || serviceIdentity(agent.ctx.get('fs')) !== serviceIdentity(fs)
@ -333,10 +335,10 @@ export class SkillHubService extends TypertRemoteService {
if (!outcome.result) throw outcome.error;
const result = outcome.result;
const unavailable = targets.filter(entry => !entry.target).map(entry => entry.scope);
const items = result.items.map(item => {
const items = result.items.map((item, index) => {
const itemKey = this.installedItemKey(agent.session.id, cwd, sourceRevision, item);
const locationKey = createHmac('sha256', this.installedReferenceKey).update(JSON.stringify([agent.session.id, cwd, sourceRevision, item.namespace, item.slug, item.scope, item.targetPath])).digest('base64url');
return { itemKey, locationKey, installationState: item.installationState ?? 'enabled', namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state };
return { ...presentation[index], itemKey, locationKey, installationState: item.installationState ?? 'enabled', namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state };
});
return { items, complete: result.complete, issues: result.issues, unavailable };
}

View file

@ -0,0 +1,89 @@
import { constants } from 'node:fs';
import { lstat, open, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve } from 'node:path';
import { parseDocument } from 'yaml';
import type { InstalledProjectionItem, InstalledProjectionTarget } from './generated/native-contract.js';
export type InstalledPresentation = { displayName?: string; summary?: string };
export const MAX_PRESENTATION_BYTES = 64 * 1024;
function text(value: unknown, limit: number): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = Array.from(value, character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 ? ' ' : character).join('').replace(/\s+/gu, ' ').trim();
return normalized ? normalized.slice(0, limit) : undefined;
}
/** Presentation only: never return the instruction body or treat these fields as identity. */
export function parseInstalledPresentation(prefix: Uint8Array): InstalledPresentation {
if (prefix.byteLength > MAX_PRESENTATION_BYTES) return {};
const content = new TextDecoder('utf-8', { fatal: true });
try {
const lines = content.decode(prefix, { stream: true }).replace(/^\uFEFF/u, '').split(/\r?\n/u);
if (lines[0] !== '---') return {};
const closing = lines.findIndex((line, index) => index > 0 && line === '---');
if (closing < 0) return {};
const document = parseDocument(lines.slice(1, closing).join('\n'), { uniqueKeys: true });
if (document.errors.length || document.warnings.length) return {};
const data: unknown = document.toJS({ maxAliasCount: 0 });
if (!data || typeof data !== 'object' || Array.isArray(data)) return {};
const fields = data as Record<string, unknown>;
const displayName = text(fields.name, 128);
const summary = text(fields.description, 1024);
return { ...(displayName ? { displayName } : {}), ...(summary ? { summary } : {}) };
} catch { return {}; }
}
async function canonicalDirectory(path: string): Promise<boolean> {
const info = await lstat(path);
return info.isDirectory() && !info.isSymbolicLink() && await realpath(path) === path;
}
async function readPresentation(item: InstalledProjectionItem, root: string, signal: AbortSignal): Promise<InstalledPresentation> {
signal.throwIfAborted();
if (item.state !== 'present' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(item.slug)
|| join(root, item.slug) !== item.targetPath) return {};
try {
if (!await canonicalDirectory(item.targetPath)) return {};
const path = join(item.targetPath, item.installationState === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md');
const before = await lstat(path, { bigint: true });
if (!before.isFile() || before.isSymbolicLink()) return {};
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
try {
const opened = await file.stat({ bigint: true });
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return {};
const bytes = new Uint8Array(MAX_PRESENTATION_BYTES);
let size = 0;
while (size < bytes.length) {
signal.throwIfAborted();
const { bytesRead } = await file.read(bytes, size, bytes.length - size, size);
if (!bytesRead) break;
size += bytesRead;
}
const after = await file.stat({ bigint: true });
const current = await lstat(path, { bigint: true });
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs
|| current.dev !== opened.dev || current.ino !== opened.ino || !current.isFile()
|| !await canonicalDirectory(root) || !await canonicalDirectory(item.targetPath)) return {};
return parseInstalledPresentation(bytes.subarray(0, size));
} finally { await file.close(); }
} catch { signal.throwIfAborted(); return {}; }
}
/** One bounded local read per valid target; no inventory traversal, discovery, or network call. */
export async function readInstalledPresentations(items: readonly InstalledProjectionItem[], roots: readonly InstalledProjectionTarget[], signal: AbortSignal): Promise<InstalledPresentation[]> {
const approved = new Map<string, string>();
for (const root of roots) {
signal.throwIfAborted();
if (!isAbsolute(root.root) || resolve(root.root) !== root.root) continue;
try { if (await canonicalDirectory(root.root)) approved.set(root.scope, root.root); } catch { signal.throwIfAborted(); }
}
const result: InstalledPresentation[] = [];
for (const item of items) {
signal.throwIfAborted();
const root = approved.get(item.scope);
result.push(root ? await readPresentation(item, root, signal) : {});
}
signal.throwIfAborted();
return result;
}

View file

@ -103,6 +103,8 @@ export type SkillUseSelection = { status: 'ready'; itemKey: string }
export interface InstalledSkillList {
items: { itemKey: string; locationKey?: string; namespace: string; slug: string; version: string; scope: 'user' | 'project';
/** Optional local frontmatter for display only; never used for selection or authorization. */
displayName?: string; summary?: string;
state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable'; installationState?: 'enabled' | 'disabled' }[];
/** Completeness of the inventory projection; unavailable roots are reported independently. */
complete: boolean;

View file

@ -0,0 +1,54 @@
import { expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MAX_PRESENTATION_BYTES, parseInstalledPresentation, readInstalledPresentations } from '../src/host/installed-presentation.js';
import type { InstalledProjectionItem } from '../src/host/generated/native-contract.js';
const bytes = (value: string) => new TextEncoder().encode(value);
const definition = '---\nname: local-helper\ndescription: |\n 整理工作记录\n 生成周报\n---\nPRIVATE INSTRUCTION BODY';
const presentation = { displayName: 'local-helper', summary: '整理工作记录 生成周报' };
test('frontmatter presents authored names and block descriptions without leaking body or other fields', () => {
expect(parseInstalledPresentation(bytes(definition))).toEqual(presentation);
expect(parseInstalledPresentation(bytes('---\nname: "quoted: name"\ndescription: "真实说明"\nsecret: private\n---\nprivate'))).toEqual({ displayName: 'quoted: name', summary: '真实说明' });
});
test('malformed, duplicate, tagged, aliased and non-string metadata fail closed', () => {
for (const value of ['no frontmatter', '---\nname: [\n---', '---\nname: one\nname: two\n---', '---\nname: !custom one\n---', '---\nname: &name one\ndescription: *name\n---', '---\nname: [one]\ndescription: 12\n---']) expect(parseInstalledPresentation(bytes(value))).toEqual({});
});
test('prefix and fields are bounded, preserving valid headers when unicode bodies are cut mid-character', () => {
expect(parseInstalledPresentation(new Uint8Array(MAX_PRESENTATION_BYTES + 1))).toEqual({});
expect(parseInstalledPresentation(bytes('---\nname: '+ 'a'.repeat(MAX_PRESENTATION_BYTES)))).toEqual({});
expect(parseInstalledPresentation(bytes('---\nname: '+ 'n'.repeat(200)+'\ndescription: '+ 'd'.repeat(2000)+'\n---'))).toEqual({ displayName: 'n'.repeat(128), summary: 'd'.repeat(1024) });
expect(parseInstalledPresentation(bytes('# Body title\nNo metadata'))).toEqual({});
for (let offset = 0; offset < 3; offset++) expect(parseInstalledPresentation(bytes(definition+' '.repeat(offset)+'中'.repeat(MAX_PRESENTATION_BYTES)).subarray(0, MAX_PRESENTATION_BYTES))).toEqual(presentation);
});
async function fixture(run: (root: string, item: InstalledProjectionItem, read: (items?: InstalledProjectionItem[], signal?: AbortSignal) => ReturnType<typeof readInstalledPresentations>) => Promise<void>) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-presentation-')));
const item: InstalledProjectionItem = { namespace: 'demo', slug: 'helper', version: '1', scope: 'user', installationState: 'enabled', state: 'present', targetPath: join(root, 'helper') };
await mkdir(item.targetPath);
try { await run(root, item, (items = [item], signal = new AbortController().signal) => readInstalledPresentations(items, [{ scope: 'user', root }], signal)); }
finally { await rm(root, { recursive: true, force: true }); }
}
test('enabled and disabled installations read only their fixed entry with a bounded prefix', async () => fixture(async (_root, item, read) => {
await writeFile(join(item.targetPath, 'SKILL.md'), definition+'中'.repeat(MAX_PRESENTATION_BYTES));
await writeFile(join(item.targetPath, 'SKILL.md.disabled'), '---\nname: disabled-helper\ndescription: 停用时仍可展示\n---');
expect(await read()).toEqual([presentation]);
expect(await read([{ ...item, installationState: 'disabled' }])).toEqual([{ displayName: 'disabled-helper', summary: '停用时仍可展示' }]);
}));
test('missing, untrusted and invalid targets omit display fields without changing projection', async () => fixture(async (root, item, read) => {
expect(await read()).toEqual([{}]); await writeFile(join(item.targetPath, 'SKILL.md'), definition);
expect(await read([{ ...item, state: 'mismatch' }, { ...item, slug: '../helper' }, { ...item, targetPath: root }, { ...item, scope: 'project' }])).toEqual([{}, {}, {}, {}]);
await expect(read([item], AbortSignal.abort())).rejects.toThrow();
}));
test.skipIf(process.platform === 'win32')('symlinked files and directories and FIFO entries cannot expose content or block', async () => fixture(async (root, item, read) => {
const outside = join(root, 'private.md'); await writeFile(outside, definition);
const entry = join(item.targetPath, 'SKILL.md'); await symlink(outside, entry);
expect(await read()).toEqual([{}]); await rm(entry);
execFileSync('mkfifo', [entry]); expect(await read()).toEqual([{}]); await rm(entry);
await rm(item.targetPath, { recursive: true });
const alternate = join(root, 'other'); await mkdir(alternate); await writeFile(join(alternate, 'SKILL.md'), definition);
await symlink(alternate, item.targetPath); expect(await read()).toEqual([{}]);
expect(await readInstalledPresentations([{ ...item, targetPath: join(item.targetPath, 'helper') }], [{ scope: 'user', root: item.targetPath }], new AbortController().signal)).toEqual([{}]);
}));

View file

@ -1,6 +1,6 @@
import { expect, test } from 'bun:test';
import { randomBytes } from 'node:crypto';
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises';
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import SkillHubService from '../src/host/index.js';
@ -10,7 +10,7 @@ import type { InstalledSkillList } from '../src/host/types.js';
async function fixture(run: (fixture: {
list(): Promise<InstalledSkillList>; read(key: string): Promise<unknown>;
records: InstalledProjectionItem[]; session: { id: string; header: { cwd: string } };
setComplete(value: boolean): void; revision(value: string): void; restartHost(): void; project: string; home: string;
listReads(): number; setComplete(value: boolean): void; revision(value: string): void; restartHost(): void; project: string; home: string;
}) => Promise<void>) {
// Match the Host's canonical roots (macOS /var aliases and Windows short temp paths).
const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-installed-references-')));
@ -23,20 +23,20 @@ async function fixture(run: (fixture: {
const session = { id: 'session-a', header: { cwd: project } };
const agent = { session, ctx: { get: () => fs } };
const records: InstalledProjectionItem[] = [];
let complete = true; let revision = 'source';
let complete = true; let revision = 'source'; let listReads = 0;
const service = Object.assign(Object.create(SkillHubService.prototype), {
lifetime: new AbortController(), installedReferenceKey: randomBytes(32),
localRuntime: { listInstalled: async (_registry: string, roots: { scope: string; root: string }[]) => ({
localRuntime: { listInstalled: async (_registry: string, roots: { scope: string; root: string }[]) => { listReads++; return {
items: records.filter(item => roots.some(target => target.scope === item.scope && join(target.root, item.slug) === item.targetPath)),
complete, issues: complete ? [] : ['inventory-unreadable'],
}) },
}; } },
ctx: { invocation: { request: { args: { agentId: 'session-a' } } }, get: () => ({ get: () => agent }) },
assertSource: (value: string) => { if (value !== revision) throw new Error('source changed'); },
getContext: () => ({ configured: true, registryUrl: 'https://registry.example' }),
});
await run({ list: () => service.listInstalled('source', new AbortController().signal),
read: key => service.readInstalledItem('source', key, new AbortController().signal), records, session,
setComplete: value => { complete = value; }, revision: value => { revision = value; },
listReads: () => listReads, setComplete: value => { complete = value; }, revision: value => { revision = value; },
restartHost: () => { service.installedReferenceKey = randomBytes(32); }, project, home });
} finally {
if (previousHome === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previousHome;
@ -125,3 +125,19 @@ test('toggle invalidates old action references but preserves exact location iden
await read(after.itemKey);
});
});
test('local presentation keeps reference identity and uses a single inventory projection per list', async () => {
await fixture(async ({ records, home, list, read, listReads }) => {
const item = record(home, 0); records.push(item);
const before = (await list()).items[0]; expect(listReads()).toBe(1);
await mkdir(item.targetPath, { recursive: true });
await writeFile(join(item.targetPath, 'SKILL.md'), '---\nname: authored-name\ndescription: 本地真实说明\n---\nPRIVATE BODY');
const after = (await list()).items[0]; expect(listReads()).toBe(2);
expect(after).toEqual({ ...before, displayName: 'authored-name', summary: '本地真实说明' });
await read(after.itemKey);
await writeFile(join(item.targetPath, 'SKILL.md'), '---\nname: renamed-display\ndescription: 修改展示文字\n---');
expect((await list()).items[0].itemKey).toBe(before.itemKey);
expect(records[0]).toEqual(item);
});
});

View file

@ -17,7 +17,7 @@ export async function probeInstalledUpdateContext(page) {
await page.evaluate(() => window.fixture.workspace('B'));
await page.waitForFunction(() => !document.querySelector('.skillhub-installed-summary')?.textContent.includes('已检查'));
await scan();
await page.locator('.astron-sh-installed-card').first().getByRole('button', { name: '查看更新', exact: true }).click();
await page.locator('.astron-sh-installed-card').first().getByRole('button', { name: /^查看 .* 详情$/ }).click();
const detail = page.getByRole('region', { name: '已安装技能详情', exact: true });
await detail.getByRole('button', { name: '更新', exact: true }).waitFor();
await detail.getByRole('button', { name: '更新', exact: true }).click();

View file

@ -48,8 +48,9 @@ export async function probeInstalledUpdateOverview(page, screenshot) {
assert.equal(realScanRpc.filter(path => path.endsWith('/checkInstalledUpdateScan')).length, items.length);
assert(!realScanRpc.some(path => path.endsWith('/checkUpdate')));
assert.equal(rpc.slice(realStart).filter(path => path.endsWith('/endInstalledUpdateScan')).length, 1);
const actualUpdate = list.locator('.astron-sh-installed-card').filter({ has: page.getByRole('button', { name: '查看更新', exact: true }) }).first();
await actualUpdate.getByRole('button', { name: '查看更新', exact: true }).click();
const actualUpdate = list.locator('.astron-sh-installed-card').filter({ has: page.locator('.skillhub-installed-badges .astron-sh-badge-info') }).first();
await actualUpdate.getByRole('button', { name: /^查看 .* 详情$/ }).focus();
await page.keyboard.press('Enter');
const realDetail = page.getByRole('region', { name: '已安装技能详情', exact: true });
await realDetail.getByRole('button', { name: '更新', exact: true }).first().click();
await page.getByRole('button', { name: '确认更新', exact: true }).waitFor();
@ -57,6 +58,7 @@ export async function probeInstalledUpdateOverview(page, screenshot) {
await page.getByRole('button', { name: '取消', exact: true }).click();
await page.getByRole('button', { name: '确认更新', exact: true }).waitFor({ state: 'hidden' });
await realDetail.getByRole('button', { name: '← 返回已安装', exact: true }).click();
assert(await actualUpdate.getByRole('button', { name: /^查看 .* 详情$/ }).evaluate(button => button === document.activeElement), 'Return restores card focus');
// Stable fixtures are real installed records; overwrite read-only responses to cover mixed outcomes.
const multi = items.find(item => items.filter(other => other.slug === item.slug).length > 1);
assert(multi, 'Needs a multi-location installation');
@ -89,8 +91,8 @@ export async function probeInstalledUpdateOverview(page, screenshot) {
await screenshot('progress');
await page.getByText(`已检查 ${items.length}/${items.length} · 1 个技能可更新 · 1 处检查失败`, { exact: true }).waitFor();
assert.equal(peak, 3); assert.equal(calls.length, items.length);
assert(await list.getByText('多位置安装有更新').count() === 1);
assert(await list.getByText('更新需处理,请查看安装状态。').count() >= 1);
assert(await list.locator('.skillhub-installed-badges').getByText('可更新', { exact: true }).count() === 1);
assert(await list.getByText('更新需处理').count() >= 1);
await screenshot('mixed-light');
const filter = list.getByLabel('更新筛选');
await filter.getByRole('button', { name: '可更新', exact: true }).click();
@ -99,7 +101,7 @@ export async function probeInstalledUpdateOverview(page, screenshot) {
await keyword.fill('does-not-exist');
await list.getByText('没有匹配的技能。', { exact: true }).waitFor();
await keyword.fill(multi.slug);
await list.getByRole('button', { name: '查看更新', exact: true }).click();
await list.getByRole('button', { name: /^查看 .* 详情$/ }).click();
const detail = page.getByRole('region', { name: '已安装技能详情', exact: true });
await detail.getByRole('button', { name: '更新', exact: true }).first().waitFor();
assert.equal(await detail.getByRole('button', { name: '更新', exact: true }).count(), items.filter(item => item.slug === multi.slug).length);

View file

@ -1,7 +1,7 @@
import { expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { InstalledSkills, type InstalledServices } from '../src/client/installed-skills.js';
import { installedCardBadges, installedCardVersions, installedUpdateKeys, InstalledSkills, type InstalledServices } from '../src/client/installed-skills.js';
import { installedNavigationFor } from '../src/client/installed-navigation.js';
import { UpgradeRequests, RemovalRequests } from '../src/client/upgrade-requests.js';
import { selectSkillItem } from '../src/client/skill-primary-actions.js';
@ -20,9 +20,16 @@ test('installed defaults to one skill card with no position filter or management
const html = renderToStaticMarkup(<QueryClientProvider client={client}><InstalledSkills services={services} sourceRevision="revision" source="https://registry.example"
remote={{} as TypertRemoteNamespaceMap['skillhub']} modes={{ versions: 'auto', reviews: 'auto', securityAudit: 'auto' }} scrollContainer={{ current: null }} navigation={installedNavigationFor({}, 'session', 'revision')} /></QueryClientProvider>);
expect(html.match(/astron-sh-installed-card"/g)).toHaveLength(1);
expect(html).toContain('多个版本 · v1 / v2'); expect(html).toContain('暂无摘要');
expect(html).toContain('检查更新'); expect(html).not.toContain('0 处检查失败');
expect(html).toContain('1 个技能'); expect(html).toContain('使用技能'); expect(html).toContain('管理安装');
expect(html).toContain('aria-label="writer 更多操作"'); expect(html).toContain('aria-haspopup="menu"');
expect(html).toContain('1 个技能'); expect(html).toContain('使用技能'); expect(html).not.toContain('>管理安装</button>'); expect(html).not.toContain('>详情</button>'); expect(html).toContain('aria-label="查看 writer @demo/writer 详情"');
for (const text of ['筛选安装范围', 'role="combobox"', '>停用</button>', '>启用</button>', '>卸载</button>']) expect(html).not.toContain(text);
client.setQueryData(['skillhub-installed', 'session', 'revision'], { items: [{ ...item, displayName: '写作助手', summary: '整理草稿并校对文档。' }], complete: true, unavailable: [], issues: [] });
const disabled = renderToStaticMarkup(<QueryClientProvider client={client}><InstalledSkills services={services} sourceRevision="revision" source="https://registry.example"
remote={{} as TypertRemoteNamespaceMap['skillhub']} modes={{ versions: 'auto', reviews: 'auto', securityAudit: 'auto' }} scrollContainer={{ current: null }} navigation={{ ...installedNavigationFor({}, 'session', 'revision'), keyword: '写作' }} /></QueryClientProvider>);
expect(disabled).toContain('>已停用</span>'); expect(disabled).not.toContain('>使用技能</button>'); expect(disabled).not.toContain('>管理安装</button>');
expect(disabled).toContain('aria-label="查看 写作助手 @demo/writer 详情"'); expect(disabled).toContain('整理草稿并校对文档。'); expect(disabled).not.toContain('暂无摘要'); expect(disabled).toContain('astron-sh-badge-neutral');
} finally { client.clear(); upgrades.dispose(); removals.dispose(); }
});
test('late cancelled selector responses cannot supply a key', async () => {
@ -43,3 +50,33 @@ test('an open installation detail survives removal of its last record without se
expect(html).toContain('aria-label="安装管理"'); expect(html).not.toContain('使用已安装版本</button>'); expect(html).not.toContain('role="dialog"');
} finally { client.clear(); }
});
test('installed card status stays concise without hiding failed or blocked checks', () => {
const enabled = { ...item, installationState: 'enabled' as const };
const update = { state: 'upgradable' as const, currentVersion: '1', candidateVersion: '2', reason: '', checkedAt: '2026-10-29' };
expect(installedCardBadges([enabled], {}, true)).toEqual([]);
expect(installedCardBadges([item], { user: update }, true).map(badge => badge.label)).toEqual(['已停用', '可更新至 2']);
expect(installedCardBadges([enabled], {}, false).map(badge => badge.label)).toEqual(['安装待核验']);
const sibling = { ...enabled, itemKey: 'project' };
expect(installedCardBadges([enabled, sibling], { user: update, project: { ...update, state: 'blocked' } }, true).map(badge => badge.label)).toEqual(['更新需处理']);
expect(installedCardBadges([item, { ...item, itemKey: 'project' }], { user: update, project: { state: 'failed' } }, true).map(badge => badge.label)).toEqual(['已停用', '检查失败']);
expect(installedCardBadges([enabled, sibling], { user: update }, true).map(badge => badge.label)).toEqual(['可更新']);
});
test('installed versions describe actual copies rather than the remote update candidate', () => {
expect(installedCardVersions([item]).label).toBe('v1');
expect(installedCardVersions([item, { ...item, scope: 'project' }]).label).toBe('v1');
const mixed = installedCardVersions([item, { ...item, scope: 'project', version: '2' }]);
expect(mixed.label).toBe('多个版本 · v1 / v2');
expect(mixed.title).toBe('所有项目 · v1;仅此项目 · v2');
});
test('card update check limits the existing read-only scan to current exact installation references', () => {
const project = { ...item, itemKey: 'project', scope: 'project' as const };
const other = { ...item, itemKey: 'other', slug: 'other' };
expect(installedUpdateKeys([item, project, other], {}, false, [item, project])).toEqual(['user', 'project']);
expect(installedUpdateKeys([project, other], {}, false, [item])).toEqual([]);
expect(installedUpdateKeys([item, project, other], { project: { state: 'failed' } }, true)).toEqual(['project']);
expect(installedUpdateKeys([item, project, other], {}, false)).toEqual(['user', 'project', 'other']);
});