feat(dsh): simplify source settings with unified cards

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-03 09:51:07 +08:00
parent 9d05f26716
commit b37140a2e9
16 changed files with 324 additions and 186 deletions

View file

@ -25,7 +25,7 @@
用户已确认同一 DSH Profile/插件实例共享当前来源,不做每会话独立选择。
- 技能中心顶部提供紧凑的来源选择,具名来源的新增、编辑、删除放在现有设置页;不新增管理中心。
- 技能中心顶部不展示来源选择器。来源选择及具名来源的新增、编辑、删除统一放在现有设置页的“技能来源”中;选择后保存生效,不新增管理中心。
- 浏览页只展示当前市场。已安装页展示全部可信受管安装,仍以使用技能为主要操作;仅其他来源安装显示一个来源标识,管理在详情页二级区域进行。
- 其他来源详情仅展示可信本地内容和本地管理,不向当前市场请求同名详情,也不提供错误的官方链接、远程版本或升级按钮。
- 当前来源更新筛选和汇总只统计当前来源。没有匹配安装时显示“当前来源暂无可检查的技能”。
@ -38,7 +38,7 @@
地址验证及来源比较复用现有逻辑,不扩大 URL 等价规则;相同来源去重,名称只用于展示,不能成为库存归属。地址不接受凭证、查询串和片段。添加或改名不自动切换;修改当前条目的 URL 时将列表和当前地址原子保存,并按来源切换处理。
复用 Host configForms 的 revision CAS 和订阅:快速选择只写 registryUrl;删除当前项等复合编辑原子提交。其他页面通过配置通知和 Host sourceRevision 丢弃旧读取及确认。无额外数据库、localStorage 或库存迁移。
复用 Host configForms 的 revision CAS 和订阅:设置页来源选择写入 registryUrl 草稿并在保存时生效;删除当前项等复合编辑原子提交。其他页面通过配置通知和 Host sourceRevision 丢弃旧读取及确认。无额外数据库、localStorage 或库存迁移。
## 全来源本地管理与更新隔离
@ -88,3 +88,7 @@
真实模型首轮把来源地址误用为上下文引用,Host 正确拒绝但对话检查失败。补充参数字段说明,明确当前来源仅代表检查资格,区分只读检查和写入确认,并为安装引用过期与操作回执过期分别提供恢复指引。该指引不放松 Host 校验,不承诺强制约束模型措辞。
修复后真实模型新会话三轮完成查询、切源后当前来源检查,以及按钮操作后重新查询;旧失败会话经一次新列表读取恢复。真实模型调用使用隔离的本地 Registry 数据,未执行升级/卸载,不代表生产 SaaS 全链路验收。旧历史仍有措辞偏差,新回复也可能冗长,原始失败记录保留。
## 设置页信息层级
设置页采用“技能来源 / 页面显示”两个胶囊标签,复用宿主控件与主题;Tab仅切换内容,不保存配置、不丢弃草稿,也不触发连接测试。每个标签用一张内容卡片包裹。来源页以同一列表选择和管理来源,行内显示名称与地址,新增或编辑时才展开字段;不重复显示当前地址输入及保存来源列表。旧配置中未保存到列表的当前来源仍可见可管理。测试连接和断开操作作用于草稿,保存前不切换宿主;页面显示集中版本、审核与评论选项。共用明确的保存/重置区域,保存跨Tab草稿并保持原配置revision CAS。只读、加载、保存中、冲突与连接测试失败仍需可见,隐藏Tab中的校验错误应能引导定位。窄屏不出现横向溢出,Tab支持键盘操作。

View file

@ -124,3 +124,16 @@ The Host SHALL restrict individual and bulk update checks and upgrade preparatio
- **WHEN** a user asks the conversation agent to check installed skills for updates
- **THEN** the tool contract directs the agent to obtain fresh opaque routing references from the installed list, check only current-source items, and distinguish update eligibility from an available newer version
- **AND** a stale context is not presented as missing human approval; the agent is directed to refresh once and report an unresolved failure rather than repeatedly retry or invent a confirmation step
### Requirement: Settings grouped without losing unsaved changes
The settings page SHALL group source configuration and detail visibility into keyboard-accessible tabs. Switching tabs SHALL preserve the same unsaved draft and SHALL NOT save or test a connection. Each tab SHALL use one content card. Source selection and management SHALL share a single list with inline editing on demand, without a duplicated current-address editor. A current URL absent from saved sources SHALL remain visible and manageable.
#### Scenario: Save changes from multiple settings tabs
- **WHEN** a user edits the source and detail visibility in separate tabs and saves
- **THEN** all pending edits are submitted together with their original configuration revision
- **AND** an invalid hidden source field brings the source tab and management area into view; conflicting external edits are rejected without silently rebasing the draft
#### Scenario: Return to the saved values
- **WHEN** edits return to the saved values without an intervening configuration revision change
- **THEN** no unsaved-change state remains and save is disabled
- **AND** returning values after an external revision change does not bypass conflict detection

View file

@ -421,6 +421,12 @@
- [x] 48.1 兼容保存具名来源,复用 Host 配置 CAS、订阅及共享切换。
- [x] 48.2 全来源可信安装投影、来源身份贯穿本地操作和断开后的管理。
- [x] 48.3 单项及批量更新只允许当前来源,处理切换竞态和已受理事务。
- [x] 48.4 紧凑来源选择及其他来源本地详情,保持精简操作。
- [x] 48.4 设置页管理来源选择及其他来源本地详情,技能中心不展示来源选择器,保持精简操作。
- [x] 48.5 完成 registry-switching-design.md 十项验收、CLI 兼容回归和真实 DSH 预览,更新用户文档。
- [x] 48.6 补真实模型自然语言验收:参数引用、切源更新隔离、卡片操作后续对话;修复模型将来源地址误作 sourceRevision 的指引缺口,保留首轮失败证据。
## 49. 设置页信息层级与视觉优化
- [x] 49.1 两个胶囊Tab、内容卡片与统一来源选择/行内编辑,保留草稿和CAS契约。
- [x] 49.2 验证跨Tab保存/重置、来源管理、只读/冲突/连接错误及明暗/窄屏。
- [x] 49.3 更新真实Host预览与证据,区分测试来源能力和正式服务能力。

View file

@ -120,16 +120,16 @@ The Loader entry ID is `astron-skillhub`; `skillhub` is the Host/Remote service
Open DSH **Plugins → @astron-team/skillhub-dsh → SkillHub 设置**.
1. Enter the HTTP(S) instance base address in **Registry 地址**. A deployment subpath is allowed; credentials, query parameters, fragments and API endpoint suffixes are not.
1. Select, add or edit a source in the **技能来源** card. Use an HTTP(S) instance base address. A deployment subpath is allowed; credentials, query parameters, fragments and API endpoint suffixes are not.
2. Select **测试连接** (Test connection) to test the current draft URL without saving it. The result reports public catalog or basic-search availability and elapsed time. Requests have a 10-second timeout; failures can be retried. Editing the address clears the previous result. Success does not establish download or all optional-module availability.
3. Choose visibility for versions, security audit and reviews.
3. Open **页面显示** (Page display) to choose visibility for versions, security audit and reviews. Switching tabs retains the draft and does not save it.
4. Select **保存配置** (Save). After the saved confirmation, open **技能中心** and search for a public test skill.
Add named addresses under **保存的来源** and save, then switch from the Skill Center header. The active source is shared across pages and conversations of the same Profile/plugin instance. Configuration revision checks reject conflicting edits rather than overwriting another page.
Configure and switch sources under the **技能来源** tab in SkillHub settings. Selection and management share a single list; adding or editing reveals inline fields. Disconnecting takes effect only after saving and preserves installed skills. Both tabs share Save and Reset, applying all pending edits together. Select a saved source and save to apply it; the Skill Center header does not show a source selector. The active source is shared across pages and conversations of the same Profile/plugin instance. Configuration revision checks reject conflicting edits rather than overwriting another page.
Switching preserves existing installations. Installed skills from other trusted sources remain locally usable and manageable, including availability checks, enable/disable and removal; only the current source participates in update checks and upgrades. Same-name skills from different sources remain distinct. Removing the active saved source disconnects without deleting installations; old-source details never borrow current-market content.
**已配置** means a nonempty address is saved, not that connectivity has been tested. An empty address disconnects the source. **重置修改** (Reset changes) restores the current Host snapshot without a network reload or save. Read-only connections cannot edit configuration. On a revision conflict, reset the draft before editing the current values.
**当前使用** identifies the saved active source; **待保存** identifies a pending selection. Neither indicates a successful connection test. Choose **不连接技能来源** and save to disconnect. **重置修改** (Reset changes) restores the current Host snapshot without a network reload or save. Read-only connections cannot edit configuration. On a revision conflict, reset the draft before editing the current values.
| Field | Meaning |
| --- | --- |

View file

@ -120,16 +120,18 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。
进入 DSH **Plugins/插件 → @astron-team/skillhub-dsh → SkillHub 设置**。
1. 在“技能来源”填写 **Registry 地址**,使用实例的 HTTP(S) 基础地址,可包含部署子路径;不要填写 API 路由、凭证、查询参数或片段。
1. 在“技能来源”卡片中选择来源,或添加、编辑来源。地址使用实例的 HTTP(S) 基础地址,可包含部署子路径;不要填写 API 路由、凭证、查询参数或片段。
2. 点击“测试连接”,使用当前草稿地址测试,不保存配置。结果显示公开目录可读或仅支持基础搜索及耗时;最长等待 10 秒,失败或超时可重试。修改地址后清除旧测试结果,连接成功不代表技能下载或所有可选接口可用。
3. 在“详情标签”设置版本、安全审核及评论的显示方式。
3. 切换到“页面显示”标签,设置版本、安全审核及评论的显示方式;切换标签保留草稿,不会自动保存。
4. 点击“保存配置”,看到“配置已保存”后进入侧栏“技能中心”搜索公开测试技能。
在“保存的来源”添加名称和地址后保存,可从技能中心顶部快速切换。当前来源由同一 Profile/插件实例的所有页面和会话共享;并发编辑通过配置版本校验,冲突时不会覆盖其他页面。
来源配置与切换统一放在 SkillHub 设置的“技能来源”标签中,选择保存的来源后保存生效;技能中心顶部不展示来源选择器。当前来源由同一 Profile/插件实例的所有页面和会话共享;并发编辑通过配置版本校验,冲突时不会覆盖其他页面。
切换来源不删除既有安装。“已安装”保留其他来源的受管技能,支持本地使用、诊断、启停与卸载;更新检查和升级仅适用于当前来源。不同来源同名技能分开处理。删除当前保存来源会断开连接,已安装技能保留;其他来源详情不借用当前市场内容。
“已配置”只表示 Host 已保存非空来源,不代表连通测试成功。空地址表示断开来源;“重置修改”恢复当前 Host 配置快照,不发起网络重载或保存。只读连接不可编辑。若保存发生 revision 冲突,先重置修改,再基于当前值编辑。
来源选择与管理使用同一列表,新增或编辑时才展开行内字段;不会同时展示两份地址输入。两个标签共用“保存配置”和“重置修改”,一次处理所有未保存改动。断开连接也在保存后生效,不删除已安装技能。
“当前使用”表示已保存的来源,“待保存”表示当前草稿选择,都不代表连接测试成功。选择“不连接技能来源”并保存可断开;“重置修改”恢复当前 Host 配置快照,不发起网络重载或保存。只读连接不可编辑。若保存发生 revision 冲突,先重置修改,再基于当前值编辑。
| 字段 | 行为 |
| --- | --- |

View file

@ -1,4 +1,3 @@
import { RegistrySelector, type RegistryConfiguration } from './registry-selector.js';
import { sameRegistry } from './registry-config.js';
import { SkillWebsiteLink } from './skill-website-link.js';
import { UpgradeControl } from './upgrade-control.js';
@ -44,13 +43,13 @@ function unwrap<T>(result: { ok: true; value: T } | { ok: false; error: { code:
if (result.ok) return result.value;
throw new Error(catalogErrorMessage(result.error));
}
export function CatalogPage({ remote, navigation, client, onReturn, installation, installed, availability, registryConfiguration }: {
remote: Remote; navigation: CatalogNavigation; client: QueryClient; onReturn: () => void; installation?: InstallationServices; installed?: InstalledServices; availability: UiAvailability; registryConfiguration?: RegistryConfiguration;
export function CatalogPage({ remote, navigation, client, onReturn, installation, installed, availability }: {
remote: Remote; navigation: CatalogNavigation; client: QueryClient; onReturn: () => void; installation?: InstallationServices; installed?: InstalledServices; availability: UiAvailability;
}) {
return <QueryClientProvider client={client}><Catalog remote={remote} navigation={navigation} onReturn={onReturn} installation={installation} installed={installed} availability={availability} registryConfiguration={registryConfiguration} /></QueryClientProvider>;
return <QueryClientProvider client={client}><Catalog remote={remote} navigation={navigation} onReturn={onReturn} installation={installation} installed={installed} availability={availability} /></QueryClientProvider>;
}
function Catalog({ remote, navigation, onReturn, installation, installed, availability, registryConfiguration }: {
remote: Remote; navigation: CatalogNavigation; onReturn: () => void; installation?: InstallationServices; installed?: InstalledServices; availability: UiAvailability; registryConfiguration?: RegistryConfiguration;
function Catalog({ remote, navigation, onReturn, installation, installed, availability }: {
remote: Remote; navigation: CatalogNavigation; onReturn: () => void; installation?: InstallationServices; installed?: InstalledServices; availability: UiAvailability;
}) {
const queryClient = useQueryClient();
const entries = useSyncExternalStore(availability.subscribe, availability.getSnapshot);
@ -171,7 +170,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
}} />}
<header className="astron-sh-catalog-header"><div className="astron-sh-location"><Icon name="grid" /><strong>SkillHub</strong><span>/</span><span>{selected ? '技能详情' : '技能广场'}</span></div>
<div className="astron-sh-toolbar-actions">{registryConfiguration && <RegistrySelector configuration={registryConfiguration} />}<Button variant="ghost" className="astron-sh-icon-button" aria-label="查看当前来源" title={context.data?.registryUrl || '尚未配置来源'} onClick={() => setSourceOpen(value => !value)}><Icon name="info" /></Button>
<div className="astron-sh-toolbar-actions"><Button variant="ghost" className="astron-sh-icon-button" aria-label="查看当前来源" title={context.data?.registryUrl || '尚未配置来源'} onClick={() => setSourceOpen(value => !value)}><Icon name="info" /></Button>
{(installation || installed) && <Button variant="ghost" className="astron-sh-icon-button" aria-label="返回会话" title="关闭 SkillHub,返回会话" onClick={() => { if (!selected && tab === 'browse') navigation.scrollTop = scroller.current?.scrollTop ?? 0; onReturn(); }}><Icon name="close" /></Button>}</div>
</header>
{sourceOpen && <p className="astron-sh-source-info" role="status">当前来源:{context.data?.registryUrl || '尚未配置来源'}</p>}
@ -211,7 +210,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa
: installed ? <InstalledSkills key={`${installed.sessionId}:${revision}`} services={installed} onInstall={item => setInstallCandidate({ item: { namespace: item.namespace, slug: item.slug, latestVersion: item.version, summary: '' }, revision: revision! })} onRemove={item => manageSkill(item, 'remove')} onUpgrade={item => manageSkill(item, 'upgrade')} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} />
: <div className="astron-sh-empty"><p>请先打开一个 DSH 会话,再查看该用户和项目的安装记录。</p><Button onClick={onReturn}>前往会话</Button></div>
}</TabsContent><TabsContent value="browse">
{!context.data.configured ? <div className="astron-sh-empty"><h2>连接 SkillHub</h2><p>{entries.configuration ? '在 DSH 的插件页面配置来源,或从顶部选择已保存的来源。' : '来源配置暂不可用,已安装技能仍可管理。'}</p><Button onClick={() => { void context.refetch(); }}>刷新连接</Button></div> : <>
{!context.data.configured ? <div className="astron-sh-empty"><h2>连接 SkillHub</h2><p>{entries.configuration ? '在 DSH 插件页面的 SkillHub 设置中配置技能来源。' : '来源配置暂不可用,已安装技能仍可管理。'}</p><Button onClick={() => { void context.refetch(); }}>刷新连接</Button></div> : <>
<form className="astron-sh-search-form" onSubmit={event => { event.preventDefault(); search(draft.trim()); }}>
<div className="astron-sh-search-field"><Icon name="search" /><Input aria-label="搜索技能" placeholder="搜索技能名称或用途" value={draft} onChange={event => { navigation.draft = event.target.value; setDraft(event.target.value); }} /></div>
<Button type="submit">搜索</Button><Button onClick={() => { navigation.draft = ''; setDraft(''); search(''); }}>清空</Button>

View file

@ -1,6 +1,6 @@
import { editSavedRegistry, validateRegistryConfig } from './registry-config.js';
import { editSavedRegistry, registryChoices, preserveCurrentRegistry, RegistryConfigError, type SavedRegistry, sameRegistry, validateRegistryConfig } from './registry-config.js';
import { catalogErrorMessage } from './catalog-error.js';
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Input, SelectField, Skeleton } from './ui.js';
import { Button, Card, CardHeader, CardContent, Input, SelectField, Skeleton, Tabs, TabsList, TabsTrigger, TabsContent } from './ui.js';
import { ConfigStyles } from './config-styles.js';
import { NativeControlStyles } from './native-control-styles.js';
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from 'react';
@ -24,6 +24,16 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm<SkillHub
const [draft, setDraft] = useState<{ value: SkillHubConfig; revision: number }>();
const [pending, setPending] = useState(false);
const [message, setMessage] = useState('');
const [messageTone, setMessageTone] = useState<'success' | 'error' | 'info'>('info');
const [tab, setTab] = useState('source');
const [editingSource, setEditingSource] = useState<number | null>(null);
const [activeEditor, setActiveEditor] = useState<number | null>(null);
const editorRevision = useRef(state.revision);
useEffect(() => {
if (editorRevision.current === state.revision) return;
editorRevision.current = state.revision;
if (!draft) { setEditingSource(null); setActiveEditor(null); }
}, [state.revision, draft]);
const value = draft?.value ?? state.value;
const registryUrl = value?.registryUrl.trim() ?? '';
const request = useRef<AbortController>();
@ -62,10 +72,24 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm<SkillHub
</section>;
if (!value || state.status === 'unavailable') return <p role="status">当前连接无法编辑 Host 配置。</p>;
const sources = registryChoices(value);
function change(next: SkillHubConfig) {
if (state.revision === undefined) return;
setDraft({ value: next, revision: draft?.revision ?? state.revision });
setMessage('');
const current = state.value;
const unchanged = current && next.registryUrl === current.registryUrl
&& modules.every(([key]) => next.detailTabs[key] === current.detailTabs[key])
&& (next.savedRegistries?.length ?? 0) === (current.savedRegistries?.length ?? 0)
&& (next.savedRegistries ?? []).every((entry, index) => entry.name === current.savedRegistries?.[index]?.name && entry.url === current.savedRegistries?.[index]?.url);
setDraft(unchanged && (!draft || draft.revision === state.revision) ? undefined : { value: next, revision: draft?.revision ?? state.revision });
setMessage(''); setMessageTone('info');
}
function editSource(index: number, next: SavedRegistry) {
const active = activeEditor === index || sameRegistry(value?.registryUrl, value?.savedRegistries?.[index]?.url);
if (active) setActiveEditor(index);
const changed = editSavedRegistry(value!, index, next);
change(active ? { ...changed, registryUrl: next.url } : changed);
}
async function save(event: FormEvent) {
@ -73,7 +97,16 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm<SkillHub
if (!draft || !state.writable || pending) return;
let validated: SkillHubConfig;
try { validated = validateRegistryConfig(draft.value); }
catch (error) { setMessage(error instanceof Error ? error.message : '请检查来源地址。'); return; }
catch (error) {
setTab('source'); setMessageTone('error');
if (error instanceof RegistryConfigError && error.savedIndex !== undefined) setEditingSource(error.savedIndex);
else {
const next = preserveCurrentRegistry(draft.value);
setDraft({ value: next, revision: draft.revision });
setEditingSource(next.savedRegistries?.findIndex(entry => sameRegistry(entry.url, next.registryUrl)) ?? null);
}
setMessage(error instanceof Error ? error.message : '请检查来源地址。'); return;
}
setPending(true);
try {
const accepted = await form.mutate([
@ -82,53 +115,84 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm<SkillHub
...modules.map(([key]) => ({ op: 'set' as const, path: ['detailTabs', key], value: draft.value.detailTabs[key] })),
], draft.revision);
if (accepted) {
setDraft(undefined);
setMessage('配置已保存。');
setDraft(undefined); setEditingSource(null); setActiveEditor(null);
setMessageTone('success'); setMessage('配置已保存。');
} else {
setMessage('配置未保存,可能已被其他页面修改。请重置修改后再试。');
setMessageTone('error'); setMessage('配置未保存,可能已被其他页面修改。请重置修改后再试。');
}
} catch {
setMessage('保存失败,请检查连接后重试。');
setMessageTone('error'); setMessage('保存失败,请检查连接后重试。');
} finally { setPending(false); }
}
return <form className="skillhub-controls astron-sh-config" onSubmit={event => void save(event)} aria-label="SkillHub 配置">
return <form className="skillhub-controls astron-sh-config" noValidate onSubmit={event => void save(event)} aria-label="SkillHub 配置">
<NativeControlStyles /><ConfigStyles />
<header className="astron-sh-config-heading"><h2>SkillHub 设置</h2><p>管理技能来源和详情页面的展示内容。</p></header>
<header className="astron-sh-config-heading"><h2>SkillHub 设置</h2><p>连接技能来源,调整详情页展示。</p></header>
{!state.writable && <p className="astron-sh-config-notice" role="status">当前 Host 配置为只读。</p>}
<fieldset disabled={!state.writable || pending} className="astron-sh-config-stack">
<Card><CardHeader><div className="astron-sh-config-card-heading"><CardTitle>技能来源</CardTitle><span>{state.value?.registryUrl.trim() ? '已配置' : '未配置'}</span></div><CardDescription>浏览和安装实例公开的技能,无需登录。</CardDescription></CardHeader>
<CardContent><label className="astron-sh-config-field" htmlFor={`${id}-registry`}>Registry 地址</label>
<Input id={`${id}-registry`} type="url" value={value.registryUrl} placeholder="https://your-skillhub.example" aria-describedby={`${id}-registry-help`}
onChange={event => change({ ...value, registryUrl: event.target.value })} />
<div className="astron-sh-config-connection"><Button size="sm" variant="outline" disabled={!registryUrl || visibleConnection?.pending} onClick={() => void test()}>{visibleConnection?.pending ? '测试中…' : '测试连接'}</Button>
<span>{registryUrl !== (state.value?.registryUrl.trim() ?? '') ? '测试当前输入,尚未保存' : '测试当前来源'}</span>
</div>
{visibleConnection && <p className="astron-sh-config-connection-result" role="status" aria-live="polite" data-passed={visibleConnection.passed}>{visibleConnection.message}</p>}
<p className="astron-sh-config-help">测试不会保存地址,连接成功仅表示公开目录可读。</p>
<p id={`${id}-registry-help`} className="astron-sh-config-help">同一 Profile 共享来源。留空断开连接,已安装技能保留。</p>
<div className="astron-sh-saved-registries">
<div className="astron-sh-config-card-heading"><strong>保存的来源</strong><Button size="sm" variant="outline" onClick={() => change({ ...value, savedRegistries: [...(value.savedRegistries ?? []), { name: '', url: '' }] })}>添加来源</Button></div>
{(value.savedRegistries ?? []).map((entry, index) => <div className="astron-sh-registry-row" key={index}>
<label>名称<Input aria-label={`来源 ${index + 1} 名称`} value={entry.name} onChange={event => change(editSavedRegistry(value, index, { ...entry, name: event.target.value }))} /></label>
<label>地址<Input aria-label={`来源 ${index + 1} 地址`} type="url" value={entry.url} placeholder="https://your-skillhub.example" onChange={event => change(editSavedRegistry(value, index, { ...entry, url: event.target.value }))} /></label>
<Button size="sm" variant="ghost" aria-label={`移除来源 ${index + 1}`} onClick={() => change(editSavedRegistry(value, index))}>移除</Button>
</div>)}
<p className="astron-sh-config-help">保存后可在技能中心切换。移除当前来源将断开连接,不删除技能。</p>
</div>
</CardContent></Card>
<Card><CardHeader><CardTitle>详情标签</CardTitle><CardDescription>按来源能力展示版本、审核和评论,建议使用自动检测。</CardDescription></CardHeader>
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="astron-sh-config-tabs" aria-label="设置分类"><TabsTrigger value="source">技能来源</TabsTrigger><TabsTrigger value="display">页面显示</TabsTrigger></TabsList>
<fieldset disabled={!state.writable || pending} className="astron-sh-config-stack">
<TabsContent value="source" className="astron-sh-config-panel"><Card className="astron-sh-config-content-card">
<CardHeader><div className="astron-sh-config-section-heading"><div><h3>技能来源</h3><p>选择一个来源,保存后生效。</p></div>
<Button size="sm" variant="outline" onClick={() => {
const next = preserveCurrentRegistry(value);
const index = next.savedRegistries?.length ?? 0;
change({ ...next, savedRegistries: [...(next.savedRegistries ?? []), { name: '', url: '' }] }); setEditingSource(index);
}}>添加来源</Button></div></CardHeader>
<CardContent><div className="astron-sh-source-list" role="radiogroup" aria-label="技能来源">
{sources.length === 0 && <p className="astron-sh-config-empty">尚未添加来源。</p>}
{sources.map((entry, rowIndex) => {
const selected = sameRegistry(entry.url, value.registryUrl);
const current = sameRegistry(entry.url, state.value?.registryUrl);
const index = entry.savedIndex;
const editing = index !== null && index === editingSource;
const key = index === null ? 'legacy' : `saved-${index}`;
return <div className="astron-sh-source-entry" key={key} data-selected={selected}>
<div className="astron-sh-source-row"><label className="astron-sh-source-choice" htmlFor={`${id}-${key}`}>
<input id={`${id}-${key}`} type="radio" name={`${id}-source`} aria-label={`选择来源 ${rowIndex + 1}:${entry.name || '新来源'}`} checked={selected} disabled={!entry.url.trim()}
onChange={() => { setActiveEditor(null); change({ ...preserveCurrentRegistry(value), registryUrl: entry.url }); }} />
<span className="astron-sh-source-text"><strong>{entry.name || '新来源'}</strong><span>{entry.url || '填写名称和地址'}</span></span>
{(selected || current) && <span className="astron-sh-config-state">{selected && !current ? '待保存' : '当前使用'}</span>}
</label><div className="astron-sh-source-actions">
<Button size="sm" variant="ghost" aria-label={`编辑来源 ${rowIndex + 1}`} aria-expanded={editing} onClick={() => {
if (index === null) { const next = preserveCurrentRegistry(value); const nextIndex = (next.savedRegistries?.length ?? 1) - 1; change(next); setEditingSource(nextIndex); setActiveEditor(nextIndex); }
else { setEditingSource(editing ? null : index); if (selected) setActiveEditor(index); }
}}>{editing ? '收起' : '编辑'}</Button>
<Button size="sm" variant="ghost" aria-label={`移除来源 ${rowIndex + 1}`} onClick={() => {
change(index === null ? { ...value, registryUrl: '' } : editSavedRegistry(value, index));
setEditingSource(previous => index === null || previous === null ? previous : previous === index ? null : previous > index ? previous - 1 : previous);
setActiveEditor(previous => index === null || previous === null ? previous : previous === index ? null : previous > index ? previous - 1 : previous);
}}>移除</Button>
</div></div>
{editing && <div className="astron-sh-source-editor" role="group" aria-label={`编辑来源 ${rowIndex + 1} 表单`}>
<label>名称<Input aria-label={`来源 ${rowIndex + 1} 名称`} value={entry.name} onChange={event => editSource(index, { name: event.target.value, url: entry.url })} /></label>
<label>Registry 地址<Input aria-label={`来源 ${rowIndex + 1} 地址`} type="url" value={entry.url} placeholder="https://your-skillhub.example" onChange={event => editSource(index, { name: entry.name, url: event.target.value })} /></label>
</div>}
</div>;
})}
<label className="astron-sh-source-disconnect" htmlFor={`${id}-disconnected`}><input id={`${id}-disconnected`} type="radio" name={`${id}-source`} checked={!registryUrl}
onChange={() => { setActiveEditor(null); change({ ...preserveCurrentRegistry(value), registryUrl: '' }); }} /><span>不连接技能来源</span>
{!registryUrl && <span className="astron-sh-config-state">{state.value?.registryUrl.trim() ? '待保存' : '当前使用'}</span>}
</label>
</div>
<div className="astron-sh-source-footer"><p className="astron-sh-config-help">切换或移除来源不会删除已安装技能。</p>
{registryUrl && <Button size="sm" variant="outline" disabled={visibleConnection?.pending} onClick={() => void test()}>{visibleConnection?.pending ? '测试中…' : '测试连接'}</Button>}
</div>
{visibleConnection && <p className="astron-sh-config-connection-result" role="status" aria-live="polite" data-passed={visibleConnection.passed}>{visibleConnection.message}</p>}
</CardContent></Card></TabsContent>
<TabsContent value="display" className="astron-sh-config-panel"><Card className="astron-sh-config-content-card"><CardHeader><div className="astron-sh-config-section-heading"><div><h3>详情页内容</h3><p>默认按来源能力自动显示。</p></div></div></CardHeader>
<CardContent><div className="astron-sh-config-modules">{modules.map(([key, label, description]) => <div key={key} className="astron-sh-config-module">
<div><strong>{label}</strong><p>{description}</p></div><SelectField label={label} value={value.detailTabs[key]} disabled={!state.writable || pending} onValueChange={mode => change({
...value, detailTabs: { ...value.detailTabs, [key]: mode as TabMode },
})} options={[{ value: 'auto', label: '自动检测' }, { value: 'show', label: '显示' }, { value: 'hide', label: '隐藏' }]} />
</div>)}</div><p className="astron-sh-config-help">“显示”不会改变服务端访问规则;评论仅供阅读。</p></CardContent>
</Card>
</div>)}</div></CardContent></Card>
</TabsContent>
<div className="astron-sh-config-actions"><Button size="sm" type="submit" variant="default" disabled={!draft}>{pending ? '保存中…' : '保存配置'}</Button>
<Button size="sm" variant="outline" disabled={!draft} onClick={() => { request.current?.abort(); request.current = undefined; setConnection(undefined); setDraft(undefined); setMessage('已重置为当前配置,未保存的修改已清除。'); }}>重置修改</Button>
<Button size="sm" variant="ghost" disabled={!draft} onClick={() => { request.current?.abort(); request.current = undefined; setConnection(undefined); setDraft(undefined); setEditingSource(null); setActiveEditor(null); setMessageTone('info'); setMessage('已重置为当前配置,未保存的修改已清除。'); }}>重置修改</Button>
<span>{draft ? '有未保存的修改' : '修改后保存生效'}</span>
</div>
</fieldset>
<p className="astron-sh-config-feedback" role="status" aria-live="polite">{message}</p>
</fieldset>
</Tabs>
<p className="astron-sh-config-feedback" role={messageTone === 'error' ? 'alert' : 'status'} aria-live="polite" data-tone={messageTone}>{message}</p>
</form>;
}

View file

@ -1,18 +1,14 @@
/** Configuration-only layout composed from the shared shadcn components and DSH tokens. */
export function ConfigStyles() {
return <style>{`
.skillhub-controls.astron-sh-config{width:100%;max-width:720px;min-width:0;padding:20px 0;overflow-wrap:anywhere}
.skillhub-controls.astron-sh-config{width:100%;max-width:720px;min-width:0;padding:24px 0;overflow-wrap:anywhere}
.skillhub-controls .astron-sh-config-heading{margin-bottom:20px}
.skillhub-controls.astron-sh-config h2{margin:0 0 6px;font-size:20px;line-height:28px;font-weight:600}
.skillhub-controls.astron-sh-config h2{margin:0 0 6px;font-size:22px;line-height:30px;font-weight:600}
.skillhub-controls .astron-sh-config-heading p{margin:0;font-size:13px;line-height:20px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-config-stack{display:grid;gap:16px;margin:0;padding:0;border:0;min-width:0}
.skillhub-controls.astron-sh-config .astron-sh-card{border-radius:12px;background:var(--astron-sh-bg);box-shadow:0 1px 3px #00000006;min-width:0}
.skillhub-controls.astron-sh-config .astron-sh-card-header{padding:20px 20px 14px}
.skillhub-controls.astron-sh-config .astron-sh-card-title{font-size:15px;line-height:22px;font-weight:600;margin:0}
.skillhub-controls.astron-sh-config .astron-sh-card-description{font-size:13px;line-height:20px;margin:5px 0 0;color:var(--astron-sh-muted)}
.skillhub-controls.astron-sh-config .astron-sh-card-content{padding:0 20px 20px}
.skillhub-controls .astron-sh-config-card-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
.skillhub-controls .astron-sh-config-card-heading>span{font-size:12px;line-height:18px;color:var(--astron-sh-muted);white-space:nowrap}
.skillhub-controls .astron-sh-config-field{display:block;font-size:13px;line-height:20px;font-weight:500;margin-bottom:8px}
.skillhub-controls.astron-sh-config input{width:100%;height:40px;font-size:13px;line-height:20px;padding:9px 12px}
.skillhub-controls .astron-sh-config-help{margin:10px 0 0;font-size:12px;line-height:19px;color:var(--astron-sh-muted)}
@ -24,13 +20,10 @@ export function ConfigStyles() {
.skillhub-controls .astron-sh-config-module strong{font-size:13px;line-height:20px;font-weight:500}
.skillhub-controls .astron-sh-config-module p{font-size:12px;line-height:19px;color:var(--astron-sh-muted);margin:3px 0 0}
.skillhub-controls .astron-sh-config-module>.astron-sh-select-root{width:132px;flex:none}
.skillhub-controls .astron-sh-config-module .astron-sh-select-trigger{width:100%;font-size:13px;min-height:36px}
.skillhub-controls .astron-sh-config-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding-top:2px}
.skillhub-controls .astron-sh-config-actions>button{min-height:32px;height:32px;margin:0;padding:5px 12px;font-size:12px}
.skillhub-controls .astron-sh-config-module .astron-sh-select-trigger{width:100%;min-width:0;font-size:13px;min-height:36px}
.skillhub-controls .astron-sh-config-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding-top:18px;border-top:1px solid var(--astron-sh-border)}
.skillhub-controls .astron-sh-config-actions>button{min-height:36px;height:36px;margin:0;padding:7px 14px;font-size:13px}
.skillhub-controls .astron-sh-config-actions>span{font-size:12px;line-height:18px;color:var(--astron-sh-muted);margin-left:auto}
.skillhub-controls .astron-sh-config-connection{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:12px}
.skillhub-controls .astron-sh-config-connection>button{min-height:32px;height:32px;padding:5px 12px;font-size:12px}
.skillhub-controls .astron-sh-config-connection>span{font-size:12px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-config-connection-result{margin:10px 0 0;font-size:12px;line-height:19px}
.skillhub-controls .astron-sh-config-connection-result[data-passed=true]{color:light-dark(#187847,#7dddab)}
.skillhub-controls .astron-sh-config-connection-result[data-passed=false]{color:light-dark(#945414,#f0c27c)}
@ -39,10 +32,42 @@ export function ConfigStyles() {
.skillhub-controls .astron-sh-config-skeleton-title{width:100px;height:18px;margin-bottom:12px}
.skillhub-controls .astron-sh-config-skeleton-content{display:grid;gap:16px}
.skillhub-controls .astron-sh-config-skeleton-content>.astron-sh-skeleton{height:36px}
.skillhub-controls .astron-sh-saved-registries{margin-top:24px;padding-top:18px;border-top:1px solid var(--astron-sh-border)}
.skillhub-controls .astron-sh-registry-row{display:grid;grid-template-columns:minmax(100px,1fr) minmax(140px,2fr) auto;align-items:end;gap:8px;margin-top:12px}
.skillhub-controls .astron-sh-registry-row label{min-width:0;font-size:12px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-registry-row input{margin-top:4px}
@media(max-width:480px){.skillhub-controls .astron-sh-registry-row{grid-template-columns:minmax(0,1fr) auto}.skillhub-controls .astron-sh-registry-row label:nth-child(2){grid-column:1;grid-row:2}.skillhub-controls .astron-sh-registry-row button{grid-column:2;grid-row:2}.skillhub-controls.astron-sh-config .astron-sh-card-header{padding:16px 16px 12px}.skillhub-controls.astron-sh-config .astron-sh-card-content{padding:0 16px 16px}.skillhub-controls .astron-sh-config-module{gap:12px}.skillhub-controls .astron-sh-config-module>.astron-sh-select-root{width:116px}.skillhub-controls .astron-sh-config-actions>span{width:100%;margin:2px 0 0}}
.skillhub-controls .astron-sh-config-tabs{display:inline-flex;width:auto;gap:4px;padding:4px;margin:0 0 24px;border-radius:999px;background:var(--astron-sh-soft);border:1px solid var(--astron-sh-border)}
.skillhub-controls .astron-sh-config-tabs>.astron-sh-tabs-trigger{min-width:108px;min-height:34px;padding:6px 20px;border:0;border-radius:999px;background:transparent;font-size:13px;line-height:20px;font-weight:500;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-config-tabs>.astron-sh-tabs-trigger[data-state=active]{background:var(--astron-sh-bg);color:var(--astron-sh-fg);box-shadow:0 1px 3px #00000014}
.skillhub-controls .astron-sh-config-panel{min-width:0;outline-offset:4px}
.skillhub-controls .astron-sh-config-panel[data-state=inactive]{display:none}
.skillhub-controls .astron-sh-config-section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:20px}
.skillhub-controls .astron-sh-config-section-heading h3{margin:0;font-size:15px;line-height:22px;font-weight:600}
.skillhub-controls .astron-sh-config-section-heading p{margin:4px 0 0;font-size:13px;line-height:20px;color:var(--astron-sh-muted)}
.skillhub-controls .astron-sh-config-state{flex:none;padding:3px 9px;border-radius:999px;background:var(--astron-sh-soft);color:var(--astron-sh-muted);font-size:12px;line-height:18px}
.skillhub-controls .astron-sh-config-feedback[data-tone=success]{color:light-dark(#187847,#7dddab)}
.skillhub-controls .astron-sh-config-feedback[data-tone=error]{color:light-dark(#945414,#f0c27c)}
.skillhub-controls.astron-sh-config .astron-sh-config-content-card{border:1px solid var(--astron-sh-border);border-radius:12px;background:var(--astron-sh-bg);box-shadow:none}
.skillhub-controls .astron-sh-config-content-card .astron-sh-config-section-heading{margin:0}
.skillhub-controls .astron-sh-source-list{display:grid;min-width:0}
.skillhub-controls .astron-sh-source-entry{min-width:0;border-bottom:1px solid var(--astron-sh-border);padding:14px 0}
.skillhub-controls .astron-sh-source-entry:first-child{padding-top:0}
.skillhub-controls .astron-sh-source-row{display:flex;align-items:flex-start;gap:12px;min-width:0}
.skillhub-controls .astron-sh-source-choice{display:grid;grid-template-columns:16px minmax(0,1fr);gap:4px 10px;align-items:start;min-width:0;flex:1;cursor:pointer}
.skillhub-controls.astron-sh-config input[type=radio]{width:16px;height:16px;min-width:16px;padding:0;margin:3px 0 0;accent-color:var(--astron-sh-accent)}
.skillhub-controls .astron-sh-source-text{display:grid;gap:3px;min-width:0}
.skillhub-controls .astron-sh-source-text>strong{font-size:13px;line-height:20px;font-weight:600}
.skillhub-controls .astron-sh-source-text>span{font-size:12px;line-height:18px;color:var(--astron-sh-muted);overflow-wrap:anywhere}
.skillhub-controls .astron-sh-source-choice>.astron-sh-config-state{grid-column:2;width:fit-content;padding:1px 7px;font-size:11px;line-height:17px}
.skillhub-controls .astron-sh-source-entry[data-selected=true] .astron-sh-config-state{color:var(--astron-sh-accent);background:color-mix(in srgb,var(--astron-sh-accent) 9%,var(--astron-sh-bg))}
.skillhub-controls .astron-sh-source-actions{display:flex;gap:2px;flex:none}
.skillhub-controls .astron-sh-source-actions>button{padding:5px 7px;min-height:30px;height:30px;font-size:12px}
.skillhub-controls .astron-sh-source-editor{display:grid;grid-template-columns:minmax(100px,1fr) minmax(180px,2fr);gap:12px;margin:14px 0 0 26px}
.skillhub-controls .astron-sh-source-editor>label{font-size:12px;line-height:18px;color:var(--astron-sh-muted);min-width:0}
.skillhub-controls .astron-sh-source-editor input{margin-top:6px}
.skillhub-controls .astron-sh-source-disconnect{display:flex;align-items:center;gap:10px;padding:16px 0 0;font-size:13px;line-height:20px;cursor:pointer}
.skillhub-controls .astron-sh-source-disconnect input[type=radio]{margin-top:0}
.skillhub-controls .astron-sh-source-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}
.skillhub-controls .astron-sh-source-footer .astron-sh-config-help{margin:0}
.skillhub-controls .astron-sh-source-footer>button{flex:none}
.skillhub-controls .astron-sh-config-empty{margin:0;color:var(--astron-sh-muted);font-size:13px;line-height:20px}
@media(max-width:480px){.skillhub-controls.astron-sh-config{padding:16px 0}.skillhub-controls .astron-sh-config-tabs{display:flex}.skillhub-controls .astron-sh-config-tabs>.astron-sh-tabs-trigger{flex:1;min-width:0}.skillhub-controls.astron-sh-config .astron-sh-card-header{padding:16px 16px 12px}.skillhub-controls.astron-sh-config .astron-sh-card-content{padding:0 16px 16px}.skillhub-controls .astron-sh-source-row{flex-wrap:wrap;gap:8px}.skillhub-controls .astron-sh-source-actions{margin-left:26px}.skillhub-controls .astron-sh-source-choice{flex-basis:100%}.skillhub-controls .astron-sh-source-editor{grid-template-columns:minmax(0,1fr)}.skillhub-controls .astron-sh-config-module{gap:12px}.skillhub-controls .astron-sh-config-module>.astron-sh-select-root{width:116px}.skillhub-controls .astron-sh-config-actions>span{width:100%;margin:2px 0 0}.skillhub-controls .astron-sh-source-footer{align-items:flex-start;flex-direction:column}}
`}</style>;
}

View file

@ -1,4 +1,3 @@
import { RegistryConfiguration } from './registry-selector.js';
import { installedContext } from './installed-context.js';
import { ManagementToolView, type ManagementViewServices } from './management-tool-view.js';
import { SkillDraftRetentions } from './use-skill-drafts.js';
@ -58,10 +57,8 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
});
const panel = ctx.inject(['remote.skillhub', 'slots', 'sessions', 'uiSession'], (ctx) => {
const client = new QueryClient();
const registryConfiguration = new RegistryConfiguration();
ctx.inject(['configForms'], ctx => {
const form = ctx.configForms.get<SkillHubConfig>('astron-skillhub');
ctx.effect(() => registryConfiguration.bind(form));
let previous = form.getSnapshot().value?.registryUrl;
ctx.effect(() => form.subscribe(() => {
const current = form.getSnapshot().value?.registryUrl;
@ -317,7 +314,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
return withSessionRemote(scope, remote => remote.skillhub.checkUpdate(revision, itemKey, signal));
} } satisfies InstalledServices : undefined }),
}, ({ installation, installed, inputActions, sessionId }) => <SkillUseBinding controller={useSkills} sessionId={sessionId} actions={inputActions}><CatalogPage remote={ctx.remote.skillhub} navigation={navigation} client={client}
installation={installation} installed={installed} availability={availability} registryConfiguration={registryConfiguration} onReturn={() => ctx.layout.selectPanel(null)} /></SkillUseBinding>));
installation={installation} installed={installed} availability={availability} onReturn={() => ctx.layout.selectPanel(null)} /></SkillUseBinding>));
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: panelId, label: '技能中心', order: 10 }, () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.6" /><rect x="3" y="14" width="7" height="7" rx="1.6" /><rect x="14" y="14" width="7" height="7" rx="1.6" /><path d="M17.5 2.5v8M13.5 6.5h8" /></svg>));
});
});

View file

@ -183,11 +183,6 @@ export function NativeControlStyles() {
.skillhub-controls .astron-sh-location>strong,.skillhub-controls .astron-sh-location>span{white-space:nowrap;flex-shrink:0}
.skillhub-controls .astron-sh-location>span:last-child{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}
.skillhub-controls .astron-sh-toolbar-actions{flex:0 1 auto;white-space:nowrap;min-width:0;align-items:center}
.skillhub-controls .astron-sh-registry-selector{position:relative;min-width:0;width:180px;max-width:30vw}
.skillhub-controls .astron-sh-registry-selector>.astron-sh-select-root{display:block;min-width:0}
.skillhub-controls .astron-sh-registry-selector .astron-sh-select-trigger{width:100%;min-width:0;min-height:32px;height:32px;font-size:12px}
.skillhub-controls .astron-sh-registry-selector .astron-sh-select-trigger>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.skillhub-controls .astron-sh-registry-selector>[role=alert]{position:absolute;right:0;top:100%;z-index:2;white-space:normal;width:240px;padding:8px;background:var(--astron-sh-bg);border:1px solid var(--astron-sh-border);border-radius:8px;font-size:12px}
.skillhub-controls .astron-sh-icon-button{flex:0 0 32px}.skillhub-controls .astron-sh-icon-button>svg{width:16px;height:16px;flex:0 0 16px}
.skillhub-controls .astron-sh-directory-tabs{display:inline-flex;align-items:center;width:fit-content;max-width:100%;height:38px;padding:3px;gap:4px;border:1px solid var(--astron-sh-border);border-radius:9px;background:var(--astron-sh-soft);margin-bottom:20px;overflow-x:auto}
.skillhub-controls .astron-sh-directory-tabs>.astron-sh-tabs-trigger{height:30px;min-height:30px;padding:4px 16px;line-height:20px;font-size:13px;white-space:nowrap;border:0;border-radius:6px;box-shadow:none;transition:none}

View file

@ -22,6 +22,20 @@ export function savedRegistryOptions(config: { registryUrl: string; savedRegistr
}
return entries;
}
/** Indexes always refer to savedRegistries; the legacy current address has no saved index. */
export function registryChoices(config: Pick<SkillHubConfig, 'registryUrl' | 'savedRegistries'>): (SavedRegistry & { savedIndex: number | null })[] {
const entries = (config.savedRegistries ?? []).map((entry, savedIndex) => ({ ...entry, savedIndex }));
return config.registryUrl.trim() && !entries.some(entry => sameRegistry(entry.url, config.registryUrl))
? [{ name: '未命名来源', url: config.registryUrl.trim(), savedIndex: null }, ...entries] : entries;
}
/** Preserve a legacy address only when the user edits/selects, never on initial read. */
export function preserveCurrentRegistry(config: SkillHubConfig): SkillHubConfig {
const legacy = registryChoices(config).find(entry => entry.savedIndex === null);
return legacy ? { ...config, savedRegistries: [...(config.savedRegistries ?? []), { name: legacy.name, url: legacy.url }] } : config;
}
export class RegistryConfigError extends Error {
constructor(message: string, readonly savedIndex?: number) { super(message); }
}
export function editSavedRegistry(config: SkillHubConfig, index: number, next?: SavedRegistry): SkillHubConfig {
const entries = [...(config.savedRegistries ?? [])];
const current = entries[index];
@ -33,15 +47,19 @@ export function editSavedRegistry(config: SkillHubConfig, index: number, next?:
}
export function validateRegistryConfig(config: SkillHubConfig): SkillHubConfig {
const registryUrl = config.registryUrl.trim();
if (registryUrl) registryAddress(registryUrl);
const seen = new Set<string>();
const savedRegistries = (config.savedRegistries ?? []).map(entry => {
const savedRegistries = (config.savedRegistries ?? []).map((entry, savedIndex) => {
try {
const name = entry.name.trim();
if (!name) throw new Error('请填写来源名称。');
const url = registryAddress(entry.url);
if (seen.has(url)) throw new Error('此来源地址已存在,请保留一个条目。');
seen.add(url);
return { name, url };
} catch (error) { throw new RegistryConfigError(error instanceof Error ? error.message : '请检查来源地址。', savedIndex); }
});
if (registryUrl) {
try { registryAddress(registryUrl); } catch (error) { throw new RegistryConfigError(error instanceof Error ? error.message : '请检查来源地址。'); }
}
return { ...config, registryUrl, savedRegistries };
}

View file

@ -1,56 +0,0 @@
import { useCallback, useState, useSyncExternalStore } from 'react';
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
import type { SkillHubConfig } from '../host/types.js';
import { savedRegistryOptions, sameRegistry } from './registry-config.js';
import { SelectField } from './ui.js';
/** Optional settings service; catalog and local management also work without it. */
export class RegistryConfiguration {
private form: ConfigForm<SkillHubConfig> | undefined;
private listeners = new Set<() => void>();
readonly getSnapshot = () => this.form;
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
bind(form: ConfigForm<SkillHubConfig>): () => void {
this.form = form;
for (const listener of this.listeners) listener();
return () => {
if (this.form !== form) return;
this.form = undefined;
for (const listener of this.listeners) listener();
};
}
}
export function RegistrySelector({ configuration }: { configuration: RegistryConfiguration }) {
const form = useSyncExternalStore(configuration.subscribe, configuration.getSnapshot);
return form ? <ConnectedSelector form={form} /> : <SelectField label="技能来源" value="unavailable" disabled onValueChange={() => {}}
options={[{ value: 'unavailable', label: '来源配置不可用' }]} />;
}
function ConnectedSelector({ form }: { form: ConfigForm<SkillHubConfig> }) {
const subscribe = useCallback((listener: () => void) => form.subscribe(listener), [form]);
const read = useCallback(() => form.getSnapshot(), [form]);
const state = useSyncExternalStore(subscribe, read);
const [pending, setPending] = useState(false);
const [message, setMessage] = useState('');
const options = state.value ? savedRegistryOptions(state.value) : [];
const selected = options.findIndex(entry => sameRegistry(entry.url, state.value?.registryUrl ?? ''));
async function select(value: string) {
if (pending || !state.writable || state.revision === undefined || !state.value) return;
const url = value === 'disconnected' ? '' : options[Number(value)]?.url;
if (url === undefined) return;
setPending(true); setMessage('');
try {
const accepted = await form.mutate([{ op: 'set', path: ['registryUrl'], value: url }], state.revision);
if (!accepted) setMessage('来源已被其他页面修改,请重新选择。');
} catch { setMessage('切换失败,请重试。'); }
finally { setPending(false); }
}
return <div className="astron-sh-registry-selector">
<SelectField label="技能来源" value={selected < 0 ? 'disconnected' : String(selected)}
disabled={pending || !state.writable || state.status !== 'ready'} onValueChange={value => void select(value)}
options={[...options.map((entry, index) => ({ value: String(index), label: entry.name })), { value: 'disconnected', label: '未连接来源' }]} />
{message && <span role="alert">{message}</span>}
</div>;
}

View file

@ -2,7 +2,6 @@ import { createRoot } from 'react-dom/client';
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client';
import type { SkillHubConfig } from '../../src/host/types.js';
import { ConfigPage } from '../../src/client/config-page.js';
import { RegistryConfiguration, RegistrySelector } from '../../src/client/registry-selector.js';
import { NativeControlStyles } from '../../src/client/native-control-styles.js';
let snapshot: ConfigFormSnapshot<SkillHubConfig> = { status: 'ready', revision: 1, writable: true, base: {}, user: {}, value: {
@ -11,12 +10,15 @@ let snapshot: ConfigFormSnapshot<SkillHubConfig> = { status: 'ready', revision:
} };
const listeners = new Set<() => void>();
const writes: { revision: number | undefined; paths: string[][] }[] = [];
let releaseWrite: (() => void) | undefined;
let holdWrites = false;
const notify = () => listeners.forEach(listener => listener());
const form: ConfigForm<SkillHubConfig> = {
getSnapshot: () => snapshot,
subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },
mutate: async (operations, revision) => {
writes.push({ revision, paths: operations.map(op => op.path.map(String)) });
if (holdWrites) await new Promise<void>(resolve => { releaseWrite = resolve; });
if (!snapshot.writable || revision !== snapshot.revision) return false;
const value = structuredClone(snapshot.value!);
for (const operation of operations) {
@ -31,15 +33,17 @@ const form: ConfigForm<SkillHubConfig> = {
},
set: async () => false, unset: async () => false,
};
const connection = new RegistryConfiguration();
const unbind = connection.bind(form);
Object.assign(window, { registryFixture: {
holdWrites: () => { holdWrites = true; },
releaseWrite: () => { holdWrites = false; releaseWrite?.(); },
state: () => snapshot,
writes: () => writes,
replaceSources: () => { snapshot = { ...snapshot, revision: snapshot.revision! + 1, value: { ...snapshot.value!, registryUrl: 'https://b.example', savedRegistries: [{ name: '替换来源', url: 'https://replacement.example' }, { name: '来源 B', url: 'https://b.example' }] } }; notify(); },
legacyCurrent: () => { snapshot = { ...snapshot, revision: snapshot.revision! + 1, value: { ...snapshot.value!, registryUrl: 'https://legacy.example', savedRegistries: [{ name: '来源 A', url: 'https://a.example' }, { name: '来源 B', url: 'https://b.example' }] } }; notify(); },
readonly: () => { snapshot = { ...snapshot, writable: false }; notify(); },
unavailable: () => unbind(),
unavailable: () => { snapshot = { ...snapshot, status: 'unavailable', value: undefined }; notify(); },
externalSource: () => { snapshot = { ...snapshot, revision: snapshot.revision! + 1, value: { ...snapshot.value!, registryUrl: snapshot.value!.savedRegistries![1]!.url } }; notify(); },
} });
createRoot(document.getElementById('root')!).render(<main className="skillhub-controls"><NativeControlStyles />
<section aria-label="快速切换"><RegistrySelector configuration={connection} /></section>
<ConfigPage form={form} testConnection={async () => { throw new Error('not exercised'); }} />
</main>);

View file

@ -9,37 +9,75 @@ page.on('pageerror', error => errors.push(error.message));
try {
await page.goto(entry);
const settings = page.getByRole('form', { name: 'SkillHub 配置' });
const selector = page.getByRole('region', { name: '快速切换' }).getByRole('combobox', { name: '技能来源' });
const save = () => settings.getByRole('button', { name: '保存配置', exact: true }).click();
const sourceTab = () => settings.getByRole('tab', { name: '技能来源', exact: true }).click();
const displayTab = () => settings.getByRole('tab', { name: '页面显示', exact: true }).click();
const edit = index => settings.getByRole('button', { name: `编辑来源 ${index}`, exact: true }).click();
const input = (index, name) => settings.getByRole('textbox', { name: `来源 ${index} ${name}`, exact: true });
const selected = name => settings.getByRole('radio', { name, exact: true });
const saved = () => settings.getByText('配置已保存。', { exact: true }).waitFor();
if (process.env.DSH_PROBE_SCREENSHOT) await page.screenshot({ path: `${process.env.DSH_PROBE_SCREENSHOT}-desktop.png`, fullPage: true });
await settings.getByRole('textbox', { name: '来源 1 名称', exact: true }).fill('A 草稿');
await selector.click();
await page.getByRole('option', { name: '来源 B', exact: true }).click();
await page.waitForFunction(() => window.registryFixture.state().value.registryUrl === 'https://b.example');
await settings.getByRole('button', { name: '保存配置', exact: true }).click();
await settings.getByText('配置未保存,可能已被其他页面修改。请重置修改后再试。').waitFor();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.savedRegistries[0].name), '来源 A');
// Open a non-current editor, select it, then clear/retype its active address.
await edit(2);
await selected('选择来源 2:来源 B').check();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.registryUrl), 'https://a.example');
await input(2, '地址').fill('');
await input(2, '地址').fill('https://new-b.example');
await save(); await saved();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.registryUrl), 'https://new-b.example');
await edit(1);
await input(1, '名称').fill('临时名称'); await input(1, '名称').fill('来源 A');
assert.equal(await settings.getByRole('button', { name: '保存配置', exact: true }).isDisabled(), true);
await input(1, '名称').fill('跨页草稿'); await displayTab();
await settings.getByRole('combobox', { name: '版本', exact: true }).click();
await page.getByRole('option', { name: '隐藏', exact: true }).click();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.detailTabs.versions), 'auto');
await settings.getByRole('tab', { name: '页面显示', exact: true }).focus(); await page.keyboard.press('ArrowLeft');
await input(1, '名称').waitFor(); assert.equal(await input(1, '名称').inputValue(), '跨页草稿');
await page.evaluate(() => window.registryFixture.holdWrites()); await save();
await settings.getByRole('button', { name: '保存中…', exact: true }).waitFor();
assert.equal(await input(1, '地址').isDisabled(), true); await displayTab();
assert.equal(await settings.getByRole('combobox', { name: '版本', exact: true }).isDisabled(), true);
await page.evaluate(() => window.registryFixture.releaseWrite()); await saved();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.detailTabs.versions), 'hide');
await sourceTab(); await edit(1); await input(1, '名称').fill('来源 A'); await save(); await saved();
await edit(1); await input(1, '名称').fill('A 草稿');
await page.evaluate(() => window.registryFixture.externalSource()); await input(1, '名称').fill('来源 A');
assert.equal(await settings.getByRole('button', { name: '保存配置', exact: true }).isDisabled(), false);
await save(); await settings.getByText('配置未保存,可能已被其他页面修改。请重置修改后再试。').waitFor();
await settings.getByRole('button', { name: '重置修改', exact: true }).click();
await settings.getByRole('textbox', { name: '来源 2 地址', exact: true }).fill('https://new-b.example');
await settings.getByRole('button', { name: '保存配置', exact: true }).click();
await page.waitForFunction(() => window.registryFixture.state().value.registryUrl === 'https://new-b.example');
assert.equal(await page.evaluate(() => window.registryFixture.state().value.savedRegistries[1].url), 'https://new-b.example');
await settings.getByRole('button', { name: '移除来源 2', exact: true }).click();
await settings.getByRole('button', { name: '保存配置', exact: true }).click();
await page.waitForFunction(() => window.registryFixture.state().value.registryUrl === '');
await settings.getByRole('button', { name: '添加来源', exact: true }).click();
await settings.getByRole('textbox', { name: '来源 2 名称', exact: true }).fill('重复');
await settings.getByRole('textbox', { name: '来源 2 地址', exact: true }).fill('https://a.example/');
await settings.getByRole('button', { name: '保存配置', exact: true }).click();
// A pristine open editor must not bind to a different row after an external replacement.
await selected('选择来源 1:来源 A').check(); await save(); await saved(); await edit(1);
await page.evaluate(() => window.registryFixture.replaceSources());
await input(1, '名称').waitFor({ state: 'hidden' });
await edit(1); await input(1, '地址').fill('https://edited-replacement.example'); await save(); await saved();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.registryUrl), 'https://b.example');
// Legacy current entry is not a saved index; selecting B explicitly retains it in draft.
await page.evaluate(() => window.registryFixture.legacyCurrent());
await selected('选择来源 1:未命名来源').waitFor(); assert.equal(await selected('选择来源 1:未命名来源').isChecked(), true);
await selected('选择来源 3:来源 B').check(); await save(); await saved();
const retained = await page.evaluate(() => window.registryFixture.state().value);
assert.equal(retained.registryUrl, 'https://b.example'); assert.equal(retained.savedRegistries[2].url, 'https://legacy.example');
assert.equal(retained.savedRegistries.some(source => 'savedIndex' in source), false);
await settings.getByRole('button', { name: '移除来源 3', exact: true }).click(); await save(); await saved();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.registryUrl), 'https://b.example');
await settings.getByRole('button', { name: '移除来源 2', exact: true }).click(); await save(); await saved();
assert.equal(await page.evaluate(() => window.registryFixture.state().value.registryUrl), '');
// Validate a hidden editor and route the user to the exact failing row.
await settings.getByRole('button', { name: '添加来源', exact: true }).click(); await displayTab(); await save();
await input(2, '名称').waitFor(); await settings.getByText('请填写来源名称。', { exact: true }).waitFor();
await input(2, '名称').fill('重复'); await input(2, '地址').fill('https://a.example/'); await displayTab(); await save();
await settings.getByText('此来源地址已存在,请保留一个条目。').waitFor();
assert.equal(await settings.getByRole('tab', { name: '技能来源', exact: true }).getAttribute('aria-selected'), 'true');
assert.equal(await input(2, '地址').isVisible(), true);
await settings.getByRole('button', { name: '重置修改', exact: true }).click();
await page.evaluate(() => window.registryFixture.readonly());
await page.waitForFunction(() => document.querySelector('[aria-label="技能来源"]').disabled);
assert.equal(await settings.getByRole('button', { name: '添加来源', exact: true }).isDisabled(), true);
await displayTab(); assert.equal(await settings.getByRole('combobox', { name: '版本', exact: true }).isDisabled(), true);
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
if (process.env.DSH_PROBE_SCREENSHOT) await page.screenshot({ path: `${process.env.DSH_PROBE_SCREENSHOT}-mobile.png`, fullPage: true });
await page.evaluate(() => window.registryFixture.unavailable());
await selector.getByText('来源配置不可用').waitFor();
await page.evaluate(() => window.registryFixture.unavailable()); await page.getByText('当前连接无法编辑 Host 配置。', { exact: true }).waitFor();
assert.deepEqual(errors, []);
console.log(JSON.stringify({ passed: true, cases: ['shared selection', 'stale draft CAS refusal', 'active URL atomic edit', 'active removal disconnect', 'duplicate refusal', 'readonly', 'narrow layout', 'missing config service'] }));
console.log(JSON.stringify({ passed: true, cases: ['single source rows and save-only selection', 'active edit survives empty URL', 'same-revision dirty reset', 'cross-tab draft and keyboard navigation', 'pending locks edits', 'CAS after external update', 'pristine editor invalidated after external row replacement', 'legacy current index preservation', 'active removal disconnect', 'hidden invalid row opens exact editor', 'readonly', 'narrow layout', 'missing service'] }));
} finally { await browser.close(); }

View file

@ -25,18 +25,27 @@ async function boot(tab, catalog = true) {
await tab.addLocatorHandler(gettingStarted, async () => { await gettingStarted.getByRole('button', { name: /^(Close|关闭)$/ }).click(); });
if (catalog) {
await center.click();
await tab.getByRole('combobox', { name: '技能来源', exact: true }).waitFor();
await tab.getByRole('button', { name: '查看当前来源', exact: true }).waitFor();
assert.equal(await tab.getByRole('combobox', { name: '技能来源', exact: true }).count(), 0);
} else {
await tab.getByText(/^(Plugins|插件)$/, { exact: true }).first().click();
}
}
async function select(tab, label) {
await tab.getByRole('combobox', { name: '技能来源', exact: true }).click();
await tab.getByRole('option', { name: label, exact: true }).click();
await tab.getByText(/^(Plugins|插件)$/, { exact: true }).first().click();
await tab.getByText('@astron-team/skillhub-dsh', { exact: true }).first().click();
const form = tab.getByRole('form', { name: 'SkillHub 配置' }); await form.waitFor();
const radio = label === '未连接来源' ? form.getByRole('radio', { name: '不连接技能来源', exact: false }) : form.getByRole('radio', { name: new RegExp('^选择来源 \\d+:' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$') });
await radio.check();
await form.getByRole('button', { name: '保存配置', exact: true }).click();
await form.getByText('配置已保存。', { exact: true }).waitFor();
await tab.getByRole('button', { name: '技能中心', exact: true }).click();
}
async function selected(tab, label) {
await tab.waitForFunction(label => [...document.querySelectorAll('[role="combobox"]')].some(element => element.getAttribute('aria-label') === '技能来源' && element.textContent.trim() === label), label);
const expected = label === '未连接来源' ? '尚未配置来源' : label.startsWith('来源 A') ? 'http://127.0.0.1:43219' : 'http://127.0.0.1:43220';
await tab.waitForFunction(expected => document.querySelector('[aria-label="查看当前来源"]')?.getAttribute('title') === expected, expected);
}
try {
stage = 'boot-primary'; await boot(page, verifyRestart);
if (!verifyRestart) {
@ -45,19 +54,15 @@ try {
await page.getByText(/^(Plugins|插件)$/, { exact: true }).first().click();
await page.getByText('@astron-team/skillhub-dsh', { exact: true }).first().click();
const form = page.getByRole('form', { name: 'SkillHub 配置' }); await form.waitFor();
const names = form.getByRole('textbox', { name: /^来源 \d+ 名称$/ });
assert((await names.count()) <= 2, 'Dedicated fixture must not contain unrelated saved sources');
const existingSources = await names.count();
for (let index = existingSources; index < 2; index++) {
const rows = form.locator('.astron-sh-source-entry');
assert((await rows.count()) <= 3, 'Dedicated fixture must not contain unrelated saved sources');
while (await rows.count()) await form.getByRole('button', { name: '移除来源 1', exact: true }).click();
for (const [index, name, port] of [[1, '来源 A', 43219], [2, '来源 B', 43220]]) {
await form.getByRole('button', { name: '添加来源', exact: true }).click();
await form.getByLabel(`来源 ${index + 1} 名称`, { exact: true }).waitFor();
await form.getByLabel(`来源 ${index} 名称`, { exact: true }).fill(name);
await form.getByLabel(`来源 ${index} 地址`, { exact: true }).fill(`http://127.0.0.1:${port}`);
}
await form.getByLabel('来源 1 名称', { exact: true }).fill('来源 A(准备)');
await form.getByLabel('来源 1 名称', { exact: true }).fill('来源 A');
await form.getByLabel('来源 1 地址', { exact: true }).fill('http://127.0.0.1:43219');
await form.getByLabel('来源 2 名称', { exact: true }).fill('来源 B');
await form.getByLabel('来源 2 地址', { exact: true }).fill('http://127.0.0.1:43220');
await form.getByLabel('Registry 地址', { exact: true }).fill('http://127.0.0.1:43220');
await form.getByRole('radio', { name: '选择来源 2:来源 B', exact: true }).check();
await form.getByRole('button', { name: '保存配置', exact: true }).click();
await form.getByText('配置已保存。', { exact: true }).waitFor();
await page.getByRole('button', { name: '技能中心', exact: true }).click();
@ -69,9 +74,12 @@ try {
await page.getByText(/^(Plugins|插件)$/, { exact: true }).first().click();
await page.getByText('@astron-team/skillhub-dsh', { exact: true }).first().click();
const form = page.getByRole('form', { name: 'SkillHub 配置' }); await form.waitFor();
assert(await form.getByRole('radio', { name: '选择来源 2:来源 B', exact: true }).isChecked());
await form.getByRole('button', { name: '编辑来源 1', exact: true }).click();
assert.equal(await form.getByLabel('来源 1 名称', { exact: true }).inputValue(), '来源 A');
await form.getByRole('button', { name: '编辑来源 2', exact: true }).click();
assert.equal(await form.getByLabel('来源 2 名称', { exact: true }).inputValue(), '来源 B');
assert.equal(await form.getByLabel('Registry 地址', { exact: true }).inputValue(), 'http://127.0.0.1:43220');
assert.equal(await form.getByLabel('来源 2 地址', { exact: true }).inputValue(), 'http://127.0.0.1:43220');
await page.screenshot({ path: join(output, 'registry-switching-restart.png') });
assert.deepEqual(errors, []);
const result = { passed: true, realHost: true, modelCalled: false, restartVerified: true, namedSourcesPersisted: true, selectedSourcePersisted: true };
@ -95,16 +103,19 @@ try {
stage = 'config-cas'; await page.getByText(/^(Plugins|插件)$/, { exact: true }).first().click();
await page.getByText('@astron-team/skillhub-dsh', { exact: true }).first().click();
const form = page.getByRole('form', { name: 'SkillHub 配置' }); await form.waitFor();
await form.getByRole('button', { name: '编辑来源 1', exact: true }).click();
await form.getByLabel('来源 1 名称', { exact: true }).fill('来源 A(保存验证)');
await select(other, '来源 A');
await form.getByRole('button', { name: '保存配置', exact: true }).click();
await form.getByText('配置未保存,可能已被其他页面修改。请重置修改后再试。', { exact: true }).waitFor();
await form.getByRole('button', { name: '重置修改', exact: true }).click();
await form.getByRole('button', { name: '编辑来源 1', exact: true }).click();
await form.getByLabel('来源 1 名称', { exact: true }).fill('来源 A(保存验证)');
await form.getByRole('button', { name: '保存配置', exact: true }).click();
await form.getByText('配置已保存。', { exact: true }).waitFor();
await selected(other, '来源 A(保存验证)');
await page.screenshot({ path: join(output, 'registry-switching-settings.png') });
await form.getByRole('button', { name: '编辑来源 1', exact: true }).click();
await form.getByLabel('来源 1 名称', { exact: true }).fill('来源 A');
await form.getByRole('button', { name: '保存配置', exact: true }).click();
await selected(other, '来源 A');

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { Config } from '../src/host/config.ts';
import { editSavedRegistry, savedRegistryOptions, validateRegistryConfig } from '../src/client/registry-config.ts';
import { editSavedRegistry, registryChoices, preserveCurrentRegistry, RegistryConfigError, savedRegistryOptions, validateRegistryConfig } from '../src/client/registry-config.ts';
import type { SkillHubConfig } from '../src/host/types.ts';
const base: SkillHubConfig = { registryUrl: 'https://a.example', detailTabs: { versions: 'auto', reviews: 'hide', securityAudit: 'show' },
savedRegistries: [{ name: 'A', url: 'https://a.example/' }, { name: 'B', url: 'https://b.example' }] };
@ -54,3 +54,21 @@ describe('saved registry configuration', () => {
expect(validateRegistryConfig({ ...base, registryUrl: '' }).registryUrl).toBe('');
});
});
test('legacy current choices retain saved indexes and persist only after an explicit edit or switch', () => {
const legacy = { ...base, registryUrl: 'https://legacy.example' };
expect(registryChoices(legacy).map(entry => entry.savedIndex)).toEqual([null, 0, 1]);
expect(legacy.savedRegistries).toHaveLength(2);
const retained = preserveCurrentRegistry(legacy);
expect(retained.savedRegistries).toHaveLength(3);
expect(retained.savedRegistries?.[2]).toEqual({ name: '未命名来源', url: legacy.registryUrl });
expect(registryChoices(retained).map(entry => entry.savedIndex)).toEqual([0, 1, 2]);
expect(editSavedRegistry(retained, 1).registryUrl).toBe(legacy.registryUrl);
expect(editSavedRegistry(retained, 2).registryUrl).toBe('');
expect(preserveCurrentRegistry(retained)).toBe(retained);
});
test('source validation identifies the saved editor independently of duplicate names', () => {
const choices = { ...base, savedRegistries: [{ name: 'Same', url: 'https://a.example' }, { name: 'Same', url: 'https://a.example/' }] };
try { validateRegistryConfig(choices); throw new Error('expected rejection'); }
catch (error) { expect(error).toBeInstanceOf(RegistryConfigError); expect((error as RegistryConfigError).savedIndex).toBe(1); }
});