Merge remote-tracking branch 'origin/master' into turtle/plugin-sticky-header-29bfdb179e18

# Conflicts:
#	packages/client/ui-plugin-manager/README.i18n.yaml
#	packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
#	packages/client/ui-primitives/README.i18n.yaml
This commit is contained in:
Turtle committed 2026-10-19 22:12:10 +08:00
commit dbccedbc13
126 files changed
+5220 -181

No files matched your search

@@ -9,8 +9,8 @@
en: edefff7cd986a509
zh: a270839fa86976a2
/agent-note-guided-plugin-installation/decision:
en: 30f1e636973df06a
zh: 0f874b88a5d4d7f5
en: 5448b9f8aaa9a569
zh: 9024c5328c5da3bd
/agent-note-guided-plugin-installation/alternatives-considered:
en: be64b541f4d81e7c
zh: 8600531c08ab4288
@@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed.
**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
@@ -20,7 +20,7 @@ Status: implemented
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
/agent-note-plugin-detail-documents:
en: 2cca350bb3d00987
zh: 02689ad1893d30fe
/agent-note-plugin-detail-documents/problem:
en: 018296714f6bba40
zh: 5926db9dca265dd7
/agent-note-plugin-detail-documents/decision:
en: 638ccade77cb14db
zh: c70a83e2ed0e72f7
/agent-note-plugin-detail-documents/alternatives-considered:
en: 791b7839125f9f99
zh: f75e3abeb9777494
/agent-note-plugin-detail-documents/consequences:
en: 0a37ee01fc43ff26
zh: 156571e1d3181015
@@ -0,0 +1,50 @@
# Agent Note: Plugin detail documents
Status: implemented
English | [中文](2026-10-15-plugin-detail-documents.zh.md)
## Problem
Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin.
Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read.
## Decision
Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/<language>.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it.
`meta.detail` lives in the `locale/<language>.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document.
Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections.
The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison.
Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url.<base>.insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it.
The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text.
## Alternatives considered
**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library.
**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories.
**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents.
**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository.
**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts.
**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred.
**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads.
## Consequences
- Authors write a user document per language under `meta.detail`; other packages show their README.
- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages.
- A repository README is read at the default branch and can differ from the listed version.
- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`.
- Private repositories work when the user's Git credentials reach the host.
- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles.
@@ -0,0 +1,50 @@
# Agent Note: 插件详细介绍文档
Status: implemented
[English](2026-10-15-plugin-detail-documents.md) | 中文
## 问题
插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。
DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。
## 决策
详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/<language>.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。
`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/<language>.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。
**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。
由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。
市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url.<base>.insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。
Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。
## 考虑过的替代方案
**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。
**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。
**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。
**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。
**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。
**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。
**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。
## 后果
- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。
- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。
- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。
- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。
- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。
- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。
@@ -195,6 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+2
View File
@@ -212,6 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
@@ -6,6 +6,53 @@
- code: "@deepseek-ai/dsh-experimental-badge-skill-bundle"
- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。
- img "DSH 徽章技能功能预览"
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: In Web or Desktop, open Plugins and enable
- strong: DSH badge skill
- text: in Official. Switch it off to remove its profile layer.
- paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- code: cordis.patch.yml
- text: contributes the
- code: skill-badge
- text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors.
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Capability implementation
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through the capability implementation, which owns model context and results.
- heading "KV Cache effect" [level=4]
- paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- heading "包含的组件" [level=4]
- text: 共 1 个 · 1 已停用
- list:
+2
View File
@@ -844,6 +844,8 @@ describe('web e2e: plugin manager', () => {
expect(fits).toBe(true)
await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor()
await detail.getByText(badgeVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await detail.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'),
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[badgeVersion, '{{version}}']],
+30 -4
View File
@@ -2482,7 +2482,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2511,6 +2512,14 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
/** Largest bundle detail document, and largest locale file read for one, in bytes. */
detailMaxBytes?: number
/** Largest image served beside a bundle detail document, in bytes. */
detailImageMaxBytes?: number
/** Deadline of one npm file CDN request for an offered bundle's detail document. */
detailFetchTimeoutMs?: number
/** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources?: NpmFileSource[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
@@ -2521,8 +2530,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2533,8 +2542,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document. */
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-market -->
@@ -4850,6 +4875,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
+30 -4
View File
@@ -2484,7 +2484,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2513,6 +2514,14 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
/** Largest bundle detail document, and largest locale file read for one, in bytes. */
detailMaxBytes?: number
/** Largest image served beside a bundle detail document, in bytes. */
detailImageMaxBytes?: number
/** Deadline of one npm file CDN request for an offered bundle's detail document. */
detailFetchTimeoutMs?: number
/** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources?: NpmFileSource[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
@@ -2523,8 +2532,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2535,8 +2544,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document. */
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-market -->
@@ -4852,6 +4877,7 @@ export interface Config {
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0894bd9f35430753
zh: 2d28090e71134edd
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
en: 2382017c7f84ace6
zh: 03af99592baaab9f
en: 68b463cd3d2e5fb0
zh: 80ea797c5770c573
/cookbook-adding-a-workspace-package/6-verify:
en: 80c7fb90b1175aae
zh: 2ba37962dbc57299
+3 -1
View File
@@ -14,7 +14,7 @@ packages/<group>/<pkg>/
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../../<group>/<dep> for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
locale/en.json # optional display metadata: meta.title and meta.description
locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail
locale/zh.json # translations using the same fields
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
@@ -147,6 +147,8 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description.
+3 -1
View File
@@ -14,7 +14,7 @@ packages/<group>/<pkg>/
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../../<group>/<dep> for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
locale/en.json # optional display metadata: meta.title and meta.description
locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail
locale/zh.json # translations using the same fields
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
@@ -147,6 +147,8 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。
+5 -5
View File
@@ -56,11 +56,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:359`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:365`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:398`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
+5 -5
View File
@@ -58,11 +58,11 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:359`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:365`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:398`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
+4 -1
View File
@@ -19,6 +19,7 @@ flowchart TD
pkg_native_command["native-command"]
pkg_output_retention["output-retention"]
pkg_package_manifest["package-manifest"]
pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
@@ -1303,6 +1304,7 @@ flowchart TD
pkg_api_session_controller --> pkg_workspace
pkg_plugin_market --> pkg_plugin_manager
pkg_plugin_market --> pkg_settings
pkg_plugin_market --> pkg_subprocess
pkg_plugin_market --> pkg_tools
pkg_plugin_market --> pkg_typert_protocol
pkg_headless --> pkg_agent
@@ -1448,6 +1450,7 @@ flowchart TD
| [`native-command`](../packages/util/native-command) | `util` | — |
| [`output-retention`](../packages/util/output-retention) | `util` | — |
| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
@@ -1763,7 +1766,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
+4 -1
View File
@@ -21,6 +21,7 @@ flowchart TD
pkg_native_command["native-command"]
pkg_output_retention["output-retention"]
pkg_package_manifest["package-manifest"]
pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
@@ -1305,6 +1306,7 @@ flowchart TD
pkg_api_session_controller --> pkg_workspace
pkg_plugin_market --> pkg_plugin_manager
pkg_plugin_market --> pkg_settings
pkg_plugin_market --> pkg_subprocess
pkg_plugin_market --> pkg_tools
pkg_plugin_market --> pkg_typert_protocol
pkg_headless --> pkg_agent
@@ -1450,6 +1452,7 @@ flowchart TD
| [`native-command`](../packages/util/native-command) | `util` | — |
| [`output-retention`](../packages/util/output-retention) | `util` | — |
| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
@@ -1765,7 +1768,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
+4 -4
View File
@@ -6,11 +6,11 @@
en: 962b4398c9052959
zh: 14471c340a9f6e60
/profile-management/management-records:
en: 56c269c5f6dc107c
zh: 535fe725b924b1e6
en: 2def418f04e9fc10
zh: af0bd1610bdc2f2b
/profile-management/market-records:
en: a28421bc6481b0ed
zh: 73142d6163a20f50
en: 3dfc62d837d61b9b
zh: ec223be315f3dc62
/profile-management/profile-reconciliation:
en: 42a093314af774d4
zh: 7c1784a3af02b31c
+37
View File
@@ -14,6 +14,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one.
`BundleDetail` is a profile bundle's long description: `{ status: 'found' }` with its `origin` (`locale` or `readme`), its `path` inside the package, and its `markdown`, or `{ status: 'none' }`; [`readBundleDetail`](../../packages/boot/plugin-manager/README.md#bundle-detail-documents) documents the order and the image route. `NpmFileSource` names a public npm file CDN, `jsdelivr`, `unpkg`, or `npmmirror`; a read's `npmFileSources` lists the ones to ask first.
`PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first.
`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from.
@@ -26,6 +28,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`MarketQuery` selects one page of an index: fuzzy `text`, a `category`, `labels` that must all be present, a `MarketSort`, `offset`, `limit` up to 100, `refresh` to fetch again instead of using a cached document, and `fetchedAt` to continue the pages of one document. `MarketSearchResult` carries the index URL, `title`, optional `description`, market `metadata`, the document's `fetchedAt`, the page's `entries`, the `total` number of matches, and the `nextOffset`. Each `MarketListingEntry` adds `defaultVersion`, the version installed by default, and `installation`, the profile's status for the package name; each of its `versions` adds `install`, the exact spec and optional first registry for `installBundle`, and `compatibility`, the evaluation of its `engines.dsh` including a version exemption.
`PluginDetail` is a market entry's long description: `{ status: 'found' }` with its `origin` (`locale`, `readme`, or `repository`), its `markdown`, and a `document` id whose images `GET /api/plugin-market/detail-file` serves, or `{ status: 'none' }`. The [plugin market documents the order](../../packages/boot/plugin-market/README.md#plugin-detail-documents) its sources are read in.
## Profile reconciliation
<a id="profile-reconciliation"></a>
@@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
/**
* Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
* README. An installed bundle is read from its directory without network access; an official bundle the installation
* offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
* @param name - the bundle's package name, as `listBundles()` lists it.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
* a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
* @returns the document with its origin and path, or `none`.
* @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
* `plugin-manager/detail-unavailable`.
*/
@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise<BundleDetail>
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
@@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with
* or `plugin-market/invalid-index`.
*/
async entry(url: string, name: string): Promise<MarketListingEntry | undefined>
/**
* Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
* CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
* in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
* version without one, of its `package.json` `repository`, at the default branch. An npm version from another
* registry has no readable package files, so only the entry's `repository` is read.
* @param url - a configured index URL.
* @param name - the plugin's package name.
* @param version - one listed version.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
* npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
* configured are not asked.
* @returns the document with its origin and image document id, or `none`.
* @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
* `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
*/
@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise<PluginDetail>
```
Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts)
+37
View File
@@ -14,6 +14,8 @@
`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
`BundleDetail` 是 profile 中组合包的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale` 或 `readme`)、在包内的 `path` 和 `markdown`;否则为 `{ status: 'none' }`。[`readBundleDetail`](../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 说明读取顺序和图片路由。`NpmFileSource` 指定一个公共 npm 文件 CDN:`jsdelivr`、`unpkg` 或 `npmmirror`;一次读取的 `npmFileSources` 列出先请求的 CDN。
`PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。
`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。
@@ -26,6 +28,8 @@
`MarketQuery` 选择索引的一页:模糊匹配的 `text`、一个 `category`、必须全部具备的 `labels`、一个 `MarketSort`、`offset`、不超过 100 的 `limit`、表示重新获取而不使用缓存文档的 `refresh`,以及用于继续读取同一文档后续页的 `fetchedAt`。`MarketSearchResult` 携带索引 URL、`title`、可选的 `description`、市场 `metadata`、该文档的 `fetchedAt`、本页的 `entries`、匹配总数 `total` 和 `nextOffset`。每个 `MarketListingEntry` 额外携带 `defaultVersion`,即默认安装的版本,以及 `installation`,即该包名在当前 profile 中的状态;其 `versions` 中的每个版本额外携带 `install`,即交给 `installBundle` 的精确 spec 和可选的首选 registry,以及 `compatibility`,即包含版本豁免在内的 `engines.dsh` 评估结果。
`PluginDetail` 是市场条目的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale`、`readme` 或 `repository`)、`markdown`,以及一个 `document` id,该文档中的图片由 `GET /api/plugin-market/detail-file` 提供;否则为 `{ status: 'none' }`。读取其来源的顺序见[插件市场的说明](../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents)。
## Profile 重新组合
<a id="profile-reconciliation"></a>
@@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
/**
* Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
* README. An installed bundle is read from its directory without network access; an official bundle the installation
* offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
* @param name - the bundle's package name, as `listBundles()` lists it.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
* a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
* @returns the document with its origin and path, or `none`.
* @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
* `plugin-manager/detail-unavailable`.
*/
@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise<BundleDetail>
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
@@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with
* or `plugin-market/invalid-index`.
*/
async entry(url: string, name: string): Promise<MarketListingEntry | undefined>
/**
* Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
* CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
* in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
* version without one, of its `package.json` `repository`, at the default branch. An npm version from another
* registry has no readable package files, so only the entry's `repository` is read.
* @param url - a configured index URL.
* @param name - the plugin's package name.
* @param version - one listed version.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
* npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
* configured are not asked.
* @returns the document with its origin and image document id, or `none`.
* @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
* `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
*/
@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise<PluginDetail>
```
Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts)
+3 -3
View File
@@ -32,8 +32,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type {
BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError,
PackageResult,
BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind,
ManagementError, PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind,
PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection,
PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt,
@@ -44,7 +44,7 @@ export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote'
export type {} from '@deepseek-ai/dsh-plugin-market/remote'
export type {
MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketInstallationStatus, MarketListingEntry, MarketListingVersion,
MarketQuery, MarketSearchResult, MarketSort,
MarketQuery, MarketSearchResult, MarketSort, PluginDetail, PluginDetailDocumentId, PluginDetailOrigin,
} from '@deepseek-ai/dsh-plugin-market/types'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote'
@@ -1,4 +1,4 @@
import { Context } from '@deepseek-ai/cordis'
import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -240,26 +240,39 @@ describe('Session file uploads', () => {
await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
})
it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
it('resolves a cold ordinary Agent and releases the resolver with its registering fiber', async () => {
const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
await disposeAgent()
const resolveAgent = vi.fn(async () => {
await ctx.agents.register(agent)
return agent
})
const disposeResolver = uploads.registerAgentResolver(resolveAgent)
expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
const resolverOwner = {
name: 'resolver-owner',
inject: ['fileUploads'],
apply: (owner: Context) => {
owner.effect(() => owner.fileUploads.registerAgentResolver(resolveAgent), 'test: Agent resolver')
},
}
const first = ctx.plugin(resolverOwner)
await first
expect(() => { ctx.fileUploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
await expect(uploads.uploadStream({
sessionId: SESSION,
data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
})).resolves.toMatchObject({ file: { bytes: 1 } })
expect(resolveAgent).toHaveBeenCalledWith(SESSION)
await first.dispose()
const second = ctx.plugin(resolverOwner)
await second
expect(second.state).toBe(FiberState.ACTIVE)
await second.dispose()
const disposeResolver = ctx.fileUploads.registerAgentResolver(resolveAgent)
disposeResolver()
const replacement = vi.fn(async () => agent)
const disposeReplacement = uploads.registerAgentResolver(replacement)
const disposeReplacement = ctx.fileUploads.registerAgentResolver(replacement)
disposeResolver()
expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
expect(disposeReplacement).toBeTypeOf('function')
expect(() => { ctx.fileUploads.registerAgentResolver(replacement) }).toThrow('already registered')
disposeReplacement()
})
@@ -21,8 +21,11 @@
en: 483285658e267dd5
zh: 846bec2daac59a96
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
en: 861333e423369690
zh: 6d16604bd24766d3
en: 64089ca8084f5e0f
zh: 8328a53cd6420a82
/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents:
en: 99b084ff497011d2
zh: 6c047796c1991784
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
en: 495dadf79c48c43c
zh: 9884dd04a5bb49dc
+15
View File
@@ -92,6 +92,21 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
| `versionListLimit` | `20` | How many of the newest versions above the installed one a version listing reads and returns. |
| `versionLookupConcurrency` | `4` | How many single-version registry lookups a version listing runs at once. |
| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. |
| `detailMaxBytes` | `1048576` | Largest bundle detail document, and largest locale file read for one, in bytes. |
| `detailImageMaxBytes` | `5242880` | Largest image served beside a bundle detail document, in bytes. |
| `detailFetchTimeoutMs` | `15000` | Deadline of one npm file CDN request for an offered bundle's detail document. |
| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. |
<a id="bundle-detail-documents"></a>
### Bundle detail documents
`readBundleDetail(name, language, { npmFileSources? })` reads the long description that a bundle's page in **Plugins** shows, for a bundle `listBundles()` lists: the locale `meta.detail` file, otherwise the README, as steps 1 and 2 of the [document order](../../util/plugin-detail/README.md#document-order) define them. Without either, the result is `{ status: 'none' }`; the repository a bundle declares is not read. `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order; configured CDNs it omits follow, and CDNs that `detailNpmFileSources` does not list are not asked. A found document carries its `origin` (`locale` or `readme`), its `path` inside the package, and its Markdown without a leading YAML front matter block.
An installed bundle is read from its directory, without network access. An official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version; one offered as a development link has no document. Nothing is cached, so a reinstalled bundle shows its own document.
`GET /api/plugin-manager/detail-file?bundle=<name>&document=<path>&path=<destination>`, with `&sources=<cdn>,...` to ask those CDNs first, serves an image beside a bundle's document to authenticated owner connections. The destination is relative to the document, or to the package root when it starts with `/`. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside the package are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`; another path or an unlisted bundle answers 404.
Failures: `plugin-manager/unknown-bundle` for a bundle `listBundles()` does not list or whose directory cannot be resolved, `plugin-manager/invalid-language`, and `plugin-manager/detail-unavailable` with `reason` `too-large`, `network`, or `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. The image route answers them with 404, 413, 502, and 400.
-----
+15
View File
@@ -92,6 +92,21 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
| `versionListLimit` | `20` | 版本列表读取并返回的、比已安装版本新的最新版本个数。 |
| `versionLookupConcurrency` | `4` | 版本列表同时运行的单版本注册表查询个数。 |
| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 |
| `detailMaxBytes` | `1048576` | 组合包详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小,单位字节。 |
| `detailImageMaxBytes` | `5242880` | 与组合包详细介绍文档一同提供的图片的最大大小,单位字节。 |
| `detailFetchTimeoutMs` | `15000` | 为读取当前安装提供、尚未安装的组合包的详细介绍文档而发出的单次 npm 文件 CDN 请求的时限。 |
| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 读取当前安装提供、尚未安装的组合包时按顺序使用的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前。 |
<a id="bundle-detail-documents"></a>
### 组合包详细介绍文档
`readBundleDetail(name, language, { npmFileSources? })` 为 `listBundles()` 列出的组合包读取**插件**页中该组合包页面显示的详细介绍:locale `meta.detail` 文件,否则为 README,即[文档顺序](../../util/plugin-detail/README.zh.md#document-order)第 1 步和第 2 步的定义。两者都没有时,结果为 `{ status: 'none' }`;不读取组合包声明的仓库。`npmFileSources` 按顺序列出读取当前安装提供、尚未安装的组合包时先请求的 npm 文件 CDN;它未列出的已配置 CDN 排在其后,`detailNpmFileSources` 未列出的 CDN 不会被请求。找到的文档携带其 `origin`(`locale` 或 `readme`)、在包内的 `path`,以及去掉开头 YAML front matter 块后的 Markdown 内容。
已安装的组合包从其目录读取,不访问网络。当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取;以开发链接形式提供的组合包没有文档。结果不被缓存,因此重新安装的组合包显示其自身的文档。
`GET /api/plugin-manager/detail-file?bundle=<name>&document=<path>&path=<destination>`(加上 `&sources=<cdn>,...` 时先请求这些 CDN)向已通过认证的 owner 连接提供组合包文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包根目录。只提供包内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和 `Cache-Control: no-store`;其他路径或未列出的组合包返回 404。
失败:`listBundles()` 未列出或无法解析其目录的组合包为 `plugin-manager/unknown-bundle`;无效语言为 `plugin-manager/invalid-language`;`plugin-manager/detail-unavailable` 携带 `reason`,取值为 `too-large`、`network`,或在 locale 文件或 `meta.detail` 格式错误、指定的文件不存在时为 `invalid`。图片路由对这些失败依次返回 404、413、502 和 400。
-----
+5 -2
View File
@@ -67,7 +67,9 @@
"semver": "^7.8.5",
"yaml": "^2.9.0",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-values": "workspace:*"
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-plugin-detail": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -109,6 +111,7 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*"
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*"
}
}
@@ -0,0 +1,104 @@
/**
* Detail documents of profile bundles: an installed bundle's `meta.detail` file or README from its directory, and an
* official bundle the installation offers, before it is installed, from the npm file CDNs at its offered version. Reads
* keep no state; an image request resolves its bundle and document again.
*/
import semver from 'semver'
import { resolveBundleDir } from '@deepseek-ai/dsh-app-boot'
import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
import {
DetailFailure, detailImage, languageChain, localFiles, npmFileSourceOrder, npmFiles, readPackageDocument,
type DetailFiles, type NpmFileSource,
} from '@deepseek-ai/dsh-plugin-detail'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { BundleDetail, BundleInfo } from './types.ts'
/** Limits and sources of bundle detail reads. */
export interface BundleDetailOptions {
readonly profile: ProfileContext
readonly maxDocumentBytes: number
readonly maxImageBytes: number
readonly npmFileSources: readonly NpmFileSource[]
/** Deadline of one CDN request. */
readonly timeoutMs: number
/** The plugin manager's disposal. */
readonly lifetime: AbortSignal
}
/**
* The files of a listed bundle: its directory when its files can be read, otherwise its offered npm version.
* @param bundle - the bundle as `listBundles()` reports it.
* @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
* @param options - profile, sources, and limits.
* @returns the files, or undefined for a bundle offered at no published version.
* @throws {RemoteError} `plugin-manager/unknown-bundle` when the bundle's directory cannot be resolved.
*/
function filesOf(bundle: BundleInfo, preferred: readonly string[], options: BundleDetailOptions): DetailFiles | undefined {
if (bundle.availability !== 'missing') {
let dir: string
try {
dir = resolveBundleDir('dsh', bundle.name, options.profile.installAnchor, options.profile.dir)
} catch (error) {
throw new RemoteError('plugin-manager/unknown-bundle', (error as Error).message, { name: bundle.name }, { cause: error })
}
// `listBundles()` reports the version of every bundle whose manifest it read; one without is read as unversioned.
return localFiles(dir, String(bundle.version))
}
const version = bundle.installTarget?.version
if (version === undefined || semver.valid(version) !== version) return undefined
const sources = npmFileSourceOrder(options.npmFileSources, preferred)
return npmFiles(bundle.name, version, sources, { timeoutMs: options.timeoutMs, lifetime: options.lifetime })
}
/** Rethrow a detail failure as the Remote error clients read. */
function remoteFailure(error: unknown): never {
if (error instanceof DetailFailure) {
throw new RemoteError('plugin-manager/detail-unavailable', error.message, { reason: error.reason }, { cause: error })
}
throw error
}
/**
* Read a bundle's detail document in a display language.
* @param bundle - the bundle as `listBundles()` reports it.
* @param language - a BCP 47 language id.
* @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
* @param options - profile, sources, and limits.
* @returns the document, or `none` when the bundle has neither a `meta.detail` file nor a README.
* @throws {RemoteError} `plugin-manager/invalid-language`, `plugin-manager/unknown-bundle`, or
* `plugin-manager/detail-unavailable`.
*/
export async function readBundleDocument(
bundle: BundleInfo, language: string, preferred: readonly string[], options: BundleDetailOptions,
): Promise<BundleDetail> {
let languages: string[]
try {
languages = languageChain(language)
} catch (error) {
throw new RemoteError('plugin-manager/invalid-language', (error as Error).message, { language }, { cause: error })
}
const files = filesOf(bundle, preferred, options)
if (files === undefined) return { status: 'none' }
const document = await readPackageDocument(files, '', languages, options.maxDocumentBytes, options.lifetime).catch(remoteFailure)
return document === undefined ? { status: 'none' } : { status: 'found', ...document }
}
/**
* Read an image beside a bundle's detail document.
* @param bundle - the bundle as `listBundles()` reports it.
* @param document - the document's path inside the bundle, as {@link readBundleDocument} returned it.
* @param destination - the image destination as the document writes it.
* @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
* @param options - profile, sources, and limits.
* @returns the bytes and media type, or undefined for a path outside the bundle, an unsupported type, or a missing file.
* @throws {RemoteError} `plugin-manager/unknown-bundle` or `plugin-manager/detail-unavailable`.
*/
export async function readBundleImage(
bundle: BundleInfo, document: string, destination: string, preferred: readonly string[], options: BundleDetailOptions,
): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> {
const image = detailImage(document, destination)
const files = image === undefined ? undefined : filesOf(bundle, preferred, options)
if (image === undefined || files === undefined) return undefined
const bytes = await files.read(image.path, options.maxImageBytes, options.lifetime).catch(remoteFailure)
return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType }
}
+78 -3
View File
@@ -10,7 +10,12 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import z from '@deepseek-ai/schemastery'
import semver from 'semver'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { TypertRemoteService, Remote, RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { detailImageResponse, NPM_FILE_SOURCES } from '@deepseek-ai/dsh-plugin-detail'
import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {} from '@deepseek-ai/dsh-client-connection'
import { readBundleDocument, readBundleImage, type BundleDetailOptions } from './bundle-detail.ts'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
@@ -32,8 +37,8 @@ import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts'
import type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError,
PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry,
RegistryAttempt, UpdateBundleOptions,
} from './types.ts'
@@ -67,8 +72,19 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
/** Largest bundle detail document, and largest locale file read for one, in bytes. */
detailMaxBytes?: number
/** Largest image served beside a bundle detail document, in bytes. */
detailImageMaxBytes?: number
/** Deadline of one npm file CDN request for an offered bundle's detail document. */
detailFetchTimeoutMs?: number
/** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources?: NpmFileSource[]
}
/** The Fetch route serving images beside bundle detail documents. */
const DETAIL_FILE_PATH = '/api/plugin-manager/detail-file'
/** An http(s) URL, as pnpm's `--registry` takes it. */
const REGISTRY_URL = /^https?:\/\/\S+$/
@@ -288,6 +304,10 @@ export class PluginManager extends TypertRemoteService {
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
versionListLimit: z.number().step(1).min(1).default(20),
versionLookupConcurrency: z.number().step(1).min(1).default(4),
detailMaxBytes: z.natural().min(1).default(1024 * 1024),
detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024),
detailFetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000),
detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]),
})
/** Management bundles remain protected if their files become unreadable. */
private readonly managementBundles = new Set<string>()
@@ -312,6 +332,7 @@ export class PluginManager extends TypertRemoteService {
private readonly abort = new AbortController()
/** Installations by request id, from their call until it settles. */
private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
private readonly detail: BundleDetailOptions
constructor(ctx: Context, config: Config) {
super(ctx, 'pluginManager')
@@ -331,10 +352,64 @@ export class PluginManager extends TypertRemoteService {
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
fallbackRegistries: (config as Required<Config>).fallbackRegistries.map(normalizeRegistry),
}
const required = config as Required<Config>
this.detail = {
profile: this.profile, maxDocumentBytes: required.detailMaxBytes, maxImageBytes: required.detailImageMaxBytes,
npmFileSources: required.detailNpmFileSources, timeoutMs: required.detailFetchTimeoutMs, lifetime: this.abort.signal,
}
ctx.effect(() => async () => {
this.abort.abort()
await Promise.allSettled([...this.packageOperations])
}, 'plugin-manager: package cancellation')
ctx.inject(['connection'], (scope) => {
scope.effect(() => scope.connection.fetch.register({
path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request),
}), `plugin-manager: ${DETAIL_FILE_PATH}`)
})
}
/**
* Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
* README. An installed bundle is read from its directory without network access; an official bundle the installation
* offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
* @param name - the bundle's package name, as `listBundles()` lists it.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
* a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
* @returns the document with its origin and path, or `none`.
* @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
* `plugin-manager/detail-unavailable`.
*/
@Remote
async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise<BundleDetail> {
return await readBundleDocument(await this.listedBundle(name), language, options?.npmFileSources ?? [], this.detail)
}
private async listedBundle(name: string): Promise<BundleInfo> {
const bundle = (await this.listBundles()).find(candidate => candidate.name === name)
if (bundle === undefined) throw new RemoteError('plugin-manager/unknown-bundle', `No such bundle: ${name}`, { name })
return bundle
}
/**
* Answer `GET ${DETAIL_FILE_PATH}?bundle=<name>&document=<path>&path=<destination>[&sources=<cdn>,...]` with one image
* beside a bundle document; `sources` names npm file CDNs to ask first for an offered bundle, as `readBundleDetail` does.
*/
private async serveDetailFile(request: Request): Promise<Response> {
const params = new URL(request.url).searchParams
const [bundle, document, path] = [params.get('bundle'), params.get('document'), params.get('path')]
// Images are read again on every request, so a reinstalled bundle shows its own.
const cache = 'no-store'
if (bundle === null || document === null || path === null) return detailImageResponse('invalid', cache)
try {
const image = await readBundleImage(await this.listedBundle(bundle), document, path, params.get('sources')?.split(',') ?? [], this.detail)
return detailImageResponse(image, cache)
} catch (error) {
const failure = remoteErrorOf(error)
if (failure?.code === 'plugin-manager/unknown-bundle') return detailImageResponse(undefined, cache)
if (failure?.code === 'plugin-manager/detail-unavailable') return detailImageResponse(failure.details.reason, cache)
throw error
}
}
/** Read exact plugin-version exemptions saved in this profile.
+33
View File
@@ -4,6 +4,23 @@ import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
import type {} from '@deepseek-ai/dsh-typert-protocol'
import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The profile lists no such bundle, or its directory cannot be resolved. */
'plugin-manager/unknown-bundle': { readonly name: string }
/** The display language is not a BCP 47 language id. */
'plugin-manager/invalid-language': { readonly language: string }
/**
* A bundle's detail document or image exists but cannot be read: `too-large` beyond a size limit, `network` when the
* npm file CDNs cannot be reached, `invalid` for a malformed locale file or `meta.detail`, or a named file that does
* not exist. Bundle files come from local directories and public CDNs, which never answer `denied`.
*/
'plugin-manager/detail-unavailable': { readonly reason: DetailFailureReason }
}
}
/** Reasons a profile control cannot modify its target. */
export type ReadOnlyReason = 'management-required' | 'unaddressable'
@@ -341,6 +358,22 @@ export interface PluginInstallLogChunk {
readonly exitCode?: number | null
}
/**
* A bundle's long description, or `none` when it has neither a `meta.detail` file nor a README. Images resolve beside
* `path` through `GET /api/plugin-manager/detail-file?bundle=<name>&document=<path>&path=<destination>`.
*/
export type BundleDetail =
| {
readonly status: 'found'
/** `locale` for the file locale `meta.detail` names, `readme` for the package's README. */
readonly origin: 'locale' | 'readme'
/** `/`-separated path of the document inside the package. */
readonly path: string
/** Markdown text without a byte order mark or a leading YAML front matter block. */
readonly markdown: string
}
| { readonly status: 'none' }
/** What changed in the profile, for consumers that show it. */
export interface PluginChange {
/** The operation that changed it. */
@@ -0,0 +1,177 @@
/** Bundle detail documents and their images through a real profile Include and Loader. */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Service, type Context } from '@deepseek-ai/cordis'
import { boot, getDshRuntimeVersion, initProfile, loadProfileDirectory, OFFICIAL_ON_DEMAND_CATALOG, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import PluginManager, { type Config } from '../src/index.ts'
import * as operations from '../src/operations.ts'
afterEach(() => { vi.unstubAllGlobals() })
/** The `connection` Fetch route registry, recording the routes the manager registers. */
class RecordingConnection extends Service {
/** The instance the latest fixture mounted. */
static current: RecordingConnection | undefined
readonly routes = new Map<string, (request: Request) => Promise<Response>>()
readonly fetch = {
register: (route: { path: string; fetch: (request: Request) => Promise<Response> }) => {
this.routes.set(route.path, route.fetch)
return () => Promise.resolve(void this.routes.delete(route.path))
},
}
constructor(ctx: Context) {
super(ctx, 'connection')
RecordingConnection.current = this
}
}
async function fixture(config: Config = {}) {
const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-detail-')))
let owner: Context | undefined
onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
const dir = join(home, 'profiles', 'test')
writeFileSync(join(home, 'package.json'), JSON.stringify({ name: 'installation', dependencies: {} }))
initProfile(dir, ['core', 'documented'])
const bundle = (name: string, files: Record<string, string>) => {
const path = join(dir, 'node_modules', name)
for (const [file, contents] of Object.entries({ 'cordis.patch.yml': '[]', ...files })) {
mkdirSync(join(path, file, '..'), { recursive: true })
writeFileSync(join(path, file), contents)
}
writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
}
bundle('core', { 'cordis.patch.yml': JSON.stringify([{ insert: [{ id: 'manager', name: 'cordis:manager', config }, { id: 'connection', name: 'cordis:connection' }] }]) })
bundle('documented', {
'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
'locale/zh.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }),
'guide/DETAIL.md': '---\ntitle: x\n---\n# Documented\n\n![shot](img/shot.svg)\n',
'guide/DETAIL.zh.md': '# 有说明\n',
'guide/img/shot.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>',
})
bundle('plain', { 'readme.md': '# Plain' })
bundle('bare', {})
bundle('broken', { 'locale/en.json': JSON.stringify({ meta: { detail: '../outside.md' } }) })
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { documented: '1.0.0', plain: '1.0.0', bare: '1.0.0', broken: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const profile: ProfileContext = {
name: 'test', startedBundles: loadProfileDirectory('test', dir, join(home, 'package.json')).layers.map(layer => layer.packageName),
dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
}
const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => {
owner = root
root.provide('profileContext', profile)
Object.assign(root.loader.builtins, { manager: PluginManager, connection: RecordingConnection })
})
const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
onTestFinished(() => { registry.mockRestore() })
const connection = RecordingConnection.current as RecordingConnection
const image = (query: string) => (connection.routes.get('/api/plugin-manager/detail-file') as (request: Request) => Promise<Response>)(
new Request(`http://dsh.local/api/plugin-manager/detail-file?${query}`))
return { manager: ctx.pluginManager, image, connection }
}
const failureOf = async (promise: Promise<unknown>) => remoteErrorOf(await promise.catch((error: unknown) => error))
it('reads an installed bundle\'s localized meta.detail file or README from its directory', async () => {
const fetch = vi.fn(() => Promise.reject(new Error('no network')))
vi.stubGlobal('fetch', fetch)
const { manager } = await fixture()
expect(await manager.readBundleDetail('documented', 'en-US')).toEqual({
status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Documented\n\n![shot](img/shot.svg)\n',
})
expect(await manager.readBundleDetail('documented', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 有说明\n' })
expect(await manager.readBundleDetail('plain', 'en')).toEqual({ status: 'found', origin: 'readme', path: 'readme.md', markdown: '# Plain' })
expect(await manager.readBundleDetail('bare', 'en')).toEqual({ status: 'none' })
expect(fetch).not.toHaveBeenCalled()
expect(await failureOf(manager.readBundleDetail('broken', 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'invalid' } })
expect(await failureOf(manager.readBundleDetail('absent', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'absent' } })
expect(await failureOf(manager.readBundleDetail('plain', 'x y'))).toMatchObject({ code: 'plugin-manager/invalid-language', details: { language: 'x y' } })
})
it('serves images beside a bundle document without caching and refuses other paths', async () => {
const { image, manager, connection } = await fixture({ detailImageMaxBytes: 20 })
const ok = await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')
expect([ok.status, ok.headers.get('content-type'), ok.headers.get('cache-control'), ok.headers.get('content-security-policy')?.startsWith('sandbox')])
.toEqual([413, 'text/plain; charset=utf-8', 'no-store', true])
expect(await ok.text()).toBe('too-large')
expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=..%2F..%2Fpackage.json')).status).toBe(404)
expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fmissing.png')).status).toBe(404)
expect((await image('bundle=absent&document=README.md&path=a.png')).status).toBe(404)
expect((await image('bundle=documented&document=README.md')).status).toBe(400)
const read = vi.spyOn(manager, 'listBundles').mockRejectedValueOnce(new TypeError('bug'))
await expect(image('bundle=documented&document=README.md&path=a.png')).rejects.toThrow('bug')
read.mockRestore()
const { image: roomy } = await fixture()
const served = await roomy('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')
expect([served.status, served.headers.get('content-type')]).toEqual([200, 'image/svg+xml'])
expect(await served.text()).toContain('<svg')
expect([...connection.routes.keys()]).toEqual(['/api/plugin-manager/detail-file'])
})
it('reads an offered official bundle from the npm CDNs at its offered version, npmmirror first when asked', async () => {
const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName
const version = getDshRuntimeVersion()
const fetch = vi.fn((url: string) => Promise.resolve(url === `https://registry.npmmirror.com/${name}/${version}/files/README.md`
? new Response('# Official')
: url.includes('/files/') ? new Response('missing', { status: 404 }) : Promise.reject(new Error(`unexpected ${url}`))))
vi.stubGlobal('fetch', fetch)
const { manager, image } = await fixture()
expect(await manager.readBundleDetail(name, 'en', { npmFileSources: ['npmmirror'] })).toEqual({ status: 'found', origin: 'readme', path: 'README.md', markdown: '# Official' })
expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/locale/en.json`)
fetch.mockClear()
expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png&sources=npmmirror`)).status).toBe(404)
expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/a.png`)
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline'))))
expect(await failureOf(manager.readBundleDetail(name, 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'network' } })
expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png`)).status).toBe(502)
})
it.each([undefined, { npmFileSources: [] }])('asks the CDNs in configured order unless npmmirror is asked first (%o)', async (options) => {
const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName
const version = getDshRuntimeVersion()
const fetch = vi.fn(() => Promise.resolve(new Response('missing', { status: 404 })))
vi.stubGlobal('fetch', fetch)
const { manager } = await fixture({ detailNpmFileSources: ['unpkg', 'npmmirror'] })
expect(await manager.readBundleDetail(name, 'en', options)).toEqual({ status: 'none' })
expect(fetch.mock.calls[0]).toEqual([`https://unpkg.com/${name}@${version}/locale/en.json`, expect.anything()])
})
it('shows no document for an offered bundle without a published version', async () => {
const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName
const { manager } = await fixture()
const list = manager.listBundles.bind(manager)
vi.spyOn(manager, 'listBundles').mockImplementation(async () => (await list()).map(bundle => bundle.name === name
? { ...bundle, installTarget: { spec: 'link:../x', version: 'link:../x' } }
: bundle))
expect(await manager.readBundleDetail(name, 'en')).toEqual({ status: 'none' })
expect((await failureOf(manager.readBundleDetail('documented', 'en')))).toBeUndefined()
})
it('reports a listed bundle whose directory is gone, and keeps failures other than detail failures', async () => {
const { manager, image } = await fixture()
const list = manager.listBundles.bind(manager)
const listing = vi.spyOn(manager, 'listBundles').mockImplementation(async () => [...await list(), {
name: 'vanished', official: false, availability: 'profile', enabled: false, installed: true, optional: false, removable: true, updatable: false, rows: [], overrides: [],
}])
expect(await failureOf(manager.readBundleDetail('vanished', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'vanished' } })
listing.mockRestore()
// A failure outside the detail rules, here decoding the document, reaches the caller unchanged.
const decoder = new TextDecoder()
const decoding = vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation((input) => {
if (decoding.mock.calls.length > 1) throw new TypeError('bug')
decoding.mockRestore()
const text = decoder.decode(input)
vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation(() => { throw new TypeError('bug') })
return text
})
await expect(manager.readBundleDetail('documented', 'en')).rejects.toThrow('bug')
vi.restoreAllMocks()
expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')).status).toBe(200)
})
@@ -64,6 +64,15 @@
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../util/plugin-detail"
},
{
"path": "../../util/timeout"
},
{
"path": "../../client/connection/tsconfig.host.json"
}
]
}
+11 -8
View File
@@ -9,17 +9,20 @@
en: ee5478f5717295df
zh: 75a03a6db791fc6b
/deepseek-ai-dsh-plugin-market/summary:
en: 05b6a148b03fa0a4
zh: e5568747e1cf1d16
en: 5fc3f5439344cb9a
zh: 73e549c0b21b3843
/deepseek-ai-dsh-plugin-market/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-plugin-market/use-this-package:
en: 880da6f2249be5dc
zh: af79801b129d774c
en: 0553ab42ee4d83ff
zh: 4cc8163884357247
/deepseek-ai-dsh-plugin-market/use-this-package/plugin-detail-documents:
en: 0e15de273c9acaae
zh: 4fd93c92b71d4398
/deepseek-ai-dsh-plugin-market/understand-the-implementation:
en: fe70456b69339c7a
zh: 46e53647850af839
en: 678705f57d986377
zh: 68d090edfe04acde
/deepseek-ai-dsh-plugin-market/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -36,8 +39,8 @@
en: 02cf6033092eb236
zh: cb928bfa6d39a7f7
/deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work:
en: 2097c5644d5b6d85
zh: 1deb4c97e7efbfc6
en: 427d9c58ee09bea4
zh: ec1c2bade3b56014
/deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+30 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md).
`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. It also reads each market entry's long description, a Markdown document with images, for Discover entry pages. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md).
## Table of Contents
@@ -31,7 +31,15 @@ The optional [custom plugin markets bundle](../../experimental/plugin-market-bun
| `indexes` | `[]` | Index URLs in display order. A usable URL is in canonical form and uses `http:` or `https:`, without credentials or fragment. The field is volatile: Discover, Settings, and `setIndexes` change it without remounting this service or `plugin-manager`. |
| `fetchTimeoutMs` | `15000` | Deadline for one index fetch, including its body. |
| `maxBytes` | `134217728` | Largest accepted index body. |
| `cacheTtlMs` | `600000` | Lifetime of a successfully read document. |
| `cacheTtlMs` | `600000` | Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. |
| `detailMaxBytes` | `1048576` | Largest detail document, and largest locale file read for one. |
| `detailImageMaxBytes` | `5242880` | Largest image served beside a detail document. |
| `detailRepositoryMaxBytes` | `52428800` | Largest amount one Git command adds to a partial clone; a repository whose fetch adds more is not read. |
| `detailCacheMaxBytes` | `524288000` | Largest total size of the partial clones; least recently used ones are deleted beyond it after each fetch. |
| `detailCacheDir` | `.plugin-market/repositories` | Directory of the partial clones, relative to the profile directory; required, as an absolute path, when the service runs outside a dsh profile. |
| `detailGitTimeoutMs` | `60000` | Deadline of one Git command of the clone fallback. |
| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first, and CDNs this list omits are not asked. |
| `detailGitlabHosts` | `['gitlab.com']` | Hosts whose GitLab repository files API serves public repositories, compared without letter case. |
Settings and profile files can store any http(s) string, so the service validates each URL when it lists or reads it. `listIndexes` reports an unusable URL with its refusal, `search` and `entry` refuse it, and no request is sent to it; a repeated URL is listed once.
@@ -40,6 +48,7 @@ The Remote methods are:
- `listIndexes()` returns each configured URL with the title and description of its last successful read and, when the last read failed, its error. It never fetches.
- `setIndexes(urls)` normalizes and validates each URL not already configured, keeps configured ones as written, refuses a repeated URL, and writes it through `settings.update` to this service's profile entry. It fails with `plugin-market/settings-unavailable` without Settings or a profile entry. The committed list is announced by `plugin-market/changed`.
- `search(url, query)` accepts only a configured URL, so the service never fetches an arbitrary address. It returns one page of the plugins that match `query`, with the index title, description, and declarations, the document's `fetchedAt`, the `total` number of matches, and the `nextOffset`; each plugin carries its `defaultVersion` and `installation`, and each version its `install` and `compatibility`.
- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` reads the long description of one listed version of a configured index's entry from that version's source: the npm file CDNs for a version without `registry` or with the npm or npmmirror registry, or its pinned Git commit and `path`. An npm version from another registry has no readable package files.
The service method `entry(url, name)`, which is not a Remote method, returns one plugin of a configured index in the same form, or `undefined` when the index does not list it.
@@ -57,6 +66,18 @@ A query selects plugins with every one of its conditions:
Text matches the package name, author, keywords, and every language of the title, description, and category and label titles. It is split into runs of letters, marks, and digits; a run of Chinese, Japanese, or Korean characters becomes its overlapping character pairs and its last character, so text written without spaces matches without a dictionary. Words are compared after NFKC normalization and without case. Every query word must match an indexed word as a prefix or, from four characters, within an edit distance of one fifth of its length rounded to the nearest integer, at most 2. Name and title matches weigh three times, and keyword matches twice, a match elsewhere.
### Plugin detail documents
`readEntryDetail` reads all three steps of the [document order](../../util/plugin-detail/README.md#document-order): the locale `meta.detail` file and the README from the entry version's source, then the declared repository's README. For an npm version the README is the one npm shows for it. A Git entry whose `repository` is its own source directory skips the repository step. A malformed locale file or `meta.detail`, or a named file that does not exist, fails the read instead of moving to the next step.
Without any, the result is `{ status: 'none' }`. A found document carries its `origin` (`locale`, `readme`, or `repository`), its Markdown without a leading YAML front matter block, and a `document` id. Results, and the `too-large`, `denied`, and `invalid` failures that reading again would repeat, are cached for `cacheTtlMs`; `refresh` reads again instead, and concurrent reads share one read. A `document` id stays valid while a read returns it or an image request names it at least once every `cacheTtlMs`; an unused id is forgotten, and its images answer 404 until the document is read again.
Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url.<base>.insteadOf` rules without prompting, so an https URL can be routed to ssh; ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set.
`GET /api/plugin-market/detail-file?document=<id>&path=<destination>` serves an image beside a read document to authenticated owner connections. The destination is relative to the document, or to the package or repository root when it starts with `/`; its query and fragment are ignored. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside that root are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and a private cache for `cacheTtlMs`. A missing file, unknown document, or other path answers 404; the failures below answer 413, 502, 403, 503, and 400; failure responses are not cached.
A source that exists but cannot be read fails with `plugin-market/detail-unavailable` and one `reason`: `too-large` for a document, image, or repository over its limit; `network` when a source cannot be reached or times out; `denied` when Git refuses the repository with the user's credentials; `git-unavailable` when a repository must be cloned and Git cannot run; `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. An unlisted entry or version fails with `plugin-market/unknown-plugin`; an invalid language fails with `plugin-market/invalid-query`. A Git that cannot start and a local Git command that fails, such as on a full disk, fail the read without a detail reason and are not cached.
Failures use `plugin-market/invalid-query`, `plugin-market/invalid-index-url`, `plugin-market/unknown-index`, `plugin-market/fetch-failed`, and `plugin-market/invalid-index`; the last carries the JSON path of the offending value.
The `plugin_market` tool lists the configured indexes, pages the plugins of one index in index order or, with a `query`, by best match, or pages one plugin's versions. It requires no approval because it reads only the sources the user configured and changes nothing; each version it returns carries the `target` and `registry` to pass to `plugin_manager`'s `install_bundle`, which keeps its own approval. The tool cannot change index sources. Creator mode keeps its row beside `plugin_manager`, and the row registers the tool only while `pluginMarket` is mounted; other presets keep it disabled.
@@ -74,6 +95,8 @@ Filtering and ordering read only the document; installation status and compatibi
Compatibility is computed per version with `evaluatePluginCompatibility`, the version's `engines.dsh`, and the profile's version exemptions, the same check installation applies. A plugin's default version is its highest compatible version without a prerelease tag, or its highest compatible prerelease when it lists no version without one; a plugin with no compatible version has none. Installation status comes from `pluginManager.listBundles()` by package name, measured against the default version, or the highest version without one: `not-installed`, also for an on-demand catalog entry the profile has not installed; `installed` at that version; `update-available` when that version is greater; `different-version` when the installed version is unknown, not valid SemVer, or greater; and `provided` for a bundle the DSH installation ships.
Entry detail reads share one reader that the service owns; document selection and the npm file CDNs come from [`dsh-plugin-detail`](../../util/plugin-detail/README.md). Each partial clone is a bare repository under `detailCacheDir`, named by a hash of its URL, initialized in a staging directory and renamed into place, and fetched with `--depth 1 --filter=blob:none` so only commits and trees arrive; Git downloads a file's contents when it is read. Commands run without inherited `GIT_*` variables other than the ssh program, with hooks, the file-system monitor, submodule recursion, and every transport but https, http, and ssh disabled, and no working tree is created. The directory size is checked every 200 ms while Git downloads; growth past `detailRepositoryMaxBytes` within one command stops Git, and output keeps a file's final newline byte. A file read stops once its contents pass the read's limit, and an image read completes even when the browser abandons the request, so leaving a page never interrupts a clone. A ref under `refs/dsh/` records each fetched commit, so a commit is never fetched lazily without the size check. Operations on one repository run one at a time, also across processes that share `detailCacheDir`, such as Web and Desktop on one profile, through a lock file beside the repository; eviction deletes a repository only after them, enforces `detailCacheMaxBytes` after every fetch and every file read, and deletes the repository in use last, after its operation, when it alone exceeds the total; one that fails or is cancelled, for any reason but a file over its read's limit, deletes the repository, so the next read starts from a fresh clone. Eviction also removes staging directories older than `detailGitTimeoutMs`. Disposal stops Git and waits for it.
`marketInstallTarget` builds install arguments: `name@version` with the version's registry for npm, and `git+<url>.git#<commit>` with `&path:/<path>` for a package directory in Git.
-----
@@ -104,6 +127,11 @@ Append-only: results enter the conversation as ordinary tool results and do not
- **Last write wins** — `setIndexes` replaces the whole list, so two windows editing sources at the same time can overwrite each other's change.
- **Redirects refused** — an index served only behind a redirect must be configured at its final URL.
- **Whole-document reads** — the Host downloads and keeps each index up to `maxBytes` and builds its text index in memory; a synthetic index of 20,000 plugins took about 0.8 s of build time and 130 MiB of heap. Parsing runs in a Worker, but receiving the validated index still blocks the Host event loop while the clone is deserialized: about 35 ms for a synthetic 7.6 MiB index of 20,000 plugins and about 190 ms for 38 MiB and 100,000 plugins. Larger markets need a server-side query protocol.
- **Default-branch READMEs** — a repository README is read at the default branch, which can differ from the listed version; npm records no commit the CDNs expose.
- **Private npm registries** — the public CDNs do not serve packages of other registries, so only the entry's repository can provide such a version's document.
- **Fetching an unadvertised commit** — a pinned Git entry is fetched by commit id, which the server must allow (`uploadpack.allowReachableSHA1InWant` or `allowAnySHA1InWant`); GitHub and GitLab allow it, and a server that refuses reports `denied`.
- **Exact README names through APIs** — the single-file APIs of public repositories are asked for `README.md`, `readme.md`, `Readme.md`, `README.markdown`, and `README` only; another letter case, such as `README.MD`, is found only in a clone.
- **No credentials for APIs** — GitHub and GitLab APIs are read anonymously; private repositories always use the clone fallback, which needs the user's Git credentials for the host.
- **Pages can change** — `fetchedAt` pins pages only while the document is cached; after another read replaces it, a reader must start again from the first page.
<a id="dev-note"></a>
+31 -2
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。
`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。它还为发现区的条目页面读取每个市场条目的详细介绍,即一份带图片的 Markdown 文档。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。
## 目录
@@ -31,7 +31,15 @@ kind: "package-reference"
| `indexes` | `[]` | 按显示顺序排列的索引 URL。可用的 URL 采用规范形式,使用 `http:` 或 `https:`,不含凭据或片段。该字段是 volatile 字段:发现区、设置和 `setIndexes` 修改它时,不会重新挂载本服务或 `plugin-manager`。 |
| `fetchTimeoutMs` | `15000` | 单次索引获取(包括响应体)的时限。 |
| `maxBytes` | `134217728` | 接受的最大索引响应体。 |
| `cacheTtlMs` | `600000` | 成功读取的文档的缓存时长。 |
| `cacheTtlMs` | `600000` | 成功读取的索引文档、详细介绍的读取结果以及仓库默认分支提交的缓存时长。 |
| `detailMaxBytes` | `1048576` | 详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小。 |
| `detailImageMaxBytes` | `5242880` | 与详细介绍文档一同提供的图片的最大大小。 |
| `detailRepositoryMaxBytes` | `52428800` | 单条 Git 命令向一个部分克隆增加的最大数据量;获取时增加的数据量超过该值的仓库不被读取。 |
| `detailCacheMaxBytes` | `524288000` | 所有部分克隆的最大总大小;每次获取后,超出时删除最近最少使用的部分克隆。 |
| `detailCacheDir` | `.plugin-market/repositories` | 部分克隆所在的目录,相对于 profile 目录;服务在 dsh profile 之外运行时必须以绝对路径配置。 |
| `detailGitTimeoutMs` | `60000` | 克隆回退方式中单条 Git 命令的时限。 |
| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 按顺序读取的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前,本列表未列出的 CDN 不会被请求。 |
| `detailGitlabHosts` | `['gitlab.com']` | 通过 GitLab 仓库文件 API 提供公开仓库的主机,比较时不区分大小写。 |
设置和 profile 文件可以存储任意 http(s) 字符串,因此服务在列出或读取每个 URL 时校验它。`listIndexes` 报告不可用的 URL 及其拒绝原因,`search` 和 `entry` 拒绝该 URL,且不会向它发送请求;重复的 URL 只列出一次。
@@ -40,6 +48,7 @@ Remote 方法如下:
- `listIndexes()` 返回每个已配置 URL,以及其最近一次成功读取得到的标题和描述;最近一次读取失败时,还返回其错误。它从不发起获取。
- `setIndexes(urls)` 规范化并校验每个尚未配置的 URL,已配置的 URL 保持原样,拒绝重复的 URL,并通过 `settings.update` 把列表写入本服务的 profile 条目。没有 Settings 或 profile 条目时,以 `plugin-market/settings-unavailable` 失败。提交后的列表通过 `plugin-market/changed` 通告。
- `search(url, query)` 只接受已配置的 URL,因此服务从不获取任意地址。它返回匹配 `query` 的一页插件,并附带索引的标题、描述和声明、该文档的 `fetchedAt`、匹配总数 `total` 和 `nextOffset`;每个插件携带 `defaultVersion` 和 `installation`,每个版本携带 `install` 和 `compatibility`。
- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` 从已配置索引中某个条目的一个已列出版本的来源读取该版本的详细介绍:没有 `registry` 或使用 npm 或 npmmirror 注册表的版本从 npm 文件 CDN 读取,Git 版本从其固定的 Git 提交和 `path` 读取。来自其他注册表的 npm 版本没有可读取的包文件。
服务方法 `entry(url, name)` 不是 Remote 方法,它以相同形式返回已配置索引中的一个插件;索引未列出该插件时返回 `undefined`。
@@ -57,6 +66,19 @@ Remote 方法如下:
文本匹配包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言。文本被拆分为由字母、组合符号和数字构成的连续片段;由中文、日文或韩文字符构成的片段变为相邻的两字组合及其最后一个字符,因此不带空格书写的文本无需词典也能匹配。词在 NFKC 规范化后不区分大小写地比较。查询中的每个词都必须匹配某个已索引的词,可以是前缀,或者在词长至少四个字符时,编辑距离不超过其长度的五分之一(四舍五入),且最多为 2。名称和标题匹配的权重是其他位置的三倍,关键词匹配是两倍。
<a id="plugin-detail-documents"></a>
### 插件详细介绍文档
`readEntryDetail` 读取[文档顺序](../../util/plugin-detail/README.zh.md#document-order)的全部三步:先从条目版本的来源读取 locale `meta.detail` 文件和 README,再读取所声明仓库的 README。对 npm 版本,README 即 npm 为该版本显示的 README。`repository` 就是自身来源目录的 Git 条目跳过仓库这一步。locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时,读取失败,而不是进入下一步。
三者都没有时,结果为 `{ status: 'none' }`。找到的文档携带其 `origin`(`locale`、`readme` 或 `repository`)、去掉开头 YAML front matter 块后的 Markdown 内容和 `document` id。结果,以及再次读取也会重复出现的 `too-large`、`denied` 和 `invalid` 失败,缓存 `cacheTtlMs`;传入 `refresh` 时改为重新读取;并发读取共享一次读取。只要每个 `cacheTtlMs` 内至少有一次读取返回某个 `document` id 或有一次图片请求引用它,该 id 就保持有效;未使用的 id 会被遗忘,在再次读取该文档之前,其图片请求返回 404。
`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url.<base>.insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问;除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。
`GET /api/plugin-market/detail-file?document=<id>&path=<destination>` 向已通过认证的 owner 连接提供已读取文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包或仓库的根目录;路径中的查询和片段被忽略。只提供该根目录内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和时长为 `cacheTtlMs` 的私有缓存。文件不存在、文档未知或其他路径返回 404;下文所列的失败依次返回 413、502、403、503 和 400;失败响应不被缓存。
存在但无法读取的来源以 `plugin-market/detail-unavailable` 失败,并携带一个 `reason`:文档、图片或仓库超过其大小限制时为 `too-large`;来源无法访问或超时时为 `network`;Git 使用用户的凭据仍被仓库拒绝时为 `denied`;仓库必须克隆而 Git 无法运行时为 `git-unavailable`;locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时为 `invalid`。未列出的条目或版本以 `plugin-market/unknown-plugin` 失败;无效的语言以 `plugin-market/invalid-query` 失败。Git 无法启动,以及本地 Git 命令失败(例如磁盘已满)时,读取失败且不带详细介绍的失败原因,也不被缓存。
失败使用 `plugin-market/invalid-query`、`plugin-market/invalid-index-url`、`plugin-market/unknown-index`、`plugin-market/fetch-failed` 和 `plugin-market/invalid-index`;最后一种携带违规值的 JSON 路径。
`plugin_market` 工具列出已配置的索引,按索引顺序分页列出某个索引的插件,或在给出 `query` 时按最佳匹配分页列出,或分页列出某个插件的版本。它不需要批准,因为它只读取用户配置的源且不做任何改动;它返回的每个版本都携带要传给 `plugin_manager` 的 `install_bundle` 的 `target` 和 `registry`,`install_bundle` 仍保留自己的批准要求。该工具不能修改索引源。Creator 模式在 `plugin_manager` 旁边保留它的行,该行只在 `pluginMarket` 已挂载时注册工具;其他预设保持禁用。
@@ -74,6 +96,8 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒
兼容性按版本计算:使用 `evaluatePluginCompatibility`,传入该版本的 `engines.dsh` 和 profile 的版本豁免,与安装时执行的检查相同。插件的默认版本是其不带先行版本号的最高兼容版本;插件完全没有此类版本时,使用其最高兼容的先行版本;没有兼容版本的插件没有默认版本。安装状态按包名根据 `pluginManager.listBundles()` 计算,以默认版本为准,没有默认版本时以最高版本为准:`not-installed`(包括当前 profile 尚未安装的按需目录条目);版本相同时为 `installed`;该版本更高时为 `update-available`;已安装版本未知、不是有效的 SemVer 或更高时为 `different-version`;DSH 安装随附的组合包(bundle)为 `provided`。
条目详细介绍的读取共用服务拥有的一个读取器;文档选择和 npm 文件 CDN 来自 [`dsh-plugin-detail`](../../util/plugin-detail/README.zh.md)。每个部分克隆是 `detailCacheDir` 下的一个裸仓库,以其 URL 的哈希命名,先在暂存目录中初始化,再重命名到目标位置,并使用 `--depth 1 --filter=blob:none` 获取,因此只下载提交和树对象;读取文件时 Git 才下载其内容。命令运行时不继承除 ssh 程序之外的 `GIT_*` 变量,禁用 hook、文件系统监视器、子模块递归,以及 https、http 和 ssh 之外的所有传输协议,且不创建工作树。Git 下载期间每 200 毫秒检查一次目录大小;单条命令执行期间增长超过 `detailRepositoryMaxBytes` 时停止 Git,且输出保留文件末尾的换行字节。文件内容超过本次读取的限制时,读取即停止;浏览器放弃请求时,图片读取仍会完成,因此离开页面不会中断克隆。`refs/dsh/` 下的 ref 记录每个已获取的提交,因此不会在没有大小检查的情况下延迟获取提交。对同一仓库的操作逐个执行,共享 `detailCacheDir` 的多个进程(例如同一 profile 上的 Web 和 Desktop)之间也是如此,由仓库旁的一个锁文件实现;淘汰只在这些操作结束后删除该仓库,并在每次获取和每次文件读取后执行 `detailCacheMaxBytes` 总量限制;正在使用的仓库单独超过该总量时,淘汰最后删除它,并等到其操作结束后再删除;除文件超过本次读取的限制外,任何原因导致操作失败或被取消时,都删除该仓库,使下一次读取从新的克隆开始。淘汰时还会删除存在时间超过 `detailGitTimeoutMs` 的暂存目录。销毁时停止 Git 并等待其退出。
`marketInstallTarget` 构建安装参数:npm 为 `name@version` 加该版本的 registry,Git 为 `git+<url>.git#<commit>`,包位于子目录时追加 `&path:/<path>`。
-----
@@ -104,6 +128,11 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒
- **后写入者覆盖**——`setIndexes` 会替换整个列表,因此两个窗口同时编辑索引源时,可能互相覆盖对方的修改。
- **拒绝重定向**——只能经重定向访问的索引必须以其最终 URL 配置。
- **读取整份文档**——Host 下载并保留每个不超过 `maxBytes` 的索引,并在内存中构建其文本索引;一个包含 20,000 个插件的合成索引构建用时约 0.8 秒,占用约 130 MiB 堆内存。解析在 Worker 中运行,但接收校验后的索引时,反序列化 clone 仍会阻塞 Host 事件循环:包含 20,000 个插件的 7.6 MiB 合成索引约 35 毫秒,包含 100,000 个插件的 38 MiB 索引约 190 毫秒。更大的市场需要服务端查询协议。
- **默认分支的 README**——仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致;CDN 公开的 npm 数据不记录提交。
- **私有 npm 注册表**——公共 CDN 不提供其他注册表的包,因此此类版本的文档只能来自条目的仓库。
- **获取未公布的提交**——固定版本的 Git 条目按提交 id 获取,这需要服务器允许(`uploadpack.allowReachableSHA1InWant` 或 `allowAnySHA1InWant`);GitHub 和 GitLab 允许这样做,拒绝的服务器报告 `denied`。
- **API 只查询精确的 README 文件名**——公开仓库的单文件 API 只查询 `README.md`、`readme.md`、`Readme.md`、`README.markdown` 和 `README`;其他大小写形式(例如 `README.MD`)只能在克隆中找到。
- **API 不使用凭据**——GitHub 和 GitLab API 以匿名方式读取;私有仓库总是使用克隆回退方式,这要求用户拥有该主机的 Git 凭据。
- **分页可能变化**——`fetchedAt` 只在文档仍被缓存时固定分页;另一次读取替换文档后,读取方必须从第一页重新开始。
<a id="dev-note"></a>
+9
View File
@@ -50,11 +50,16 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/dsh-plugin-detail": "workspace:*",
"@deepseek-ai/dsh-plugin-market-index": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"execa": "^10.0.1",
"hosted-git-info": "^9.0.3",
"minisearch": "^7.2.0",
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -64,6 +69,7 @@
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
},
@@ -77,13 +83,16 @@
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cosmokit": "workspace:~",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-config-editor": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@types/hosted-git-info": "^3.0.5",
"@types/semver": "^7.8.0"
}
}
@@ -0,0 +1,79 @@
/**
* Git repository files for plugin detail documents, through GitHub or GitLab single-file APIs with a cached partial
* clone as the fallback for repositories those APIs cannot serve anonymously.
*/
import {
DetailFailure, encodePath, fetchBody, README_NAMES, readmeByName, type DetailFiles, type ReadFile, type RemoteFileOptions,
} from '@deepseek-ai/dsh-plugin-detail'
import type { RepositoryCache } from './repository-cache.ts'
/** A repository as single-file APIs and Git address it. */
export interface RepositoryRef {
/** Lower-case host name, without port. */
readonly host: string
/** The project path below the host, such as `owner/repo` or `group/subgroup/project`, without `.git`. */
readonly project: string
/** URL Git fetches from: `https://<host>/<project>.git`, or the configured http(s) URL as written for other forms. */
readonly url: string
}
/** Which single-file API a host offers anonymously. */
type FileApi = 'github' | 'gitlab' | undefined
/** Single-file API settings and the clone fallback of repository sources. */
export interface RepositoryFileOptions extends RemoteFileOptions {
/** Lower-case hosts serving the GitLab repository files API; `github.com` always uses GitHub's raw file host. */
readonly gitlabHosts: readonly string[]
/** The clone cache's reads. */
readonly cache: Pick<RepositoryCache, 'isPublic' | 'read' | 'readme'>
}
function apiUrl(api: Exclude<FileApi, undefined>, repository: RepositoryRef, ref: string, path: string): string {
return api === 'github'
? `https://raw.githubusercontent.com/${repository.project}/${encodeURIComponent(ref)}/${encodePath(path)}`
// The repository URL's origin keeps a self-hosted GitLab's scheme and port.
: `${new URL(repository.url).origin}/api/v4/projects/${encodeURIComponent(repository.project)}/repository/files/${encodeURIComponent(path)}/raw?ref=${encodeURIComponent(ref)}`
}
/**
* Files of one repository at a commit, or at its default branch. Hosts with a single-file API are read through it; a
* missing answer is confirmed with an anonymous request to the repository's smart HTTP endpoint, and a repository that
* needs credentials, or a host without such an API, is read from the cached partial clone.
* @param repository - the repository.
* @param commit - a full commit id, or undefined for the default branch.
* @param options - API hosts, deadline, lifetime, and the clone cache.
* @returns the files.
*/
export function repositoryFiles(repository: RepositoryRef, commit: string | undefined, options: RepositoryFileOptions): DetailFiles {
const api: FileApi = repository.host === 'github.com' ? 'github' : options.gitlabHosts.includes(repository.host) ? 'gitlab' : undefined
const ref = commit ?? 'HEAD'
/** An API read: the bytes, or undefined for an answer that does not distinguish a missing file from a private repository. */
const fromApi = async (path: string, maxBytes: number, signal: AbortSignal, via: Exclude<FileApi, undefined>) => {
const body = await fetchBody(apiUrl(via, repository, ref, path), maxBytes, signal, options)
if (body.ok) return body.bytes
if (body.status !== 404 && body.status !== 401 && body.status !== 403) {
throw new DetailFailure('network', `${repository.url}: ${path} answered HTTP ${String(body.status)}`)
}
return undefined
}
return {
key: `git:${repository.url}#${ref}`,
async read(path, maxBytes, signal) {
if (api !== undefined) {
const bytes = await fromApi(path, maxBytes, signal, api)
if (bytes !== undefined || await options.cache.isPublic(repository.url)) return bytes
}
return await options.cache.read(repository.url, commit, path, maxBytes, signal)
},
async readme(dir, maxBytes, signal) {
if (api !== undefined) {
const read: ReadFile = (path, max, readSignal) => fromApi(path, max, readSignal, api)
// The first name settles most lookups; the others are worth asking only where a miss is authoritative.
const found = await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(0, 1))
if (found !== undefined) return found
if (await options.cache.isPublic(repository.url)) return await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(1))
}
return await options.cache.readme(repository.url, commit, dir, maxBytes, signal)
},
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Plugin market entry detail documents: the package document {@link readPackageDocument} selects from the entry
* version's source, otherwise the README of the repository directory the entry or its package declares; cached reads
* and the images beside a read document.
*/
import { createHash } from 'node:crypto'
import { posix } from 'node:path'
import { brandString } from '@deepseek-ai/dsh-brand'
import { DetailFailure, detailImage, joinPath, markdownOf, readPackageDocument, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail'
import hostedGitInfo from 'hosted-git-info'
import { repositoryFiles, type RepositoryFileOptions, type RepositoryRef } from './detail-files.ts'
import type { PluginDetail, PluginDetailDocumentId, PluginDetailOrigin } from './types.ts'
/** A repository directory whose README is the last detail source. */
export interface RepositoryLocation extends RepositoryRef {
/** Package directory inside the repository, `''` for its root. */
readonly directory: string
}
/** Where one package's detail sources are. */
export interface DetailLocation {
/** The package's files, when they can be read; `locale/<language>.json` and the README are read from them. */
readonly files?: DetailFiles
/** Package directory inside `files`, `''` for its root. */
readonly dir: string
/**
* The repository read last, at its default branch; `manifest` reads `repository` from the package's `package.json`
* in `files`.
*/
readonly repository: RepositoryLocation | 'manifest' | undefined
}
/** Limits of detail documents and their images. */
export interface DetailLimits {
readonly maxDocumentBytes: number
readonly maxImageBytes: number
/** Lifetime of a read result, including a failure that a retry would repeat. */
readonly ttlMs: number
}
/** Failures that reading again within `ttlMs` would repeat. */
const LASTING_FAILURES = new Set<DetailFailure['reason']>(['too-large', 'denied', 'invalid'])
/**
* The repository a URL or shorthand names: hosts npm knows by their owner and project, so a GitHub page such as
* `/o/r/tree/main/pkg` names `o/r`; other http(s) URLs by their path, without a GitLab `/-/` page suffix.
* @param url - a manifest `repository` URL or shorthand, or a market index's `repository.url`.
* @returns the repository, or undefined for a gist, another protocol, or fewer than two path segments.
*/
export function repositoryOf(url: string): RepositoryRef | undefined {
// A committish such as `#semver:^1` names a revision, not the repository.
const declared = url.replace(/#.*$/u, '')
const hosted = hostedGitInfo.fromUrl(declared)
if (hosted !== undefined) {
if (hosted.type === 'gist') return undefined
const project = `${hosted.user}/${hosted.project}`
return { host: hosted.domain.toLowerCase(), project, url: `https://${hosted.domain}/${project}.git` }
}
const address = declared.replace(/^git\+/u, '')
if (!URL.canParse(address)) return undefined
const parsed = new URL(address)
const project = parsed.pathname.replace(/\/-\/.*$/u, '').replace(/^\/+|\/+$/gu, '').replace(/\.git$/u, '')
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username !== '' || parsed.password !== '' || project.split('/').length < 2) {
return undefined
}
return { host: parsed.hostname.toLowerCase(), project, url: `${parsed.protocol}//${parsed.host}/${project}.git` }
}
/**
* Parse a manifest `repository` into the repository and directory its README is read from.
* @param value - a manifest `repository` string or object.
* @returns the location, or undefined when it names no repository {@link repositoryOf} accepts; a `directory` that
* leaves the repository is dropped.
*/
export function manifestRepository(value: unknown): RepositoryLocation | undefined {
const object = typeof value === 'object' && value !== null ? value as { url?: unknown; directory?: unknown } : undefined
const declared = object === undefined ? value : object.url
if (typeof declared !== 'string') return undefined
const repository = repositoryOf(declared)
if (repository === undefined) return undefined
return { ...repository, directory: typeof object?.directory === 'string' ? normalizedDirectory(object.directory) ?? '' : '' }
}
/** A `/`-separated directory without empty, `.`, or `..` segments; undefined for one that leaves its root. */
function normalizedDirectory(directory: string): string | undefined {
const normalized = posix.normalize(directory.replace(/\\/gu, '/')).replace(/\/+$/u, '')
if (normalized === '.' || normalized === '') return ''
return normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized) ? undefined : normalized
}
/** A read document and the files its images resolve against. */
interface DetailDocument {
readonly files: DetailFiles
/** Path of the document inside `files`. */
readonly path: string
/** When a read returned the document or an image beside it was served last. */
usedAt: number
}
/** A cached read: the document, or a failure that reading again would repeat. */
type CachedRead =
| { readonly value: PluginDetail; readonly expiresAt: number }
| { readonly failure: DetailFailure; readonly expiresAt: number }
/** Reads and caches detail documents for one service. */
export class DetailReader {
private readonly results = new Map<string, CachedRead>()
private readonly pending = new Map<string, Promise<PluginDetail>>()
private readonly documents = new Map<PluginDetailDocumentId, DetailDocument>()
/**
* @param limits - document and image sizes and the result lifetime.
* @param repositories - single-file API, clone, and request settings of repository reads.
*/
constructor(private readonly limits: DetailLimits, private readonly repositories: RepositoryFileOptions) {}
/**
* Read a package's detail document, sharing concurrent reads and reusing a result, or a failure that reading again
* would repeat, for `ttlMs`.
* @param location - the package's sources.
* @param languages - the chain from `languageChain` of `dsh-plugin-detail`.
* @param refresh - read again instead of reusing a cached result.
* @returns the first document the sources provide, or `none`.
* @throws {DetailFailure} when a source that should provide a document cannot be read.
*/
read(location: DetailLocation, languages: readonly string[], refresh = false): Promise<PluginDetail> {
const { repository } = location
const repositoryKey = typeof repository === 'string' ? repository : repository === undefined ? '' : `${repository.url}:${repository.directory}`
const key = JSON.stringify([location.files?.key ?? '', location.dir, repositoryKey, languages])
const now = Date.now()
const cached = this.results.get(key)
if (!refresh && cached !== undefined && cached.expiresAt > now) {
if ('failure' in cached) return Promise.reject(cached.failure)
if (cached.value.status === 'found') this.use(cached.value.document)
return Promise.resolve(cached.value)
}
let pending = this.pending.get(key)
if (pending === undefined) {
pending = this.resolve(location, languages, this.repositories.lifetime).then((value) => {
this.remember(key, { value, expiresAt: Date.now() + this.limits.ttlMs })
return value
}, (error: unknown) => {
if (error instanceof DetailFailure && LASTING_FAILURES.has(error.reason)) {
this.remember(key, { failure: error, expiresAt: Date.now() + this.limits.ttlMs })
}
throw error
}).finally(() => { this.pending.delete(key) })
this.pending.set(key, pending)
}
return pending
}
/**
* Read an image beside a document.
* @param id - the document id a detail read returned.
* @param authored - the image destination as the document writes it: relative to the document, or to the package or
* repository root when it starts with `/`; a query or fragment is ignored.
* @returns the bytes and media type, or undefined for an unknown document, a path outside its root, an unsupported
* image type, or a missing file.
* @throws {DetailFailure} when the source fails or the image exceeds `maxImageBytes`.
*/
async image(id: string, authored: string): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> {
const document = this.use(brandString<PluginDetailDocumentId>(id))
const image = document === undefined ? undefined : detailImage(document.path, authored)
if (document === undefined || image === undefined) return undefined
// A request the browser abandons still completes, so a cancelled image never interrupts a shared clone.
const bytes = await document.files.read(image.path, this.limits.maxImageBytes, this.repositories.lifetime)
return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType }
}
/**
* Mark a document used and forget documents unused for `ttlMs`, so ids live as long as a read or an image request
* returns them. A cached read is younger than `ttlMs` and marks its document, so no cached read names a forgotten id.
*/
private use(id: PluginDetailDocumentId): DetailDocument | undefined {
const now = Date.now()
for (const [known, document] of this.documents) if (now - document.usedAt > this.limits.ttlMs) this.documents.delete(known)
const document = this.documents.get(id)
if (document !== undefined) document.usedAt = now
return document
}
/** Cache a read and drop expired ones, so the cache holds only reads younger than `ttlMs`. */
private remember(key: string, read: CachedRead): void {
const now = Date.now()
for (const [cachedKey, cached] of this.results) if (cached.expiresAt <= now) this.results.delete(cachedKey)
this.results.set(key, read)
}
private async resolve(location: DetailLocation, languages: readonly string[], signal: AbortSignal): Promise<PluginDetail> {
const { files, dir } = location
let repository = location.repository === 'manifest' ? undefined : location.repository
if (files !== undefined) {
const document = await readPackageDocument(files, dir, languages, this.limits.maxDocumentBytes, signal)
if (document !== undefined) return this.found(document.origin, files, document.path, document.markdown)
if (location.repository === 'manifest') repository = await this.manifestRepositoryOf(files, dir, signal)
}
if (repository === undefined) return { status: 'none' }
const source = repositoryFiles(repository, undefined, this.repositories)
const readme = await source.readme(repository.directory, this.limits.maxDocumentBytes, signal)
return readme === undefined ? { status: 'none' } : this.found('repository', source, readme.path, markdownOf(readme.bytes))
}
private async manifestRepositoryOf(files: DetailFiles, dir: string, signal: AbortSignal): Promise<RepositoryLocation | undefined> {
const bytes = await files.read(joinPath(dir, 'package.json'), this.limits.maxDocumentBytes, signal)
if (bytes === undefined) return undefined
try {
return manifestRepository((JSON.parse(new TextDecoder().decode(bytes)) as { repository?: unknown } | null)?.repository)
} catch (_malformed) {
// A manifest the package manager accepted parses; an unreadable one names no repository to read.
return undefined
}
}
private found(origin: PluginDetailOrigin, files: DetailFiles, path: string, markdown: string): PluginDetail {
const document = brandString<PluginDetailDocumentId>(createHash('sha256').update(`${files.key}\0${path}`).digest('hex').slice(0, 32))
this.documents.set(document, { files, path, usedAt: Date.now() })
this.use(document)
return { status: 'found', origin, markdown, document }
}
}
+163 -3
View File
@@ -1,5 +1,6 @@
/** Host reader of configured plugin market indexes for the Discover page and the `plugin_market` tool. */
import { isAbsolute, join, resolve } from 'node:path'
import type { Context, Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import semver from 'semver'
@@ -8,15 +9,25 @@ import { marketInstallTarget, type MarketIndex, type MarketPlugin, type MarketVe
import type { BundleInfo } from '@deepseek-ai/dsh-plugin-manager/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
DetailFailure, detailImageResponse, languageChain, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles,
} from '@deepseek-ai/dsh-plugin-detail'
import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types'
import { DetailReader, repositoryOf, type DetailLocation, type RepositoryLocation } from './detail.ts'
import { repositoryFiles, type RepositoryFileOptions } from './detail-files.ts'
import { normalizeIndexUrl } from './index-url.ts'
import { RepositoryCache } from './repository-cache.ts'
import { parseIndexInWorker } from './parse-thread.ts'
import { MarketSearch } from './search.ts'
// Type-only: the `pluginManager` service, `settings.update`, and the Loader's `loader/volatile-update` event.
// Type-only: the `pluginManager` service, `settings.update`, the Loader's `loader/volatile-update` event, and the
// `connection` Fetch routes.
import type {} from '@deepseek-ai/dsh-plugin-manager'
import type {} from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {
MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketListingEntry, MarketListingVersion, MarketQuery, MarketSearchResult,
PluginDetail,
} from './types.ts'
export type * from './types.ts'
@@ -36,8 +47,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document. */
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
/** The exact diagnostic of a failure, including a non-Error rejection. */
@@ -55,6 +82,12 @@ function causeChainOf(error: unknown, depth = 0): string {
: messageOf(error)
}
/** The Fetch route serving images beside detail documents. */
const DETAIL_FILE_PATH = '/api/plugin-market/detail-file'
/** Registries whose packages the public npm file CDNs serve. */
const PUBLIC_REGISTRIES = new Set(['registry.npmjs.org', 'registry.npmmirror.com'])
/** Largest page `search` returns. */
const MAX_LIMIT = 100
@@ -80,6 +113,14 @@ export default class PluginMarket extends TypertRemoteService {
fetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000),
maxBytes: z.natural().min(1).default(128 * 1024 * 1024),
cacheTtlMs: z.natural().default(600000),
detailMaxBytes: z.natural().min(1).default(1024 * 1024),
detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024),
detailRepositoryMaxBytes: z.natural().min(1).default(50 * 1024 * 1024),
detailCacheMaxBytes: z.natural().min(1).default(500 * 1024 * 1024),
detailCacheDir: z.string(),
detailGitTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(60000),
detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]),
detailGitlabHosts: z.array(z.string()).default(['gitlab.com']),
})
private readonly lifetime = new AbortController()
@@ -87,10 +128,34 @@ export default class PluginMarket extends TypertRemoteService {
private readonly pending = new Map<string, Promise<IndexDocument>>()
private readonly errors = new Map<string, string>()
private readonly entryId: string | undefined
private readonly repositories: RepositoryCache
private readonly details: DetailReader
constructor(ctx: Context, private readonly config: Config) {
super(ctx, 'pluginMarket')
this.entryId = ctx.fiber.entry?.options.id
const profile = ctx.get('profileContext')
const cacheDir = profile === undefined
? config.detailCacheDir
: resolve(profile.dir, config.detailCacheDir ?? join('.plugin-market', 'repositories'))
if (cacheDir === undefined || !isAbsolute(cacheDir)) throw new Error('plugin-market: detailCacheDir must be an absolute path when the service runs without a dsh profile')
const lifetime = this.lifetime.signal
this.repositories = new RepositoryCache({
dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes,
// The URL forms repositories are read from, and ssh, which `url.<base>.insteadOf` may route them to.
timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime,
// Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate.
sizePollMs: 200,
})
this.details = new DetailReader(
{ maxDocumentBytes: config.detailMaxBytes, maxImageBytes: config.detailImageMaxBytes, ttlMs: config.cacheTtlMs },
this.detailRepositoryOptions(),
)
ctx.inject(['connection'], (scope) => {
scope.effect(() => scope.connection.fetch.register({
path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request),
}), `plugin-market: ${DETAIL_FILE_PATH}`)
})
ctx.on('loader/volatile-update', () => {
const configured = new Set(this.sources().flatMap(source => source.error === undefined ? [source.url] : []))
for (const url of [...this.cache.keys(), ...this.errors.keys()]) {
@@ -103,7 +168,7 @@ export default class PluginMarket extends TypertRemoteService {
})
ctx.effect(() => async () => {
this.lifetime.abort(new Error('Plugin market disposed'))
await Promise.allSettled(this.pending.values())
await Promise.allSettled([...this.pending.values(), this.repositories.settled()])
})
}
@@ -200,6 +265,101 @@ export default class PluginMarket extends TypertRemoteService {
return plugin === undefined ? undefined : (await this.annotator())(plugin)
}
/**
* Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
* CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
* in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
* version without one, of its `package.json` `repository`, at the default branch. An npm version from another
* registry has no readable package files, so only the entry's `repository` is read.
* @param url - a configured index URL.
* @param name - the plugin's package name.
* @param version - one listed version.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
* npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
* configured are not asked.
* @returns the document with its origin and image document id, or `none`.
* @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
* `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
*/
@Remote
async readEntryDetail(
url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] },
): Promise<PluginDetail> {
const languages = this.languages(language)
const plugin = (await this.document(url, false)).index.plugins.find(candidate => candidate.name === name)
const listed = plugin?.versions.find(candidate => candidate.version === version)
if (plugin === undefined || listed === undefined) {
throw new RemoteError('plugin-market/unknown-plugin', `${url} lists no ${name}@${version}`, { name, version })
}
const declared = plugin.repository
const found = declared === undefined ? undefined : repositoryOf(declared.url)
let repository: RepositoryLocation | undefined = found === undefined ? undefined : { ...found, directory: declared?.directory ?? '' }
const { source } = listed
let location: DetailLocation
if (source.type === 'git') {
const ref = repositoryOf(source.url)
/* v8 ignore next -- parseMarketIndex admits only https Git URLs with an owner and a project */
if (ref === undefined) return { status: 'none' }
const dir = source.path ?? ''
// A repository that names the source directory itself adds nothing to the source.
if (repository?.url === ref.url && repository.directory === dir) repository = undefined
location = { files: repositoryFiles(ref, source.commit, this.detailRepositoryOptions()), dir, repository }
} else if (source.registry === undefined || PUBLIC_REGISTRIES.has(new URL(source.registry).host)) {
location = this.npmLocation(name, version, repository ?? 'manifest', options?.npmFileSources ?? [])
} else {
location = { dir: '', repository }
}
return await this.readDetail(location, languages, options?.refresh === true)
}
private languages(language: string): string[] {
try {
return languageChain(language)
} catch (error) {
throw new RemoteError('plugin-market/invalid-query', messageOf(error), { field: 'language', reason: 'format' }, { cause: error })
}
}
private detailRepositoryOptions(): RepositoryFileOptions {
const { config } = this
return {
timeoutMs: config.fetchTimeoutMs, lifetime: this.lifetime.signal,
gitlabHosts: config.detailGitlabHosts.map(host => host.toLowerCase()), cache: this.repositories,
}
}
/** The detail location of a published npm version on the configured CDNs, the preferred ones first. */
private npmLocation(name: string, version: string, repository: RepositoryLocation | 'manifest', preferred: readonly string[]): DetailLocation {
const sources = npmFileSourceOrder(this.config.detailNpmFileSources, preferred)
return { dir: '', repository, files: npmFiles(name, version, sources, { timeoutMs: this.config.fetchTimeoutMs, lifetime: this.lifetime.signal }) }
}
private async readDetail(location: DetailLocation, languages: readonly string[], refresh: boolean): Promise<PluginDetail> {
try {
return await this.details.read(location, languages, refresh)
} catch (error) {
if (!(error instanceof DetailFailure)) throw error
throw new RemoteError('plugin-market/detail-unavailable', error.message, { reason: error.reason }, { cause: error })
}
}
/** Answer `GET ${DETAIL_FILE_PATH}?document=<id>&path=<destination>` with one image beside a read detail document. */
private async serveDetailFile(request: Request): Promise<Response> {
const params = new URL(request.url).searchParams
const document = params.get('document')
const path = params.get('path')
// Images stay cached as long as their document's result.
const cache = `private, max-age=${String(Math.floor(this.config.cacheTtlMs / 1000))}`
if (document === null || path === null) return detailImageResponse('invalid', cache)
try {
return detailImageResponse(await this.details.image(document, path), cache)
} catch (error) {
if (!(error instanceof DetailFailure)) throw error
return detailImageResponse(error.reason, cache)
}
}
private async document(url: string, refresh: boolean, fetchedAt?: number): Promise<IndexDocument> {
const source = this.sources().find(candidate => candidate.url === url)
if (source === undefined) throw new RemoteError('plugin-market/unknown-index', `Not a configured index source: ${url}`, { url })
@@ -0,0 +1,402 @@
/**
* Cached partial clones for plugin detail documents of repositories that need the user's Git credentials or have no
* anonymous single-file API. Each repository is a bare repository fetched with depth 1 and without file contents; Git
* downloads a file's contents only when it is read. No working tree is created, so no checkout filter, hook, LFS
* download, or symbolic link takes effect. A repository whose operation fails or is cancelled is deleted, so an
* interrupted command never leaves a damaged clone behind.
*/
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readdir, rename, rm, stat, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { execa } from 'execa'
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { DetailFailure, readmeName, type FoundFile } from '@deepseek-ai/dsh-plugin-detail'
/**
* Git deadlines one operation's lock waits for: an operation runs at most a fetch and a file read that contact the
* remote, each bounded by the deadline, plus short local commands.
*/
const OPERATION_COMMANDS = 4
/** Limits, location, and transports of the clone cache. */
export interface RepositoryCacheOptions {
/** Directory holding one bare repository per repository URL. */
readonly dir: string
/** Largest amount one command adds to a repository; a command that adds more is stopped and the repository deleted. */
readonly maxRepositoryBytes: number
/** Largest total size; least recently used repositories are deleted after a fetch until the cache fits. */
readonly maxCacheBytes: number
/** Deadline of one Git command and of one anonymous accessibility check. */
readonly timeoutMs: number
/** How long a repository's default-branch commit and its anonymous accessibility are reused. */
readonly ttlMs: number
/** Git transports Git may use, such as `https` and `ssh`; `url.<base>.insteadOf` in the user's configuration still applies. */
readonly transports: readonly string[]
/** Disposal of the owning service. */
readonly lifetime: AbortSignal
/** Interval between size checks of a repository while Git fetches into it. */
readonly sizePollMs: number
}
/** Output that names a connection failure or a timeout rather than a refusal by the server. */
const NETWORK_FAILURE = new RegExp([
'Could not resolve host', 'Failed to connect', 'Connection (?:refused|reset)', 'timed out', 'Network is unreachable', 'SSL', 'TLS',
].join('|'), 'iu')
/** One Git command's outcome. */
interface GitResult {
readonly stdout: Buffer
readonly failed: boolean
/** The output exceeded the command's buffer. */
readonly overflowed: boolean
/** How the command ended, such as its exit code or the deadline it passed, followed by its error output. */
readonly diagnostic: string
}
/** A file over the size its read accepts; the clone itself stays usable. */
class FileTooLarge extends DetailFailure {
/** @param path - the file. @param maxBytes - the accepted size. */
constructor(path: string, maxBytes: number) {
super('too-large', `${path} exceeds ${String(maxBytes)} bytes`)
}
}
/** The failure of a Git command that contacts the remote: `network` for a timeout or a connection failure, otherwise `denied`. */
function remoteFailureOf(result: GitResult, label: string): DetailFailure {
return new DetailFailure(NETWORK_FAILURE.test(result.diagnostic) ? 'network' : 'denied', `${label}: ${result.diagnostic}`)
}
/** A value reused until it expires. */
interface Expiring<T> {
readonly value: T
readonly expiresAt: number
}
async function sizeOf(path: string): Promise<number> {
let total = 0
for (const entry of await readdir(path, { withFileTypes: true, recursive: true })) {
if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size
}
return total
}
/** Staging directories that an initialization in progress owns; an older one was left by a stopped process. */
const STAGING_PREFIX = '.init-'
/**
* The environment of every Git command: the scrubbed parent environment without Git overrides such as `GIT_DIR`, which a
* hook that launched DSH can leave, and without prompts or LFS downloads.
*/
function gitEnv(): Record<string, string> {
const env = Object.fromEntries(Object.entries(scrubbedParentEnv())
.filter(([name]) => !name.startsWith('GIT_') || name === 'GIT_SSH' || name === 'GIT_SSH_COMMAND'))
// A user's own ssh program keeps precedence; the default only keeps ssh from waiting for a prompt.
const ssh = env.GIT_SSH === undefined && env.GIT_SSH_COMMAND === undefined ? { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } : {}
return {
...env, ...ssh, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never',
GCM_INTERACTIVE: 'never', GIT_LFS_SKIP_SMUDGE: '1', GIT_LITERAL_PATHSPECS: '1',
}
}
/** Partial clones of detail repositories under one directory, owned by one service. */
export class RepositoryCache {
private readonly locks = new Map<string, Promise<void>>()
private readonly heads = new Map<string, Expiring<string>>()
/**
* Whether `git --version` has succeeded. A missing Git is checked this way before any operation, because Windows
* reports a missing command as an ordinary exit code that would otherwise read as a refused fetch.
*/
private gitStarts = false
private readonly visibility = new Map<string, Expiring<boolean>>()
private readonly checks = new Map<string, Promise<boolean>>()
private readonly running = new Set<Promise<void>>()
/** @param options - location, limits, deadline, transports, and lifetime. */
constructor(private readonly options: RepositoryCacheOptions) {}
/**
* Whether a repository's smart HTTP endpoint answers without credentials, which makes a missing single-file API answer
* authoritative. The request carries no user configuration, credential helper, or `.netrc` entry; concurrent checks of
* one repository share it, and its answer is reused for `ttlMs`.
* @param url - the repository's https URL, ending in `.git`.
* @returns true for a 2xx answer; false for 401, 403, 404, or a redirect, which hosts send for a private repository.
* @throws {DetailFailure} `network` when the host cannot be reached or answers another status.
*/
isPublic(url: string): Promise<boolean> {
const cached = this.visibility.get(url)
if (cached !== undefined && cached.expiresAt > Date.now()) return Promise.resolve(cached.value)
let pending = this.checks.get(url)
if (pending === undefined) {
pending = this.check(url).finally(() => { this.checks.delete(url) })
this.checks.set(url, pending)
}
return pending
}
private async check(url: string): Promise<boolean> {
const endpoint = `${url}/info/refs?service=git-upload-pack`
let response: Response
try {
response = await fetch(endpoint, { redirect: 'manual', signal: AbortSignal.any([this.options.lifetime, AbortSignal.timeout(this.options.timeoutMs)]) })
} catch (error) {
throw new DetailFailure('network', `Could not reach ${endpoint}: ${(error as Error).message}`, { cause: error })
}
await response.body?.cancel()
const { status } = response
if (!response.ok && status !== 401 && status !== 403 && status !== 404 && (status < 300 || status > 399)) {
throw new DetailFailure('network', `${endpoint} answered HTTP ${String(status)}`)
}
this.visibility.set(url, { value: response.ok, expiresAt: Date.now() + this.options.ttlMs })
return response.ok
}
/**
* Read one regular file of a repository.
* @param url - the repository URL.
* @param commit - a full commit id, or undefined for the default branch.
* @param path - `/`-separated path relative to the repository root.
* @param maxBytes - largest accepted size.
* @param signal - cancellation of the read.
* @returns the bytes, or undefined when the commit has no regular file at `path`.
* @throws {DetailFailure} when Git cannot fetch the commit, the repository grows past its limit, or the file exceeds `maxBytes`.
*/
read(url: string, commit: string | undefined, path: string, maxBytes: number, signal: AbortSignal): Promise<Uint8Array | undefined> {
return this.locked(url, async (repository) => {
const resolved = await this.commit(repository, url, commit, signal)
const blob = (await this.files(repository, resolved, path, signal)).get(path)
return blob === undefined ? undefined : await this.blob(repository, url, blob, path, maxBytes, signal)
})
}
/**
* Read the README of a repository directory: `README.md`, `README.markdown`, or `README` in any letter case.
* @param url - the repository URL.
* @param commit - a full commit id, or undefined for the default branch.
* @param dir - directory relative to the repository root; `''` for the root.
* @param maxBytes - largest accepted size.
* @param signal - cancellation of the read.
* @returns the file, or undefined when the directory has none.
* @throws {DetailFailure} as {@link read} does.
*/
readme(url: string, commit: string | undefined, dir: string, maxBytes: number, signal: AbortSignal): Promise<FoundFile | undefined> {
return this.locked(url, async (repository) => {
const resolved = await this.commit(repository, url, commit, signal)
const files = await this.files(repository, resolved, dir === '' ? undefined : `${dir}/`, signal)
const prefix = dir === '' ? '' : `${dir}/`
const name = readmeName([...files.keys()].map(path => path.slice(prefix.length)))
if (name === undefined) return undefined
const path = `${prefix}${name}`
return { path, bytes: await this.blob(repository, url, files.get(path) as string, path, maxBytes, signal) }
})
}
/** Wait until every running Git command has ended; the lifetime signal stops them. */
async settled(): Promise<void> {
await Promise.allSettled([...this.running])
}
/**
* Serialize operations on one repository, so a fetch and the reads after it never interleave, and delete the
* repository when an operation fails or is cancelled for any reason but a file over its read's size.
*/
private locked<T>(url: string, operation: (repository: string) => Promise<T>): Promise<T> {
const repository = join(this.options.dir, createHash('sha256').update(url).digest('hex').slice(0, 24))
return this.serialized(repository, () => operation(repository).catch(async (error: unknown) => {
if (!(error instanceof FileTooLarge)) await rm(repository, { recursive: true, force: true })
throw error
}))
}
/**
* Run an operation after every earlier operation on the same repository directory has ended, in this service and in
* other processes that share the cache directory, such as Web and Desktop on one profile.
*/
private serialized<T>(repository: string, operation: () => Promise<T>): Promise<T> {
const previous = this.locks.get(repository) ?? Promise.resolve()
const next = previous.then(async () => {
await mkdir(this.options.dir, { recursive: true })
// An operation runs at most this many Git commands, each bounded by the Git deadline.
const waitMs = OPERATION_COMMANDS * this.options.timeoutMs
return await withFileLock(repository, operation, { waitMs, signal: this.options.lifetime })
})
// The caller receives the outcome; the lock and the settlement wait only for completion.
const tracked: Promise<void> = next.then(() => undefined, () => undefined).finally(() => {
if (this.locks.get(repository) === tracked) this.locks.delete(repository)
this.running.delete(tracked)
})
this.locks.set(repository, tracked)
this.running.add(tracked)
return next
}
/** The commit to read: a fetched commit, fetching it or the default branch when necessary. */
private async commit(repository: string, url: string, commit: string | undefined, signal: AbortSignal): Promise<string> {
if (!this.gitStarts) {
// `git version <release>` is one short line.
await this.run(this.options.dir, ['--version'], signal, 1024).catch((error: unknown) => {
throw new DetailFailure('git-unavailable', (error as Error).message, { cause: error })
})
this.gitStarts = true
}
if (!existsSync(repository)) {
// A repository appears complete or not at all: it is initialized beside its final name, then renamed.
const staging = await mkdtemp(join(this.options.dir, STAGING_PREFIX))
try {
await this.run(staging, ['init', '--quiet', '--bare', '--template='], signal)
for (const [key, value] of [['remote.origin.url', url], ['remote.origin.promisor', 'true'], ['remote.origin.partialclonefilter', 'blob:none']]) {
await this.run(staging, ['config', key as string, value as string], signal)
}
await rename(staging, repository)
} finally {
await rm(staging, { recursive: true, force: true })
}
}
await utimes(repository, new Date(), new Date())
// An object lookup would fetch a missing commit lazily from the promisor remote, without the size watch; a ref
// written after a completed fetch records the commit instead, and eviction removes it with the repository.
const head = this.heads.get(url)
const known = commit ?? (head !== undefined && head.expiresAt > Date.now() ? head.value : undefined)
if (known !== undefined && !(await this.git(['show-ref', '--verify', '--quiet', `refs/dsh/${known}`], repository, signal)).failed) return known
await this.fetch(repository, url, commit ?? 'HEAD', signal)
const fetched = commit ?? (await this.run(repository, ['rev-parse', 'FETCH_HEAD'], signal)).toString('utf8').trim()
await this.run(repository, ['update-ref', `refs/dsh/${fetched}`, fetched], signal)
if (commit === undefined) this.heads.set(url, { value: fetched, expiresAt: Date.now() + this.options.ttlMs })
return fetched
}
/** Fetch one commit without file contents. */
private async fetch(repository: string, url: string, target: string, signal: AbortSignal): Promise<void> {
const args = ['fetch', '--quiet', '--depth', '1', '--filter=blob:none', '--no-tags', 'origin', target]
await this.watched(repository, url, args, signal, `${url}: git fetch`)
await this.evict(repository)
}
/**
* Run a Git command that may download objects, stopping it when the repository grows past its limit during or by the
* command; the caller's lock then deletes the repository.
*/
private async watched(
repository: string, url: string, args: readonly string[], signal: AbortSignal, label: string,
maxBuffer = this.options.maxRepositoryBytes,
): Promise<GitResult> {
const limit = this.options.maxRepositoryBytes
// The limit applies to what one command adds, so a clone that earlier reads filled stays usable.
const before = await sizeOf(repository)
const oversized = new AbortController()
const timer = setInterval(() => {
void sizeOf(repository).then((size) => { if (size - before > limit) oversized.abort() }, /* v8 ignore next -- Git renamed or
deleted a file during the walk, which no test can time; the next tick measures again */ (_racing: unknown) => {})
}, this.options.sizePollMs)
let result: GitResult
try {
result = await this.git(args, repository, signal, oversized.signal, maxBuffer)
} finally {
clearInterval(timer)
}
if (oversized.signal.aborted || (!result.failed && await sizeOf(repository) - before > limit)) {
throw new DetailFailure('too-large', `${url}: one fetch adds more than ${String(limit)} bytes`)
}
if (result.failed && !result.overflowed) throw remoteFailureOf(result, label)
return result
}
/**
* Delete least recently used repositories until the cache fits, `kept` last, and staging directories older than the
* Git deadline. Each deletion is queued behind the operations on its repository and not awaited, because the caller
* holds the lock of `kept` and another repository's queued operation may be waiting to evict `kept`.
*/
private async evict(kept: string): Promise<void> {
const now = Date.now()
const entries: { path: string; size: number; used: number }[] = []
for (const entry of await readdir(this.options.dir, { withFileTypes: true })) {
const path = join(this.options.dir, entry.name)
try {
const used = (await stat(path)).mtimeMs
if (entry.name.startsWith(STAGING_PREFIX)) {
if (now - used > this.options.timeoutMs) await rm(path, { recursive: true, force: true })
} else if (entry.isDirectory()) {
entries.push({ path, size: await sizeOf(path), used })
}
} catch (_removed) {
// Another operation deleted the directory while it was measured; it no longer counts.
}
}
let total = entries.reduce((sum, entry) => sum + entry.size, 0)
// The repository in use goes last: it is deleted only when it alone exceeds the cache, after its operation ends.
for (const entry of entries.sort((a, b) => Number(a.path === kept) - Number(b.path === kept) || a.used - b.used)) {
if (total <= this.options.maxCacheBytes) return
void this.serialized(entry.path, () => rm(entry.path, { recursive: true, force: true })).catch(/* v8 ignore next -- a
forced recursive delete fails only on file permissions no test sets; the repository stays and is measured again
after the next fetch */ (_unremovable: unknown) => {})
total -= entry.size
}
}
/**
* The regular files directly below a tree path, or the file at a path, with their blob ids; listing reads only trees,
* so it fetches no file contents.
*/
private async files(repository: string, commit: string, path: string | undefined, signal: AbortSignal): Promise<Map<string, string>> {
const listing = (await this.run(repository, ['ls-tree', '-z', commit, ...path === undefined ? [] : ['--', path]], signal)).toString('utf8')
const files = new Map<string, string>()
for (const line of listing.split('\0')) {
// Symbolic links (120000), submodules (160000), and trees are not files.
const fields = /^100(?:644|755) blob ([0-9a-f]+)\t(.+)$/su.exec(line)
if (fields !== null) files.set(fields[2] as string, fields[1] as string)
}
return files
}
/** Read a blob, fetching its contents under the repository size limit when the clone does not hold them yet. */
private async blob(
repository: string, url: string, blob: string, path: string, maxBytes: number, signal: AbortSignal,
): Promise<Uint8Array> {
// The buffer stops Git once the contents pass `maxBytes`, so an oversized file never enters memory whole.
const result = await this.watched(repository, url, ['cat-file', 'blob', blob], signal, `git cat-file ${path}`, maxBytes)
if (result.overflowed) throw new FileTooLarge(path, maxBytes)
// Reading a blob can download it, so the cache total is enforced after every read as after every fetch.
await this.evict(repository)
return result.stdout
}
/**
* Run a Git command on local objects and return its output. A failure, such as a missing Git, a full disk, or a timeout,
* is a plain error: it says nothing about the repository, so no read caches it.
*/
private async run(repository: string, args: readonly string[], signal: AbortSignal, maxBuffer?: number): Promise<Buffer> {
const result = await this.git(args, repository, signal, undefined, maxBuffer)
if (result.failed) throw new Error(`git ${args[0] as string}: ${result.diagnostic}`)
return result.stdout
}
/**
* Run Git with options that keep it from running repository-supplied programs and allow only the configured transports.
* Cancellation by `signal` or the lifetime rejects with its reason; `stop` ends Git with a failed result.
*/
private async git(
args: readonly string[], cwd: string, signal: AbortSignal, stop?: AbortSignal, maxBuffer = this.options.maxRepositoryBytes,
): Promise<GitResult> {
const safety = [
// An empty `core.askPass` ends Git's askpass lookup where Windows drops the empty `GIT_ASKPASS`, so no dialog opens.
'core.askPass=', 'core.hooksPath=/dev/null', 'core.fsmonitor=false', 'submodule.recurse=false', 'gc.auto=0', 'maintenance.auto=false',
'protocol.allow=never', ...this.options.transports.map(transport => `protocol.${transport}.allow=always`),
].flatMap(setting => ['-c', setting])
const result = await execa('git', [...safety, ...args], {
// A blob's last byte may be a newline, so the output is kept exactly as Git wrote it.
cwd, env: gitEnv(), extendEnv: false, stdin: 'ignore', encoding: 'buffer', stripFinalNewline: false, reject: false,
maxBuffer: { stdout: maxBuffer, stderr: this.options.maxRepositoryBytes },
timeout: this.options.timeoutMs, cancelSignal: AbortSignal.any([signal, this.options.lifetime, ...stop === undefined ? [] : [stop]]),
killDescendants: true, killSignal: 'SIGKILL',
})
signal.throwIfAborted()
this.options.lifetime.throwIfAborted()
// Git that is missing or cannot run says nothing about the repository, so no read caches the failure.
return {
stdout: Buffer.from(result.stdout), failed: result.failed,
overflowed: result.isMaxBuffer && result.stdout.byteLength >= maxBuffer,
diagnostic: `${String(result.shortMessage)}\n${Buffer.from(result.stderr).toString('utf8').trim()}`,
}
}
}
+32
View File
@@ -1,7 +1,9 @@
/** JSON-safe records and events of the plugin market Remote namespace. */
import type { MarketIndexMetadata, MarketInstallTarget, MarketPlugin, MarketVersion } from '@deepseek-ai/dsh-plugin-market-index'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {} from '@deepseek-ai/dsh-typert-protocol'
import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
@@ -17,6 +19,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'plugin-market/invalid-query': { readonly field: string; readonly reason: string }
/** Index sources cannot be saved without the settings service and this service's profile entry. */
'plugin-market/settings-unavailable': Record<string, never>
/** The index lists no such plugin or version. */
'plugin-market/unknown-plugin': { readonly name: string; readonly version: string }
/**
* A detail document exists but cannot be read: `too-large` beyond a size limit, `network` when a source cannot be
* reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when the
* repository must be cloned and Git cannot run, `invalid` for a malformed locale file or `meta.detail`, or a named
* file that does not exist.
*/
'plugin-market/detail-unavailable': { readonly reason: DetailFailureReason }
}
}
@@ -127,6 +138,27 @@ export interface MarketSearchResult {
readonly nextOffset: number | null
}
/** Opaque id of a read detail document; image requests name it to read files beside the document. */
export type PluginDetailDocumentId = Branded<'PluginDetailDocumentId'>
/**
* Where a detail document comes from: `locale` for the file `meta.detail` names, `readme` for the package's README, and
* `repository` for the README of the directory the package's `repository` names.
*/
export type PluginDetailOrigin = 'locale' | 'readme' | 'repository'
/** A plugin's long description, or `none` when no source provides one. */
export type PluginDetail =
| {
readonly status: 'found'
readonly origin: PluginDetailOrigin
/** Markdown text, decoded as UTF-8. */
readonly markdown: string
/** Relative image paths of the document resolve beside it through `GET /api/plugin-market/detail-file`. */
readonly document: PluginDetailDocumentId
}
| { readonly status: 'none' }
declare module '@deepseek-ai/cordis' {
interface Events {
/**
@@ -40,7 +40,7 @@ describe.skipIf(!built)('built index parsing (plain node)', () => {
const ctx = new Context()
try {
await ctx.plugin(Manager)
await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies) })
await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies), detailCacheDir: '/nonexistent/plugin-market-detail-cache' })
const query = { sort: { kind: 'index' }, offset: 0, limit: 10 }
const valid = await ctx.pluginMarket.search('https://m.example/valid.json', query)
const invalid = await ctx.pluginMarket.search('https://m.example/invalid.json', query).catch(error => error)
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Service, type Context } from '@deepseek-ai/cordis'
import { expect, it } from 'vitest'
import { expect, it, vi } from 'vitest'
import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
import ConfigEditor from '@deepseek-ai/dsh-config-editor'
import Settings from '@deepseek-ai/dsh-settings'
@@ -55,3 +55,74 @@ it('saves index sources without remounting, announces them, and restores them at
try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) }
}
})
/** The `connection` Fetch route registry, recording the routes the market registers. */
class RecordingConnection extends Service {
static readonly routes = new Map<string, (request: Request) => Promise<Response>>()
readonly fetch = {
register: (route: { path: string; fetch: (request: Request) => Promise<Response> }) => {
RecordingConnection.routes.set(route.path, route.fetch)
return () => Promise.resolve(void RecordingConnection.routes.delete(route.path))
},
}
constructor(ctx: Context) { super(ctx, 'connection') }
}
it('reads a market entry detail document and serves its images through the composed profile', async () => {
const home = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-')))
const commit = 'c'.repeat(40)
const raw = `https://raw.githubusercontent.com/o/r/${commit}/pkg`
const files: Record<string, string> = {
'https://first.example/index.json': JSON.stringify({ format: 1, title: 'Market', plugins: [{
name: 'from-git', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit, path: 'pkg' }, engines: { dsh: '*' } }],
}] }),
[`${raw}/locale/en.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
[`${raw}/locale/zh.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }),
[`${raw}/guide/DETAIL.md`]: '# Test entry\n\n![diagram](img/diagram.svg)\n',
[`${raw}/guide/DETAIL.zh.md`]: '# 测试条目\n',
[`${raw}/guide/img/diagram.svg`]: '<svg xmlns="http://www.w3.org/2000/svg"/>',
// The repository is public, so a file missing from the raw host is missing from the repository.
'https://github.com/o/r.git/info/refs?service=git-upload-pack': 'refs',
}
vi.stubGlobal('fetch', vi.fn((url: string) => Promise.resolve(url in files ? new Response(files[url]) : new Response('missing', { status: 404 }))))
let ctx: Awaited<ReturnType<typeof boot>> | undefined
try {
const dir = join(home, 'profiles', 'test')
initProfile(dir, ['test-bundle'])
const bundle = join(dir, 'node_modules', 'test-bundle')
mkdirSync(bundle, { recursive: true })
writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n')
writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } }))
writeFileSync(join(bundle, 'cordis.patch.yml'), readFileSync(new URL('./fixtures/market.patch.yml', import.meta.url)))
writeFileSync(join(dir, 'cordis.yml'), '- name: cordis:connection\n')
const profile: ProfileContext = {
name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'),
installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
}
ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => {
root.provide('profileContext', profile)
Object.assign(root.loader.builtins, {
editor: ConfigEditor, settings: Settings, manager: StubPluginManager, market: PluginMarket, connection: RecordingConnection,
})
})
const market = ctx.get('pluginMarket')!
const detail = await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'en-US')
expect(detail).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Test entry\n\n![diagram](img/diagram.svg)\n' })
expect(await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 测试条目\n' })
if (detail.status !== 'found') throw new Error('expected a document')
const serve = RecordingConnection.routes.get('/api/plugin-market/detail-file')!
const image = await serve(new Request(`http://dsh.local/api/plugin-market/detail-file?document=${detail.document}&path=img%2Fdiagram.svg`))
expect(image.status).toBe(200)
expect(image.headers.get('content-type')).toBe('image/svg+xml')
expect(image.headers.get('content-security-policy')).toContain('sandbox')
expect(await image.text()).toContain('<svg')
expect((await serve(new Request(`http://dsh.local/api/plugin-market/detail-file?document=${detail.document}&path=..%2F..%2Fpackage.json`))).status).toBe(404)
await ctx.fiber.dispose()
ctx = undefined
expect(RecordingConnection.routes.has('/api/plugin-market/detail-file')).toBe(false)
} finally {
vi.unstubAllGlobals()
try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) }
}
})
@@ -0,0 +1,107 @@
/** Repository files through GitHub and GitLab single-file APIs, with the clone fallback. */
import { afterEach, expect, it, vi } from 'vitest'
import { DetailFailure } from '@deepseek-ai/dsh-plugin-detail'
import { repositoryFiles, type RepositoryFileOptions } from '../src/detail-files.ts'
const signal = new AbortController().signal
const lifetime = new AbortController().signal
const options = { timeoutMs: 5000, lifetime }
const text = (bytes: Uint8Array | undefined): string | undefined => bytes === undefined ? undefined : new TextDecoder().decode(bytes)
afterEach(() => { vi.unstubAllGlobals() })
/** Answer requests by URL with a fresh response each time; any other URL fails the test. */
function serve(routes: Record<string, Error | (() => Response)>) {
const fetch = vi.fn((url: string) => {
const route = routes[url]
if (route === undefined) throw new Error(`unexpected fetch ${url}`)
if (route instanceof Error) return Promise.reject(route)
return Promise.resolve(route())
})
vi.stubGlobal('fetch', fetch)
return fetch
}
/** A clone cache double recording its calls. */
function clones(answers: { public?: boolean | Error; read?: string; readme?: string } = {}) {
const calls: string[] = []
const cache = {
isPublic: vi.fn((url: string) => {
calls.push(`public ${url}`)
return answers.public instanceof Error ? Promise.reject(answers.public) : Promise.resolve(answers.public ?? false)
}),
read: vi.fn((url: string, commit: string | undefined, path: string) => {
calls.push(`read ${url} ${String(commit)} ${path}`)
return Promise.resolve(answers.read === undefined ? undefined : Buffer.from(answers.read))
}),
readme: vi.fn((url: string, commit: string | undefined, dir: string) => {
calls.push(`readme ${url} ${String(commit)} ${dir}`)
return Promise.resolve(answers.readme === undefined ? undefined : { path: `${dir}/README.md`, bytes: Buffer.from(answers.readme) })
}),
}
const settings: RepositoryFileOptions = {
...options, gitlabHosts: ['gitlab.example'], cache,
}
return { calls, settings }
}
const GITHUB = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' }
const GITLAB = { host: 'gitlab.example', project: 'g/s/p', url: 'https://gitlab.example/g/s/p.git' }
const OTHER = { host: 'git.example', project: 'o/r', url: 'https://git.example/o/r.git' }
const COMMIT = 'a'.repeat(40)
it('reads public repositories through the GitHub and GitLab single-file APIs', async () => {
serve({
[`https://raw.githubusercontent.com/o/r/${COMMIT}/p/README.md`]: () => new Response('# gh'),
'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }),
'https://raw.githubusercontent.com/o/r/HEAD/readme.md': () => new Response('no', { status: 404 }),
'https://raw.githubusercontent.com/o/r/HEAD/Readme.md': () => new Response('# root'),
'https://raw.githubusercontent.com/o/r/HEAD/missing.png': () => new Response('no', { status: 404 }),
'https://gitlab.example/api/v4/projects/g%2Fs%2Fp/repository/files/guide%2Fa.md/raw?ref=HEAD': () => new Response('# gl'),
'https://raw.githubusercontent.com/o/r/HEAD/down.md': () => new Response('down', { status: 500 }),
})
const { calls, settings } = clones({ public: true })
const pinned = repositoryFiles(GITHUB, COMMIT, settings)
expect(pinned.key).toBe(`git:https://github.com/o/r.git#${COMMIT}`)
expect(await pinned.readme('p', 100, signal)).toMatchObject({ path: 'p/README.md' })
const head = repositoryFiles(GITHUB, undefined, settings)
expect(await head.readme('', 100, signal)).toMatchObject({ path: 'Readme.md' })
expect(await head.read('missing.png', 100, signal)).toBeUndefined()
await expect(head.read('down.md', 100, signal)).rejects.toMatchObject({ reason: 'network' })
expect(text(await repositoryFiles(GITLAB, undefined, settings).read('guide/a.md', 100, signal))).toBe('# gl')
// Each ambiguous answer asks whether the repository is public; the clone cache shares and caches that check.
expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git'])
})
it('reads repositories that need credentials, and repositories on hosts without a single-file API, from the clone', async () => {
serve({
'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }),
'https://raw.githubusercontent.com/o/r/HEAD/a.png': () => new Response('no', { status: 403 }),
})
const { calls, settings } = clones({ public: false, read: 'png', readme: '# private' })
const files = repositoryFiles(GITHUB, undefined, settings)
expect(await files.readme('', 100, signal)).toMatchObject({ path: '/README.md' })
expect(text(await files.read('a.png', 100, signal))).toBe('png')
const other = repositoryFiles(OTHER, COMMIT, settings)
expect(text(await other.read('b.png', 100, signal))).toBe('png')
expect((await other.readme('d', 100, signal))?.path).toBe('d/README.md')
expect(calls).toEqual([
'public https://github.com/o/r.git',
'readme https://github.com/o/r.git undefined ',
'public https://github.com/o/r.git',
'read https://github.com/o/r.git undefined a.png',
`read https://git.example/o/r.git ${COMMIT} b.png`,
`readme https://git.example/o/r.git ${COMMIT} d`,
])
const cleartext = repositoryFiles({ host: 'git.example', project: 'o/r', url: 'http://git.example/o/r.git' }, undefined, settings)
expect(text(await cleartext.read('c.png', 100, signal))).toBe('png')
})
it('reports a failed anonymous check as the read\'s failure', async () => {
serve({ 'https://raw.githubusercontent.com/o/r/HEAD/a.md': () => new Response('no', { status: 404 }) })
const { calls, settings } = clones({ public: new DetailFailure('network', 'offline') })
const files = repositoryFiles(GITHUB, undefined, settings)
await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' })
await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' })
expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git'])
})
@@ -0,0 +1,182 @@
/** Market entry detail documents: package document, repository README, caching, and images beside a read document. */
import { afterEach, expect, it, vi } from 'vitest'
import { DetailFailure, readmeName, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail'
import { DetailReader, manifestRepository, repositoryOf, type DetailLocation } from '../src/detail.ts'
import type { RepositoryFileOptions } from '../src/detail-files.ts'
/** Await a rejection that matches `fields` and whose message contains `message`. */
async function expectRejection(promise: Promise<unknown>, fields: object, message: string): Promise<void> {
await expect(promise).rejects.toMatchObject(fields)
await expect(promise).rejects.toThrow(message)
}
/** In-memory files under one key. */
function memory(key: string, contents: Record<string, string>): DetailFiles & { reads: string[] } {
const reads: string[] = []
const read = (path: string, maxBytes: number) => {
reads.push(path)
const value = contents[path]
if (value === undefined) return Promise.resolve(undefined)
if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path))
return Promise.resolve(new TextEncoder().encode(value))
}
return {
key, reads, read,
async readme(dir, maxBytes) {
const prefix = dir === '' ? '' : `${dir}/`
const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length)))
if (name === undefined) return undefined
const path = `${prefix}${name}`
return { path, bytes: (await read(path, maxBytes)) as Uint8Array }
},
}
}
const lifetime = new AbortController().signal
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
function reader(repositoryContents: Record<string, string> = {}, limits: { ttlMs?: number } = {}) {
const raw = vi.fn((url: string) => Promise.resolve(new Response(repositoryContents[url] ?? 'missing', { status: url in repositoryContents ? 200 : 404 })))
vi.stubGlobal('fetch', raw)
const repositories: RepositoryFileOptions = {
timeoutMs: 1000, lifetime, gitlabHosts: [],
cache: { isPublic: () => Promise.resolve(true), read: () => Promise.resolve(undefined), readme: () => Promise.resolve(undefined) },
}
return new DetailReader({ maxDocumentBytes: 100, maxImageBytes: 10, ttlMs: limits.ttlMs ?? 60_000 }, repositories)
}
const EN_ZH = ['zh', 'en']
it('parses repositories from manifests, shorthands, and web pages', () => {
expect(manifestRepository('github:o/r')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' })
expect(manifestRepository({ type: 'git', url: 'git+https://gitlab.com/g/s/p.git#main', directory: './packages/a/' }))
.toEqual({ host: 'gitlab.com', project: 'g/s/p', url: 'https://gitlab.com/g/s/p.git', directory: 'packages/a' })
expect(manifestRepository({ url: 'git+https://git.example:8443/team/repo.git', directory: '../x' }))
.toEqual({ host: 'git.example', project: 'team/repo', url: 'https://git.example:8443/team/repo.git', directory: '' })
expect(manifestRepository({ url: 'github:o/r', directory: '/abs' })).toMatchObject({ directory: '' })
expect(manifestRepository({ url: 'https://git.example/team/repo', directory: '.' })).toMatchObject({ directory: '' })
expect(manifestRepository({ url: 'https://git.example/team/repo', directory: 7 })).toMatchObject({ directory: '' })
for (const value of [undefined, null, 7, { url: 7 }, 'not a repository', 'ftp://git.example/a/b', 'https://git.example/one', 'gist:abc']) {
expect(manifestRepository(value)).toBeUndefined()
}
expect(repositoryOf('https://github.com/o/r/tree/main/pkg')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' })
expect(repositoryOf('https://git.corp.example/g/p/-/tree/main')).toEqual({ host: 'git.corp.example', project: 'g/p', url: 'https://git.corp.example/g/p.git' })
expect(repositoryOf('https://user:[email protected]/a/b')).toBeUndefined()
expect(repositoryOf('http://intranet/a/b/')).toEqual({ host: 'intranet', project: 'a/b', url: 'http://intranet/a/b.git' })
})
it('reads the package document first, and reports an invalid one', async () => {
const files = memory('npm:a@1', {
'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
'guide/DETAIL.md': '# Detail',
'README.md': '# Readme',
})
const details = reader()
expect(await details.read({ files, dir: '', repository: 'manifest' }, EN_ZH)).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Detail' })
expect(await details.read({ files: memory('npm:r@1', { 'README.md': '# Readme' }), dir: '', repository: undefined }, ['en']))
.toMatchObject({ origin: 'readme', markdown: '# Readme' })
const missing = memory('npm:b@1', { 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) })
await expectRejection(details.read({ files: missing, dir: '', repository: undefined }, ['en']), { reason: 'invalid' }, 'GONE.md')
})
it('reads the repository README last, from package.json or the given repository, at the default branch', async () => {
const details = reader({
'https://raw.githubusercontent.com/o/r/HEAD/packages/a/README.md': '# Repo A',
'https://raw.githubusercontent.com/o/r/HEAD/README.md': '# Repo',
})
const files = memory('npm:a@1', { 'package.json': JSON.stringify({ repository: { url: 'github:o/r', directory: 'packages/a' } }) })
expect(await details.read({ files, dir: '', repository: 'manifest' }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo A' })
expect(await details.read({ files: memory('npm:b@1', {}), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' })
expect(await details.read({ files: memory('npm:c@1', { 'package.json': '{' }), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' })
const repository = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' }
expect(await details.read({ dir: '', repository }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo' })
expect(await details.read({ dir: '', repository: { ...repository, directory: 'missing' } }, ['en'])).toEqual({ status: 'none' })
expect(await details.read({ dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' })
})
it('reports a failure of the declared repository', async () => {
const details = reader()
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline'))))
const github = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' }
await expectRejection(details.read({ dir: '', repository: github }, ['en']), { reason: 'network' }, 'offline')
})
it('shares concurrent reads, reuses results and lasting failures until they expire, and asks again after a network failure', async () => {
vi.useFakeTimers()
const files = memory('npm:a@1', { 'README.md': '# A' })
const details = reader({}, { ttlMs: 1000 })
const location: DetailLocation = { files, dir: '', repository: undefined }
const [first, second] = await Promise.all([details.read(location, ['en']), details.read(location, ['en'])])
expect(second).toBe(first)
expect(await details.read(location, ['en'])).toBe(first)
expect(files.reads.filter(path => path === 'README.md')).toHaveLength(1)
vi.advanceTimersByTime(1001)
await details.read(location, ['en'])
expect(files.reads.filter(path => path === 'README.md')).toHaveLength(2)
const big = memory('npm:big@1', { 'README.md': 'x'.repeat(200) })
await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' })
await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' })
expect(big.reads.filter(path => path === 'README.md')).toHaveLength(1)
let calls = 0
const flaky: DetailFiles = {
key: 'flaky', read: () => Promise.resolve(undefined),
readme: () => (++calls === 1 ? Promise.reject(new DetailFailure('network', 'offline')) : Promise.resolve(undefined)),
}
await expect(details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'network' })
expect(await details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' })
const broken: DetailFiles = { key: 'broken', read: () => Promise.reject(new TypeError('bug')), readme: () => Promise.resolve(undefined) }
await expect(details.read({ files: broken, dir: '', repository: undefined }, ['en'])).rejects.toThrow('bug')
})
it('forgets a document id unused for the result lifetime, and keeps one that reads or images keep using', async () => {
vi.useFakeTimers()
const details = reader({}, { ttlMs: 1000 })
const read = (key: string) => details.read({ files: memory(key, { 'README.md': '# A', 'a.png': 'png' }), dir: '', repository: undefined }, ['en'])
const kept = await read('npm:kept@1')
const idle = await read('npm:idle@1')
if (kept.status !== 'found' || idle.status !== 'found') throw new Error('expected documents')
vi.advanceTimersByTime(600)
expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' })
vi.advanceTimersByTime(600)
// The cached read of `kept` expired, but its image request keeps the id; `idle` went unused for longer than the lifetime.
expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' })
expect(await details.image(idle.document, 'a.png')).toBeUndefined()
expect(await read('npm:idle@1')).toEqual(idle)
expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' })
vi.advanceTimersByTime(500)
// A cached read marks its document used.
expect(await read('npm:idle@1')).toEqual(idle)
vi.advanceTimersByTime(700)
expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) // A cached read without a document marks nothing.
const empty = { files: memory('npm:empty@1', {}), dir: '', repository: undefined } as const
expect(await details.read(empty, ['en'])).toEqual({ status: 'none' })
expect(await details.read(empty, ['en'])).toEqual({ status: 'none' })
})
it('reads images beside a document and refuses other paths', async () => {
const files = memory('npm:a@1', {
'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
'guide/DETAIL.md': '# Detail', 'guide/img/a.png': 'png', 'logo.SVG': '<svg/>', 'guide/notes.txt': 'text', 'guide/big.gif': 'x'.repeat(20),
'guide/a b.webp': 'webp',
})
const details = reader()
const detail = await details.read({ files, dir: '', repository: undefined }, ['en'])
if (detail.status !== 'found') throw new Error('expected a document')
expect(await details.image(detail.document, 'img/a.png?raw=1#x')).toEqual({ bytes: new TextEncoder().encode('png'), mediaType: 'image/png' })
expect(await details.image(detail.document, '/logo.SVG')).toMatchObject({ mediaType: 'image/svg+xml' })
expect(await details.image(detail.document, './a%20b.webp')).toMatchObject({ mediaType: 'image/webp' })
for (const path of ['../../etc/passwd.png', 'notes.txt', 'img/missing.png']) {
expect(await details.image(detail.document, path)).toBeUndefined()
}
await expect(details.image(detail.document, 'big.gif')).rejects.toMatchObject({ reason: 'too-large' })
expect(await details.image('unknown', 'img/a.png')).toBeUndefined()
const root = await details.read({ files: memory('npm:b@1', { 'README.md': '# B', 'b.png': 'b' }), dir: '', repository: undefined }, ['en'])
if (root.status !== 'found') throw new Error('expected a document')
expect(await details.image(root.document, 'b.png')).toMatchObject({ mediaType: 'image/png' })
})
@@ -0,0 +1,184 @@
/** The detail Remote methods and the image route of the plugin market service. */
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context, Service } from '@deepseek-ai/cordis'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
import { DetailReader } from '../src/detail.ts'
import PluginMarket from '../src/index.ts'
import { RepositoryCache } from '../src/repository-cache.ts'
import { parseIndexBody } from '../src/parse.ts'
vi.mock('../src/parse-thread.ts', () => ({
parseIndexInWorker: async (body: Uint8Array, url: string) => await Promise.resolve(parseIndexBody(body, url)),
}))
const INDEX = 'https://market.example/index.json'
const COMMIT = 'b'.repeat(40)
let home: string
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-remote-'))
// Every repository of these tests is public; no test runs Git against a network host.
vi.spyOn(RepositoryCache.prototype, 'isPublic').mockResolvedValue(true)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
rmSync(home, { recursive: true, force: true })
})
/** Serve fixed bodies by URL; any other URL answers 404. */
function serve(routes: Record<string, unknown>) {
const fetch = vi.fn((url: string) => {
const body = routes[url]
if (body instanceof Error) return Promise.reject(body)
if (body === undefined) return Promise.resolve(new Response('missing', { status: 404 }))
return Promise.resolve(new Response(typeof body === 'string' ? body : JSON.stringify(body)))
})
vi.stubGlobal('fetch', fetch)
return fetch
}
const index = {
format: 1, title: 'Market', plugins: [
{
name: 'from-git', repository: { url: 'https://github.com/o/mono', directory: 'other' },
versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }],
},
{
name: 'from-git-root', repository: { url: 'https://example.com/only' },
versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/root', commit: COMMIT }, engines: { dsh: '*' } }],
},
{
name: 'from-git-self', repository: { url: 'https://github.com/o/self', directory: 'pkg' },
versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/self', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }],
},
{ name: 'bare', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } }] },
{
name: 'from-npm', versions: [
{ version: '2.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } },
{ version: '1.0.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://npm.corp.example/' }, engines: { dsh: '*' } },
{ version: '0.9.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://registry.npmmirror.com/' }, engines: { dsh: '*' } },
],
repository: { url: 'https://github.com/o/npm' },
},
],
}
async function mount(options: { profile?: boolean; connection?: boolean } = {}) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
class Manager extends Service {
constructor(inner: Context) { super(inner, 'pluginManager') }
listBundles() { return Promise.resolve([]) }
listVersionExemptions() { return { exemptions: {}, warnings: [] } }
}
await ctx.plugin(Manager)
if (options.profile === true) {
ctx.provide('profileContext', {
name: 'test', startedBundles: [], dir: join(home, 'profile'), patchPath: join(home, 'profile', 'cordis.patch.yml'),
installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
})
}
const routes = new Map<string, (request: Request) => Promise<Response>>()
if (options.connection === true) {
class Connection extends Service {
readonly fetch = {
register: (route: { path: string; fetch: (request: Request) => Promise<Response> }) => {
routes.set(route.path, route.fetch)
return () => Promise.resolve(void routes.delete(route.path))
},
}
constructor(inner: Context) { super(inner, 'connection') }
}
await ctx.plugin(Connection)
}
await ctx.plugin(PluginMarket, { indexes: [INDEX], ...options.profile === true ? {} : { detailCacheDir: join(home, 'cache') } })
return { market: ctx.pluginMarket, routes }
}
const failureOf = async (promise: Promise<unknown>) => remoteErrorOf(await promise.catch((error: unknown) => error))
it('requires a clone cache directory outside a profile', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
class Manager extends Service {
constructor(inner: Context) { super(inner, 'pluginManager') }
}
await ctx.plugin(Manager)
await expect(ctx.plugin(PluginMarket, { indexes: [] })).rejects.toThrow('detailCacheDir must be an absolute path')
})
it('reads market entries from their version source and the entry repository', async () => {
const requests = serve({
[INDEX]: index,
[`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git',
[`https://raw.githubusercontent.com/o/root/${COMMIT}/README.md`]: '# Root',
'https://cdn.jsdelivr.net/npm/[email protected]/README.md': '# Npm',
'https://raw.githubusercontent.com/o/npm/HEAD/README.md': '# Npm repository',
})
const { market } = await mount({ profile: true })
expect(await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Git' })
expect(await market.readEntryDetail(INDEX, 'from-git-root', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Root' })
expect(await market.readEntryDetail(INDEX, 'bare', '1.0.0', 'en')).toEqual({ status: 'none' })
// The entry's repository is the source directory just read, so its default branch is not read again.
expect(await market.readEntryDetail(INDEX, 'from-git-self', '1.0.0', 'en')).toEqual({ status: 'none' })
expect(requests.mock.calls.map(([url]) => url).filter(url => url.includes('/o/self/HEAD/'))).toEqual([])
expect(await market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Npm' })
// A private registry's package files are not on the public CDNs; only the entry's repository is read.
expect(await market.readEntryDetail(INDEX, 'from-npm', '1.0.0', 'en')).toMatchObject({ origin: 'repository', markdown: '# Npm repository' })
expect(await market.readEntryDetail(INDEX, 'from-npm', '0.9.0', 'en')).toMatchObject({ origin: 'repository' })
expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '3.0.0', 'en')))
.toMatchObject({ code: 'plugin-market/unknown-plugin', details: { name: 'from-npm', version: '3.0.0' } })
expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', 'en'))).toMatchObject({ code: 'plugin-market/unknown-plugin' })
expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', '!'))).toMatchObject({ code: 'plugin-market/invalid-query' })
serve({ [INDEX]: index, 'https://registry.npmmirror.com/from-npm/2.0.0/files/README.md': '# Mirror' })
const { market: mirrored } = await mount()
expect(await mirrored.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en', { npmFileSources: ['npmmirror'] })).toMatchObject({ markdown: '# Mirror' })
})
it('reports unreadable sources as detail-unavailable and keeps other failures', async () => {
serve({ [INDEX]: index, 'https://cdn.jsdelivr.net/npm/[email protected]/locale/en.json': new Error('offline') })
const { market } = await mount()
await market.search(INDEX, { sort: { kind: 'index' }, offset: 0, limit: 1 })
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline'))))
expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')))
.toMatchObject({ code: 'plugin-market/detail-unavailable', details: { reason: 'network' } })
const read = vi.spyOn(DetailReader.prototype, 'read').mockRejectedValueOnce(new TypeError('bug'))
try {
await expect(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).rejects.toThrow('bug')
} finally {
read.mockRestore()
}
})
it('serves images beside a read document and maps failures to statuses', async () => {
serve({
[INDEX]: index,
[`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git ![a](a.png)',
[`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/a.png`]: 'png',
})
const { market, routes } = await mount({ connection: true })
const detail = await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en')
if (detail.status !== 'found') throw new Error('expected a document')
const route = routes.get('/api/plugin-market/detail-file')!
const get = (query: string) => route(new Request(`http://dsh.local/api/plugin-market/detail-file${query}`))
const ok = await get(`?document=${detail.document}&path=a.png`)
expect([ok.status, ok.headers.get('content-type'), ok.headers.get('x-content-type-options'), await ok.text()])
.toEqual([200, 'image/png', 'nosniff', 'png'])
expect((await get(`?document=${detail.document}`)).status).toBe(400)
expect((await get(`?document=${detail.document}&path=missing.png`)).status).toBe(404)
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('x'.repeat(10), { headers: { 'content-length': String(6 * 1024 * 1024) } }))))
expect((await get(`?document=${detail.document}&path=large.png`)).status).toBe(413)
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline'))))
expect((await get(`?document=${detail.document}&path=b.png`)).status).toBe(502)
const image = vi.spyOn(DetailReader.prototype, 'image').mockRejectedValueOnce(new TypeError('bug'))
try {
await expect(get(`?document=${detail.document}&path=c.png`)).rejects.toThrow('bug')
} finally {
image.mockRestore()
}
})
@@ -3,6 +3,9 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { expect, it, onTestFinished, vi } from 'vitest'
/** Clone cache directory of services whose tests read no repository; nothing creates it. */
const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache'
vi.mock('../src/parse-thread.ts', () => ({ parseIndexInWorker: () => Promise.reject(new Error('parser crashed')) }))
const { default: PluginMarket } = await import('../src/index.ts')
@@ -18,7 +21,7 @@ it('reports a parse Worker failure as plugin-market/fetch-failed', async () => {
listVersionExemptions() { return { exemptions: {}, warnings: [] } }
}
await ctx.plugin(Manager)
await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'] })
await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'], detailCacheDir: UNUSED_CACHE })
const failure = await ctx.pluginMarket.search('https://market.example/index.json', { sort: { kind: 'index' }, offset: 0, limit: 1 })
.then(() => { throw new Error('resolved') }, (error: unknown) => remoteErrorOf(error))
expect(failure).toMatchObject({ code: 'plugin-market/fetch-failed', details: { reason: 'parser crashed' } })
@@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import PluginMarket, { type Config, type MarketQuery } from '../src/index.ts'
import { parseIndexBody } from '../src/parse.ts'
/** Clone cache directory of services whose tests read no repository; nothing creates it. */
const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache'
// Parse in-process; parse-worker.spec.ts runs the real Worker.
vi.mock('../src/parse-thread.ts', () => ({
parseIndexInWorker: async (body: Uint8Array, url: string, signal: AbortSignal) => {
@@ -66,7 +69,7 @@ async function mount(config: Partial<{ indexes: string[]; fetchTimeoutMs: number
captured = resolved
}
}
await ctx.plugin(CapturingMarket, { indexes: [URL_A], ...config })
await ctx.plugin(CapturingMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE, ...config })
if (captured === undefined) throw new Error('market did not mount')
return { ctx, market: ctx.pluginMarket, config: captured }
}
@@ -0,0 +1,295 @@
/** Partial clones of real local repositories: reads, README lookup, limits, eviction, and Git failures. */
import { execFileSync } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DetailFailure } from '@deepseek-ai/dsh-plugin-detail'
import { RepositoryCache, type RepositoryCacheOptions } from '../src/repository-cache.ts'
/** Await a rejection that matches `fields` and whose message contains `message`. */
async function expectRejection(promise: Promise<unknown>, fields: object, message: string): Promise<void> {
await expect(promise).rejects.toMatchObject(fields)
await expect(promise).rejects.toThrow(message)
}
let root: string
const lifetime = new AbortController()
const signal = new AbortController().signal
const text = (bytes: Uint8Array | undefined): string => new TextDecoder().decode(bytes)
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-repositories-'))
// Git reads no configuration of the developer running the tests.
mkdirSync(join(root, 'home'))
vi.stubEnv('HOME', join(root, 'home'))
vi.stubEnv('XDG_CONFIG_HOME', join(root, 'home'))
})
afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
rmSync(root, { recursive: true, force: true })
})
function git(cwd: string, ...args: string[]): string {
// Fixture repositories ignore inherited Git overrides, such as a hook's `GIT_DIR`, and every user or system configuration.
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')))
return execFileSync('git', ['-c', 'user.name=t', '-c', '[email protected]', '-c', 'commit.gpgsign=false', ...args], {
cwd, encoding: 'utf8',
env: { ...env, HOME: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'home'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
}).trim()
}
/** The cache directory name of a repository URL. */
function cloneName(url: string): string {
return createHash('sha256').update(url).digest('hex').slice(0, 24)
}
/** Bytes of the regular files below a directory. */
function dirSize(dir: string): number {
return readdirSync(dir, { recursive: true, encoding: 'utf8' })
.map(name => lstatSync(join(dir, name)))
.reduce((sum, entry) => sum + (entry.isFile() ? entry.size : 0), 0)
}
/** A repository with `files` committed on its default branch, served as a `file:` URL. */
function repository(name: string, files: Record<string, string | Uint8Array>): { url: string; commit: string; dir: string } {
const dir = join(root, 'sources', name)
mkdirSync(dir, { recursive: true })
git(dir, 'init', '--quiet', '--initial-branch=main')
// A partial fetch from a local repository needs the server side to accept object filters.
git(dir, 'config', 'uploadpack.allowFilter', 'true')
git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true')
for (const [path, contents] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), contents)
}
git(dir, 'add', '-A')
git(dir, 'commit', '--quiet', '-m', 'init')
return { url: pathToFileURL(dir).href, commit: git(dir, 'rev-parse', 'HEAD'), dir }
}
function cache(options: Partial<RepositoryCacheOptions> = {}): RepositoryCache {
return new RepositoryCache({
dir: join(root, 'cache'), maxRepositoryBytes: 10 * 1024 * 1024, maxCacheBytes: 100 * 1024 * 1024,
timeoutMs: 30_000, ttlMs: 60_000, transports: ['file', 'ssh'], lifetime: lifetime.signal, sizePollMs: 1, ...options,
})
}
it('reads files at a pinned commit and the default branch, fetching contents only when read', async () => {
const source = repository('a', { 'packages/p/README.md': '# P', 'packages/p/img/a.png': 'png', 'big.bin': 'x'.repeat(4096) })
const repositories = cache()
expect(new TextDecoder().decode(await repositories.read(source.url, source.commit, 'packages/p/img/a.png', 100, signal))).toBe('png')
expect(await repositories.read(source.url, source.commit, 'packages/p/missing.png', 100, signal)).toBeUndefined()
expect(await repositories.read(source.url, source.commit, 'packages/p', 100, signal)).toBeUndefined()
await expect(repositories.read(source.url, source.commit, 'big.bin', 100, signal)).rejects.toMatchObject({ reason: 'too-large' })
// A file over its read's size leaves the clone usable.
expect(readdirSync(join(root, 'cache'))).toHaveLength(1)
writeFileSync(join(source.dir, 'packages/p/README.md'), '# P2')
git(source.dir, 'commit', '--quiet', '-am', 'second')
expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2')
writeFileSync(join(source.dir, 'packages/p/README.md'), '# P3')
git(source.dir, 'commit', '--quiet', '-am', 'third')
// The default branch commit stays cached for the TTL.
expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2')
expect(await repositories.readme(source.url, source.commit, 'packages/p/img', 100, signal)).toBeUndefined()
expect(new TextDecoder().decode((await repositories.readme(source.url, source.commit, 'packages/p', 100, signal))?.bytes)).toBe('# P')
await repositories.settled()
})
it('picks the README of a root directory by name order, in any letter case', async () => {
const source = repository('b', { 'readme.markdown': 'second', 'Readme.md': 'first', 'docs/readme': 'plain' })
const repositories = cache()
expect(await repositories.readme(source.url, source.commit, '', 100, signal)).toMatchObject({ path: 'Readme.md' })
expect(await repositories.readme(source.url, source.commit, 'docs', 100, signal)).toMatchObject({ path: 'docs/readme' })
})
it('asks the smart HTTP endpoint without credentials whether a repository is public, and caches the answer', async () => {
const answers: Record<string, Response | Error> = {
'https://git.example/public.git/info/refs?service=git-upload-pack': new Response('refs'),
'https://git.example/private.git/info/refs?service=git-upload-pack': new Response('no', { status: 401 }),
'https://git.example/moved.git/info/refs?service=git-upload-pack': new Response(null, { status: 302, headers: { location: '/login' } }),
'https://git.example/broken.git/info/refs?service=git-upload-pack': new Response('no', { status: 500 }),
'https://git.example/down.git/info/refs?service=git-upload-pack': new Error('offline'),
}
const fetch = vi.fn((url: string, init: RequestInit) => {
expect(init.redirect).toBe('manual')
const answer = answers[url]
return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer as Response)
})
vi.stubGlobal('fetch', fetch)
const repositories = cache()
const [first, second] = await Promise.all([repositories.isPublic('https://git.example/public.git'), repositories.isPublic('https://git.example/public.git')])
expect([first, second]).toEqual([true, true])
expect(await repositories.isPublic('https://git.example/public.git')).toBe(true)
expect(await repositories.isPublic('https://git.example/private.git')).toBe(false)
expect(await repositories.isPublic('https://git.example/moved.git')).toBe(false)
await expectRejection(repositories.isPublic('https://git.example/broken.git'), { reason: 'network' }, 'HTTP 500')
await expectRejection(repositories.isPublic('https://git.example/down.git'), { reason: 'network' }, 'offline')
expect(fetch).toHaveBeenCalledTimes(5)
})
it('reports an unreachable host as a network failure, a failed local command as a plain error, and a Git that cannot run', async () => {
vi.stubEnv('GIT_SSH_COMMAND', 'ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5')
await expectRejection(cache().read('ssh://127.0.0.1:1/owner/repo.git', undefined, 'README.md', 100, signal), { reason: 'network' }, 'git fetch')
const source = repository('t', { 'README.md': 'x' })
const repositories = cache()
await repositories.read(source.url, source.commit, 'README.md', 100, signal)
// A clone whose commit refs cannot be written fails a local command after a successful fetch; that says nothing about
// the repository, and the clone is deleted.
const [clone] = readdirSync(join(root, 'cache'))
rmSync(join(root, 'cache', clone as string, 'refs', 'dsh'), { recursive: true })
writeFileSync(join(root, 'cache', clone as string, 'refs', 'dsh'), 'not a directory')
writeFileSync(join(source.dir, 'README.md'), 'y')
git(source.dir, 'commit', '--quiet', '-am', 'next')
const broken = repositories.read(source.url, git(source.dir, 'rev-parse', 'HEAD'), 'README.md', 100, signal)
await expect(broken).rejects.toThrow('git update-ref')
await expect(broken).rejects.not.toBeInstanceOf(DetailFailure)
expect(readdirSync(join(root, 'cache'))).toEqual([])
// Git that cannot answer `git --version` within the deadline cannot run.
const other = repository('t2', { 'README.md': 'x' })
await expectRejection(cache({ timeoutMs: 1 }).read(other.url, other.commit, 'README.md', 100, signal), { reason: 'git-unavailable' }, 'timed out')
vi.stubEnv('PATH', join(root, 'no-git'))
const missing = cache().read(other.url, other.commit, 'README.md', 100, signal)
await expectRejection(missing, { reason: 'git-unavailable' }, 'git --version')
expect(readdirSync(join(root, 'cache'))).toEqual([])
})
it('reports a repository Git cannot fetch as denied', async () => {
const repositories = cache()
const missing = pathToFileURL(join(root, 'missing')).href
const failure = await repositories.read(missing, undefined, 'README.md', 100, signal).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DetailFailure)
expect(failure).toMatchObject({ reason: 'denied' })
expect(readdirSync(join(root, 'cache'))).toEqual([])
const source = repository('d', { 'README.md': 'x' })
await expect(repositories.read(source.url, 'f'.repeat(40), 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'denied' })
await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).resolves.toBeDefined()
})
it('stops a fetch once the repository grows past its limit, and deletes the repository', async () => {
const source = repository('e', { 'README.md': 'x' })
// The empty repository already exceeds one byte, so the first size check stops Git.
const repositories = cache({ maxRepositoryBytes: 1 })
await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'too-large' })
expect(readdirSync(join(root, 'cache'))).toEqual([])
})
it('refuses a repository that a completed fetch left past its limit', async () => {
// Distinct files make distinct trees, so the fetch adds one tree object per directory.
const source = repository('e2', Object.fromEntries(Array.from({ length: 1000 }, (_value, index) => [`dir${String(index)}/f.txt`, String(index)])))
const repositories = cache({ maxRepositoryBytes: 4096, sizePollMs: 3_600_000 })
await expect(repositories.read(source.url, source.commit, 'dir0/f.txt', 100, signal)).rejects.toMatchObject({ reason: 'too-large' })
expect(readdirSync(join(root, 'cache'))).toEqual([])
})
it('deletes least recently used repositories beyond the cache size and stale staging directories', async () => {
const first = repository('f', { 'README.md': 'first' })
const second = repository('g', { 'README.md': 'second' })
const third = repository('h', { 'README.md': 'third' })
// The total holds one of these clones, not two.
const probe = cache({ dir: join(root, 'probe') })
await probe.read(first.url, first.commit, 'README.md', 100, signal)
const one = dirSize(join(root, 'probe'))
const repositories = cache({ maxCacheBytes: Math.floor(one * 2.5) })
mkdirSync(join(root, 'cache', '.init-stale'), { recursive: true })
utimesSync(join(root, 'cache', '.init-stale'), new Date(0), new Date(0))
mkdirSync(join(root, 'cache', '.init-running'))
writeFileSync(join(root, 'cache', 'stray.txt'), 'not a repository')
// An entry that disappears while it is measured no longer counts.
symlinkSync(join(root, 'gone'), join(root, 'cache', 'vanished'))
await repositories.read(first.url, first.commit, 'README.md', 100, signal)
expect(readdirSync(join(root, 'cache')).filter(name => name.startsWith('.init-'))).toEqual(['.init-running'])
rmSync(join(root, 'cache', '.init-running'), { recursive: true })
rmSync(join(root, 'cache', 'vanished'))
rmSync(join(root, 'cache', 'stray.txt'))
await repositories.read(second.url, second.commit, 'README.md', 100, signal)
await repositories.settled()
expect(readdirSync(join(root, 'cache'))).toHaveLength(2)
await repositories.read(third.url, third.commit, 'README.md', 100, signal)
await repositories.settled()
// The least recently used clone goes first; the two newer ones fit.
expect(readdirSync(join(root, 'cache')).sort()).toEqual([second.url, third.url].map(cloneName).sort())
// A cached commit is read again without a fetch.
await repositories.read(third.url, third.commit, 'README.md', 100, signal)
expect(existsSync(join(root, 'cache'))).toBe(true)
})
it('completes concurrent fetches of different repositories that each evict the other', async () => {
const a = repository('ca', { 'README.md': 'a' })
const b = repository('cb', { 'README.md': 'b' })
const repositories = cache({ maxCacheBytes: 1 })
const reads = await Promise.all([a, b].map(source => repositories.readme(source.url, undefined, '', 100, signal)))
expect(reads.map(read => text(read?.bytes))).toEqual(['a', 'b'])
await repositories.settled()
expect(readdirSync(join(root, 'cache')).length).toBeLessThanOrEqual(1)
})
it('returns a blob whose last byte is a newline intact', async () => {
const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])
const source = repository('nl', { 'a.png': bytes })
expect(await cache().read(source.url, source.commit, 'a.png', 100, signal)).toEqual(Buffer.from(bytes))
})
it('enforces the cache total after a blob read, deleting the repository in use after its read when it alone is too large', async () => {
const source = repository('blob', { 'README.md': 'r', 'big.bin': randomBytes(200_000) })
const repositories = cache()
await repositories.read(source.url, source.commit, 'README.md', 100, signal)
await repositories.settled()
const kept = readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))
expect(kept).toHaveLength(1)
// The clone without contents fits; reading the large blob downloads it and grows the repository past the total.
const small = cache({ maxCacheBytes: 150_000 })
expect((await small.read(source.url, source.commit, 'big.bin', 300_000, signal))?.byteLength).toBe(200_000)
// The deletion waits for the read that holds the repository, then runs.
await new Promise((resolve) => { setTimeout(resolve, 50) })
await small.settled()
expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toEqual([])
})
it('serializes one repository across caches that share the directory, as two processes on one profile do', async () => {
const source = repository('shared', { 'README.md': 'shared' })
const [first, second] = [cache(), cache()]
const reads = await Promise.all([first, second].map(each => each.read(source.url, source.commit, 'README.md', 100, signal)))
expect(reads.map(read => text(read))).toEqual(['shared', 'shared'])
await Promise.all([first.settled(), second.settled()])
expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toHaveLength(1)
})
it('stops Git when the caller cancels or the service is disposed', async () => {
const source = repository('i', { 'README.md': 'x' })
const cancelled = new AbortController()
cancelled.abort(new Error('cancelled'))
await expect(cache().read(source.url, source.commit, 'README.md', 100, cancelled.signal)).rejects.toThrow('cancelled')
const disposed = new AbortController()
disposed.abort(new Error('disposed'))
await expect(cache({ lifetime: disposed.signal }).read(source.url, source.commit, 'README.md', 100, signal)).rejects.toThrow('disposed')
// Neither a repository nor its staging directory remains.
expect(readdirSync(join(root, 'cache'))).toEqual([])
})
it('reports file contents Git can no longer fetch and deletes the clone, ignoring an inherited GIT_DIR', async () => {
vi.stubEnv('GIT_DIR', join(root, 'elsewhere'))
const source = repository('j', { 'README.md': 'x', 'b.png': 'b' })
const repositories = cache()
await repositories.read(source.url, source.commit, 'README.md', 100, signal)
rmSync(source.dir, { recursive: true, force: true })
await expectRejection(repositories.read(source.url, source.commit, 'b.png', 100, signal), { reason: 'denied' }, 'git cat-file b.png')
expect(readdirSync(join(root, 'cache'))).toEqual([])
})
it('fetches a cached default-branch commit again after eviction, and reports a clone that can no longer fetch it', async () => {
const first = repository('k', { 'README.md': 'first' })
const second = repository('l', { 'README.md': 'second' })
const repositories = cache({ maxCacheBytes: 1 })
await repositories.read(first.url, undefined, 'README.md', 100, signal)
await repositories.read(second.url, undefined, 'README.md', 100, signal)
expect(text(await repositories.read(first.url, undefined, 'README.md', 100, signal))).toBe('first')
const [clone] = readdirSync(join(root, 'cache'))
rmSync(join(root, 'cache', clone as string, 'objects', 'pack'), { recursive: true, force: true })
rmSync(first.dir, { recursive: true, force: true })
await expectRejection(repositories.read(first.url, undefined, 'README.md', 100, signal), { reason: 'denied' }, 'git fetch')
})
@@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import PluginMarket from '../src/index.ts'
import * as tool from '../src/tools.ts'
/** Clone cache directory of services whose tests read no repository; nothing creates it. */
const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache'
const URL_A = 'https://market.example/index.json'
afterEach(() => { vi.unstubAllGlobals() })
@@ -26,7 +29,7 @@ async function fixture() {
listVersionExemptions() { return { exemptions: {}, warnings: [] } }
}
await ctx.plugin(Manager)
await ctx.plugin(PluginMarket, { indexes: [URL_A] })
await ctx.plugin(PluginMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE })
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
const fiber = await ctx.plugin(tool)
@@ -167,7 +170,7 @@ it('registers the tool only while the pluginMarket service is mounted', async ()
await ctx.plugin(ToolRuntime)
await ctx.plugin(tool)
expect(ctx.tools.get('plugin_market')).toBeUndefined()
const market = await ctx.plugin(PluginMarket, { indexes: [] })
const market = await ctx.plugin(PluginMarket, { indexes: [], detailCacheDir: UNUSED_CACHE })
await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeDefined() })
await market.dispose()
await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeUndefined() })
+6 -1
View File
@@ -19,6 +19,11 @@
{ "path": "../../util/values" },
{ "path": "../../settings/settings" },
{ "path": "../../typert/protocol" },
{ "path": "../../core/tools" }
{ "path": "../../core/tools" },
{ "path": "../../util/brand" },
{ "path": "../../subprocess/subprocess" },
{ "path": "../../client/connection/tsconfig.host.json" },
{ "path": "../../util/plugin-detail" },
{ "path": "../../util/atomic-write" }
]
}
+8 -5
View File
@@ -57,7 +57,10 @@ export class FileUploads extends TypertRemoteService {
static inject = ['agents', 'attachments', 'commands', 'connection']
private readonly stagedFiles = new WeakMap<Session, Map<FileUploadReceiptId, StagedFileUpload>>()
private agentResolver: AgentResolver | undefined
// Inside a method called through `ctx.fileUploads`, Cordis returns a new proxy
// for every function-valued property read from `this`, so `===` against the
// registered resolver would always be false; a plain object is returned as is.
private readonly agentResolution: { resolver: AgentResolver | undefined } = { resolver: undefined }
/** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */
constructor(ctx: Context) {
@@ -87,10 +90,10 @@ export class FileUploads extends TypertRemoteService {
* @returns disposer removing this resolver.
*/
registerAgentResolver(resolve: AgentResolver): () => void {
if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
this.agentResolver = resolve
if (this.agentResolution.resolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
this.agentResolution.resolver = resolve
return () => {
if (this.agentResolver === resolve) this.agentResolver = undefined
if (this.agentResolution.resolver === resolve) this.agentResolution.resolver = undefined
}
}
@@ -212,7 +215,7 @@ export class FileUploads extends TypertRemoteService {
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) return live
const resolver = this.agentResolver
const resolver = this.agentResolution.resolver
if (resolver === undefined) {
throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId })
}
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
en: 58d78e3a5bea65e0
zh: e2e1fb97ac9dda53
en: 8133025bc0c7f66d
zh: 652e8365383fe37f
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: fcb2e69c8b028c0a
zh: d505a075679974eb
@@ -33,8 +33,8 @@
en: e9da90f03691101b
zh: 902cda935a4ce3c8
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points:
en: 5266d86638cc5cd7
zh: 398d710df9806cea
en: 54d7666c630da471
zh: d7ded257d0fd5559
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
en: 76e0908903ab8fde
zh: ce6ae1cdd6aea971
+3 -1
View File
@@ -33,6 +33,8 @@ As available header width decreases, Refresh and Add plugin first become icon-on
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/<language>.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again.
On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable.
The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
@@ -92,7 +94,7 @@ The bundle's patch must declare the row under that id, and the registration exis
### Detail page extension points
A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`.
A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the source on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`.
```tsx ignore-check
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
@@ -33,6 +33,8 @@ kind: "package-reference"
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/<language>.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。
官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。
首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
@@ -92,7 +94,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
### 详情页扩展点
对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。
对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在来源信息之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。
```tsx ignore-check
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
@@ -0,0 +1,58 @@
/** A bundle's long description on its page: the Markdown document the Host reads for it. */
import { useEffect, useMemo, type ReactNode } from 'react'
import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { bundleDetailKey, type BundleDetailFailure, type BundleDetailsState } from './detail-store.ts'
type Translate = PropsLocale<'pluginManager'>['t']
const FAILURE_KEYS = {
'too-large': 'detailTooLarge',
'network': 'detailNetwork',
'denied': 'detailFailed',
'git-unavailable': 'detailFailed',
'invalid': 'detailInvalid',
'other': 'detailFailed',
} as const satisfies Record<BundleDetailFailure, string>
/** What the section reads and calls; the page passes them from its injected face. */
export interface BundleDetailSectionProps {
readonly name: string
readonly version: string | undefined
/** Whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs. */
readonly fromNpm: boolean
readonly language: string
readonly details: BundleDetailsState
readonly t: Translate
readonly load: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void
readonly retry: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void
readonly image: (name: string, document: string, destination: string, fromNpm: boolean) => string | undefined
}
/**
* Read and render a bundle's detail document in the active language; relative images load from the Host beside it.
* @param props - the bundle, the language, the reads, and the page's callbacks.
* @returns the section, or null when the bundle has no document.
*/
export function BundleDetailSection(props: BundleDetailSectionProps): ReactNode {
const { name, version, fromNpm, language, details, t, load, retry, image } = props
const key = bundleDetailKey(name, version, language)
const state = details[key]
// The key names the read completely; a read the controller forgot, after a profile change, starts again.
const missing = state === undefined
useEffect(() => { if (missing) load(name, version, language, fromNpm) }, [key, missing])
const labels = useDetailDocumentLabels({
title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'),
codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('detailFootnotes'),
})
const detail = state?.status === 'ready' ? state.detail : undefined
const document = detail?.status === 'found' ? detail.path : undefined
const images = useMemo<MarkdownPathImages | undefined>(
() => document === undefined ? undefined : { resolve: value => image(name, document, value, fromNpm) },
[name, document, fromNpm, image],
)
const shown = detailDocumentState(state?.status === 'error'
? { status: 'error', message: t(FAILURE_KEYS[state.failure]) }
: state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined })
return <DetailDocument state={shown} labels={labels} images={images} onRetry={() => { retry(name, version, language, fromNpm) }} />
}
@@ -33,6 +33,7 @@ import { managementText, noticeText, packageText, registryText, restartText, row
import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts'
import type { ConfigPageForm } from './slot-contract.ts'
import { UpdateDialog, VersionSection } from './versions.tsx'
import { BundleDetailSection } from './BundleDetailSection.tsx'
import css from './PluginManagerPage.module.css'
import badgeSkillPreview from './assets/badge-skill-preview.svg'
import badgeSkillPreviewZh from './assets/badge-skill-preview.zh.svg'
@@ -721,12 +722,14 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {
* and configure controls; and where it comes from.
*/
function PackageDetail({
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions,
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions, detail,
onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, onEnsureVersions, onCheckVersions, onUpdateVersion,
}: {
readonly pkg: PackageView
/** The package's version check, when one started. */
readonly versions: VersionCheck | undefined
/** The bundle's detail document section, shown first among the page's sections. */
readonly detail: ReactNode
readonly t: Translate
readonly resolveText: ResolveText
readonly busy: boolean
@@ -813,6 +816,7 @@ function PackageDetail({
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
{preview === undefined ? null : <PackagePreview src={preview} title={title} t={t} />}
<div className={css.detailSections}>
{detail}
{configured
? (
<section className={css.detailSection} data-plugin-config>
@@ -1552,12 +1556,12 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
// What is open; a package that leaves the list (uninstalled) drops back to the cards.
const view = props.useStore(state => state.view), { setView, setTab } = props.actions
const tabs = props.usePluginTabs(snapshot => snapshot)
const bundleDetails = props.useBundleDetails(snapshot => snapshot)
const language = props.useLocale(snapshot => snapshot.active)
const selectedTab = props.useStore(state => state.tab)
// A contributed tab that left the ledger falls back to Installed, and stays there when it returns.
const tab = tabs.some(item => item.id === selectedTab) ? selectedTab : INSTALLED_TAB
useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab])
// A bundle an install just enabled is shown where its card is.
useEffect(() => { if (state.highlight !== null) setTab(INSTALLED_TAB) }, [state.highlight, setTab])
const tabsId = useId()
const [activation, setActivation] = useState<string | null>(null)
// The bar keeps one row while the list is scrolled, as the page heading only needs its resting height at the top.
@@ -1791,6 +1795,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
configure={configure(openPkg)}
renderSlot={renderSlot}
inMore={openPkgInMore}
detail={(
<BundleDetailSection name={openPkg.name} version={openPkg.version} fromNpm={openPkg.availability === 'missing'}
language={language} details={bundleDetails} t={t}
load={props.loadDetail} retry={props.retryDetail} image={props.detailImage} />
)}
onBack={() => { setView({ kind: openPkgInMore ? 'more' : 'list' }) }}
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
onUninstall={() => { props.uninstall(openPkg.name) }}
@@ -1837,7 +1846,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
</div>
)
: null}
{showsCards && activated !== undefined && !state.install.open
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
{installedCards && activated !== undefined && !state.install.open
? renderSlot('plugins.bundle.activation', {
packageName: activated.name,
onDismiss: () => { setActivation(null) },
@@ -0,0 +1,124 @@
/** Bundle detail reads: the long description of each bundle the page opens, read from the Host by name and language. */
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { BundleDetail, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry'
/** Why a bundle detail read failed: the Host's `plugin-manager/detail-unavailable` reason, or `other`. */
export type BundleDetailFailure = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other'
/** One bundle detail read. */
export type BundleDetailState =
| { readonly status: 'loading' }
| { readonly status: 'ready'; readonly detail: BundleDetail }
| { readonly status: 'error'; readonly failure: BundleDetailFailure }
/** Bundle detail reads by {@link bundleDetailKey}. */
export type BundleDetailsState = Readonly<Record<string, BundleDetailState>>
/**
* The identity of a bundle detail read: the bundle, its installed version, and the display language.
* @param name - the bundle's package name.
* @param version - the installed or offered version, so an updated bundle reads again.
* @param language - the display language.
* @returns the key of the read in {@link BundleDetailsState}.
*/
export function bundleDetailKey(name: string, version: string | undefined, language: string): string {
return JSON.stringify([name, version ?? null, language])
}
/** The Host route of images beside bundle detail documents, relative to the application base. */
const DETAIL_FILE_ROUTE = 'api/plugin-manager/detail-file'
function failureOf(failure: RemoteFailure): BundleDetailFailure {
return failure.code === 'plugin-manager/detail-unavailable' ? failure.details.reason : 'other'
}
/** Reads bundle detail documents and publishes them to the page. */
export class BundleDetailController {
/** Detail reads published to the page. */
readonly store: SnapshotStore<BundleDetailsState> = createSnapshotStore<BundleDetailsState>({})
/** The latest read of each key, numbered across the controller's lifetime; an older read's answer is dropped. */
private readonly reads = new Map<string, number>()
private sequence = 0
/** npm file CDNs to ask first, from the Host's latest registry comparison: npmmirror when its registry answered fastest. */
private npmFileSources: 'npmmirror'[] = []
private disposed = false
/**
* @param ctx - the page plugin's context, whose `remote.pluginManager` namespace answers.
* @param base - the application base URL that Host routes resolve against.
*/
constructor(private readonly ctx: ClientContext, private readonly base: () => string) {}
/**
* Read a bundle's document unless the read already ran or runs.
* @param name - the bundle's package name.
* @param version - its installed or offered version.
* @param language - the display language.
* @param fromNpm - whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs.
*/
load(name: string, version: string | undefined, language: string, fromNpm: boolean): void {
if (this.store.getSnapshot()[bundleDetailKey(name, version, language)] === undefined) void this.read(name, version, language, fromNpm)
}
/**
* Read a bundle's document again.
* @param name - the bundle's package name.
* @param version - its installed or offered version.
* @param language - the display language.
* @param fromNpm - whether the Host reads the bundle from the npm file CDNs.
*/
retry(name: string, version: string | undefined, language: string, fromNpm: boolean): void {
void this.read(name, version, language, fromNpm)
}
/**
* The URL of an image beside a bundle's document.
* @param name - the bundle's package name.
* @param document - the document's path inside the bundle.
* @param destination - the image destination as the document writes it.
* @param fromNpm - whether the Host reads the bundle from the npm file CDNs.
* @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination.
*/
image(name: string, document: string, destination: string, fromNpm: boolean): string | undefined {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined
const sources = fromNpm ? this.npmFileSources : []
const query = new URLSearchParams({ bundle: name, document, path: destination, ...sources.length > 0 ? { sources: sources.join(',') } : {} })
return new URL(`${DETAIL_FILE_ROUTE}?${query.toString()}`, this.base()).href
}
/** Forget every read, so each shown bundle reads its document again; bundle files can change at any profile change. */
reset(): void {
this.reads.clear()
this.store.set({})
}
/** Stop publishing late settlements. */
dispose(): void {
this.disposed = true
}
private async read(name: string, version: string | undefined, language: string, fromNpm: boolean): Promise<void> {
const key = bundleDetailKey(name, version, language)
const read = ++this.sequence
this.reads.set(key, read)
this.publish(key, { status: 'loading' })
// The remote is captured before any wait: the plugin's context may be disposed while the comparison runs.
const { remote } = this.ctx
const current = (): boolean => !this.disposed && this.reads.get(key) === read
// Only a bundle read from the CDNs waits for the registry comparison.
if (fromNpm) {
const fastest = await remote.pluginRegistryProbe.fastest()
// A retry or reset started while the comparison ran settles the key instead.
if (!current()) return
this.npmFileSources = fastest.ok && fastest.value === NPMMIRROR_REGISTRY ? ['npmmirror'] : []
}
const answer = await remote.pluginManager.readBundleDetail(name, language, { npmFileSources: fromNpm ? this.npmFileSources : [] })
if (current()) this.publish(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: failureOf(answer.error) })
}
private publish(key: string, state: BundleDetailState): void {
this.store.set({ ...this.store.getSnapshot(), [key]: state })
}
}
@@ -84,6 +84,8 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
// A page never rendered holds no snapshot to refresh.
const refresh = (): void => {
// A profile change can replace a bundle's files, and a restarted Host is read afresh.
controller.details.reset()
if (controller.getSnapshot().status !== 'idle') void controller.load()
}
const disposers = [
@@ -286,6 +286,13 @@ export const zh = {
reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。',
reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。',
reasonOperationError: 'Host 报告了一个错误',
detailTitle: '详细介绍',
detailLoading: '正在读取详细介绍',
detailTooLarge: '详细介绍超过大小限制,未加载',
detailNetwork: '无法连接到详细介绍的来源',
detailInvalid: '插件声明的详细介绍文件无效',
detailFailed: '无法读取详细介绍',
detailFootnotes: '脚注',
} satisfies Record<string, string>
/** Plugin manager locale key union. */
@@ -572,4 +579,11 @@ export const en = {
reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.',
reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.',
reasonOperationError: 'The Host reported an error.',
detailTitle: 'Details',
detailLoading: 'Reading details',
detailTooLarge: 'The details exceed the size limit and were not loaded.',
detailNetwork: 'Could not reach the source of the details.',
detailInvalid: 'The plugin declares an invalid details file.',
detailFailed: 'Could not read the details.',
detailFootnotes: 'Footnotes',
} satisfies Record<PluginManagerLocaleKey, string>
@@ -41,6 +41,8 @@ import type { ConfigLedger } from './config-ledger.ts'
import type { PluginTabInstall } from './slot-contract.ts'
import type { PluginTab } from './tab-ledger.ts'
import { shortName } from './presentation.ts'
import { BundleDetailController, type BundleDetailsState } from './detail-store.ts'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
/** The action a failed notice names. */
export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable'
@@ -389,7 +391,17 @@ export interface PluginManagerFace {
configLedger: HostObservable<ConfigLedger>
/** Tabs other plugins contribute, bound by the renderer as usePluginTabs. */
pluginTabs: HostObservable<readonly PluginTab[]>
/** Bundle detail reads, bound by the renderer as useBundleDetails. */
bundleDetails: SnapshotStore<BundleDetailsState>
/** The active locale, bound as useLocale, for the language detail documents are read in. */
locale: HostObservable<LocaleSnapshot>
}
/** Read a bundle's detail document unless the read already ran or runs. */
loadDetail: BundleDetailController['load']
/** Read a bundle's detail document again. */
retryDetail: BundleDetailController['retry']
/** The URL of an image beside a bundle's detail document, or undefined for a destination the page loads as written. */
detailImage: BundleDetailController['image']
/** Read the Host once the tab first renders. */
ensure: () => void
/** Read the Host again, then check every updatable package for newer versions. */
@@ -631,6 +643,8 @@ export class PluginManagerController {
private registryRead: RegistryRead | undefined
/** A Discover entry chose the dialog's registry; that choice applies to this dialog only and is never remembered. */
private transientRegistry = false
/** Bundle detail documents the page shows. */
readonly details: BundleDetailController
/** The registry last used from this browser, kept across dialogs and page loads; null until one was used. */
private readonly registryMemory: SnapshotStore<RegistryChoice | null> = createSnapshotStore<RegistryChoice | null>(null, {
persist: { name: 'dsh.plugin-manager.install-registry' },
@@ -642,6 +656,7 @@ export class PluginManagerController {
constructor(
private readonly ctx: ClientContext,
) {
this.details = new BundleDetailController(ctx, () => document.baseURI)
this.store = createSnapshotStore<PluginManagerState>({
status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null,
install: IDLE_INSTALL, confirm: null, highlight: null, versions: {}, update: null,
@@ -659,6 +674,7 @@ export class PluginManagerController {
/** Stop publishing and drop every late settlement. */
dispose(): void {
this.disposed = true
this.details.dispose()
this.registryRead = undefined
this.request = undefined
this.generation += 1
@@ -674,9 +690,16 @@ export class PluginManagerController {
inject(
configLedger: HostObservable<ConfigLedger>, pluginTabs: HostObservable<readonly PluginTab[]>, resolveText: PluginManagerFace['resolveText'],
): PluginManagerFace {
const details = this.details
return {
resolveText,
hooks: { pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe() },
hooks: {
pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe(),
bundleDetails: details.store, locale: this.ctx.locale,
},
loadDetail: (name, version, language, fromNpm) => { details.load(name, version, language, fromNpm) },
retryDetail: (name, version, language, fromNpm) => { details.retry(name, version, language, fromNpm) },
detailImage: (name, document, destination, fromNpm) => details.image(name, document, destination, fromNpm),
configForm: id => this.ctx.configForms.get(id),
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
refresh: () => { void this.refresh() },
@@ -160,7 +160,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'plugins.detail.badge': { kind: 'list'; scope: 'root'; owner: PluginDetailProps }
/**
* Sections under a detail page's own content: after the rows on a bundle's
* Sections under a detail page's own content: after the source on a bundle's
* page, after the configuration on a row's or an official plugin's page.
* An entry draws its own section chrome and renders null for a subject it
* has nothing for.
@@ -41,6 +41,7 @@ async function bench() {
listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })),
readBundleDetail: vi.fn(() => Promise.resolve({ ok: true as const, value: { status: 'none' as const } })),
},
})
const panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
@@ -186,8 +187,15 @@ describe('ui-plugin-manager browser plugin', () => {
face.ensure()
await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
expect(b.list).toHaveBeenCalledTimes(1)
// Bundle detail reads go to the Host by name; a profile change forgets them.
face.loadDetail('dsh-a', '1.0.0', 'en', false)
await vi.waitFor(() => { expect(face.hooks.bundleDetails.getSnapshot()).not.toEqual({}) })
face.retryDetail('dsh-a', '1.0.0', 'en', false)
expect(face.detailImage('dsh-a', 'README.md', 'a.png', false)).toContain('api/plugin-manager/detail-file?bundle=dsh-a')
expect(face.hooks.locale.getSnapshot().active).toBe('zh')
b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }])
await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
expect(face.hooks.bundleDetails.getSnapshot()).toEqual({})
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) })
@@ -10,6 +10,8 @@ import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@de
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import { StrictMode, type ReactNode } from 'react'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { bundleDetailKey, type BundleDetailsState } from '../src/client/detail-store.ts'
import { createNavigationStore } from '../src/client/navigation-store.ts'
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx'
@@ -111,6 +113,7 @@ function renderTab(
const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
const ledger = createSnapshotStore<ConfigLedger>({ ...NO_CONFIG, ...config })
const tabLedger = createSnapshotStore<readonly PluginTab[]>(tabs)
const bundleDetails = createSnapshotStore<BundleDetailsState>({})
const actions = {
ensure: vi.fn(),
refresh: vi.fn(),
@@ -142,6 +145,10 @@ function renderTab(
runUpdate: vi.fn(),
closeUpdate: vi.fn(),
toggleUpdateOutput: vi.fn(),
loadDetail: vi.fn(),
retryDetail: vi.fn(),
detailImage: vi.fn((name: string, document: string, destination: string, _fromNpm: boolean) =>
`https://dsh.example/api/plugin-manager/detail-file?bundle=${name}&document=${document}&path=${destination}`),
}
const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') }
const standard = {
@@ -163,6 +170,8 @@ function renderTab(
usePluginManager: bindSnapshotSelector(store),
useConfigLedger: bindSnapshotSelector(ledger),
usePluginTabs: bindSnapshotSelector(tabLedger),
useBundleDetails: bindSnapshotSelector(bundleDetails),
useLocale: bindSnapshotSelector(createSnapshotStore<LocaleSnapshot>({ active: 'en', locales: [], revision: 0 })),
useConfigurations: bindSnapshotSelector(createSnapshotStore<SettingsMirrorSnapshot>({
status: 'ready', error: null,
view: { writable: true, hasDocument: true, namespaces: Object.keys(forms).map(ns => ({
@@ -206,6 +215,7 @@ function renderTab(
store,
actions,
set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
setDetails: (next: BundleDetailsState) => { act(() => { bundleDetails.set(next) }) },
setLanguage: (dict: typeof en) => {
locale.setLocale(dict === zh ? 'zh' : 'en')
currentT = translate(dict)
@@ -216,7 +226,7 @@ function renderTab(
describe('PluginManagerPage', () => {
it('shows no tabs without contributions, and switches between Installed and a contributed tab', () => {
const { actions, navigation, setTabs, set } = renderTab({ packages: [pkg()] }, {}, {
const { actions, navigation, setTabs } = renderTab({ packages: [pkg()] }, {}, {
'plugins.tab:discover': (_view, owner) => {
const tab = owner as PluginTabProps
return (
@@ -250,13 +260,20 @@ describe('PluginManagerPage', () => {
setTabs([])
expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
expect(navigation.getSnapshot().tab).toBe('installed')
// Enabling a bundle an install added highlights its card, so the page shows Installed.
// A contributed tab that returns can be selected again.
setTabs([{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
it('marks the bundle an install enabled without leaving a contributed tab', () => {
const { navigation, set } = renderTab({ packages: [pkg()] }, {}, {}, {}, [{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
// Enable now publishes the new bundle's mark; the mark scrolls to a visible card and selects no page.
set({ highlight: 'dsh-better-sidebar' })
expect(navigation.getSnapshot().tab).toBe('installed')
expect(screen.getByRole('tabpanel', { name: en.installedTab })).toBeTruthy()
expect(navigation.getSnapshot().tab).toBe('discover')
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
it('collapses the page bar to one row while the page body is scrolled', () => {
@@ -452,6 +469,34 @@ describe('PluginManagerPage', () => {
expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull()
})
it('shows a bundle\'s detail document first among its sections, and its failures with a retry', () => {
const b = renderTab({ packages: [pkg()] })
act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) })
expect(b.actions.loadDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false)
expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy()
const key = bundleDetailKey('dsh-better-sidebar', '0.16.0', 'en')
b.setDetails({ [key]: { status: 'ready', detail: { status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Sidebar\n\n![shot](img/a.png)' } } })
const sections = [...document.querySelectorAll('[data-plugin-detail="dsh-better-sidebar"] section')]
const order = sections.map(section => section.hasAttribute('data-detail-document') ? 'detail' : section.hasAttribute('data-plugin-source') ? 'source' : 'other')
expect(order[0]).toBe('detail')
expect(order).toContain('source')
expect(screen.getByRole('heading', { name: 'Sidebar' })).toBeTruthy()
expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src'))
.toBe('https://dsh.example/api/plugin-manager/detail-file?bundle=dsh-better-sidebar&document=guide/DETAIL.md&path=img/a.png')
const failures = [['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailFailed], ['invalid', en.detailInvalid], ['other', en.detailFailed]] as const
for (const [failure, text] of failures) {
b.setDetails({ [key]: { status: 'error', failure } })
expect(screen.getByRole('alert').textContent).toBe(text)
}
fireEvent.click(screen.getByRole('button', { name: en.retry }))
expect(b.actions.retryDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false)
b.setDetails({ [key]: { status: 'ready', detail: { status: 'none' } } })
expect(document.querySelector('[data-detail-document]')).toBeNull()
// A read the controller forgot, after a profile change, starts again.
b.setDetails({})
expect(b.actions.loadDetail).toHaveBeenCalledTimes(2)
})
it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => {
const b = renderTab({ packages: [
pkg({ source: 'github:someone/dsh-better-sidebar' }),
@@ -470,6 +515,8 @@ describe('PluginManagerPage', () => {
expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn])
act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) })
expect(facts()).toEqual([en.sourceSpec, en.sourceNotInstalled])
// A bundle whose files are not installed is read from the npm file CDNs.
expect(b.actions.loadDetail).toHaveBeenLastCalledWith('dsh-missing', undefined, 'en', true)
})
it('preserves the requested bundle through StrictMode effect replay and page remounts', () => {
@@ -830,12 +877,12 @@ describe('PluginManagerPage', () => {
expect(chinese.getAttribute('src')).toMatch(/badge-skill-preview\.zh\.svg$/)
fireEvent.error(chinese)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByRole('status').textContent).toBe(zh.previewUnavailable)
expect(screen.getByText(zh.previewUnavailable).getAttribute('role')).toBe('status')
fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', name) }))
expect(actions.setEnabled).toHaveBeenCalledWith(name, true)
setLanguage(en)
expect(screen.getByRole('img').getAttribute('src')).toBe(english)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.queryByText(en.previewUnavailable)).toBeNull()
})
it('shows no preview for bundles without one', () => {
@@ -2343,6 +2390,24 @@ it('dismisses activation guidance until the user enables the bundle again', () =
expect(screen.getByText('Later')).toBeTruthy()
})
it('keeps activation guidance with the Installed list when a contributed tab enables the bundle', () => {
const name = 'dsh-better-sidebar'
const subject = { spec: `${name}@0.16.0`, status: 'accepted', kind: 'registry', name, bundle: true, registry: null } as const
const { set } = renderTab({
install: { ...IDLE_INSTALL, open: true, phase: 'done', subject, installed: name },
}, { bundles: new Set([name]) }, {
[`plugins.bundle.activation:${name}`]: () => <div>Enable guidance</div>,
}, {}, [{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('button', { name: en.installEnableNow }))
set({ install: IDLE_INSTALL, packages: [pkg()] })
expect(screen.getByText('Enable guidance')).toBeTruthy()
// A contributed tab shows its own content, not guidance for the Installed list.
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.queryByText('Enable guidance')).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: en.installedTab }))
expect(screen.getByText('Enable guidance')).toBeTruthy()
})
it('supplies the accepted entry values and atomic mutation action to a custom plugin page', () => {
const mutate = vi.fn(async () => true)
const form: ConfigPageForm = {
@@ -0,0 +1,125 @@
import { Context } from '@deepseek-ai/cordis'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { BundleDetail } from '@deepseek-ai/dsh-api-remotes/client'
import { expect, it, onTestFinished, vi } from 'vitest'
import { BundleDetailController, bundleDetailKey } from '../src/client/detail-store.ts'
type Answer<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteError<never> }
const ok = <T>(value: T): Promise<Answer<T>> => Promise.resolve({ ok: true, value })
const FOUND: BundleDetail = { status: 'found', origin: 'readme', path: 'README.md', markdown: '# A' }
const flush = () => new Promise((resolve) => { setTimeout(resolve, 0) })
function setup() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const manager = {
readBundleDetail: vi.fn((_name: string, _language: string, _options: object): Promise<Answer<BundleDetail>> => ok(FOUND)),
}
const probe = { fastest: vi.fn((): Promise<Answer<string | null>> => ok('https://registry.npmmirror.com/')) }
new TestRemote(ctx, { pluginManager: manager, pluginRegistryProbe: probe })
const controller = new BundleDetailController(ctx, () => 'https://dsh.example/prefix/')
const state = (name: string, version: string | undefined, language: string) =>
controller.store.getSnapshot()[bundleDetailKey(name, version, language)]
return { controller, manager, probe, state }
}
it('reads each bundle version once per language and publishes the result', async () => {
const { controller, manager, state } = setup()
controller.load('a', '1.0.0', 'en', false)
controller.load('a', '1.0.0', 'en', false)
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' })
await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) })
controller.load('a', undefined, 'zh', false)
await vi.waitFor(() => { expect(state('a', undefined, 'zh')?.status).toBe('ready') })
expect(manager.readBundleDetail.mock.calls).toEqual([['a', 'en', { npmFileSources: [] }], ['a', 'zh', { npmFileSources: [] }]])
})
it('reports failures by reason, reads again on retry, and settles a retried key with the latest answer', async () => {
const { controller, manager, state } = setup()
manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/detail-unavailable' as never, 'big', { reason: 'too-large' } as never) }))
manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/unknown-bundle' as never, 'gone', { name: 'b' } as never) }))
controller.load('a', '1.0.0', 'en', false)
controller.load('b', '1.0.0', 'en', false)
await vi.waitFor(() => {
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'too-large' })
expect(state('b', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'other' })
})
let first: (value: Answer<BundleDetail>) => void = () => {}
manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve }))
controller.retry('a', '1.0.0', 'en', false)
controller.retry('a', '1.0.0', 'en', false)
await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) })
first({ ok: true, value: { status: 'none' } })
await flush()
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND })
})
it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => {
const { controller, manager, state } = setup()
let answer: (value: Answer<BundleDetail>) => void = () => {}
manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
controller.load('a', '1.0.0', 'en', false)
controller.reset()
answer({ ok: true, value: FOUND })
await flush()
expect(controller.store.getSnapshot()).toEqual({})
// A read from before a reset that settles after the read that replaced it does not overwrite it.
let stale: (value: Answer<BundleDetail>) => void = () => {}
manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve }))
controller.load('a', '1.0.0', 'en', false)
controller.reset()
manager.readBundleDetail.mockImplementationOnce(() => ok({ status: 'none' }))
controller.load('a', '1.0.0', 'en', false)
await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) })
stale({ ok: true, value: FOUND })
await flush()
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } })
controller.reset()
manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
controller.load('a', '1.0.0', 'en', false)
controller.dispose()
answer({ ok: true, value: FOUND })
await flush()
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' })
})
it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => {
const { controller, manager, probe, state } = setup()
controller.load('installed', '1.0.0', 'en', false)
await vi.waitFor(() => { expect(state('installed', '1.0.0', 'en')?.status).toBe('ready') })
expect(probe.fastest).not.toHaveBeenCalled()
expect(controller.image('x', 'README.md', 'a.png', true)).not.toContain('sources=')
controller.load('offered', '1.0.0', 'en', true)
await vi.waitFor(() => { expect(state('offered', '1.0.0', 'en')?.status).toBe('ready') })
expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: ['npmmirror'] }])
expect(controller.image('offered', 'README.md', 'a.png', true)).toContain('&sources=npmmirror')
expect(controller.image('installed', 'README.md', 'a.png', false)).not.toContain('sources=')
probe.fastest.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('remote/unavailable' as never, 'gone', {} as never) }))
controller.retry('offered', '1.0.0', 'en', true)
await vi.waitFor(() => { expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: [] }]) })
})
it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => {
const { controller, manager, probe, state } = setup()
let settle: (value: Answer<string | null>) => void = () => {}
probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve }))
controller.load('offered', '1.0.0', 'en', true)
controller.reset()
settle({ ok: true, value: 'https://registry.npmmirror.com/' })
await flush()
controller.load('offered', '1.0.0', 'en', true)
controller.dispose()
settle({ ok: true, value: 'https://registry.npmmirror.com/' })
await flush()
expect(manager.readBundleDetail).not.toHaveBeenCalled()
expect(state('offered', '1.0.0', 'en')).toEqual({ status: 'loading' })
})
it('addresses relative images through the Host route and leaves other destinations alone', () => {
const { controller } = setup()
expect(controller.image('@acme/x', 'guide/D.md', 'img/a b.png', false))
.toBe('https://dsh.example/prefix/api/plugin-manager/detail-file?bundle=%40acme%2Fx&document=guide%2FD.md&path=img%2Fa+b.png')
for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top']) {
expect(controller.image('x', 'README.md', destination, false)).toBeUndefined()
}
})
@@ -9,17 +9,17 @@
en: 62972eb0dff8c145
zh: 682cc03b492cd730
/deepseek-ai-dsh-client-ui-plugin-market/summary:
en: f95719fc57b4e921
zh: bf8a63b54127c147
en: 55a3d1b12cd7be61
zh: a961cf0212b3fda8
/deepseek-ai-dsh-client-ui-plugin-market/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-client-ui-plugin-market/use-this-package:
en: 2690b95e156cc473
zh: 0d9e897ad628f9dd
en: f873bf36195df821
zh: e13c78ba3f3e29f1
/deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation:
en: c56790cb58083f85
zh: a3d4ad4bad08e483
en: 4107a6967934f6b6
zh: 46acec8bedb86329
/deepseek-ai-dsh-client-ui-plugin-market/model-experience:
en: 08d25ab9cbdd7567
zh: 26e6e75c123951ee
@@ -27,8 +27,8 @@
en: 4f0eadf94828e66e
zh: 4f8a6ec170ebbfb6
/deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work:
en: 5e98bb00293c12e3
zh: 279b09a42c515d17
en: 493d1326b1645f3f
zh: 878a53ed6cb7878d
/deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+7 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them.
Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them.
## Table of Contents
@@ -28,12 +28,14 @@ Open **Plugins** and select **Discover**. With no index source, the tab shows an
The toolbar holds the market menu, which switches markets and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the market declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the market declares categories.
Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. A **Versions** list follows in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact.
Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact.
**Install** chooses the default version and opens a confirmation dialog with the entry's icon, title, that version, the author, its source facts, an exemption notice when one applies, and a trust notice. Confirming opens the Plugins install dialog at its check screen with the entry's exact spec and registry; there is no spec form, and a refusal offers **Retry**. The registry an entry names applies to that install only.
The Host [`pluginMarket.search`](../../boot/plugin-market/README.md#use-this-package) matches, filters, orders, and pages; the tab sends the search text 250 ms after typing stops. Search matches names, authors, keywords, and every language of titles, descriptions, categories, and labels, fuzzily. Label filters must all match. **Sort** offers **Best match** while there is search text, **Default order**, **Publish date**, and each declared sort key, which starts in its declared direction; a directed sort adds a row that reverses it. Typing switches **Default order** to **Best match**, and clearing the text switches back. Categories, labels, and sort keys are listed by localized title. The list loads 50 entries and adds 50 with each **Show more**.
The description section shows the default version's long description in the active UI language: the version's locale `meta.detail` file, otherwise its package README, otherwise its repository README, in the [order `pluginMarket` documents](../../boot/plugin-market/README.md#plugin-detail-documents). While the document loads, the section shows three skeleton lines; an entry without a document shows no section; a failed read names its reason, such as a size limit, an unreachable source, or a refused repository, with **Retry**. Raw HTML is omitted, and a document taller than the section's height limit scrolls inside it. Relative image paths load from the Host beside the document, absolute HTTP(S) images load directly without a referrer, and relative links stay text. The section is the shared [`DetailDocument`](../ui-primitives/README.md#component-catalog) primitive.
Reading a market shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the market's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`.
-----
@@ -41,6 +43,8 @@ Reading a market shows card skeletons. A failed read shows a short sentence with
<a id="understand-the-implementation"></a>
## Understand the implementation
Detail reads are kept in a second snapshot store keyed by index URL, entry, version, and language; a section starts its read when it renders, an npm version's read first asks the Host's registry comparison and passes `npmFileSources: ['npmmirror']` when npmmirror answered fastest, only the latest read of a key settles it, **Retry** asks the Host to read again instead of returning its cached result, and a connection reset forgets every read, because a restarted Host no longer knows the document ids its images use, so each shown section reads its document again. Image URLs resolve `api/plugin-market/detail-file` against `document.baseURI`, so they keep a deployment prefix and use the Desktop `dsh-app://app/` origin, which [`MarkdownText`](../ui-primitives/README.md) accepts for that route.
The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another market starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork.
-----
@@ -59,6 +63,7 @@ None.
<a id="known-limitations-and-deferred-work"></a>
- **No upgrade action** — an available update is shown as a status only.
- **Plain Markdown only** — detail documents omit raw HTML, including HTML images and layout, and relative links, including in-page anchors, are not clickable.
- **Remote icons** — URL icons are requested by the browser, which discloses the user's IP address to the image host. When the Web UI is served over HTTPS, the browser blocks or upgrades `http:` icons from an `http:` index, and a failed icon shows the default artwork.
<a id="dev-note"></a>
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。
「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。
## 目录
@@ -28,12 +28,14 @@ kind: "package-reference"
工具栏包含:插件市场菜单,用于切换插件市场,最后一项为**管理索引源…**;搜索框;插件市场声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件市场声明了分类时,其后显示分类按钮,**全部**排在第一位。
每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示和默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。其后是按 SemVer 降序排列的**版本**列表;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。
每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。
**安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。
Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-package) 负责匹配、过滤、排序和分页;标签页在停止输入 250 毫秒后发送搜索文本。搜索模糊匹配名称、作者、关键词,以及标题、描述、分类和标签的所有语言。所选标签必须全部匹配。有搜索文本时,**排序**提供**最佳匹配**,此外还提供**默认顺序**、**发布时间**以及每个已声明的排序键,排序键以其声明的方向开始;有方向的排序会额外显示一行,用于反转方向。输入文本会把**默认顺序**切换为**最佳匹配**,清空文本时切换回来。分类、标签和排序键按本地化标题列出。列表加载 50 个条目,每次点击**显示更多**再增加 50 个。
详细介绍部分以当前界面语言显示默认版本的详细介绍:按 [`pluginMarket` 说明的顺序](../../boot/plugin-market/README.zh.md#plugin-detail-documents),依次为该版本 locale 中 `meta.detail` 指定的文件、其包 README、其仓库 README。文档加载期间,该部分显示三行骨架屏;没有文档的条目不显示该部分;读取失败时说明原因,例如超过大小限制、来源无法访问或仓库拒绝访问,并提供**重试**。原始 HTML 会被省略;文档高度超过该部分的高度上限时,在该部分内滚动。相对路径的图片从 Host 上该文档旁的位置加载,绝对 HTTP(S) 地址的图片不带 referrer 直接加载,相对链接保持为文本。该部分使用共享的 [`DetailDocument`](../ui-primitives/README.zh.md#component-catalog) 原语。
读取插件市场时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件市场上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。
-----
@@ -41,6 +43,8 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-
<a id="understand-the-implementation"></a>
## 理解实现
详细介绍的读取结果保存在第二个快照存储中,以索引 URL、条目、版本和语言为键;该部分在渲染时开始读取;npm 版本的读取先询问 Host 的注册表比较,npmmirror 响应最快时传入 `npmFileSources: ['npmmirror']`;只有某个键最新一次的读取才会让该部分得到结果;**重试**要求 Host 重新读取,而不是返回其缓存的结果;连接重置时丢弃所有读取结果,因为重启后的 Host 不再知道其图片使用的文档 id,所以每个已显示的部分会重新读取其文档。图片 URL 基于 `document.baseURI` 解析 `api/plugin-market/detail-file`,因此保留部署前缀,并在 Desktop 中使用 `dsh-app://app/` origin;[`MarkdownText`](../ui-primitives/README.zh.md) 对该路由接受此 origin。
本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。
-----
@@ -59,6 +63,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-
<a id="known-limitations-and-deferred-work"></a>
- **没有升级操作**——可用更新只显示为状态。
- **只支持纯 Markdown**——详细介绍文档省略原始 HTML(包括 HTML 图片和排版),相对链接(包括页内锚点)不可点击。
- **远程图标**——URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址。Web UI 通过 HTTPS 提供时,浏览器会拦截或升级来自 `http:` 索引的 `http:` 图标,加载失败的图标显示默认插画。
<a id="dev-note"></a>
@@ -291,6 +291,14 @@
margin-top: 16px;
}
.detailDocument {
margin-top: 16px;
}
.detailDocument:empty {
display: none;
}
.sectionTitle {
margin: 0;
font-size: 14px;
@@ -11,6 +11,7 @@ import type { DiscoverFace, DiscoverState, ListingFailure, ListingState, SourceF
import {
displayDeclarations, indexLabel, presentedVersion, sourceFacts, withText, type DiscoverQuery, type DisplayDeclaration, type SourceFact,
} from './presentation.ts'
import { PluginDetailSection } from './PluginDetailSection.tsx'
import css from './DiscoverSection.module.css'
/** Props of the Discover tab. */
@@ -183,11 +184,13 @@ function VersionRow({ entry, version, title, t, language, onInstall }: {
)
}
function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onOpenInstalled }: {
function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInstall, onOpenInstalled }: {
readonly entry: MarketListingEntry
readonly listing: MarketSearchResult
readonly t: Translate
readonly face: DiscoverActions
/** The detail section's bound hooks. */
readonly hooks: Pick<DiscoverSectionProps, 'useDetails' | 'useLocale'>
readonly language: string
readonly onBack: () => void
readonly onInstall: (version: MarketListingVersion) => void
@@ -229,6 +232,19 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO
</span>
</p>
)}
<div className={css.detailDocument}>
<PluginDetailSection request={{ url: listing.url, name: entry.name, version: version.version, npm: version.source.type === 'npm' }} t={t}
loadDetail={face.loadDetail} retryDetail={face.retryDetail} detailImage={face.detailImage}
useDetails={hooks.useDetails} useLocale={hooks.useLocale} />
</div>
<section className={css.detailSection}>
<h4 className={css.sectionTitle}>{t('versionsTitle')}</h4>
<ul className={css.versions} aria-label={t('versionsLabel', { name: title })}>
{entry.versions.map(item => (
<VersionRow key={item.version} entry={entry} version={item} title={title} t={t} language={language} onInstall={onInstall} />
))}
</ul>
</section>
<section className={css.detailSection}>
<h4 className={css.sectionTitle}>{t('factSource')}</h4>
<dl className={css.facts}>
@@ -259,14 +275,6 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO
<div><dt>{t('factMarket')}</dt><dd>{face.resolveText(listing.title)}</dd></div>
</dl>
</section>
<section className={css.detailSection}>
<h4 className={css.sectionTitle}>{t('versionsTitle')}</h4>
<ul className={css.versions} aria-label={t('versionsLabel', { name: title })}>
{entry.versions.map(item => (
<VersionRow key={item.version} entry={entry} version={item} title={title} t={t} language={language} onInstall={onInstall} />
))}
</ul>
</section>
</div>
)
}
@@ -386,11 +394,12 @@ function unfiltered(query: DiscoverQuery): boolean {
return query.text.trim() === '' && query.category === null && query.labels.length === 0
}
function Browser({ state, listing, t, face, language, onInstall, openPackage }: {
function Browser({ state, listing, t, face, hooks, language, onInstall, openPackage }: {
readonly state: DiscoverState
readonly listing: ListingState
readonly t: Translate
readonly face: DiscoverActions
readonly hooks: Pick<DiscoverSectionProps, 'useDetails' | 'useLocale'>
readonly language: string
readonly onInstall: (choice: InstallChoice) => void
readonly openPackage: (name: string) => void
@@ -413,7 +422,7 @@ function Browser({ state, listing, t, face, language, onInstall, openPackage }:
const openEntry = opened === null ? undefined : value?.entries.find(entry => entry.name === opened.name) ?? opened
if (openEntry !== undefined && value !== null) {
return (
<EntryDetail entry={openEntry} listing={value} t={t} face={face} language={language} onBack={() => { setOpened(null) }}
<EntryDetail entry={openEntry} listing={value} t={t} face={face} hooks={hooks} language={language} onBack={() => { setOpened(null) }}
onInstall={(version) => { onInstall({ entry: openEntry, version }) }} onOpenInstalled={() => { openPackage(openEntry.name) }} />
)
}
@@ -580,7 +589,8 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode {
)
} else {
body = (
<Browser key={state.listing.url} state={state} listing={state.listing} t={t} face={face} language={language}
<Browser key={state.listing.url} state={state} listing={state.listing} t={t} face={face}
hooks={{ useDetails: props.useDetails, useLocale: props.useLocale }} language={language}
onInstall={(choice) => { setConfirming(choice) }} openPackage={openPackage} />
)
}
@@ -0,0 +1,47 @@
/** A market entry's long description: the Markdown document the Host reads for one listed version. */
import { useEffect, useMemo, type ReactNode } from 'react'
import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { detailKey, type DetailFace, type DetailFailureKind, type DetailRequest } from './market-store.ts'
const FAILURE_KEYS = {
'too-large': 'detailTooLarge',
'network': 'detailNetwork',
'denied': 'detailDenied',
'git-unavailable': 'detailGitUnavailable',
'invalid': 'detailInvalid',
'other': 'detailFailed',
} as const satisfies Record<DetailFailureKind, string>
/** Props of the detail section: the entry version, the dictionary, and the injected detail reads. */
export type PluginDetailSectionProps = { readonly request: DetailRequest } & PropsLocale<'pluginMarket'> & InjectFace<DetailFace>
/**
* Read and render an entry version's detail document in the active language. Relative images load from the Host beside
* the document.
* @param props - the entry version, the dictionary, and the injected detail reads.
* @returns the section, or null when the version has no document.
*/
export function PluginDetailSection(props: PluginDetailSectionProps): ReactNode {
const { request, t, loadDetail, retryDetail, detailImage } = props
const language = props.useLocale(snapshot => snapshot.active)
const key = detailKey(request, language)
const state = props.useDetails(details => details[key])
// The key names the request completely; a read the controller forgot, after a connection reset, starts again.
const missing = state === undefined
useEffect(() => { if (missing) loadDetail(request, language) }, [key, missing])
const labels = useDetailDocumentLabels({
title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'),
codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('footnotes'),
})
const detail = state?.status === 'ready' ? state.detail : undefined
const document = detail?.status === 'found' ? detail.document : undefined
const images = useMemo<MarkdownPathImages | undefined>(
() => document === undefined ? undefined : { resolve: value => detailImage(document, value) },
[document, detailImage],
)
const shown = detailDocumentState(state?.status === 'error'
? { status: 'error', message: t(FAILURE_KEYS[state.failure]) }
: state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined })
return <DetailDocument state={shown} labels={labels} images={images} onRetry={() => { retryDetail(request, language) }} />
}
@@ -1,6 +1,7 @@
/**
* Discover, browser half: the Plugins page tab that browses the plugin market indexes the user configures through the
* `pluginMarket` Remote and installs an entry through the page's install dialog.
* `pluginMarket` Remote and installs an entry through the page's install dialog; entry pages show each version's
* detail document.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -28,7 +29,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'pluginMarket'
/** Services the section reads. */
export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket']
export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remote.pluginRegistryProbe']
/**
* Contribute Discover to the Plugins page and keep it current on Host changes.
@@ -36,13 +37,16 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket']
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries')
const controller = new DiscoverController(ctx)
const controller = new DiscoverController(ctx, () => document.baseURI)
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller')
ctx.effect(() => {
const disposers = [
ctx.remote.$on('plugin-market/changed', () => { controller.sourcesChanged() }),
ctx.remote.$on('plugin-manager/changed', () => { controller.bundlesChanged() }),
ctx.on('connection/reset', () => { controller.sourcesChanged() }),
ctx.on('connection/reset', () => {
controller.sourcesChanged()
controller.detailsReset()
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-plugin-market: host invalidations')
@@ -80,6 +80,15 @@ export const zh = {
versionPrerelease: '预发布',
confirmTitle: '安装「{name}」?',
installTrust: '插件在本机以你的权限运行。请确认你信任它的来源',
detailTitle: '详细介绍',
detailLoading: '正在读取详细介绍',
detailTooLarge: '详细介绍或它所在的仓库超过大小限制,未加载',
detailNetwork: '无法连接到详细介绍的来源',
detailDenied: '无法访问详细介绍所在的仓库,请检查 Git 凭据',
detailGitUnavailable: '详细介绍所在的仓库需要用 Git 读取,但无法运行 Git。请安装 Git 后重试',
detailInvalid: '插件声明的详细介绍文件无效',
detailFailed: '无法读取详细介绍',
footnotes: '脚注',
} as const
/** Dictionary keys owned by this plugin. */
@@ -165,4 +174,13 @@ export const en = {
versionPrerelease: 'Prerelease',
confirmTitle: 'Install "{name}"?',
installTrust: 'Plugins run on this machine with your permissions. Make sure you trust where this one comes from.',
detailTitle: 'Details',
detailLoading: 'Reading details',
detailTooLarge: 'The details or their repository exceed the size limit and were not loaded.',
detailNetwork: 'Could not reach the source of the details.',
detailDenied: 'Could not access the repository of the details. Check your Git credentials.',
detailGitUnavailable: 'The details are in a repository that must be read with Git, and Git could not run. Install Git, then retry.',
detailInvalid: 'The plugin declares an invalid details file.',
detailFailed: 'Could not read the details.',
footnotes: 'Footnotes',
} satisfies Record<PluginMarketLocaleKey, string>
@@ -1,9 +1,10 @@
/** Discover state: configured index sources, the selected index's query and loaded results, and source editing. */
import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MarketIndexSummary, MarketSearchResult, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { MarketIndexSummary, MarketSearchResult, PluginDetail, PluginDetailDocumentId, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { declaredQuery, INITIAL_QUERY, type DiscoverQuery } from './presentation.ts'
@@ -48,13 +49,61 @@ export interface DiscoverState {
readonly sourceFailure: SourceFailure | null
}
/** What the Discover tab receives through injection. */
export interface DiscoverFace {
/** The market entry version a detail read is about. */
export interface DetailRequest {
readonly url: string
readonly name: string
readonly version: string
/** Whether the version is an npm package; the Host reads those of the public registries from the npm file CDNs. */
readonly npm: boolean
}
/** Why a detail read failed: the Host's `plugin-market/detail-unavailable` reason, or `other` for any other failure. */
export type DetailFailureKind = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other'
/** One detail read. */
export type DetailState =
| { readonly status: 'loading' }
| { readonly status: 'ready'; readonly detail: PluginDetail }
| { readonly status: 'error'; readonly failure: DetailFailureKind }
/** Detail reads by {@link detailKey}. */
export type DetailsState = Readonly<Record<string, DetailState>>
/**
* The identity of a detail read.
* @param request - the plugin.
* @param language - the display language.
* @returns the key of the read in {@link DetailsState}.
*/
export function detailKey(request: DetailRequest, language: string): string {
return JSON.stringify([language, request])
}
/** What the entry detail section receives through injection. */
export interface DetailFace {
readonly hooks: {
/** Detail reads, bound as `useDetails`. */
readonly details: SnapshotStore<DetailsState>
/** The active locale, bound as `useLocale`. */
readonly locale: HostObservable<LocaleSnapshot>
}
/** Read a plugin's detail document unless the read already ran or runs. */
readonly loadDetail: (request: DetailRequest, language: string) => void
/** Read a failed detail document again, bypassing the Host's cached result. */
readonly retryDetail: (request: DetailRequest, language: string) => void
/**
* The URL of an image beside a detail document.
* @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination.
*/
readonly detailImage: (document: PluginDetailDocumentId, destination: string) => string | undefined
}
/** What the Discover tab receives through injection. */
export interface DiscoverFace extends DetailFace {
readonly hooks: DetailFace['hooks'] & {
/** Discover state, bound as `useDiscover`. */
readonly discover: SnapshotStore<DiscoverState>
/** The active locale, bound as `useLocale`, for collation. */
readonly locale: HostObservable<LocaleSnapshot>
}
/** Resolve market text in the current language. */
readonly resolveText: (text: LocalizedText) => string
@@ -90,6 +139,13 @@ function sourceFailure(failure: RemoteFailure): SourceFailure {
return { kind: 'other', reason: failure.message }
}
function detailFailure(failure: RemoteFailure): DetailFailureKind {
return failure.code === 'plugin-market/detail-unavailable' ? failure.details.reason : 'other'
}
/** The Host route of images beside detail documents, relative to the application base. */
const DETAIL_FILE_ROUTE = 'api/plugin-market/detail-file'
function listingFailure(failure: RemoteFailure): ListingFailure {
return failure.code === 'plugin-market/invalid-index' ? 'invalid' : 'fetch'
}
@@ -98,7 +154,13 @@ function listingFailure(failure: RemoteFailure): ListingFailure {
export class DiscoverController {
/** Shared Discover state published to the tab. */
readonly store: SnapshotStore<DiscoverState> = createSnapshotStore<DiscoverState>(INITIAL)
/** Detail reads published to detail sections. */
readonly details: SnapshotStore<DetailsState> = createSnapshotStore<DetailsState>({})
private disposed = false
/** The latest detail read of each key; an older read's answer is dropped. */
private readonly detailReads = new Map<string, number>()
/** Numbers detail reads across the controller's lifetime, so a read started before a reset never matches one after it. */
private detailSequence = 0
private indexRead = 0
private listingRead = 0
/** Select the last source once the list it was added to arrives. */
@@ -106,8 +168,11 @@ export class DiscoverController {
/** Runs the listing read that failed last. */
private failed: () => void = () => {}
/** @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. */
constructor(private readonly ctx: ClientContext) {}
/**
* @param ctx - the plugin context whose `remote.pluginMarket` namespace answers.
* @param base - the application base URL that Host routes resolve against.
*/
constructor(private readonly ctx: ClientContext, private readonly base: () => string) {}
/** Stop publishing late settlements. */
dispose(): void {
@@ -120,8 +185,10 @@ export class DiscoverController {
* @returns the tab's injected state sources and actions.
*/
face(resolveText: DiscoverFace['resolveText']): DiscoverFace {
const detail = this.detailFace()
return {
hooks: { discover: this.store, locale: this.ctx.locale },
...detail,
hooks: { ...detail.hooks, discover: this.store },
resolveText,
ensure: () => { if (this.state().status === 'idle') void this.loadIndexes() },
reload: () => { void this.loadIndexes() },
@@ -152,6 +219,30 @@ export class DiscoverController {
}
}
/** Detail reads and image addressing of the entry detail section. */
private detailFace(): DetailFace {
return {
hooks: { details: this.details, locale: this.ctx.locale },
loadDetail: (request, language) => {
if (this.details.getSnapshot()[detailKey(request, language)] === undefined) void this.readDetail(request, language)
},
retryDetail: (request, language) => { void this.readDetail(request, language, true) },
detailImage: (document, destination) => {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined
return new URL(`${DETAIL_FILE_ROUTE}?document=${encodeURIComponent(document)}&path=${encodeURIComponent(destination)}`, this.base()).href
},
}
}
/**
* Forget every detail read after the connection reset: a restarted Host no longer knows the document ids that address
* their images, and each shown section reads its document again.
*/
detailsReset(): void {
this.detailReads.clear()
this.details.set({})
}
/** Re-read the sources after the Host announced a change or the connection reset; an unrendered tab stays idle. */
sourcesChanged(): void {
if (this.state().status !== 'idle') void this.loadIndexes()
@@ -164,6 +255,32 @@ export class DiscoverController {
if (listing?.status === 'ready') void this.load(listing.url, listing.query, Math.max((listing.value as MarketSearchResult).entries.length, PAGE_SIZE), false)
}
private async readDetail(request: DetailRequest, language: string, refresh = false): Promise<void> {
const key = detailKey(request, language)
const read = ++this.detailSequence
this.detailReads.set(key, read)
this.publishDetail(key, { status: 'loading' })
// The remote is captured before any wait: the plugin's context may be disposed while the comparison runs.
const { remote } = this.ctx
const current = (): boolean => !this.disposed && this.detailReads.get(key) === read
// Only an npm version waits for the registry comparison; a Git version is read at once.
// npmmirror is asked first where its registry answered the Host's comparison fastest.
let npmFileSources: 'npmmirror'[] = []
if (request.npm) {
const fastest = await remote.pluginRegistryProbe.fastest()
// A retry or reset started while the comparison ran settles the key instead.
if (!current()) return
if (fastest.ok && fastest.value === NPMMIRROR_REGISTRY) npmFileSources = ['npmmirror']
}
const { url, name, version } = request
const answer = await remote.pluginMarket.readEntryDetail(url, name, version, language, { refresh, npmFileSources })
if (current()) this.publishDetail(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: detailFailure(answer.error) })
}
private publishDetail(key: string, state: DetailState): void {
this.details.set({ ...this.details.getSnapshot(), [key]: state })
}
private state(): DiscoverState {
return this.store.getSnapshot()
}
@@ -6,6 +6,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { expect, it, onTestFinished, vi } from 'vitest'
import { apply, inject } from '../src/client/index.ts'
import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client'
import { brandString } from '@deepseek-ai/dsh-brand'
import type { DiscoverFace } from '../src/client/market-store.ts'
it('contributes Discover to the Plugins page, follows Host changes, and withdraws with its fiber', async () => {
@@ -18,7 +20,7 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw
read: vi.fn(),
setIndexes: vi.fn(),
}
const remote = new TestRemote(ctx, { pluginMarket: market })
const remote = new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: { fastest: vi.fn() } })
const slots = ctx.get('slots') as SlotRegistry
slots.register({ name: 'root', children: { 'plugins.tab': { kind: 'list', scope: 'root' } } } as never, () => null)
const fiber = await ctx.plugin({ inject: [...inject], apply }).await()
@@ -32,6 +34,8 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw
const injected: object = entry!.inject!()
const face = injected as DiscoverFace
expect(face.resolveText({ en: 'English' })).toBe('English')
expect(face.detailImage(brandString<PluginDetailDocumentId>('d'), 'img/a b.png'))
.toBe(new URL('api/plugin-market/detail-file?document=d&path=img%2Fa%20b.png', document.baseURI).href)
face.ensure()
await vi.waitFor(() => { expect(face.hooks.discover.getSnapshot().status).toBe('ready') })
remote.emit('plugin-market/changed', [])
@@ -6,7 +6,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { DiscoverSection, type DiscoverSectionProps } from '../src/client/DiscoverSection.tsx'
import type { DiscoverState, ListingState } from '../src/client/market-store.ts'
import type { DetailsState, DiscoverState, ListingState } from '../src/client/market-store.ts'
import { INITIAL_QUERY, type DiscoverQuery } from '../src/client/presentation.ts'
import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts'
@@ -86,6 +86,7 @@ function renderTab(state: Partial<DiscoverState> = {}, dictionary: Record<Plugin
ensure: vi.fn(), reload: vi.fn(), select: vi.fn(), search: vi.fn(), more: vi.fn(), refresh: vi.fn(), retry: vi.fn(),
toggleSources: vi.fn(),
editDraft: vi.fn(), addSource: vi.fn(), removeSource: vi.fn(),
loadDetail: vi.fn(), retryDetail: vi.fn(), detailImage: vi.fn(() => undefined),
}
const install = vi.fn()
const openPackage = vi.fn()
@@ -94,6 +95,7 @@ function renderTab(state: Partial<DiscoverState> = {}, dictionary: Record<Plugin
resolveText: (text: string | Record<string, string>) => typeof text === 'string' ? text : text[language] ?? text.en,
useDiscover: bindSnapshotSelector(store),
useLocale: bindSnapshotSelector(locale),
useDetails: bindSnapshotSelector(createSnapshotStore<DetailsState>({})),
} as DiscoverSectionProps
render(<DiscoverSection {...props} />)
const set = (next: Partial<DiscoverState>): void => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }
@@ -278,9 +280,11 @@ describe('DiscoverSection', () => {
})
it('opens an entry page with its source facts and actions, and returns to the list', () => {
const { install, openPackage } = renderTab()
const { install, openPackage, actions } = renderTab()
fireEvent.click(card('dsh-a').getByRole('button', { name: 'View Alpha' }))
const detail = within(document.querySelector('[data-market-detail="dsh-a"]') as HTMLElement)
expect(actions.loadDetail).toHaveBeenCalledWith({ url: A, name: 'dsh-a', version: '1.0.0', npm: true }, 'en')
expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy()
expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy()
expect(detail.getByText('dsh-a')).toBeTruthy()
for (const value of ['Jane', '[email protected]', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy()
@@ -297,6 +301,7 @@ describe('DiscoverSection', () => {
fireEvent.click(detail.getByRole('button', { name: en.backToList }))
fireEvent.click(card('dsh-b').getByRole('button', { name: 'View dsh-b' }))
const git = within(document.querySelector('[data-market-detail="dsh-b"]') as HTMLElement)
expect(actions.loadDetail).toHaveBeenLastCalledWith({ url: A, name: 'dsh-b', version: '1.0.0', npm: false }, 'en')
expect(git.queryByRole('link')).toBeNull()
for (const value of ['https://github.com/o/r', COMMIT, 'packages/b', 'Themes']) expect(git.getByText(value)).toBeTruthy()
fireEvent.click(git.getByRole('button', { name: 'View dsh-b in Installed' }))
@@ -1,9 +1,12 @@
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult } from '@deepseek-ai/dsh-api-remotes/client'
import type {
MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult, PluginDetail, PluginDetailDocumentId,
} from '@deepseek-ai/dsh-api-remotes/client'
import { brandString } from '@deepseek-ai/dsh-brand'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts'
import { detailKey, DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts'
import { INITIAL_QUERY } from '../src/client/presentation.ts'
const A = 'https://a.example/index.json'
@@ -45,11 +48,15 @@ function setup(indexes: MarketIndexSummary[] = [{ url: A }, { url: B }]) {
listIndexes: vi.fn(() => ok(state.indexes)),
search: vi.fn((url: string, _query: MarketQuery) => ok(listing(url, url === A ? 'A' : 'B', url === A ? 'Desc' : undefined))),
setIndexes: vi.fn((urls: string[]) => { state.indexes = urls.map(url => ({ url: url.trim() })); return ok(undefined) }),
readEntryDetail: vi.fn(
(_url: string, _name: string, _version: string, _language: string, _options: object): Promise<Answer<PluginDetail>> => ok({ status: 'none' }),
),
}
new TestRemote(ctx, { pluginMarket: market })
const controller = new DiscoverController(ctx)
const probe = { fastest: vi.fn((): Promise<Answer<string | null>> => ok('https://registry.npmmirror.com/')) }
new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: probe })
const controller = new DiscoverController(ctx, () => 'https://dsh.example/prefix/')
const face = controller.face(text => typeof text === 'string' ? text : text.en)
return { controller, face, market, state, snapshot: () => controller.store.getSnapshot() }
return { controller, face, market, probe, state, snapshot: () => controller.store.getSnapshot() }
}
describe('DiscoverController', () => {
@@ -343,3 +350,127 @@ describe('DiscoverController', () => {
expect(snapshot().listing?.status).toBe('more')
})
})
describe('detail reads', () => {
const BUNDLE = { url: A, name: 'b', version: '1.0.0', npm: true } as const
const ENTRY = { url: B, name: 'e', version: '2.0.0', npm: false } as const
const FOUND: PluginDetail = { status: 'found', origin: 'readme', markdown: '# B', document: brandString<PluginDetailDocumentId>('doc') }
it('reads each plugin once per language and publishes the result', async () => {
const { controller, face, market } = setup()
market.readEntryDetail.mockImplementationOnce(() => ok(FOUND))
face.loadDetail(BUNDLE, 'en')
face.loadDetail(BUNDLE, 'en')
expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' })
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) })
face.loadDetail(ENTRY, 'zh')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
// An npm version asks the registry comparison and reads npmmirror first where it won; a Git version does not ask.
expect(market.readEntryDetail.mock.calls).toEqual([
[A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: ['npmmirror'] }], [B, 'e', '2.0.0', 'zh', { refresh: false, npmFileSources: [] }],
])
})
it('reports failures by reason and reads again on retry', async () => {
const { controller, face, market } = setup()
// The Git entry's read starts at once; the npm one waits for the registry comparison, so answers go by name.
market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/unknown-plugin', 'gone', { name: 'e', version: '2.0.0' }))
market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/detail-unavailable', 'big', { reason: 'too-large' }))
face.loadDetail(BUNDLE, 'en')
face.loadDetail(ENTRY, 'en')
await vi.waitFor(() => {
expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'error', failure: 'too-large' })
expect(controller.details.getSnapshot()[detailKey(ENTRY, 'en')]).toEqual({ status: 'error', failure: 'other' })
})
face.retryDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
// A retry bypasses the Host's cached result.
expect(market.readEntryDetail.mock.calls.at(-1)).toEqual([A, 'b', '1.0.0', 'en', { refresh: true, npmFileSources: ['npmmirror'] }])
})
it('settles a retried read with the retry\'s answer', async () => {
const { controller, face, market } = setup()
let first: (value: Answer<PluginDetail>) => void = () => {}
market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve }))
market.readEntryDetail.mockImplementationOnce(() => ok(FOUND))
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledOnce() })
face.retryDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) })
first({ ok: true, value: { status: 'none' } })
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND })
})
it('forgets every read after the connection reset and drops the answers of forgotten reads', async () => {
const { controller, face, market } = setup()
let answer: (value: Answer<PluginDetail>) => void = () => {}
face.loadDetail(ENTRY, 'zh')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]?.status).toBe('ready') })
market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(2) })
controller.detailsReset()
expect(controller.details.getSnapshot()).toEqual({})
answer({ ok: true, value: FOUND })
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(controller.details.getSnapshot()).toEqual({})
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') })
// A read from before a reset that settles after the read that replaced it does not overwrite it.
let stale: (value: Answer<PluginDetail>) => void = () => {}
market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve }))
controller.detailsReset()
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(3) })
controller.detailsReset()
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
stale({ ok: true, value: FOUND })
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } })
})
it('keeps the configured CDN order where npmmirror did not answer fastest', async () => {
const { controller, face, market, probe } = setup()
probe.fastest.mockImplementationOnce(() => ok('https://registry.npmjs.org/'))
face.loadDetail(BUNDLE, 'en')
await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') })
expect(market.readEntryDetail.mock.calls).toEqual([[A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: [] }]])
})
it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => {
const { controller, face, market, probe } = setup()
let settle: (value: Answer<string | null>) => void = () => {}
probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve }))
face.loadDetail(BUNDLE, 'en')
controller.detailsReset()
settle({ ok: true, value: null })
await new Promise((resolve) => { setTimeout(resolve, 0) })
face.loadDetail(BUNDLE, 'en')
controller.dispose()
settle({ ok: true, value: null })
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(market.readEntryDetail).not.toHaveBeenCalled()
})
it('stops publishing detail reads after disposal', async () => {
const { controller, face, market } = setup()
let answer: (value: Answer<PluginDetail>) => void = () => {}
market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
face.loadDetail(BUNDLE, 'en')
controller.dispose()
answer({ ok: true, value: FOUND })
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' })
})
it('addresses relative images through the Host route and leaves other destinations alone', () => {
const { face } = setup()
const doc = brandString<PluginDetailDocumentId>('d 1')
expect(face.detailImage(doc, 'img/a.png')).toBe('https://dsh.example/prefix/api/plugin-market/detail-file?document=d%201&path=img%2Fa.png')
for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top', 'mailto:a@b']) {
expect(face.detailImage(doc, destination)).toBeUndefined()
}
})
})
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client'
import { brandString } from '@deepseek-ai/dsh-brand'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { PluginDetailSection, type PluginDetailSectionProps } from '../src/client/PluginDetailSection.tsx'
import { detailKey, type DetailRequest, type DetailsState } from '../src/client/market-store.ts'
import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts'
afterEach(cleanup)
const COMMON: Record<string, string> = { 'copy': 'Copy', 'copied': 'Copied', 'codeBlock.title': 'Code block', 'codeBlock.wrap': 'Wrap', 'codeBlock.unwrap': 'Unwrap', 'retry': 'Retry' }
function translator(dictionary: Record<PluginMarketLocaleKey, string>): PluginDetailSectionProps['t'] {
const strings: Record<string, string> = dictionary
return (key: string) => strings[key] ?? COMMON[key] ?? key
}
const REQUEST: DetailRequest = { url: 'https://a.example/index.json', name: 'a', version: '1.0.0', npm: true }
const DOCUMENT = brandString<PluginDetailDocumentId>('doc')
function setup(initial: DetailsState = {}, language = 'en') {
const details = createSnapshotStore<DetailsState>(initial)
const locale = createSnapshotStore<LocaleSnapshot>({ active: language, locales: [], revision: 0 })
const face = {
loadDetail: vi.fn(), retryDetail: vi.fn(),
detailImage: vi.fn((document: PluginDetailDocumentId, destination: string) => `https://dsh.example/api/plugin-market/detail-file?document=${document}&path=${destination}`),
useDetails: bindSnapshotSelector(details), useLocale: bindSnapshotSelector(locale),
}
const set = (key: string, state: DetailsState[string]): void => { act(() => { details.set({ ...details.getSnapshot(), [key]: state }) }) }
const forget = (): void => { details.set({}) }
return { face, set, locale, forget }
}
it('reads the document of the request in the active language and renders its Markdown with images beside it', () => {
const { face, set, locale, forget } = setup()
const view = render(<PluginDetailSection request={REQUEST} t={translator(en)} {...face} />)
expect(face.loadDetail).toHaveBeenCalledWith(REQUEST, 'en')
expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy()
set(detailKey(REQUEST, 'en'), { status: 'ready', detail: { status: 'found', origin: 'readme', markdown: '# Alpha\n\n![shot](img/shot.png) [guide](docs/guide.md)', document: DOCUMENT } })
expect(screen.queryByRole('heading', { name: en.detailTitle })).toBeNull()
expect(screen.getByRole('heading', { name: 'Alpha' })).toBeTruthy()
expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')).toBe('https://dsh.example/api/plugin-market/detail-file?document=doc&path=img/shot.png')
// Relative links stay text.
expect(screen.queryByRole('link', { name: 'guide' })).toBeNull()
expect(screen.getByText('guide')).toBeTruthy()
act(() => { locale.set({ active: 'zh', locales: [], revision: 1 }) })
expect(face.loadDetail).toHaveBeenLastCalledWith(REQUEST, 'zh')
view.rerender(<PluginDetailSection request={REQUEST} t={translator(zh)} {...face} />)
expect(face.loadDetail).toHaveBeenCalledTimes(2)
// A read the controller forgot starts again; a settled one does not.
set(detailKey(REQUEST, 'zh'), { status: 'ready', detail: { status: 'none' } })
act(() => { forget() })
expect(face.loadDetail).toHaveBeenCalledTimes(3)
})
it('renders nothing without a document, and a reason with a retry when the read failed', () => {
const key = detailKey(REQUEST, 'en')
const { face, set } = setup({ [key]: { status: 'ready', detail: { status: 'none' } } })
const view = render(<PluginDetailSection request={REQUEST} t={translator(en)} {...face} />)
expect(view.container.innerHTML).toBe('')
const reasons = [
['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailDenied],
['invalid', en.detailInvalid], ['other', en.detailFailed],
] as const
for (const [failure, text] of reasons) {
set(key, { status: 'error', failure })
expect(screen.getByRole('alert').textContent).toBe(text)
}
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(face.retryDetail).toHaveBeenCalledWith(REQUEST, 'en')
})
@@ -18,14 +18,14 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 55672e1a36c1ddec
zh: c68b694e833cac98
en: 8fea4c25aa26fc31
zh: 7e9eebe7f712cf53
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: 12fe9d9b1410edef
zh: f2dfdc01170d7cce
en: 4b6e7c1dd8d8c2a7
zh: 227747b459518e37
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
+2 -1
View File
@@ -66,6 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. |
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
| `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. |
| `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. |
| `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. |
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
@@ -99,7 +100,7 @@ The catalog above lists what each export is for; this section covers the behavio
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
+2 -1
View File
@@ -66,6 +66,7 @@ kind: "package-library"
| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 |
| `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
| `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 |
| `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 |
| `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 |
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
@@ -99,7 +100,7 @@ kind: "package-library"
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
@@ -0,0 +1,65 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.document {
min-width: 0;
/* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */
max-height: min(560px, 60vh);
overflow-y: auto;
padding: 4px 16px;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: var(--dsw-radius-lg);
overflow-wrap: anywhere;
}
.document:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: 3px;
}
.failure {
display: flex;
align-items: center;
gap: 10px;
}
.error {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-state-error-primary);
}
.skeleton {
display: flex;
flex-direction: column;
gap: 10px;
}
.bar {
display: block;
height: 12px;
border-radius: var(--dsw-radius-xs);
background: var(--dsw-alias-bg-skeleton);
animation: dsh-plugin-detail-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
}
.long { width: min(480px, 90%); }
.short { width: min(240px, 50%); }
@keyframes dsh-plugin-detail-skeleton {
0% { opacity: 1; }
40% { opacity: 0.6; }
80%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bar { animation: none; }
}
@@ -0,0 +1,120 @@
import { useMemo, type ReactNode } from 'react'
import { Button } from './Button.tsx'
import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from './markdown/MarkdownText.tsx'
import { StateDot } from './StateDot.tsx'
import css from './DetailDocument.module.css'
/** One read of a long Markdown description, such as a plugin's detail document. */
export type DetailDocumentState =
| { readonly status: 'loading' }
| { readonly status: 'found'; readonly markdown: string }
| { readonly status: 'none' }
| { readonly status: 'error'; readonly message: string }
/** A read as owners track it: in flight, settled with a document or none, or failed with a localized message. */
export type DetailDocumentRead =
| undefined
| { readonly status: 'loading' }
| { readonly status: 'ready'; readonly markdown: string | undefined }
| { readonly status: 'error'; readonly message: string }
/**
* The state {@link DetailDocument} shows for an owner's read: loading until it settles, then the document, none, or the
* failure.
* @param read - the owner's read; undefined before it starts.
* @returns the state to show.
*/
export function detailDocumentState(read: DetailDocumentRead): DetailDocumentState {
if (read?.status === 'ready') return read.markdown === undefined ? { status: 'none' } : { status: 'found', markdown: read.markdown }
return read?.status === 'error' ? read : { status: 'loading' }
}
/** Translated strings of a detail document's chrome, as an owner's dictionary supplies them. */
export interface DetailDocumentCopy {
readonly title: string
readonly loading: string
readonly retry: string
readonly copy: string
readonly copied: string
readonly codeBlock: string
readonly wrap: string
readonly unwrap: string
readonly footnotes: string
}
/**
* Assemble reference-stable labels for {@link DetailDocument} from translated strings.
* @param copy - the translated strings.
* @returns labels that keep their identity while the strings stay equal.
*/
export function useDetailDocumentLabels(copy: DetailDocumentCopy): DetailDocumentLabels {
const { title, loading, retry, footnotes } = copy
const { copy: copyLabel, copied: copiedLabel, codeBlock: codeLabel, wrap: wrapLabel, unwrap: unwrapLabel } = copy
const markdown = useMemo(() => ({ code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes }),
[copyLabel, copiedLabel, codeLabel, wrapLabel, unwrapLabel, footnotes])
return useMemo(() => ({ title, loading, retry, markdown }), [title, loading, retry, markdown])
}
/** Localized chrome of a detail document. */
export interface DetailDocumentLabels {
/** Accessible name of the scrollable document region; the section shows no heading of its own. */
readonly title: string
/** Accessible name of the loading placeholder. */
readonly loading: string
/** Retry button text. */
readonly retry: string
readonly markdown: MarkdownLabels
}
/**
* A section showing a long Markdown description, without a heading of its own: three skeleton lines while it loads, the
* document once found, or the owner's failure message with a retry. Nothing renders for `none`. A document taller than
* the section's height limit scrolls inside it, and the scroll region, named by `labels.title`, takes keyboard focus.
* Raw HTML is omitted, relative links stay text, and only images the owner's `images` resolver accepts, or absolute
* HTTP(S) images, load.
* @param props.state - the read to show.
* @param props.labels - localized region name, placeholder name, retry text, and Markdown chrome; keep `markdown` reference-stable.
* @param props.images - resolves the document's relative image destinations to displayable URLs.
* @param props.onRetry - reads the document again after a failure.
* @returns the section, or null.
*/
export function DetailDocument({ state, labels, images, onRetry }: {
readonly state: DetailDocumentState
readonly labels: DetailDocumentLabels
readonly images?: MarkdownPathImages | undefined
readonly onRetry: () => void
}): ReactNode {
if (state.status === 'none') return null
let body: ReactNode
switch (state.status) {
case 'found':
body = (
<div className={css.document} role="region" aria-label={labels.title} tabIndex={0}>
<MarkdownText text={state.markdown} labels={labels.markdown} pathImages={images} omitHtml />
</div>
)
break
case 'error':
body = (
<div className={css.failure}>
<p className={css.error} role="alert"><StateDot state="error" />{state.message}</p>
<Button variant="outline" size="sm" onClick={onRetry}>{labels.retry}</Button>
</div>
)
break
case 'loading':
body = (
<div className={css.skeleton} role="status" aria-label={labels.loading} aria-busy="true">
<span className={`${css.bar} ${css.long}`} />
<span className={`${css.bar} ${css.long}`} />
<span className={`${css.bar} ${css.short}`} />
</div>
)
break
}
return (
<section className={css.section} data-detail-document>
{body}
</section>
)
}
@@ -106,6 +106,8 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
export { DetailDocument, detailDocumentState, useDetailDocumentLabels } from './DetailDocument.tsx'
export type { DetailDocumentCopy, DetailDocumentLabels, DetailDocumentRead, DetailDocumentState } from './DetailDocument.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
@@ -32,6 +32,7 @@ function renderSettled(
labels: MarkdownLabels,
fileMentions: MarkdownFileMentions | undefined,
pathImages: MarkdownPathImages | undefined,
omitHtml: boolean,
): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
@@ -41,6 +42,7 @@ function renderSettled(
labels,
fileMentions,
pathImages,
omitHtml,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
@@ -74,8 +76,11 @@ class StreamingRenderer {
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
constructor(private readonly labels: MarkdownLabels) {}
/**
* @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes.
* @param omitHtml - Whether raw HTML renders nothing; the owner replaces the renderer when it changes.
*/
constructor(private readonly labels: MarkdownLabels, readonly omitHtml: boolean) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
@@ -110,6 +115,7 @@ class StreamingRenderer {
labels: this.labels,
fileMentions: undefined,
pathImages: undefined,
omitHtml: this.omitHtml,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
@@ -129,6 +135,7 @@ class StreamingRenderer {
labels: this.labels,
fileMentions: undefined,
pathImages: undefined,
omitHtml: this.omitHtml,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
@@ -164,15 +171,17 @@ class StreamingRenderer {
* `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while
* modified clicks retain native behavior. `variant="compact"` uses secondary
* text sizing, uniform bold headings, and tight block spacing; the default
* `body` variant uses the full document typography.
* `body` variant uses the full document typography. `omitHtml` renders raw
* HTML as nothing instead of literal text, for documents written for HTML
* renderers, such as package READMEs.
* The provider's `openFile` enables local Markdown links in settled messages,
* including `#L24` and `#L24-L30` destinations (ranges open at their first line).
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML and
* unsafe protocols are disabled. Local links without an opener remain text;
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML never
* renders as markup, and unsafe protocols are disabled. Local links without an opener remain text;
* absolute HTTP(S) images render directly.
*/
export const MarkdownText = memo(function MarkdownText({
text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
text, streaming = false, labels, fileMentions, pathImages, variant = 'body', omitHtml = false,
}: {
text: string
streaming?: boolean
@@ -180,20 +189,21 @@ export const MarkdownText = memo(function MarkdownText({
fileMentions?: MarkdownFileMentions | undefined
pathImages?: MarkdownPathImages | undefined
variant?: 'body' | 'compact'
omitHtml?: boolean
}) {
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownLabels>(labels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, labels, fileMentions, pathImages)
return renderSettled(text, labels, fileMentions, pathImages, omitHtml)
}
if (streamRef.current === null || streamLabelsRef.current !== labels) {
streamRef.current = new StreamingRenderer(labels)
if (streamRef.current === null || streamLabelsRef.current !== labels || streamRef.current.omitHtml !== omitHtml) {
streamRef.current = new StreamingRenderer(labels, omitHtml)
streamLabelsRef.current = labels
}
return streamRef.current.render(text)
}, [text, streaming, labels, fileMentions, pathImages])
}, [text, streaming, labels, fileMentions, pathImages, omitHtml])
return <div className={clsx(css.markdown, variant === 'compact' && css.compact)}
data-markdown-variant={variant === 'compact' ? variant : undefined}>{children}</div>
})
@@ -77,12 +77,17 @@ function remoteImageUrl(url: string): string | undefined {
}
}
/** Rewritten images may use Web media protocols or the Desktop application's file route. */
/** Authenticated Host media routes as the Desktop application addresses them. */
const DESKTOP_MEDIA_ROUTES = [
'dsh-app://app/api/file?', 'dsh-app://app/api/plugin-market/detail-file?', 'dsh-app://app/api/plugin-manager/detail-file?',
]
/** Rewritten images may use Web media protocols or the Desktop application's media routes. */
function vocabularyImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:'
|| url.startsWith('dsh-app://app/api/file?')
|| DESKTOP_MEDIA_ROUTES.some(route => url.startsWith(route))
? url
: undefined
} catch {
@@ -193,6 +198,8 @@ export interface MarkdownRenderContext {
readonly fileMentions: MarkdownFileMentions | undefined
/** Local-path image vocabulary; absent wherever no rewriting owner exists. */
readonly pathImages: MarkdownPathImages | undefined
/** Raw HTML renders nothing instead of literal text. */
readonly omitHtml: boolean
/** Inside an anchor's children: interactive mentions must not nest there. */
readonly inLink?: boolean
/** Reference targets visible to this pass. */
@@ -331,8 +338,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
return <code key={key}>{value}</code>
}
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
return node.value
// No HTML parser enters the pipeline: raw HTML is literal text, or nothing where the owner omits it.
return context.omitHtml ? null : node.value
case 'code':
return renderCode(node, key, context)
case 'math':
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type DetailDocumentCopy, type DetailDocumentLabels } from '../src/DetailDocument.tsx'
afterEach(cleanup)
const LABELS: DetailDocumentLabels = {
title: 'Details', loading: 'Reading details', retry: 'Retry',
markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' },
}
it('shows a placeholder, the document with resolved images, or a failure with a retry, and nothing without a document', () => {
const onRetry = vi.fn()
const view = render(<DetailDocument state={{ status: 'loading' }} labels={LABELS} onRetry={onRetry} />)
// The section has no heading of its own; its title names the document region.
expect(screen.queryByRole('heading', { name: 'Details' })).toBeNull()
expect(screen.getByRole('status', { name: 'Reading details' })).toBeTruthy()
view.rerender(<DetailDocument state={{ status: 'found', markdown: '# A\n\n![i](a.png) [doc](guide.md) <b>bold</b> kept\n\n<div align="center"><img src="x.png"></div>' }} labels={LABELS}
images={{ resolve: value => `https://host.example/${value}` }} onRetry={onRetry} />)
expect(screen.getByRole('heading', { name: 'A' })).toBeTruthy()
// The document scrolls inside a keyboard-focusable region named by the section heading.
const region = screen.getByRole('region', { name: 'Details' })
expect(region.tabIndex).toBe(0)
expect(region.contains(screen.getByRole('heading', { name: 'A' }))).toBe(true)
expect(screen.getByRole('img', { name: 'i' }).getAttribute('src')).toBe('https://host.example/a.png')
expect(screen.queryByRole('link', { name: 'doc' })).toBeNull()
// Raw HTML renders nothing: neither markup nor its literal text.
expect(view.container.textContent).not.toContain('<')
expect(view.container.textContent).toContain('bold kept')
expect(view.container.querySelector('div[align]')).toBeNull()
view.rerender(<DetailDocument state={{ status: 'error', message: 'Could not read' }} labels={LABELS} onRetry={onRetry} />)
expect(screen.getByRole('alert').textContent).toBe('Could not read')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledOnce()
view.rerender(<DetailDocument state={{ status: 'none' }} labels={LABELS} onRetry={onRetry} />)
expect(view.container.innerHTML).toBe('')
})
it('maps owner reads to shown states', () => {
expect(detailDocumentState(undefined)).toEqual({ status: 'loading' })
expect(detailDocumentState({ status: 'loading' })).toEqual({ status: 'loading' })
expect(detailDocumentState({ status: 'ready', markdown: '# A' })).toEqual({ status: 'found', markdown: '# A' })
expect(detailDocumentState({ status: 'ready', markdown: undefined })).toEqual({ status: 'none' })
expect(detailDocumentState({ status: 'error', message: 'm' })).toEqual({ status: 'error', message: 'm' })
})
it('keeps label identity while the translated strings stay equal', () => {
const copy: DetailDocumentCopy = {
title: 'Details', loading: 'Reading', retry: 'Retry', copy: 'Copy', copied: 'Copied', codeBlock: 'Code', wrap: 'Wrap', unwrap: 'Unwrap', footnotes: 'Notes',
}
const { result, rerender } = renderHook((props: DetailDocumentCopy) => useDetailDocumentLabels(props), { initialProps: copy })
const first = result.current
expect(first).toEqual({
title: 'Details', loading: 'Reading', retry: 'Retry',
markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels: { codeLabel: 'Code', wrapLabel: 'Wrap', unwrapLabel: 'Unwrap' } }, footnotes: 'Notes' },
})
rerender({ ...copy })
expect(result.current).toBe(first)
rerender({ ...copy, title: 'Detail' })
expect(result.current.markdown).toBe(first.markdown)
})
@@ -74,14 +74,21 @@ describe('MarkdownText local-path images', () => {
}
})
it('accepts only the Desktop file route from a local image resolver', () => {
it('accepts only the Desktop media routes from a local image resolver', () => {
const src = 'dsh-app://app/api/file?path=%2Ftmp%2Fgraph.png'
const view = render(<MarkdownText text={LOCAL_IMAGE} streaming pathImages={{ resolve: () => src }} />)
expect(view.container.querySelector('img')).toBeNull()
view.rerender(<MarkdownText text={LOCAL_IMAGE} pathImages={{ resolve: () => src }} />)
expect(view.getByRole('img').getAttribute('src')).toBe(src)
const detail = 'dsh-app://app/api/plugin-market/detail-file?document=d&path=a.png'
const bundle = 'dsh-app://app/api/plugin-manager/detail-file?bundle=b&document=README.md&path=a.png'
for (const route of [detail, bundle]) {
view.rerender(<MarkdownText text={LOCAL_IMAGE} pathImages={{ resolve: () => route }} />)
expect(view.getByRole('img').getAttribute('src')).toBe(route)
}
for (const result of ['dsh-app://shell/api/file?path=x', 'dsh-app://app/assets/image.png',
'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x']) {
'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x', 'dsh-app://app/api/plugin-market/other?path=x',
'dsh-app://app/api/plugin-manager/other?path=x']) {
view.rerender(<MarkdownText text={LOCAL_IMAGE} pathImages={{ resolve: () => result }} />)
expect(view.container.querySelector('img')).toBeNull()
}
@@ -23,6 +23,7 @@ function makeContext(): MarkdownRenderContext {
labels: markdownLabels,
fileMentions: undefined,
pathImages: undefined,
omitHtml: false,
targets: createReferenceTargets(),
footnoteOrder: [],
footnoteCounts: new Map(),
@@ -332,6 +332,17 @@ describe('MarkdownText', () => {
}
})
it('omits raw HTML when asked, in settled and streaming renders, and rebuilds the stream when the choice changes', () => {
const text = 'Before <b>bold</b> after\n\n<div align="center">block</div>\n\nTail'
const view = render(<MarkdownText text={text} omitHtml />)
expect(view.container.textContent).toBe('Before bold after\nTail')
expect(view.container.querySelector('div[align]')).toBeNull()
view.rerender(<MarkdownText text={text} streaming omitHtml />)
expect(view.container.textContent).toBe('Before bold after\nTail')
view.rerender(<MarkdownText text={text} streaming />)
expect(view.container.textContent).toContain('<div align="center">block</div>')
})
it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
@@ -54,8 +54,8 @@
en: 83031e7505fc228a
zh: 03e17c9428fe29fd
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
en: ea89ec5a3ee1f167
zh: e11f350571bcfff7
en: 8bcb7bd90d7c5773
zh: 17663be926a099d2
/deepseek-ai-dsh-client-ui-workspace/further-exploration:
en: 2abbf27abbd71ba4
zh: ace33fa2ea042357
+1 -1
View File
@@ -184,7 +184,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu
### Hover cards
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it.
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card, including a blank New Session card, shows its working directory below the title, abbreviating a POSIX home prefix as `~`, and omits that line when the Host reports no directory or an empty one. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it.
</details>
+1 -1
View File
@@ -184,7 +184,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序
### 悬浮卡片
Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。
Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。Session 卡片(包括空白「新会话」卡片)在标题下方显示其工作目录,POSIX 主目录前缀缩写为 `~`;Host 未报告目录或目录为空时省略该行。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。
</details>
@@ -420,12 +420,14 @@ function PinnedIndicator({ t }: { t: RowTranslate }) {
}
/**
* Hover-card body: full title, relative time, the Session's own scheduled-task
* Hover-card body: full title, display working directory, relative time, the Session's own scheduled-task
* section, and every relevant live status. The task section sits above the
* status lines so they stay the card's trailing status line.
*/
function SessionHoverContent({ node, now, renderSlot, t }: {
function SessionHoverContent({ node, cwd, now, renderSlot, t }: {
node: SessionNode
/** Display working directory; absent omits the line. */
cwd: string | undefined
now: number
renderSlot: RowRenderSlots
t: RowTranslate
@@ -437,6 +439,7 @@ function SessionHoverContent({ node, now, renderSlot, t }: {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
{cwd !== undefined && <div className={css.hoverPath}>{cwd}</div>}
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
@@ -539,11 +542,12 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* its leading decoration, and its hover-card section.
* @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
* @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
* @param props.home - host account home for POSIX hover-path abbreviation.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, t,
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, t,
}: {
node: SessionNode
currentId: string | undefined
@@ -555,6 +559,8 @@ export function SessionNodeItem({
onReveal?: (() => void) | undefined
/** Present on reorderable-list rows so every row can remain a drop target. */
drag?: RowDragProps | undefined
/** Host account home; POSIX home-rooted hover paths display as `~`. */
home?: string | undefined
t: RowTranslate
} & PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
@@ -693,7 +699,13 @@ export function SessionNodeItem({
return (
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} renderSlot={renderSlot} t={t} />}
content={<SessionHoverContent
node={node}
cwd={row.cwd === undefined ? undefined : abbreviateHomePath(row.cwd, home)}
now={now}
renderSlot={renderSlot}
t={t}
/>}
openDelayMs={800}
grouped
disabled={menuOpen || drag?.active === true}
@@ -633,6 +633,7 @@ function SessionTree({
? () => { onSessionRevealed(node.id) }
: undefined}
drag={dragProps}
home={home}
t={t}
/>
)
@@ -689,7 +690,7 @@ function SessionTree({
function FlatList({
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest,
usePanelInfo, setSessionOrder, workspaceReady, animationResetKey,
revealSessionId, onSessionRevealed, renderSlot, t,
revealSessionId, onSessionRevealed, renderSlot, home, t,
}: Pick<
SessionTreeProps,
| 'useSessionStatus'
@@ -704,6 +705,7 @@ function FlatList({
| 'onSessionRevealed'
| 'rowState'
| 'onLeaveArchivedOnly'
| 'home'
| 't'
> & {
list: SessionListState
@@ -778,6 +780,7 @@ function FlatList({
dropCommitted.current = false
},
}}
home={home}
t={t}
/>
)
@@ -1434,6 +1437,7 @@ export function WorkspaceBrowser({
setSessionOrder={saveSessionOrder}
revealSessionId={revealSessionId}
onSessionRevealed={acknowledgeSessionReveal}
home={home}
t={t}
/>
)
@@ -59,6 +59,8 @@ export interface SessionNode {
/** In the registry-global archive set: shown grayed in place and not openable. */
archived: boolean
updatedAt: number
/** Session working directory; absent when the Host reports none or an empty path. */
cwd?: string
}
/** Session order selected by the Workspace browser. */
@@ -413,6 +415,7 @@ function sessionNode(
pinned: !archived.has(s.id) && pinned.has(s.id),
archived: archived.has(s.id),
updatedAt: s.updatedAt,
...(s.cwd === undefined || s.cwd === '' ? {} : { cwd: s.cwd }),
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
}
}
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
@@ -685,6 +685,39 @@ describe('workspace browser rows', () => {
}
})
describe('Session hover-card working directory', () => {
const located: SessionNode = {
id: sid('s-cwd'), title: 'Located', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
cwd: '/home/u/project',
}
function hoverCard(node: SessionNode): void {
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} home="/home/u" t={t} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(800) })
}
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('shows a home-rooted directory as ~', () => {
hoverCard(located)
expect(screen.getByText('~/project')).toBeTruthy()
})
it('omits the line when the Host reports no directory', () => {
const { cwd: _cwd, ...withoutCwd } = located
hoverCard(withoutCwd)
expect(screen.getAllByText('Located')).toHaveLength(2)
expect(screen.queryByText(/project/)).toBeNull()
})
it('shows the directory on a blank New Session card', () => {
hoverCard({ ...located, blank: true })
expect(screen.getAllByText('新会话')).toHaveLength(2)
expect(screen.getByText('~/project')).toBeTruthy()
})
})
it('session row menu opens without opening the session and Escape closes it', () => {
const onOpen = vi.fn()
const node: SessionNode = {
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more