feat(meta): give the plugin the icon and locale DSH 0.1.7 shows in its list
0.1.7 reads `package.json#icon` plus `locale/<lang>.json` to illustrate and label a plugin in the Plugins page and the `plugin_manager` list. The plugin had neither, so it rendered as a bare row (and every other third-party entry in this profile still does — ours is now the only one with a real mark). The icon follows the two icons DSH ships for this feature (packages/experimental/*/icon.svg): a 36x36, fill-only geometric mark with a blue gradient and no stroked outlines or text. It reads as a panel with a docked right rail — the sidebar this plugin is. The first cut used two blocks separated by a gap, but the rail shared the panel's light tone at the top, so the split leaned on a 3px gap that shrinks away at list size; a darker rail flush inside one panel survives any downscale. `locale/en.json` is the discovery baseline English needs to be present for the whole directory to be read — and `locale/zh.json` carries the Chinese title and blurb. The manifest's own `description` still advertised the terminal and the browser, which this plugin handed to DSH in 0.20.0; it is now user-visible as the fallback label, so it was corrected too. Every failure in this feature is silent: bad metadata only ever degrades to a plain text row. `tests/plugin-meta.spec.ts` therefore pins both ends — the manifest wiring and the packed tarball — plus the house style of the artwork, and was red/green verified (removing `icon.svg` from `files` fails it twice, once against the real tarball). Verified on the live 3080 instance: `readPluginMeta` resolves the icon to a `data:image/svg+xml` URI and the page renders it (`ourIconExact: 1`, no page errors).
This commit is contained in:
@@ -70,7 +70,7 @@
|
||||
8. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / scope / session-projection / system-prompt / user-approval / util-time 等,与直接依赖同款精确钉版);**`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`,0.1.7 起要求 `^4.0.3`**。适配新版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。**0.1.7 新增的同类问题**见 §3.3 第 12 条(`ui-primitives` 的第二层裸 import)。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**的历史已消除,rc.1 起上游恢复发版,0.1.7 起与 DSH 同 tuple。
|
||||
9. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(**但按 §3.2 第 5 条的清单让出一部分**),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`。原生 tab 的插件侧状态在 `src/client/native/tab-adapter.tsx`。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile 上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护)。**0.1.6-alpha.2 起**:`SidebarRightGuideEntry.id` 必填且同 provider 内唯一(缺它会静默清空整个承载面);同 provider 的 guide 条目是**追加**关系,`chain` 那种「替换宿主条目」的语义只存在于 `conversation.chat.turnTail` 且已被上游改成 `list`。**0.1.7 起**:`sidebar-right` 本体的契约**零破坏**——槽名无改名、`SidebarRightTabDefinition` 没有新的必填字段、只有新增的可选 `keepMounted` 与 `ISidebarRight.mounted`,`openTabIn/openResourceIn/closeIn` 在具体 class 上字节不变;`tab.visible` 语义收紧为 `active && (...)`(全局面板/后台会话时 body 不挂载);持久化校验收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**,0.1.6 的 sidebar 自己产不出这种布局);`RightbarRoot` 现在为每个保留会话渲染一个隐藏 seat(`SidebarSessionViews`,未导出、插件拿不到)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**rc.1 起**:宿主原生指南只在列出的条目 ≤ 4 条时渲染说明(上游 `MAX_DESCRIBED_ENTRIES = 4`;更长的列表是整列丢弃,不是截断)——插件默认贡献 4 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话),**与宿主的终端条目合计后可能超过 4 条**,此时说明不渲染;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句。
|
||||
10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。**0.1.7 复查:`Session.create` 的五个参数与函数体逐字相同**;上游新增了 `buildForkSeed(events, boundary)` 导出,与本插件手工的 `buildSidechatInheritance` 职责重叠但**收益不明确**(插件还有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。
|
||||
11. **`dsh.plugin.json` 从来不是 DSH 契约**:0.1.7 全树 grep 仍 **0 命中**。DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 只服务第三方 registry(社区市场)通道,`lib/client-registry.js` 同理;`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**`dsh.manifestVersion` 也仍是「只声明、零读」**(0.1.7 的 `package-manifest` README 自己写明「Current installers and loaders do not enforce `dsh.manifestVersion` or `engines.dsh`」)。0.1.7 的官方管控面新增:`plugin-manager` 的**多 registry 回退**(`registryPlan()`:配置源 → `fallbackRegistries`(默认含 `registry.npmmirror.com`),`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)、`waitForInstall`、以及**插件展示元数据** `package.json#icon` + `locale/<lang>.json`(全新面,本插件未采纳)。`dsh.bundle.patch` 现在多了形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。
|
||||
11. **`dsh.plugin.json` 从来不是 DSH 契约**:0.1.7 全树 grep 仍 **0 命中**。DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 只服务第三方 registry(社区市场)通道,`lib/client-registry.js` 同理;`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**`dsh.manifestVersion` 也仍是「只声明、零读」**(0.1.7 的 `package-manifest` README 自己写明「Current installers and loaders do not enforce `dsh.manifestVersion` or `engines.dsh`」)。0.1.7 的官方管控面新增:`plugin-manager` 的**多 registry 回退**(`registryPlan()`:配置源 → `fallbackRegistries`(默认含 `registry.npmmirror.com`),`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)、`waitForInstall`、以及**插件展示元数据**:`package.json#icon`(36×36、扁平填充、蓝色渐变,与 DSH 自带图标同风格)+ `locale/<lang>.json` 的 `meta.title`/`description`。本插件**已采纳**(v0.21.0-alpha.1):`icon.svg` + `locale/en.json` + `locale/zh.json`,并由 `tests/plugin-meta.spec.ts` 守护「声明了就必须发布出去」。三条硬规则:icon 必须是**包内相对路径**且写进 `files`;`locale/en.json` 是发现基线(缺它整个目录都不读);`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**所有失败都是静默的**——元数据有问题只会退化成一行纯文字,没有任何报错。`dsh.bundle.patch` 现在多了形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
|
||||
## 6. 文档与测试地图
|
||||
|
||||
- **插件展示元数据**:`package.json#icon` + `locale/<lang>.json`(DSH 0.1.7+ 的 Plugins 页与 `plugin_manager` 列表),由 `tests/plugin-meta.spec.ts` 守护。
|
||||
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。
|
||||
- **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。
|
||||
- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:**5 tab + 3 viewer**——终端与浏览器已交还宿主,只读预览也按 §3.2 第 5 条让出,只剩 markdown / html / code)/ `market-manifest.spec.ts`(市场约束,含 `pnpm pack` 后的产物面)/ `manifest-consistency.spec.ts`(registry 通道形状)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面,含 guide 条目 id 唯一性)/ `theme.spec.ts`(皮肤契约,含空白面板胶囊几何)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)/ `fs-watch.spec.ts`(文件树目录 watch 的去抖、句柄上限与错误路径)/ `smoke.spec.ts` 的 settings 段(按 entry id 读写表单、**一次性 legacy 迁移**、open 工具门控)。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6.5" y="9" width="23" height="18" rx="2.5" fill="#7CB7FF" />
|
||||
<rect x="21.5" y="9" width="8" height="18" rx="2.5" fill="url(#a)" />
|
||||
<defs>
|
||||
<linearGradient id="a" x1="25.5" y1="9" x2="25.5" y2="27" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3F77D8" />
|
||||
<stop offset="1" stop-color="#145AF3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Better Sidebar",
|
||||
"description": "A VSCode-like right sidebar: file tree, editor, file changes, subagent tasks and side chat, isolated per conversation."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Better Sidebar",
|
||||
"description": "VSCode 风格右侧侧边栏:文件树、编辑器、文件变动、子代理任务与侧边对话,按会话隔离。"
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.21.0-alpha.1",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (file tree / editor / changes / tasks / side chat), isolated per conversation. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,7 +34,8 @@
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -71,7 +72,9 @@
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
"LICENSE",
|
||||
"icon.svg",
|
||||
"locale/*.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
|
||||
@@ -219,5 +222,6 @@
|
||||
"unrun": "^0.2.39",
|
||||
"vitest": "^4.1.8",
|
||||
"zustand": "~4.4.7"
|
||||
}
|
||||
},
|
||||
"icon": "./icon.svg"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Plugin presentation-metadata guard for DSH 0.1.7+.
|
||||
*
|
||||
* 0.1.7 added `readPluginMeta` (packages/boot/app-boot/src/package-meta.ts):
|
||||
* the Plugins page and `plugin_manager` list read `package.json#icon` plus
|
||||
* `<pkg>/locale/<lang>.json` to label and illustrate a plugin. DSH's own gate
|
||||
* (`scripts/verify-package-meta.ts`) is explicit that this is all-or-nothing —
|
||||
* a declared icon whose locale resources do not resolve is itself an error —
|
||||
* and the whole feature fails SILENTLY: a metadata problem only ever degrades
|
||||
* to a plain text row, so nothing tells you the icon stopped shipping.
|
||||
*
|
||||
* These assertions pin the wiring at both ends: the manifest, and the tarball
|
||||
* npm would actually publish (an icon listed in `files` but absent on disk, or
|
||||
* present on disk but not listed, both look fine in the source tree).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as {
|
||||
name: string
|
||||
version: string
|
||||
icon?: string
|
||||
files?: string[]
|
||||
exports?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The icon and locale directory names this feature is built on. */
|
||||
const ICON = 'icon.svg'
|
||||
const LOCALE_DIR = 'locale'
|
||||
|
||||
/** Every locale document, as `[filename, parsed]` pairs. */
|
||||
function locales(): Array<[string, { meta?: { title?: unknown; description?: unknown } }]> {
|
||||
const dir = resolve(ROOT, LOCALE_DIR)
|
||||
return readdirSync(dir).filter(name => name.endsWith('.json')).sort()
|
||||
.map(name => [name, JSON.parse(readFileSync(join(dir, name), 'utf8')) as { meta?: { title?: unknown; description?: unknown } }])
|
||||
}
|
||||
|
||||
/** `package.json#icon`, resolved against the manifest directory. */
|
||||
const iconPath = (): string => resolve(ROOT, pkg.icon!.replace(/^\.\//u, ''))
|
||||
|
||||
describe('DSH plugin presentation metadata', () => {
|
||||
it('declares a relative icon that exists inside the package', () => {
|
||||
expect(pkg.icon, 'package.json#icon').toBeDefined()
|
||||
// readPluginMeta rejects an absolute path or a `scheme:` outright.
|
||||
expect(pkg.icon!.startsWith('./'), 'icon must be a relative path').toBe(true)
|
||||
expect(pkg.icon!.includes('..'), 'icon must stay inside the package').toBe(false)
|
||||
expect(pkg.icon!, 'only the raster/vector types DSH accepts').toMatch(/\.(svg|png|jpe?g|webp)$/u)
|
||||
expect(existsSync(iconPath()), `${pkg.icon} must exist`).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes the icon and every locale document through files', () => {
|
||||
expect(pkg.files, 'package.json#files').toBeDefined()
|
||||
// `files` is npm's allow-list: an entry missing here never reaches the
|
||||
// tarball, and DSH's gate rejects a declared-but-unpublished icon.
|
||||
expect(pkg.files!).toContain(ICON)
|
||||
expect(pkg.files!).toContain(`${LOCALE_DIR}/*.json`)
|
||||
})
|
||||
|
||||
it('exposes the locale documents and the manifest through exports', () => {
|
||||
// `${name}/locale/<file>` is how readPluginMeta resolves a translation,
|
||||
// and `${name}/package.json` is how it reads the icon declaration.
|
||||
expect(pkg.exports?.['./locale/*.json'], 'exports["./locale/*.json"]').toBe('./locale/*.json')
|
||||
expect(pkg.exports?.['./package.json'], 'exports["./package.json"]').toBe('./package.json')
|
||||
})
|
||||
|
||||
it('ships an English baseline and complete translations beside it', () => {
|
||||
const documents = locales()
|
||||
const baseline = documents.find(([name]) => name === 'en.json')
|
||||
// English is the discovery baseline: without it readPluginMeta cannot find
|
||||
// the directory at all, so every other locale would go unread too.
|
||||
expect(baseline, `${LOCALE_DIR}/en.json must exist`).toBeDefined()
|
||||
|
||||
const shapeOf = (value: { meta?: { title?: unknown; description?: unknown } }): string[] =>
|
||||
Object.keys(value.meta ?? {}).sort()
|
||||
|
||||
for (const [name, document] of documents) {
|
||||
expect(shapeOf(document), `${name} must mirror en.json`).toEqual(shapeOf(baseline![1]))
|
||||
expect(typeof document.meta?.title, `${name} meta.title`).toBe('string')
|
||||
expect(document.meta!.title, `${name} meta.title must not be empty`).not.toBe('')
|
||||
expect(typeof document.meta?.description, `${name} meta.description`).toBe('string')
|
||||
expect(document.meta!.description, `${name} meta.description must not be empty`).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the icon in the built-in house style', () => {
|
||||
const svg = readFileSync(iconPath(), 'utf8')
|
||||
// Every icon DSH ships for this feature is a 36x36, fill-only geometric
|
||||
// mark (see packages/experimental/*/icon.svg). Pinning the frame keeps a
|
||||
// later redraw from silently changing how the mark scales in the list.
|
||||
expect(svg).toMatch(/viewBox="0 0 36 36"/u)
|
||||
expect(svg).toMatch(/width="36"/u)
|
||||
expect(svg).toMatch(/height="36"/u)
|
||||
expect(svg, 'the root must not paint a background').toMatch(/fill="none"/u)
|
||||
// No glyph outlines and no text: the built-ins are pure filled shapes, and
|
||||
// a <text> node would also render in whatever font the browser picks.
|
||||
expect(svg, 'no stroked outlines in the built-in style').not.toMatch(/stroke=/u)
|
||||
expect(svg, 'no <text> in an icon').not.toMatch(/<text[\s>]/u)
|
||||
})
|
||||
|
||||
it('actually packs the icon and locales into the tarball', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'plugin-meta-pack-'))
|
||||
try {
|
||||
execFileSync('pnpm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'pipe', shell: process.platform === 'win32' })
|
||||
const contents = execFileSync('tar', ['-tzf', `${pkg.name}-${pkg.version}.tgz`], { cwd: dir, encoding: 'utf8' })
|
||||
const files = contents.split('\n')
|
||||
expect(files, `${ICON} must be published`).toContain(`package/${ICON}`)
|
||||
for (const [name] of locales()) {
|
||||
expect(files, `${LOCALE_DIR}/${name} must be published`).toContain(`package/${LOCALE_DIR}/${name}`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
Reference in New Issue
Block a user