From d09bb7d20cd138927b21742d50266808dd67668a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:24:24 +0800 Subject: [PATCH] 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> --- .../installed-update-check-design.md | 7 +- plugins/dsh/README.en.md | 2 +- plugins/dsh/README.md | 2 +- plugins/dsh/bun.lock | 1 + plugins/dsh/package.json | 1 + plugins/dsh/src/client/installed-skills.tsx | 81 +++++++++++++---- plugins/dsh/src/client/installed-styles.tsx | 26 ++++-- .../dsh/src/client/native-control-styles.tsx | 4 + plugins/dsh/src/client/ui.tsx | 8 ++ plugins/dsh/src/host/index.ts | 6 +- .../dsh/src/host/installed-presentation.ts | 89 +++++++++++++++++++ plugins/dsh/src/host/types.ts | 2 + .../dsh/test/installed-presentation.test.ts | 54 +++++++++++ plugins/dsh/test/installed-references.test.ts | 28 ++++-- .../installed-update-context-probe.mjs | 2 +- .../installed-update-overview-probe.mjs | 12 +-- .../dsh/test/skill-primary-actions.test.tsx | 41 ++++++++- 17 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 plugins/dsh/src/host/installed-presentation.ts create mode 100644 plugins/dsh/test/installed-presentation.test.ts diff --git a/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md b/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md index 6c5c34bed..889196f37 100644 --- a/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md +++ b/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md @@ -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解析仅抽取有界字符串,不执行标签或内容。失败仅省略展示字段,不改变安装状态、完整性判定或来源。展示字段不进入安装引用、授权和更新快照。只在列表读取,保持一次库存读取,无逐卡网络详情请求、全目录摘要哈希或影响检查更新的快照热路径;会话、来源、根目录变化仍在返回前核验。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 84d0b326c..e0c7a320c 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -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. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index f6130313f..a1883ea56 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -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。 diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock index 6873c341b..b17ddab0f 100644 --- a/plugins/dsh/bun.lock +++ b/plugins/dsh/bun.lock @@ -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": { diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 98b6ada22..885eea412 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -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": { diff --git a/plugins/dsh/src/client/installed-skills.tsx b/plugins/dsh/src/client/installed-skills.tsx index 420ed12b7..b588a365a 100644 --- a/plugins/dsh/src/client/installed-skills.tsx +++ b/plugins/dsh/src/client/installed-skills.tsx @@ -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>; } type Item = InstalledSkillList['items'][number]; +export function installedCardBadges(items: Item[], results: Record, 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, 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(null); const refreshButton = useRef(null); const trigger = useRef(null); + const triggerKey = useRef(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) => { 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>({}); 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 ; 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
; })}} } diff --git a/plugins/dsh/src/client/installed-styles.tsx b/plugins/dsh/src/client/installed-styles.tsx index f38504466..033bc2748 100644 --- a/plugins/dsh/src/client/installed-styles.tsx +++ b/plugins/dsh/src/client/installed-styles.tsx @@ -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; } diff --git a/plugins/dsh/src/client/native-control-styles.tsx b/plugins/dsh/src/client/native-control-styles.tsx index 83fceae29..1e80d71ea 100644 --- a/plugins/dsh/src/client/native-control-styles.tsx +++ b/plugins/dsh/src/client/native-control-styles.tsx @@ -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} diff --git a/plugins/dsh/src/client/ui.tsx b/plugins/dsh/src/client/ui.tsx index 13a3e736c..2f7f429e8 100644 --- a/plugins/dsh/src/client/ui.tsx +++ b/plugins/dsh/src/client/ui.tsx @@ -23,6 +23,14 @@ export const Button = React.forwardRef(({ classN return ; }); 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 & VariantProps) { + return ; +} export const Input = React.forwardRef>(({ className, ...props }, ref) => ); Input.displayName = 'Input'; diff --git a/plugins/dsh/src/host/index.ts b/plugins/dsh/src/host/index.ts index b280ffc65..aa68a67ce 100644 --- a/plugins/dsh/src/host/index.ts +++ b/plugins/dsh/src/host/index.ts @@ -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 }; } diff --git a/plugins/dsh/src/host/installed-presentation.ts b/plugins/dsh/src/host/installed-presentation.ts new file mode 100644 index 000000000..195e2dbe1 --- /dev/null +++ b/plugins/dsh/src/host/installed-presentation.ts @@ -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; + 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 { + const info = await lstat(path); + return info.isDirectory() && !info.isSymbolicLink() && await realpath(path) === path; +} + +async function readPresentation(item: InstalledProjectionItem, root: string, signal: AbortSignal): Promise { + 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 { + const approved = new Map(); + 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; +} diff --git a/plugins/dsh/src/host/types.ts b/plugins/dsh/src/host/types.ts index bb6633d1a..57a50e701 100644 --- a/plugins/dsh/src/host/types.ts +++ b/plugins/dsh/src/host/types.ts @@ -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; diff --git a/plugins/dsh/test/installed-presentation.test.ts b/plugins/dsh/test/installed-presentation.test.ts new file mode 100644 index 000000000..aca823ade --- /dev/null +++ b/plugins/dsh/test/installed-presentation.test.ts @@ -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) => Promise) { + 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([{}]); +})); diff --git a/plugins/dsh/test/installed-references.test.ts b/plugins/dsh/test/installed-references.test.ts index 5cb308676..b5c183d7a 100644 --- a/plugins/dsh/test/installed-references.test.ts +++ b/plugins/dsh/test/installed-references.test.ts @@ -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; read(key: string): Promise; 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) { // 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); + }); +}); diff --git a/plugins/dsh/test/integration/installed-update-context-probe.mjs b/plugins/dsh/test/integration/installed-update-context-probe.mjs index eff731fcd..9f56dc501 100644 --- a/plugins/dsh/test/integration/installed-update-context-probe.mjs +++ b/plugins/dsh/test/integration/installed-update-context-probe.mjs @@ -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(); diff --git a/plugins/dsh/test/integration/installed-update-overview-probe.mjs b/plugins/dsh/test/integration/installed-update-overview-probe.mjs index ce3a28db2..f51af081f 100644 --- a/plugins/dsh/test/integration/installed-update-overview-probe.mjs +++ b/plugins/dsh/test/integration/installed-update-overview-probe.mjs @@ -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); diff --git a/plugins/dsh/test/skill-primary-actions.test.tsx b/plugins/dsh/test/skill-primary-actions.test.tsx index 7755b26dd..ddd4c61e5 100644 --- a/plugins/dsh/test/skill-primary-actions.test.tsx +++ b/plugins/dsh/test/skill-primary-actions.test.tsx @@ -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(); 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('>管理安装'); expect(html).not.toContain('>详情'); expect(html).toContain('aria-label="查看 writer @demo/writer 详情"'); for (const text of ['筛选安装范围', 'role="combobox"', '>停用', '>启用', '>卸载']) expect(html).not.toContain(text); + client.setQueryData(['skillhub-installed', 'session', 'revision'], { items: [{ ...item, displayName: '写作助手', summary: '整理草稿并校对文档。' }], complete: true, unavailable: [], issues: [] }); + const disabled = renderToStaticMarkup(); + expect(disabled).toContain('>已停用'); expect(disabled).not.toContain('>使用技能'); expect(disabled).not.toContain('>管理安装'); + 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('使用已安装版本'); 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']); +});