feat(plugin-market): name the plugin and version in a Discover install

A Discover install passes the entry's localized title and version with its
spec. Every install dialog screen names the plugin and version, and shows the
spec, such as a pinned git+https URL, below in smaller monospace text.
This commit is contained in:
turtle1999 committed 2026-10-15 05:37:44 +08:00
1 parent b227127d62
commit ed92c867ed
17 files changed
+125 -66

No files matched your search

@@ -33,8 +33,8 @@
en: 5266d86638cc5cd7
zh: 398d710df9806cea
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
en: d9eb9fdbccc39b3b
zh: e2f2fa1857a07678
en: 76e0908903ab8fde
zh: ce6ae1cdd6aea971
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
en: 203168fc87e939bd
zh: 72d0ccd2c54f3f44
+1 -1
View File
@@ -98,7 +98,7 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a
### Page tabs
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install(spec, registry?)` and `openPackage(name)`. `install` is for a spec the person already confirmed in the tab: the install dialog opens at its check screen with that spec, without the spec form, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
-----
@@ -98,7 +98,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
### 页面标签页
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install(spec, registry?)` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的 spec:安装对话框以该 spec 在检查界面打开,不显示 spec 表单;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
-----
@@ -1017,6 +1017,15 @@ label.registryOption {
overflow-wrap: anywhere;
}
.subjectSpec {
margin: 0;
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
.subjectDesc,
.subjectMeta {
margin: 0;
@@ -928,20 +928,45 @@ function failureText(failure: InstallState['failure'], t: Translate, install?: P
return failure.reason === '' ? t('installFailureGeneric') : failure.reason
}
/** The package the install is about: its name, one-liner, and version, as the Host read them before installing. */
function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonly t: Translate }): ReactNode {
const title = subject.name ?? subject.spec
const kindKey = SUBJECT_KIND_KEYS[subject.kind]
const description = subject.description ?? (kindKey === undefined ? undefined : t(kindKey))
interface SubjectLines {
readonly spec: string
readonly title: string
readonly description?: string | undefined
readonly version?: string | undefined
/** A confirmed version is named by its tab, so the spec follows as reference text. */
readonly showSpec: boolean
}
/** The package an install is about: its name, one-liner, version, and, when `showSpec` is set, its spec. */
function SubjectBlock({ lines, t }: { readonly lines: SubjectLines; readonly t: Translate }): ReactNode {
return (
<div className={css.subject} data-install-subject={subject.spec}>
<p className={css.subjectName}>{title}</p>
{description === undefined ? null : <p className={css.subjectDesc}>{description}</p>}
{subject.version === undefined ? null : <p className={css.subjectMeta}>{t('installVersion', { version: subject.version })}</p>}
<div className={css.subject} data-install-subject={lines.spec}>
<p className={css.subjectName}>{lines.title}</p>
{lines.description === undefined ? null : <p className={css.subjectDesc}>{lines.description}</p>}
{lines.version === undefined ? null : <p className={css.subjectMeta}>{t('installVersion', { version: lines.version })}</p>}
{lines.showSpec ? <p className={css.subjectSpec}>{lines.spec}</p> : null}
</div>
)
}
/** The checked package as the Host read it before installing; a confirmed version keeps its tab's name and version. */
function SubjectCard({ subject, confirmed, t }: {
readonly subject: InstallSubject
readonly confirmed: InstallState['confirmed']
readonly t: Translate
}): ReactNode {
const kindKey = SUBJECT_KIND_KEYS[subject.kind]
return (
<SubjectBlock t={t} lines={{
spec: subject.spec,
title: confirmed?.title ?? subject.name ?? subject.spec,
description: subject.description ?? (kindKey === undefined ? undefined : t(kindKey)),
version: subject.version ?? confirmed?.version,
showSpec: confirmed !== undefined,
}} />
)
}
/** Track one install field's composition, including Safari's 10ms post-composition Enter window. */
function useInstallComposition(active: boolean) {
const composition = useRef({ active: false, until: 0 })
@@ -1045,9 +1070,9 @@ function InstallDialog({
/>
)
}
if ((phase === 'idle' || phase === 'checking') && install.confirmed === true) {
// A confirmed spec has no form to return to: the dialog shows the spec with its check, a refusal with a retry, or,
// after a cancelled run, the spec with Install.
if ((phase === 'idle' || phase === 'checking') && install.confirmed !== undefined) {
// A confirmed spec has no form to return to: the dialog names the plugin and version above the spec, with its
// check, a refusal with a retry, or, after a cancelled run, Install.
const checking = phase === 'checking'
const problem = install.inputError
return (
@@ -1065,7 +1090,7 @@ function InstallDialog({
)}
>
<div className={css.installBody} data-install-phase={phase}>
<p className={css.subjectName}>{install.spec}</p>
<SubjectBlock t={t} lines={{ spec: install.spec, ...install.confirmed, showSpec: true }} />
{checking
? <p className={`${css.status} ${css.statusWithDot}`} role="status"><StateDot state="ongoing" />{t('installChecking')}</p>
: problem === null
@@ -1309,19 +1334,19 @@ function InstallDialog({
})
: null
// Another registry is worth offering only for a failure the Host laid at the one it asked.
const changeable = phase === 'failed' && !approvable && install.failure?.failedAt === 'registry' && install.confirmed !== true
const changeable = phase === 'failed' && !approvable && install.failure?.failedAt === 'registry' && install.confirmed === undefined
const subject = install.subject
return (
<Modal open={install.open} onClose={onClose} title={heading} headless className={css.installDialog as string}>
<div className={css.wizard} data-install-phase={phase}>
<div className={css.wizardHead}>
{phase === 'done' || (install.confirmed === true && !pending)
{phase === 'done' || (install.confirmed !== undefined && !pending)
? <span />
: (
<button type="button" className={css.wizardBack} aria-label={t(install.confirmed === true ? 'cancel' : pending ? 'installCancelAndEdit' : 'installEditAria')}
<button type="button" className={css.wizardBack} aria-label={t(install.confirmed !== undefined ? 'cancel' : pending ? 'installCancelAndEdit' : 'installEditAria')}
disabled={!stoppable} onClick={onCancel}>
<IconChevronLeftOutlineMedium aria-hidden="true" />
<span>{t(install.confirmed === true ? 'cancel' : pending ? 'installCancelAndEdit' : 'installEdit')}</span>
<span>{t(install.confirmed !== undefined ? 'cancel' : pending ? 'installCancelAndEdit' : 'installEdit')}</span>
</button>
)}
<button
@@ -1350,7 +1375,7 @@ function InstallDialog({
</div>
{subject === null
? null
: <SubjectCard subject={phase === 'done' && install.installedVersion !== null ? { ...subject, version: install.installedVersion } : subject} t={t} />}
: <SubjectCard subject={phase === 'done' && install.installedVersion !== null ? { ...subject, version: install.installedVersion } : subject} confirmed={install.confirmed} t={t} />}
{approvable
? (
<section className={css.approval} role="group" aria-labelledby={approvalId} data-install-approval>
@@ -49,7 +49,7 @@ export type { ConfigLedger, OfficialItem } from './config-ledger.ts'
export type { PluginManagerFace } from './manager-store.ts'
export type { PluginManagerLocaleKey } from './locales.ts'
export type {
ConfigPageForm, PluginActivationOwnerProps, PluginAddActionsProps, PluginTabProps,
ConfigPageForm, PluginActivationOwnerProps, PluginAddActionsProps, PluginTabInstall, PluginTabProps,
PluginConfigViewProps, PluginDetailProps, PluginPackageRef, PluginRowRef, PluginsSubject,
} from './slot-contract.ts'
@@ -33,6 +33,7 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
import type { SettingsDescribeFace, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
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'
@@ -199,8 +200,11 @@ export interface InstallState {
readonly spec: string
/** The spec form was reopened after switching away from a failed GitHub address. */
readonly mirrorRecovery?: boolean
/** The spec came from a confirmed entry of another tab: the dialog shows no spec form, only its check and outcome. */
readonly confirmed?: boolean
/**
* The spec came from a confirmed entry of another tab, with that entry's name and version: the dialog shows no spec
* form, only its check and outcome.
*/
readonly confirmed?: { readonly title: string; readonly version: string }
/** The registries the Host configured, read when the dialog opens; null until the Host answered. */
readonly registries: PluginRegistries | null
/** The registry this install asks first: the one last used, else the Host's first. */
@@ -272,7 +276,7 @@ export function isInstallPending(phase: InstallState['phase']): boolean {
*/
export function githubRecoveryRegistry(install: InstallState): Registry | undefined {
// The mirror recovery reopens the spec form, which a confirmed spec does not have.
if (install.confirmed === true || install.phase !== 'failed' || install.failure?.failedAt !== 'spec-host'
if (install.confirmed !== undefined || install.phase !== 'failed' || install.failure?.failedAt !== 'spec-host'
|| (install.failure.kind !== 'network' && install.failure.kind !== 'timeout')) return undefined
const host = install.subject?.host?.toLowerCase().split(':')[0]
if (host !== 'github.com' && !host?.endsWith('.github.com')) return undefined
@@ -338,12 +342,12 @@ export interface PluginManagerFace {
refresh: () => void
openInstall: () => void
/**
* Open the install dialog with a spec the person confirmed elsewhere, such as a Discover entry, and start checking
* it; the dialog shows no spec form. A hidden installation reopens instead.
* @param spec - the exact install spec.
* @param registry - the registry to ask first for this dialog only, never remembered; absent keeps the dialog's choice.
* Open the install dialog with a version the person confirmed elsewhere, such as a Discover entry, and start
* checking it; the dialog shows no spec form. A hidden installation reopens instead. The registry is asked first for
* this dialog only and never remembered.
* @param target - the exact spec, optional registry, and the name and version the dialog shows.
*/
installFrom: (spec: string, registry?: string) => void
installFrom: (target: PluginTabInstall) => void
/** Hide immediately, abort a check, or request cancellation while retaining the Host-owned installation. */
closeInstall: () => void
editInstallSpec: (text: string) => void
@@ -613,11 +617,11 @@ export class PluginManagerController {
this.ctx.get('productAnalytics')?.track('plugin_add_button_click', {})
this.openDialog()
},
installFrom: (spec, registry) => {
installFrom: ({ spec, registry, title, version }) => {
// A hidden installation reopens as it is; only one installation runs at a time.
if (!this.openDialog()) return
this.transientRegistry = registry !== undefined
this.patchInstall({ spec, confirmed: true, ...registry === undefined ? {} : { registry: { kind: 'custom', url: registry } } })
this.patchInstall({ spec, confirmed: { title, version }, ...registry === undefined ? {} : { registry: { kind: 'custom', url: registry } } })
void this.runInstall()
},
closeInstall: () => {
@@ -79,15 +79,26 @@ export interface PluginActivationOwnerProps {
readonly onOpenDetails: () => void
}
/** An exact version a tab asks the Plugins page to install, with the text the install dialog shows for it. */
export interface PluginTabInstall {
/** The install spec, such as `name@version` or a pinned `git+https` URL. */
readonly spec: string
/** The registry to ask first for this install only; absent keeps the dialog's registry choice. */
readonly registry?: string
/** Display name of the plugin, already localized. */
readonly title: string
/** The version being installed. */
readonly version: string
}
/** Owner props of a page tab another plugin contributes, such as Discover. */
export interface PluginTabProps {
/**
* Install an exact spec the person already confirmed: the page's install dialog opens at its check and progress
* screens, without the spec form.
* @param spec - the install spec, such as `name@version`.
* @param registry - the registry to ask first for this install only; absent keeps the dialog's registry choice.
* Install an exact version the person already confirmed: the page's install dialog opens at its check and progress
* screens, without the spec form, and names the plugin and version above the spec.
* @param target - the spec, optional registry, and display text.
*/
readonly install: (spec: string, registry?: string) => void
readonly install: (target: PluginTabInstall) => void
/**
* Switch to the Installed tab and open a bundle's page.
* @param packageName - npm package name of the bundle.
@@ -212,7 +212,7 @@ describe('PluginManagerPage', () => {
const tab = owner as PluginTabProps
return (
<>
<button type="button" onClick={() => { tab.install('[email protected]', 'https://r.example/') }}>Discover install</button>
<button type="button" onClick={() => { tab.install({ spec: '[email protected]', registry: 'https://r.example/', title: 'Alpha', version: '1.0.0' }) }}>Discover install</button>
<button type="button" onClick={() => { tab.openPackage('dsh-better-sidebar') }}>Discover open</button>
</>
)
@@ -228,7 +228,7 @@ describe('PluginManagerPage', () => {
expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull()
expect(screen.queryByRole('button', { name: en.refresh })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Discover install' }))
expect(actions.installFrom).toHaveBeenCalledWith('[email protected]', 'https://r.example/')
expect(actions.installFrom).toHaveBeenCalledWith({ spec: '[email protected]', registry: 'https://r.example/', title: 'Alpha', version: '1.0.0' })
fireEvent.click(screen.getByRole('button', { name: 'Discover open' }))
expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-better-sidebar' } })
expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull()
@@ -270,10 +270,12 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy()
})
it('shows a confirmed spec as a check and a refusal with retry, without the spec form or another registry', () => {
const confirmed: InstallState = { ...IDLE_INSTALL, open: true, spec: '[email protected]', confirmed: true, registries: REGISTRIES }
it('shows a confirmed version by name above its spec, as a check and a refusal with retry, without the spec form or another registry', () => {
const confirmed: InstallState = { ...IDLE_INSTALL, open: true, spec: '[email protected]', confirmed: { title: 'Alpha', version: '1.0.0' }, registries: REGISTRIES }
const { actions, set } = renderTab({ install: { ...confirmed, phase: 'checking' } })
expect(screen.getByRole('dialog', { name: en.installConfirmedTitle })).toBeTruthy()
const dialog = screen.getByRole('dialog', { name: en.installConfirmedTitle })
expect([...dialog.querySelectorAll('[data-install-subject] p')].map(line => line.textContent))
.toEqual(['Alpha', en.installVersion.replace('{version}', '1.0.0'), '[email protected]'])
expect(screen.getByRole('status').textContent).toBe(en.installChecking)
expect(screen.queryByRole('textbox')).toBeNull()
expect(screen.queryByRole('button', { name: en.retry })).toBeNull()
@@ -311,6 +313,9 @@ describe('PluginManagerPage', () => {
})
expect(screen.getByText(en.installFailedTitle)).toBeTruthy()
expect(screen.queryByRole('button', { name: en.installUseGithubMirror })).toBeNull()
// A Git spec checks without a name or version; the confirmed name and version head the card, above the spec.
expect([...document.querySelectorAll('[data-install-subject] p')].map(line => line.textContent))
.toEqual(['Alpha', en.installSubjectGit, en.installVersion.replace('{version}', '1.0.0'), 'git+https://github.com/o/r.git#abc'])
set({
install: {
...confirmed, phase: 'running', requestId: 'r' as never,
@@ -589,18 +589,18 @@ describe('PluginManagerController', () => {
.mockReturnValueOnce(gate.promise)
const { plugins, face, state, controller, started, track } = bench({ installBundle })
await controller.load()
face.installFrom('[email protected]', 'https://r.example/')
face.installFrom({ spec: '[email protected]', registry: 'https://r.example/', title: 'New', version: '1.0.0' })
expect(track).not.toHaveBeenCalledWith('plugin_add_button_click', {})
expect(state().install).toMatchObject({ open: true, spec: '[email protected]', confirmed: true, registry: { kind: 'custom', url: 'https://r.example/' } })
expect(state().install).toMatchObject({ open: true, spec: '[email protected]', confirmed: { title: 'New', version: '1.0.0' }, registry: { kind: 'custom', url: 'https://r.example/' } })
await started()
expect(plugins.inspect).toHaveBeenCalledWith('[email protected]', { registry: 'https://r.example/' }, expect.any(AbortSignal))
face.closeInstall()
face.installFrom('[email protected]')
face.installFrom({ spec: '[email protected]', title: 'Other', version: '2.0.0' })
expect(state().install).toMatchObject({ open: true, spec: '[email protected]' })
gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))
await vi.waitFor(() => { expect(state().install.requestId).toBeUndefined() })
face.closeInstall()
face.installFrom('[email protected]')
face.installFrom({ spec: '[email protected]', title: 'Other', version: '2.0.0' })
expect(state().install).toMatchObject({ open: true, spec: '[email protected]', registry: { kind: 'offered', registry: null } })
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenLastCalledWith('[email protected]', { registry: null }, expect.any(AbortSignal)) })
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
@@ -18,8 +18,8 @@
en: 2690b95e156cc473
zh: 0d9e897ad628f9dd
/deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation:
en: cc9e8550a01ea847
zh: eda18f5680df3e88
en: c56790cb58083f85
zh: a3d4ad4bad08e483
/deepseek-ai-dsh-client-ui-plugin-market/model-experience:
en: 08d25ab9cbdd7567
zh: 26e6e75c123951ee
+1 -1
View File
@@ -41,7 +41,7 @@ Reading a market shows card skeletons. A failed read shows a short sentence with
<a id="understand-the-implementation"></a>
## Understand the implementation
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?)` and `openPackage(name)`. 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.
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.
-----
@@ -41,7 +41,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-
<a id="understand-the-implementation"></a>
## 理解实现
本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install(spec, registry?)` 和 `openPackage(name)`。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。
本插件把标签为**发现**的 `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"` 延迟加载,失败时回退到默认插画。
-----
@@ -333,7 +333,7 @@
border-radius: 4px;
background: var(--dsw-alias-bg-module-platform);
box-decoration-break: clone;
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-family: var(--ds-font-family-code);
font-size: 12px;
}
@@ -589,7 +589,10 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode {
{body}
<SourcesDialog state={state} t={t} face={face} />
<ConfirmDialog choice={confirming} t={t} face={face} onCancel={() => { setConfirming(null) }}
onConfirm={({ version }) => { setConfirming(null); install(version.install.spec, version.install.registry) }} />
onConfirm={({ entry, version }) => {
setConfirming(null)
install({ ...version.install, title: entryTitle(entry, face), version: version.version })
}} />
</div>
)
}
@@ -145,7 +145,7 @@ describe('DiscoverSection', () => {
expect(install).not.toHaveBeenCalled()
fireEvent.click(card('dsh-a').getByRole('button', { name: 'Install Alpha' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: en.install }))
expect(install).toHaveBeenCalledWith('[email protected]', 'https://r.example/')
expect(install).toHaveBeenCalledWith({ spec: '[email protected]', registry: 'https://r.example/', title: 'Alpha', version: '1.0.0' })
expect(screen.queryByRole('dialog')).toBeNull()
for (const [name, label] of [['dsh-b', en.statusInstalled], ['dsh-c', en.statusUpdate], ['dsh-f', en.statusProvided]] as const) {
fireEvent.click(card(name).getByRole('button', { name: `View ${name} in Installed` }))
@@ -346,7 +346,7 @@ describe('DiscoverSection', () => {
const dialog = within(screen.getByRole('dialog'))
expect(dialog.getByText('[email protected]')).toBeTruthy()
fireEvent.click(dialog.getByRole('button', { name: en.install }))
expect(install).toHaveBeenCalledWith('[email protected]', undefined)
expect(install).toHaveBeenCalledWith({ spec: '[email protected]', title: 'dsh-v', version: '3.0.0-rc.1' })
})
it('marks exempted versions and offers no version of a bundle DSH provides', () => {
@@ -2205,7 +2205,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.add.actions\', () => ctx.slots.register(\n { name: \'plugins.add.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:107',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:118',
},
{
key: 'plugins.bundle.activation',
@@ -2245,7 +2245,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:115',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:126',
},
{
key: 'plugins.bundle.config',
@@ -2288,7 +2288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:131',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:142',
},
{
key: 'plugins.detail.actions',
@@ -2340,7 +2340,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:145',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:156',
},
{
key: 'plugins.detail.badge',
@@ -2392,7 +2392,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:150',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:161',
},
{
key: 'plugins.detail.section',
@@ -2444,7 +2444,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:157',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:168',
},
{
key: 'plugins.item',
@@ -2501,7 +2501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:125',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:136',
},
{
key: 'plugins.row.config',
@@ -2541,7 +2541,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:139',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:150',
},
{
key: 'plugins.tab',
@@ -2570,9 +2570,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** Owner props of a page tab another plugin contributes, such as Discover. */\nexport interface PluginTabProps {\n /**\n * Install an exact spec the person already confirmed: the page\'s install dialog opens at its check and progress\n * screens, without the spec form.\n * @param spec - the install spec, such as `name@version`.\n * @param registry - the registry to ask first for this install only; absent keeps the dialog\'s registry choice.\n */\n readonly install: (spec: string, registry?: string) => void\n /**\n * Switch to the Installed tab and open a bundle\'s page.\n * @param packageName - npm package name of the bundle.\n */\n readonly openPackage: (packageName: string) => void\n}',
'/** Owner props of a page tab another plugin contributes, such as Discover. */\nexport interface PluginTabProps {\n /**\n * Install an exact version the person already confirmed: the page\'s install dialog opens at its check and progress\n * screens, without the spec form, and names the plugin and version above the spec.\n * @param target - the spec, optional registry, and display text.\n */\n readonly install: (target: PluginTabInstall) => void\n /**\n * Switch to the Installed tab and open a bundle\'s page.\n * @param packageName - npm package name of the bundle.\n */\n readonly openPackage: (packageName: string) => void\n}',
],
ownerPropsReferences: [
'PluginTabInstall',
],
ownerPropsReferences: [],
standardProps: [
'useRemoteAccess: (action: RemoteEndpoint) => boolean',
'useResource: UseResource',
@@ -2593,7 +2595,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.tab\', () => ctx.slots.register(\n { name: \'plugins.tab\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:113',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:124',
},
{
key: 'rightbar',