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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user