dsh-panel-control 0.7.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/client.js +867 -18
- package/docs/bridged-settings-pages.md +85 -0
- package/docs/renaming-panels.md +74 -0
- package/index.js +123 -2
- package/package.json +5 -2
- package/scripts/bridge-smoke.mjs +324 -0
- package/scripts/host-smoke.mjs +39 -3
- package/scripts/layout-smoke.mjs +27 -0
- package/scripts/names-smoke.mjs +425 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# 设置页补入口:怎么识别,怎么渲染
|
|
2
|
+
|
|
3
|
+
## 问题
|
|
4
|
+
|
|
5
|
+
宿主把「侧栏面板」和「设置页」做成两套注册:
|
|
6
|
+
|
|
7
|
+
- 面板入口 = `sidebar.panellist` 里的一行图标 + 同 id 的 root 作用域 `main` keyed 条目;
|
|
8
|
+
- 设置页 = `settings.section` 里的一个 id。
|
|
9
|
+
|
|
10
|
+
多数插件两者都注册。少数只注册 `settings.section`,于是它的页面只能从设置弹窗里翻到——
|
|
11
|
+
侧栏没有入口,也没法被单独选中。设置弹窗的打开状态是宿主私有的,没有公开 API 能直接定位到
|
|
12
|
+
某个 section,所以「点一行就打开那个设置页」只能由本插件自己造一页。
|
|
13
|
+
|
|
14
|
+
## 识别
|
|
15
|
+
|
|
16
|
+
宿主的槽条目确实带有 `registrant`,但在当前客户端里它**不能用来区分插件**:client runner 把
|
|
17
|
+
大量插件放在同一个 fiber 里 apply,实测 `account/general/models/plugins/agent-presets` 以及
|
|
18
|
+
`plan-mode`、`bili`、`workspace-control-settings`、`composer-rewind-settings` 这些设置页的
|
|
19
|
+
`registrant` 都是同一个 `tk`,侧栏里大多数行也是 `tk`;只有插件市场是它自己的 `dsh-market`。
|
|
20
|
+
按 registrant 过滤会把所有第三方设置页都当成「已有入口」。
|
|
21
|
+
|
|
22
|
+
因此改为按**侧栏归属**判断,一个设置页有入口当且仅当满足任一条:
|
|
23
|
+
|
|
24
|
+
1. 它的 id 等于某个 `sidebar.panellist` 的 id;
|
|
25
|
+
2. 去掉末尾 `-settings` 后的 id 等于某个面板 id(`panel-control-settings` ↔ `panel-control`);
|
|
26
|
+
3. 某个面板 id 以 `-<id>` 结尾(`market` ↔ `dsh-market`);
|
|
27
|
+
4. 它的 label 与某个面板 label 相同(`market` / `workspace-control-settings` 都命中)。
|
|
28
|
+
|
|
29
|
+
内置设置页没有面板,因此单独列一组 id 排除:`account`、`general`、`models`、`agent-presets`、
|
|
30
|
+
`plugins`(`plugins` 同时也有面板,列在这里只是双保险)。剩下的每个 section 补一行,id 为
|
|
31
|
+
`dpc-bridge-<section id>`,order 85(在普通入口之后、本插件之前)。标题取该 section 的 label;
|
|
32
|
+
与已有行撞名时追加 ` · <section id>`。
|
|
33
|
+
|
|
34
|
+
这套判断不读第三方包内部文件,也不依赖插件白名单;代价是**启发式**:一个第三方设置页若恰好
|
|
35
|
+
与某个面板同名/id 同源,会被当成已有入口;未来新增的内置设置页若不在上面的 id 列表里,会被
|
|
36
|
+
多补一行(此时把它加进 `CORE_SECTION_IDS` 即可)。
|
|
37
|
+
|
|
38
|
+
## 渲染
|
|
39
|
+
|
|
40
|
+
桥接面板自己注册同 id 的 `main` keyed 条目,并在该条目上声明一个自有子槽(`dpc-bridge-slot-<section id>`)。
|
|
41
|
+
之所以不直接用 `ctx.slots.renderSlot('settings.section', ...)`:ctx 级 `renderSlot` 只渲染 `root`,
|
|
42
|
+
子槽只能通过「声明了它的那个条目的 props 面」渲染;`settings.section` 由设置壳声明,本插件无权调用。
|
|
43
|
+
因此桥接条目把原 section 的组件以同一份 `inject/store/locale` 重新注册进自有子槽,再通过宿主注入的
|
|
44
|
+
`props.renderSlot(slotName, { close: back }, {})` 渲染它。
|
|
45
|
+
|
|
46
|
+
这样 section 的 `inject`、`store`、locale `t`、`useStore` 和 error boundary 仍由宿主照常提供;直接调用
|
|
47
|
+
section 组件会缺这些。面板只负责外壳:顶部一条 40px 的栏(「返回对话」+ 来源 section id),
|
|
48
|
+
下面一块随页面滚动的主体。`close` 与设置壳里一样只做「关掉这一页」,这里落回对话
|
|
49
|
+
(`layout.selectPanel(null)`)。
|
|
50
|
+
|
|
51
|
+
桥接面板不常驻:开关关闭、section 消失、插件卸载时,`dispose()` 成对撤掉 `main` 与
|
|
52
|
+
`sidebar.panellist` 注册;若当时正停在这一页,先 `selectPanel(null)` 回到对话,避免选中一个
|
|
53
|
+
已经不存在的 key。
|
|
54
|
+
|
|
55
|
+
## 同步
|
|
56
|
+
|
|
57
|
+
`settings.section`、`sidebar.panellist` 与 locale 任意一处变化都会触发一次同步,120ms 去抖。
|
|
58
|
+
启动时还会按 `[350, 900, 2000, 4200, 8000, 15000]ms` 主动重扫:boot 清单的顺序不是应用顺序,
|
|
59
|
+
排在后面的插件可能在第一次同步之后才注册设置页。`settings.section` 槽声明时会重新建立订阅,
|
|
60
|
+
因为槽声明之前 `subscribe` 观察不到这个注册表。
|
|
61
|
+
|
|
62
|
+
同步本身只做增删对账,不重建未变化的行;注册成对失败时回滚已注册的那一半。
|
|
63
|
+
|
|
64
|
+
开关存在 `prefs.bridge`(默认 `true`),在「侧栏布局」卡片里;它和排序、隐藏、分组是正交的:
|
|
65
|
+
补出来的行可以像普通行一样拖动、隐藏、分组,关掉开关才会整体消失。
|
|
66
|
+
|
|
67
|
+
## 验证
|
|
68
|
+
|
|
69
|
+
- `npm run check`:`layout-smoke` 断言桥接的常量、持久化字段与自有子槽渲染没有被改掉;
|
|
70
|
+
`bridge-smoke` 在假 `ctx` 上验证识别、成对注册、开关撤回与返回对话。
|
|
71
|
+
- 刷新插件后看 `~/.dsh/panel-control/host.log`:`bridge-scan` 行记录每次扫描看到的 section 与
|
|
72
|
+
侧栏条目(`id:registrant`),`kind=bridge` 行记录最终补出的 id。目标 section 在 `sections`
|
|
73
|
+
里却没进 `ids`,说明它被归属规则当成了已有入口(或 id 不在内置列表里而被排除);一直不在
|
|
74
|
+
`sections` 里,是注册时序问题。
|
|
75
|
+
- `bridge-smoke` 里另有一条晚注册用例:先启动、后注册 section,订阅必须把新行补出来。
|
|
76
|
+
- 界面:侧栏应多出对应行;点开后是完整设置页,顶部「返回对话」可退回;设置 → 面板管理里
|
|
77
|
+
「侧栏布局」应出现开关。
|
|
78
|
+
|
|
79
|
+
## 边界
|
|
80
|
+
|
|
81
|
+
- 第三方设置页在设置壳外渲染时,若它依赖弹窗的私有上下文(宽度、滚动容器、`close` 之外的 props),
|
|
82
|
+
可能和设置弹窗里不完全一致;本插件只保证宿主注入的公开部分齐全。
|
|
83
|
+
- 补出来的行参与自动分类,会被分进 2–4 个组之一;想固定位置就手动拖动或分组。
|
|
84
|
+
- 识别是启发式:设置页 id / label 与面板重合时视为已有入口;新增内置设置页需要加进
|
|
85
|
+
`CORE_SECTION_IDS`。同一个包注册多个设置页会补多行,这是有意的。
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 重命名与包名标签
|
|
2
|
+
|
|
3
|
+
侧栏入口名由插件自己注册,通常是包名或英文短名。面板管理允许把这层显示改成你熟悉的叫法,同时在行尾留一枚插件包名标签,避免过几天忘了它是谁。
|
|
4
|
+
|
|
5
|
+
## 只改显示层
|
|
6
|
+
|
|
7
|
+
宿主拥有侧栏 DOM,插件注册的 `id`、`order`、`label` 都不该被另一个插件改写。重命名因此只覆盖三处显示:
|
|
8
|
+
|
|
9
|
+
- 面板按钮的 `span.panelTitle` 文本;
|
|
10
|
+
- 按钮的 `aria-label`(屏幕阅读器与工具提示读它);
|
|
11
|
+
- 行尾的 `span.dpc-nameTag`,内容是**插件包名**(见下一节),不是注册时的显示名。
|
|
12
|
+
|
|
13
|
+
按钮上的 `data-dpc-label` 保存原名,供引擎下一轮把自己映射回同一条注册;入口 id 和宿主数据不变。
|
|
14
|
+
|
|
15
|
+
## 包名从哪来
|
|
16
|
+
|
|
17
|
+
`sidebar.panellist` 条目只有 `id/order/label/registrant`,没有包名字段;`registrant` 是 Cordis fiber 名,运行时大多数插件共享同一个 fiber,无法拿去区分插件。包名只存在于宿主自己的模块图里,所以:
|
|
18
|
+
|
|
19
|
+
1. 客户端把当前面板 id 与桥接设置页 id 发给 Host 的 `packages` RPC;
|
|
20
|
+
2. Host 用 `clientModules.graph().entries` 拿到模块 id(即包名)与 `clientModules.clientPath(id)` 返回的 bundle 路径;
|
|
21
|
+
3. 逐个 bundle 查找槽字面量 `sidebar.panellist` / `settings.section`,并优先接受紧邻 `id:` / `key:`(或赋值)的该 id;没有注册字段时只接受距离 400 字符以内的提及,避免把注释里顺带提到的通用词当成归属;
|
|
22
|
+
4. 命中的模块 id 就是包名,按模块图 revision 缓存。
|
|
23
|
+
|
|
24
|
+
标签文本会去掉 scope 与 `dsh-` 前缀,再退掉 `client-ui-`:
|
|
25
|
+
|
|
26
|
+
| 模块 id | 标签 |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `dsh-panel-control` | `panel-control` |
|
|
29
|
+
| `@furongjun1999/dsh-memory` | `memory` |
|
|
30
|
+
| `dshmarket` | `market` |
|
|
31
|
+
| `@deepseek-ai/dsh-client-ui-plugin-manager` | `plugin-manager` |
|
|
32
|
+
|
|
33
|
+
Host 尚未返回或确实找不到时,回退到入口 id 本身(同样去 `dsh-` 前缀),所以标签绝不会空白。
|
|
34
|
+
|
|
35
|
+
## 映射回原条目
|
|
36
|
+
|
|
37
|
+
每一轮扫描都会先读 `data-dpc-label`,没有才退回 `aria-label`:
|
|
38
|
+
|
|
39
|
+
1. 按原名唯一匹配注册项;
|
|
40
|
+
2. 名称重复时,如果待匹配的注册项与未匹配的按钮数量相同、且顺序上名称逐项一致,按位置匹配;
|
|
41
|
+
3. 仍无法判断的按钮不接管,避免把顺序写到错误的面板上。
|
|
42
|
+
|
|
43
|
+
因此改名后的行在宿主重渲染、分组重排或侧栏重挂载后仍能找回自己的注册项。图标条模式没有标题文本,只更新 `aria-label`,不显示标签。
|
|
44
|
+
|
|
45
|
+
## 配置
|
|
46
|
+
|
|
47
|
+
`prefs.json` 里新增两个字段:
|
|
48
|
+
|
|
49
|
+
```jsonc
|
|
50
|
+
{
|
|
51
|
+
"names": { "billion-context": "上下文百科" },
|
|
52
|
+
"tags": true
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `names`:入口 id → 显示名。保存时去控制字符、压空白、`trim`,最多 40 字符;最多保留 200 条。
|
|
57
|
+
- `tags`:是否显示包名标签,默认 `true`。关掉只影响标签,不影响已保存的名称。
|
|
58
|
+
|
|
59
|
+
设置页「侧栏布局」卡片里有「包名标签」开关;每行的铅笔按钮打开行内输入:
|
|
60
|
+
|
|
61
|
+
- `Enter` 或失焦保存;
|
|
62
|
+
- `Esc` 取消;
|
|
63
|
+
- 清空并保存即删除自定义名称,恢复注册名。
|
|
64
|
+
|
|
65
|
+
## 边界
|
|
66
|
+
|
|
67
|
+
- 只覆盖显示,不改变插件加载、入口 id 或插件自己的配置;停用本插件后宿主标题原样恢复。
|
|
68
|
+
- 包名识别依赖 Host 的模块图接口与 bundle 内容。接口不存在、模块读不到或代码里没有可识别的注册字面量时,标签退化为入口 id;不会影响改名本身。
|
|
69
|
+
- 宿主大改版若移除 `span.panelTitle` 或改写按钮结构,重命名会退化为只改 `aria-label`;找不到对应行时该行保持原样,不会报错。
|
|
70
|
+
- 标签 `pointer-events: none`,不参与点击与拖拽;分组下拉菜单铺了不透明底色,避免透出下面的行。
|
|
71
|
+
|
|
72
|
+
## 验证
|
|
73
|
+
|
|
74
|
+
`scripts/names-smoke.mjs` 用迷你 DOM 驱动真实引擎,覆盖改名、关标签、清空恢复、窄侧栏、重复名称映射、包名标签、未知包回退与 `dispose` 还原;`npm run check` 会运行它。
|
package/index.js
CHANGED
|
@@ -486,6 +486,7 @@ async function handle(scope, request) {
|
|
|
486
486
|
if (method === 'load') return reply(rpcId, await loadPreferences());
|
|
487
487
|
if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
|
|
488
488
|
if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
|
|
489
|
+
if (method === 'packages') return reply(rpcId, await packageMap(scope, call.payload));
|
|
489
490
|
if (method === 'report') {
|
|
490
491
|
const detail = isRecord(call.payload) ? call.payload : {};
|
|
491
492
|
const text = JSON.stringify(detail).slice(0, 4_000);
|
|
@@ -536,6 +537,126 @@ function inventorySnapshot(scope) {
|
|
|
536
537
|
};
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Which package serves each panel / settings section.
|
|
542
|
+
*
|
|
543
|
+
* A slot entry carries no package field: `registrant` is the fiber that ran the
|
|
544
|
+
* registration, and the client runner applies most plugins inside one shared
|
|
545
|
+
* fiber. The package name exists only in the Host's module graph, so this maps
|
|
546
|
+
* an entry id to its module id by reading each module's served bundle and
|
|
547
|
+
* looking for the registration site: the slot literal plus the id as a quoted
|
|
548
|
+
* string. Constant-style registrations keep the two strings close together;
|
|
549
|
+
* an id is only accepted from a module that contains both.
|
|
550
|
+
*/
|
|
551
|
+
const PACKAGE_SCAN_LIMIT = 8 * 1024 * 1024;
|
|
552
|
+
const PACKAGE_CACHE_KEPT = 4;
|
|
553
|
+
const packageCache = new Map();
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* How strongly one bundle looks like the registration of `id` in `slot`.
|
|
557
|
+
*
|
|
558
|
+
* `strong` means the quoted id sits immediately after an `id:` / `key:` field
|
|
559
|
+
* (or an `id =` / `key =` assignment), which is the shape of a slot
|
|
560
|
+
* registration. Everything else is only a distance guess and must be close;
|
|
561
|
+
* a long comment can mention both words without registering anything.
|
|
562
|
+
*/
|
|
563
|
+
function slotMatch(text, slot, id) {
|
|
564
|
+
const slots = [];
|
|
565
|
+
let at = text.indexOf(slot);
|
|
566
|
+
while (at !== -1) {
|
|
567
|
+
slots.push(at);
|
|
568
|
+
at = text.indexOf(slot, at + slot.length);
|
|
569
|
+
}
|
|
570
|
+
if (slots.length === 0) return null;
|
|
571
|
+
let strong = Infinity;
|
|
572
|
+
let weak = Infinity;
|
|
573
|
+
for (const needle of [`'${id}'`, `"${id}"`, '`' + id + '`']) {
|
|
574
|
+
let pos = text.indexOf(needle);
|
|
575
|
+
while (pos !== -1) {
|
|
576
|
+
let distance = Infinity;
|
|
577
|
+
for (const slotAt of slots) distance = Math.min(distance, Math.abs(pos - slotAt));
|
|
578
|
+
const before = text.slice(Math.max(0, pos - 48), pos);
|
|
579
|
+
if (/(?:^|[\s,{;])(?:id|key)\s*[:=]\s*$/.test(before)) strong = Math.min(strong, distance);
|
|
580
|
+
else weak = Math.min(weak, distance);
|
|
581
|
+
pos = text.indexOf(needle, pos + needle.length);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (Number.isFinite(strong)) return { strong: true, distance: strong };
|
|
585
|
+
if (weak <= 400) return { strong: false, distance: weak };
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function readModuleText(modules, id, texts) {
|
|
590
|
+
if (texts.has(id)) return texts.get(id);
|
|
591
|
+
let text = null;
|
|
592
|
+
try {
|
|
593
|
+
const file = modules.clientPath(id);
|
|
594
|
+
if (file) {
|
|
595
|
+
const stat = await fs.stat(file);
|
|
596
|
+
if (stat.isFile() && stat.size <= PACKAGE_SCAN_LIMIT) text = await fs.readFile(file, 'utf8');
|
|
597
|
+
}
|
|
598
|
+
} catch {
|
|
599
|
+
text = null;
|
|
600
|
+
}
|
|
601
|
+
texts.set(id, text);
|
|
602
|
+
return text;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function packageMap(scope, payload) {
|
|
606
|
+
const modules = clientModules || serviceOf(scope, 'clientModules');
|
|
607
|
+
if (!modules || typeof modules.graph !== 'function' || typeof modules.clientPath !== 'function') {
|
|
608
|
+
return refusal('packages-unavailable', 'The Host module graph does not expose client paths.');
|
|
609
|
+
}
|
|
610
|
+
const graph = modules.graph();
|
|
611
|
+
if (!graph || !Array.isArray(graph.entries)) {
|
|
612
|
+
return refusal('packages-unavailable', 'The Host module graph is not readable.');
|
|
613
|
+
}
|
|
614
|
+
const requested = [];
|
|
615
|
+
const seen = new Set();
|
|
616
|
+
for (const raw of Array.isArray(payload && payload.ids) ? payload.ids : []) {
|
|
617
|
+
if (!isRecord(raw)) continue;
|
|
618
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
619
|
+
const slot = raw.slot === 'settings.section' ? 'settings.section' : 'sidebar.panellist';
|
|
620
|
+
if (!id || id.length > 120 || seen.has(slot + ':' + id)) continue;
|
|
621
|
+
seen.add(slot + ':' + id);
|
|
622
|
+
requested.push({ id, slot });
|
|
623
|
+
}
|
|
624
|
+
if (requested.length === 0) return { ok: true, value: { packages: {}, rev: graph.rev || null } };
|
|
625
|
+
// Stable order makes two calls for the same set share the cache entry.
|
|
626
|
+
requested.sort((left, right) => (left.slot + ':' + left.id).localeCompare(right.slot + ':' + right.id));
|
|
627
|
+
const key = String(graph.rev || '') + '|' + requested.map((row) => row.slot + ':' + row.id).join(',');
|
|
628
|
+
if (packageCache.has(key)) return { ok: true, value: packageCache.get(key) };
|
|
629
|
+
|
|
630
|
+
const packages = {};
|
|
631
|
+
const best = new Map();
|
|
632
|
+
const texts = new Map();
|
|
633
|
+
for (const entry of graph.entries) {
|
|
634
|
+
const moduleId = entry && typeof entry.id === 'string' ? entry.id : '';
|
|
635
|
+
if (!moduleId) continue;
|
|
636
|
+
const text = await readModuleText(modules, moduleId, texts);
|
|
637
|
+
if (!text) continue;
|
|
638
|
+
for (const want of requested) {
|
|
639
|
+
if (!text.includes(want.slot)) continue;
|
|
640
|
+
// A bundle may mention a generic id (the plugin manager's id is also a
|
|
641
|
+
// dictionary word). A real registration field wins over a mere mention,
|
|
642
|
+
// and within the same tier the closest match wins over graph order.
|
|
643
|
+
const match = slotMatch(text, want.slot, want.id);
|
|
644
|
+
if (!match) continue;
|
|
645
|
+
const previous = best.get(want.id);
|
|
646
|
+
if (previous) {
|
|
647
|
+
if (previous.strong && !match.strong) continue;
|
|
648
|
+
if (previous.strong === match.strong && previous.distance <= match.distance) continue;
|
|
649
|
+
}
|
|
650
|
+
best.set(want.id, { moduleId, strong: match.strong, distance: match.distance });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const [id, match] of best) packages[id] = match.moduleId;
|
|
654
|
+
const value = { packages, rev: typeof graph.rev === 'string' ? graph.rev : null };
|
|
655
|
+
packageCache.set(key, value);
|
|
656
|
+
while (packageCache.size > PACKAGE_CACHE_KEPT) packageCache.delete(packageCache.keys().next().value);
|
|
657
|
+
return { ok: true, value };
|
|
658
|
+
}
|
|
659
|
+
|
|
539
660
|
/**
|
|
540
661
|
* Write one line per apply that answers the only question a cold start leaves
|
|
541
662
|
* open: did this package even reach the Host's plugin graph?
|
|
@@ -649,8 +770,8 @@ function logBatchPlacement(scope) {
|
|
|
649
770
|
* gets the bytes it needs; everything else passes straight through, and the
|
|
650
771
|
* patch is undone on teardown.
|
|
651
772
|
*/
|
|
652
|
-
// Off by default: rewriting the shared bundle service is a whole-page change that
|
|
653
|
-
// other plugins pay for. It stays available as an explicit escape hatch only.
|
|
773
|
+
// Off by default: rewriting the shared bundle service is a whole-page change that
|
|
774
|
+
// other plugins pay for. It stays available as an explicit escape hatch only.
|
|
654
775
|
const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() === 'on';
|
|
655
776
|
let servePatched = null;
|
|
656
777
|
let serveLoggedAt = 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-panel-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"dsh": ">=0.2.1-rc.3"
|
|
7
|
+
},
|
|
5
8
|
"description": "Manage the global panel navigation at the top of the DSH sidebar: reorder, hide and group every entry, with the layout persisted per installation.",
|
|
6
9
|
"keywords": [
|
|
7
10
|
"dsh",
|
|
@@ -50,7 +53,7 @@
|
|
|
50
53
|
"url": "https://github.com/QIUZAIYOU/dsh-panel-control/issues"
|
|
51
54
|
},
|
|
52
55
|
"scripts": {
|
|
53
|
-
"check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs && node scripts/layout-smoke.mjs",
|
|
56
|
+
"check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs && node scripts/layout-smoke.mjs && node scripts/bridge-smoke.mjs && node scripts/names-smoke.mjs",
|
|
54
57
|
"smoke": "node scripts/host-smoke.mjs",
|
|
55
58
|
"doctor": "node scripts/profile-doctor.mjs",
|
|
56
59
|
"enable": "node scripts/enable-local.mjs"
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Scratch harness: does the bridge discover settings-only plugins, register the
|
|
2
|
+
// panellist+main pair, avoid plugins that already have a row, and honour the switch?
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
|
|
6
|
+
const SRC = readFileSync(new URL('../client.js', import.meta.url), 'utf8');
|
|
7
|
+
|
|
8
|
+
const stubReact = {
|
|
9
|
+
createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children } }),
|
|
10
|
+
useState: (value) => [value, () => {}],
|
|
11
|
+
useEffect: () => {},
|
|
12
|
+
useRef: (value) => ({ current: value }),
|
|
13
|
+
useMemo: (fn) => fn(),
|
|
14
|
+
useCallback: (fn) => fn,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const element = () => ({
|
|
18
|
+
style: {},
|
|
19
|
+
dataset: {},
|
|
20
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
21
|
+
setAttribute() {},
|
|
22
|
+
removeAttribute() {},
|
|
23
|
+
appendChild() {},
|
|
24
|
+
remove() {},
|
|
25
|
+
addEventListener() {},
|
|
26
|
+
removeEventListener() {},
|
|
27
|
+
contains() {
|
|
28
|
+
return false;
|
|
29
|
+
},
|
|
30
|
+
querySelector() {
|
|
31
|
+
return null;
|
|
32
|
+
},
|
|
33
|
+
querySelectorAll() {
|
|
34
|
+
return [];
|
|
35
|
+
},
|
|
36
|
+
textContent: '',
|
|
37
|
+
children: [],
|
|
38
|
+
childNodes: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const document = {
|
|
42
|
+
readyState: 'loading',
|
|
43
|
+
head: element(),
|
|
44
|
+
body: element(),
|
|
45
|
+
documentElement: element(),
|
|
46
|
+
title: '',
|
|
47
|
+
visibilityState: 'visible',
|
|
48
|
+
createElement: () => element(),
|
|
49
|
+
createTextNode: () => ({ textContent: '' }),
|
|
50
|
+
createDocumentFragment: () => element(),
|
|
51
|
+
querySelector: () => null,
|
|
52
|
+
querySelectorAll: () => [],
|
|
53
|
+
getElementById: () => null,
|
|
54
|
+
addEventListener() {},
|
|
55
|
+
removeEventListener() {},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function makeWindow(localPrefs) {
|
|
59
|
+
const memory = new Map();
|
|
60
|
+
if (localPrefs !== undefined) memory.set('dsh.panel-control.v1', JSON.stringify(localPrefs));
|
|
61
|
+
const localStorage = {
|
|
62
|
+
getItem: (key) => (memory.has(key) ? memory.get(key) : null),
|
|
63
|
+
setItem: (key, value) => memory.set(key, String(value)),
|
|
64
|
+
removeItem: (key) => memory.delete(key),
|
|
65
|
+
key: () => null,
|
|
66
|
+
length: 0,
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
document,
|
|
70
|
+
localStorage,
|
|
71
|
+
navigator: { language: 'zh-CN' },
|
|
72
|
+
location: { reload() {} },
|
|
73
|
+
CSS: { escape: (value) => String(value) },
|
|
74
|
+
addEventListener() {},
|
|
75
|
+
removeEventListener() {},
|
|
76
|
+
__DSH_BOOT__: {
|
|
77
|
+
rev: 'test-rev',
|
|
78
|
+
entries: [],
|
|
79
|
+
ids: [
|
|
80
|
+
'plugins',
|
|
81
|
+
'dsh-market',
|
|
82
|
+
'panel-control',
|
|
83
|
+
'dsh-flash-presets',
|
|
84
|
+
'billion-context',
|
|
85
|
+
'dshmarket',
|
|
86
|
+
'@deepseek-ai/dsh-client-ui-settings-general',
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
__ModuleLoader__: { def: null, load(def) { this.def = def; } },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function section(options, registrant) {
|
|
94
|
+
return { options, component: () => null, registrant };
|
|
95
|
+
}
|
|
96
|
+
function panelEntry(options, registrant) {
|
|
97
|
+
return { options, component: () => null, registrant };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function install(ctx, slots) {
|
|
101
|
+
globalThis.window = ctx;
|
|
102
|
+
globalThis.document = document;
|
|
103
|
+
globalThis.localStorage = ctx.localStorage;
|
|
104
|
+
window.__ModuleLoader__.def = null;
|
|
105
|
+
vm.runInThisContext(SRC, { filename: 'client.js' });
|
|
106
|
+
return window.__ModuleLoader__.def.factory((name) => {
|
|
107
|
+
if (name === 'react') return stubReact;
|
|
108
|
+
throw new Error('unexpected require: ' + name);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const calls = { selectPanel: [], renders: [] };
|
|
113
|
+
const layout = {
|
|
114
|
+
panelInfo: { getSnapshot: () => ({ activePanelId: null }) },
|
|
115
|
+
selectPanel(id) {
|
|
116
|
+
calls.selectPanel.push(id);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const locale = {
|
|
120
|
+
getLocale: () => ({ active: 'zh-CN' }),
|
|
121
|
+
register: () => {},
|
|
122
|
+
subscribe: () => () => {},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function makeCtx(seedSections = true) {
|
|
126
|
+
const registries = new Map();
|
|
127
|
+
const list = (name) => {
|
|
128
|
+
if (!registries.has(name)) registries.set(name, []);
|
|
129
|
+
return registries.get(name);
|
|
130
|
+
};
|
|
131
|
+
const subscribers = new Map();
|
|
132
|
+
const notify = (name) => {
|
|
133
|
+
for (const callback of subscribers.get(name) || []) Promise.resolve().then(callback);
|
|
134
|
+
};
|
|
135
|
+
const slotService = {
|
|
136
|
+
register(options, component) {
|
|
137
|
+
const record = { options, component, registrant: options.registrant || 'dsh-panel-control' };
|
|
138
|
+
list(options.name).push(record);
|
|
139
|
+
notify(options.name);
|
|
140
|
+
return () => {
|
|
141
|
+
const all = list(options.name);
|
|
142
|
+
const at = all.indexOf(record);
|
|
143
|
+
if (at >= 0) all.splice(at, 1);
|
|
144
|
+
notify(options.name);
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
entriesOfSlot(name) {
|
|
148
|
+
return list(name).slice();
|
|
149
|
+
},
|
|
150
|
+
inject(name, callback) {
|
|
151
|
+
return callback();
|
|
152
|
+
},
|
|
153
|
+
subscribe(name, callback) {
|
|
154
|
+
if (!subscribers.has(name)) subscribers.set(name, []);
|
|
155
|
+
subscribers.get(name).push(callback);
|
|
156
|
+
return () => {};
|
|
157
|
+
},
|
|
158
|
+
renderSlot(name, props, opts) {
|
|
159
|
+
calls.renders.push({ name, props, opts });
|
|
160
|
+
return stubReact.createElement('div', { 'data-slot': name, 'data-only': opts && opts.only });
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
list('sidebar.panellist').push(
|
|
164
|
+
panelEntry({ name: 'sidebar.panellist', id: 'plugins', order: 0, label: '插件' }, 'tk'),
|
|
165
|
+
);
|
|
166
|
+
list('sidebar.panellist').push(
|
|
167
|
+
panelEntry({ name: 'sidebar.panellist', id: 'dsh-market', order: 1, label: '插件市场' }, 'dsh-market'),
|
|
168
|
+
);
|
|
169
|
+
list('sidebar.panellist').push(
|
|
170
|
+
panelEntry({ name: 'sidebar.panellist', id: 'workspace-control', order: 20, label: '工作区管理' }, 'tk'),
|
|
171
|
+
);
|
|
172
|
+
list('settings.section').push(
|
|
173
|
+
section({ name: 'settings.section', id: 'general', order: 0, label: '通用' }, 'tk'),
|
|
174
|
+
);
|
|
175
|
+
list('settings.section').push(
|
|
176
|
+
section({ name: 'settings.section', id: 'market', order: 40, label: '插件市场' }, 'dsh-market'),
|
|
177
|
+
);
|
|
178
|
+
if (seedSections) {
|
|
179
|
+
list('settings.section').push(
|
|
180
|
+
section({ name: 'settings.section', id: 'plan-mode', order: 5, label: '计划模式' }, 'tk'),
|
|
181
|
+
);
|
|
182
|
+
list('settings.section').push(
|
|
183
|
+
section({ name: 'settings.section', id: 'bili', order: 100, label: () => '上下文' }, 'tk'),
|
|
184
|
+
);
|
|
185
|
+
list('settings.section').push(
|
|
186
|
+
section({ name: 'settings.section', id: 'workspace-control-settings', order: 30, label: '工作区管理' }, 'tk'),
|
|
187
|
+
);
|
|
188
|
+
list('settings.section').push(
|
|
189
|
+
section({ name: 'settings.section', id: 'composer-rewind-settings', order: 50, label: '输入历史' }, 'tk'),
|
|
190
|
+
);
|
|
191
|
+
for (const [id, label] of [
|
|
192
|
+
['account', '账户'],
|
|
193
|
+
['models', '模型'],
|
|
194
|
+
['agent-presets', '智能体预设'],
|
|
195
|
+
]) {
|
|
196
|
+
list('settings.section').push(section({ name: 'settings.section', id, order: 1, label }, 'tk'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
registries,
|
|
201
|
+
subscribers,
|
|
202
|
+
ctx: {
|
|
203
|
+
fiber: { name: 'dsh-panel-control' },
|
|
204
|
+
get(name) {
|
|
205
|
+
if (name === 'layout') return layout;
|
|
206
|
+
if (name === 'locale') return locale;
|
|
207
|
+
return undefined;
|
|
208
|
+
},
|
|
209
|
+
effect(fn) {
|
|
210
|
+
return fn();
|
|
211
|
+
},
|
|
212
|
+
slots: slotService,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
218
|
+
|
|
219
|
+
async function run(localPrefs) {
|
|
220
|
+
const app = makeCtx();
|
|
221
|
+
const mod = install(makeWindow(localPrefs), app.registries);
|
|
222
|
+
mod.apply(app.ctx);
|
|
223
|
+
await sleep(260);
|
|
224
|
+
const rows = app.registries.get('sidebar.panellist') || [];
|
|
225
|
+
const mains = app.registries.get('main') || [];
|
|
226
|
+
const labelOf = (options) => (typeof options.label === 'function' ? options.label() : options.label);
|
|
227
|
+
return {
|
|
228
|
+
ids: rows.map((row) => row.options.id),
|
|
229
|
+
labels: rows.map((row) => labelOf(row.options)),
|
|
230
|
+
mains: mains.map((row) => row.options.key),
|
|
231
|
+
mainRows: mains,
|
|
232
|
+
rows,
|
|
233
|
+
registries: app.registries,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const failures = [];
|
|
238
|
+
const check = (ok, message) => {
|
|
239
|
+
if (!ok) failures.push(message);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Boot order is not registration order: a plugin that applies after us adds its
|
|
243
|
+
// settings section later. The subscription installed when the slot is declared
|
|
244
|
+
// must pick that up without a reload.
|
|
245
|
+
async function runLate() {
|
|
246
|
+
const app = makeCtx(false);
|
|
247
|
+
const mod = install(makeWindow(undefined), app.registries);
|
|
248
|
+
mod.apply(app.ctx);
|
|
249
|
+
await sleep(300);
|
|
250
|
+
const before = (app.registries.get('sidebar.panellist') || []).map((row) => row.options.id);
|
|
251
|
+
app.ctx.slots.register(
|
|
252
|
+
{ name: 'settings.section', id: 'plan-mode', order: 5, label: '计划模式', registrant: 'tk' },
|
|
253
|
+
() => null,
|
|
254
|
+
);
|
|
255
|
+
app.ctx.slots.register(
|
|
256
|
+
{ name: 'settings.section', id: 'bili', order: 100, label: () => '上下文', registrant: 'tk' },
|
|
257
|
+
() => null,
|
|
258
|
+
);
|
|
259
|
+
await sleep(420);
|
|
260
|
+
const after = (app.registries.get('sidebar.panellist') || []).map((row) => row.options.id);
|
|
261
|
+
return { before, after };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const late = await runLate();
|
|
265
|
+
check(!late.before.some((id) => id.startsWith('dpc-bridge-')), 'bridged a section before it was registered');
|
|
266
|
+
check(
|
|
267
|
+
late.after.includes('dpc-bridge-plan-mode') && late.after.includes('dpc-bridge-bili'),
|
|
268
|
+
'late-registered settings sections were not bridged',
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const on = await run(undefined);
|
|
272
|
+
check(on.ids.includes('dpc-bridge-plan-mode'), 'missing bridge row for dsh-flash-presets');
|
|
273
|
+
check(on.ids.includes('dpc-bridge-bili'), 'missing bridge row for billion-context');
|
|
274
|
+
check(on.ids.includes('dpc-bridge-composer-rewind-settings'), 'missing bridge row for dsh-composer-rewind');
|
|
275
|
+
check(!on.ids.includes('dpc-bridge-market'), 'bridged dshmarket even though it has a panel');
|
|
276
|
+
check(!on.ids.includes('dpc-bridge-general'), 'bridged a core settings section');
|
|
277
|
+
check(!on.ids.includes('dpc-bridge-account'), 'bridged a core settings section');
|
|
278
|
+
check(!on.ids.includes('dpc-bridge-models'), 'bridged a core settings section');
|
|
279
|
+
check(!on.ids.includes('dpc-bridge-agent-presets'), 'bridged a core settings section');
|
|
280
|
+
check(!on.ids.includes('dpc-bridge-workspace-control-settings'), 'bridged a plugin whose panel id is the section base');
|
|
281
|
+
check(on.mains.includes('dpc-bridge-plan-mode') && on.mains.includes('dpc-bridge-bili'), 'a bridge row has no main entry');
|
|
282
|
+
const planRow = on.rows.find((row) => row.options.id === 'dpc-bridge-plan-mode');
|
|
283
|
+
check(planRow && planRow.options.order === 85, 'bridge rows lost their order');
|
|
284
|
+
check(planRow && planRow.options.label() === '计划模式', 'bridge label is not the section label');
|
|
285
|
+
|
|
286
|
+
const tree = on.rows.find((row) => row.options.id === 'dpc-bridge-bili').component({ size: 18, active: false });
|
|
287
|
+
check(tree && tree.type === 'svg', 'bridge icon is not an svg');
|
|
288
|
+
|
|
289
|
+
const off = await run({ version: 2, bridge: false, order: ['plugins', 'dsh-market', 'panel-control'] });
|
|
290
|
+
check(!off.ids.some((id) => id.startsWith('dpc-bridge-')), 'the switch did not remove the bridge rows');
|
|
291
|
+
|
|
292
|
+
// The settings shell owns `settings.section`, so the ctx-level renderSlot cannot
|
|
293
|
+
// reach it. The bridge declares its own child slot, registers the section
|
|
294
|
+
// component into it, and renders it through the props face the host binds.
|
|
295
|
+
const biliMain = on.mainRows.find((row) => row.options.key === 'dpc-bridge-bili');
|
|
296
|
+
check(Boolean(biliMain), 'no main key for the bridged row');
|
|
297
|
+
const slotName = 'dpc-bridge-slot-bili';
|
|
298
|
+
const declared = biliMain && biliMain.options.children;
|
|
299
|
+
check(Boolean(declared && declared[slotName]), 'the bridge main entry declared no child slot');
|
|
300
|
+
check(declared && declared[slotName] && declared[slotName].kind === 'single', 'the bridge child slot is not single');
|
|
301
|
+
const face = (name, props, opts) => {
|
|
302
|
+
if (!declared || !declared[name]) throw new Error('slot ' + name + ' is not declared by this entry children');
|
|
303
|
+
calls.renders.push({ name, props, opts });
|
|
304
|
+
return stubReact.createElement('div', { 'data-slot': name });
|
|
305
|
+
};
|
|
306
|
+
const view = biliMain ? biliMain.component({ renderSlot: face }) : null;
|
|
307
|
+
const slot = calls.renders.find((render) => render.name === slotName);
|
|
308
|
+
check(Boolean(slot), 'the bridge page did not render its child slot');
|
|
309
|
+
check(slot && typeof slot.props.close === 'function', 'the bridge page gave the section no close');
|
|
310
|
+
check(Boolean(view) && view.type === 'div', 'the bridge page did not render its frame');
|
|
311
|
+
const childEntries = on.registries.get(slotName) || [];
|
|
312
|
+
check(childEntries.length === 1, 'the section component was not re-registered into the bridge slot');
|
|
313
|
+
check(childEntries[0] && typeof childEntries[0].component === 'function', 'the bridge slot has no component');
|
|
314
|
+
if (slot) slot.props.close();
|
|
315
|
+
check(calls.selectPanel.includes(null), 'closing the bridge page did not select the Conversation');
|
|
316
|
+
|
|
317
|
+
console.log(JSON.stringify({ on: on.ids, onLabels: on.labels, mains: on.mains, off: off.ids, renders: calls.renders.length }, null, 2));
|
|
318
|
+
if (failures.length) {
|
|
319
|
+
console.error('bridge-smoke: ' + failures.length + ' problem(s)');
|
|
320
|
+
for (const failure of failures) console.error(' - ' + failure);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
console.log('bridge-smoke: ok');
|
|
324
|
+
process.exit(0);
|