dsh-panel-control 0.7.2 → 0.8.3
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 +11 -4
- package/client.js +537 -5
- package/docs/bridged-settings-pages.md +85 -0
- package/package.json +2 -2
- package/scripts/bridge-smoke.mjs +324 -0
- package/scripts/layout-smoke.mjs +16 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- **分组折叠** — 点分组标题收起该组,只留标题与计数;`Enter` / 空格同样可切换,状态写进配置。侧栏收成图标条时不显示标题,也不折叠。
|
|
11
11
|
- **侧栏溢出兜底** — 导航项变多时,官方侧栏会把下方的会话/工作区列表压到 0 高且无法滚动([讨论 #12083](https://github.com/deepseek-ai/deepseek-harness/discussions/12083))。本插件给导航列表加上限高与滚动,给下方区域留最小高度。
|
|
12
12
|
- **自动分类** — 用当前 profile 的默认模型读入口名称,自动分成 2–4 组。只处理没见过的入口,不会改动你排好的顺序;随时可手动「重新分类」。
|
|
13
|
+
- **设置页补入口** — 有的第三方插件只注册了设置页(`settings.section`),却没给面板入口,设置页只能从设置弹窗里翻到。本插件为这类插件自动补一行侧栏入口:点开就是宿主渲染的完整设置页,顶部带「返回对话」;不依赖插件白名单,整体可在「侧栏布局」里关掉。
|
|
13
14
|
- **面板管理自己不可隐藏** — 避免把自己锁在门外;未接管的节点(如「更多」)始终排在最后。
|
|
14
15
|
|
|
15
16
|
## 界面
|
|
@@ -22,13 +23,15 @@
|
|
|
22
23
|
|
|
23
24
|
两个入口:侧栏导航里的「面板管理」行,以及 **设置 → 面板管理**(同一套管理界面整块嵌在设置页)。
|
|
24
25
|
|
|
26
|
+
补出来的设置页入口也出现在同一个侧栏列表里,排在普通入口之后、本插件自己之前;标题沿用设置页自己的名称,重复时附带设置页 id 以区分。
|
|
27
|
+
|
|
25
28
|
## 安装
|
|
26
29
|
|
|
27
30
|
从 npm 安装(推荐):
|
|
28
31
|
|
|
29
32
|
```bash
|
|
30
33
|
cd C:/Users/<you>/.dsh/profiles/desktop
|
|
31
|
-
npm install dsh-panel-control@0.
|
|
34
|
+
npm install dsh-panel-control@0.8.3
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
把包名加进同一目录 `package.json` 的 `dsh.profile.bundles`:
|
|
@@ -64,24 +67,26 @@ cmd //c mklink /J node_modules\dsh-panel-control D:\Github\dsh-panel-control
|
|
|
64
67
|
|
|
65
68
|
- 高度兜底开关,以及 35 / 45 / 55 / 不限 四档上限;
|
|
66
69
|
- 分组折叠开关,以及「全部收起 / 全部展开」;
|
|
70
|
+
- 设置页入口开关(默认开)。关掉后补出来的行会连同注册一起撤掉,不只是藏起来;
|
|
67
71
|
- 折叠状态按分组 id 记在配置里,重启后保留。
|
|
68
72
|
|
|
69
73
|
## 配置与存储
|
|
70
74
|
|
|
71
|
-
布局写在 Host 侧:`~/.dsh/panel-control/prefs.json`(`~` 是这个 profile
|
|
75
|
+
布局写在 Host 侧:`~/.dsh/panel-control/prefs.json`(`~` 是这个 profile 的用户目录)。包含顺序、隐藏项、分组、折叠状态、布局开关、设置页入口开关与分类结果。
|
|
72
76
|
|
|
73
77
|
页面 origin 是 Host 的 loopback 端口,每次启动都会换端口,`localStorage` 会因此"看起来每次清空"——所以真实存储放在 Host 侧,`localStorage` 只留镜像;读不到 Host 时才回退到镜像。配置按安装维度生效,不跟随工作区。
|
|
74
78
|
|
|
75
79
|
## 排错
|
|
76
80
|
|
|
77
81
|
- **侧栏没有「面板管理」**:先重载一次页面。仍没有,看 `~/.dsh/panel-control/host.log` 与 `client.log`。
|
|
82
|
+
- **设置页入口没补出来**:`host.log` 里先搜 `bridge-scan`,启动后的前几次扫描会列出当时可见的设置页(`id:registrant`)和侧栏条目;再搜 `kind=bridge` 看最终补出的 id。目标插件出现在 `sections` 里但 `ids` 没它,说明它的 section id 或 label 与某个已有面板重合,被归属规则当成了已有入口;始终不出现,是它的设置页注册得比扫描更晚(见 `docs/bridged-settings-pages.md`)。
|
|
78
83
|
- **插件管理显示运行,界面却没变化**:页面多半还在用旧的 bundle revision(刚改过 `client.js` 时常见),重载一次即可。
|
|
79
84
|
- **改了代码没反应**:确认 `BUILD_STAMP` 与 `package.json` 的版本一致,并跑一遍 `npm run check`。
|
|
80
85
|
|
|
81
86
|
## 开发与测试
|
|
82
87
|
|
|
83
88
|
```bash
|
|
84
|
-
npm run check # 语法检查 + 宿主路由 smoke +
|
|
89
|
+
npm run check # 语法检查 + 宿主路由 smoke + 布局/桥接静态与行为断言
|
|
85
90
|
npm run smoke # 只跑宿主路由用例
|
|
86
91
|
npm run doctor # 体检 profile 安装状态
|
|
87
92
|
```
|
|
@@ -89,8 +94,9 @@ npm run doctor # 体检 profile 安装状态
|
|
|
89
94
|
- `.scratch/harness.html`:引擎测试页(伪造 DOM 与 ctx),覆盖排序、隐藏、分组、折叠、脏数据清洗。
|
|
90
95
|
- `scripts/host-smoke.mjs`:驱动宿主侧路由,覆盖设置读写、分类请求与 bundle 兜底。
|
|
91
96
|
- `scripts/layout-smoke.mjs`:断言侧栏布局的 CSS 钩子与关键数值,并校验 `BUILD_STAMP` 与版本号一致。
|
|
97
|
+
- `scripts/bridge-smoke.mjs`:在假 ctx 上跑 `client.js`,断言只给「有设置页、没有侧栏入口」的第三方插件补行,且成对注册 main、开关关闭后整体撤回、桥接页经自有子槽交给宿主渲染并能返回对话。
|
|
92
98
|
|
|
93
|
-
进一步材料:[docs/sidebar-layout.md](docs/sidebar-layout.md)(布局与折叠实现)、[docs/ui-design-notes.md](docs/ui-design-notes.md)(界面规范与踩坑)、[docs/discussion-bundle-evaluated-twice.md](docs/discussion-bundle-evaluated-twice.md)(bundle 被求值两次的事故复盘)。
|
|
99
|
+
进一步材料:[docs/sidebar-layout.md](docs/sidebar-layout.md)(布局与折叠实现)、[docs/bridged-settings-pages.md](docs/bridged-settings-pages.md)(设置页补入口的识别与渲染)、[docs/ui-design-notes.md](docs/ui-design-notes.md)(界面规范与踩坑)、[docs/discussion-bundle-evaluated-twice.md](docs/discussion-bundle-evaluated-twice.md)(bundle 被求值两次的事故复盘)。
|
|
94
100
|
|
|
95
101
|
## 边界
|
|
96
102
|
|
|
@@ -99,6 +105,7 @@ npm run doctor # 体检 profile 安装状态
|
|
|
99
105
|
- 依赖宿主侧栏的 DOM 结构(`[class*="panelList"]` 与面板按钮的 `aria-label`),官方大改版时需要跟进。
|
|
100
106
|
- 只写 `style.order`、`style.display` 与 `data-dpc-*` 属性,不移动 React 节点,不改宿主代码。
|
|
101
107
|
- 排序、隐藏、分组都只影响本机这份安装。
|
|
108
|
+
- 设置页入口按「section id / label 是否已被某个面板占用」识别归属,不依赖插件声明的注册者(client runner 会把多个插件报成同一个名字);代价是启发式:与面板同名的第三方设置页会被视为已有入口,新增内置设置页需要更新 `CORE_SECTION_IDS`。第三方设置页按宿主渲染器原位渲染,若它自己依赖设置弹窗的私有上下文,可能出现布局差异。
|
|
102
109
|
|
|
103
110
|
## 许可
|
|
104
111
|
|
package/client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Everything below therefore lives inside this closure. Re-evaluation must stay possible: when the
|
|
6
6
|
// plugin is switched off the loader drops our factory, so switching it back on re-executes this
|
|
7
7
|
// script and needs the registration that follows.
|
|
8
|
-
const BUILD_STAMP = 'dsh-panel-control/0.
|
|
8
|
+
const BUILD_STAMP = 'dsh-panel-control/0.8.3';
|
|
9
9
|
try { window.__dpcEvaluated = BUILD_STAMP; } catch { /* ignore */ }
|
|
10
10
|
/**
|
|
11
11
|
* Client half of dsh-panel-control.
|
|
@@ -265,6 +265,14 @@ function build(require) {
|
|
|
265
265
|
const SLOT_MAIN = 'main';
|
|
266
266
|
const SLOT_SETTINGS = 'settings.section';
|
|
267
267
|
const PANEL_KEY = 'panel-control';
|
|
268
|
+
/** Bridged pages get their own ids; the prefix also keeps them out of the host's reserved keys. */
|
|
269
|
+
const BRIDGE_PREFIX = 'dpc-bridge-';
|
|
270
|
+
const BRIDGE_SLOT_PREFIX = 'dpc-bridge-slot-';
|
|
271
|
+
/** Between the ordinary entries and this manager's own row (order 90). */
|
|
272
|
+
const BRIDGE_ORDER = 85;
|
|
273
|
+
const BRIDGE_DEBOUNCE_MS = 120;
|
|
274
|
+
/** Boot-time rescans: plugins register their settings page while the page starts. */
|
|
275
|
+
const BRIDGE_RETRY_MS = [350, 900, 2000, 4200, 8000, 15000];
|
|
268
276
|
const STORAGE_KEY = 'dsh.panel-control.v1';
|
|
269
277
|
/** Host route that answers "file these entries into groups" (see index.js). */
|
|
270
278
|
const RPC_CHANNEL = '/api';
|
|
@@ -377,6 +385,16 @@ function build(require) {
|
|
|
377
385
|
layoutCollapseHint: { zh: '点分组标题收起该组,标题与数量仍保留。', en: 'Click a group title to fold it; the title and count stay.' },
|
|
378
386
|
layoutMax: { zh: '高度上限', en: 'Height cap' },
|
|
379
387
|
layoutMaxNone: { zh: '不限', en: 'No cap' },
|
|
388
|
+
layoutBridge: { zh: '补全设置页入口', en: 'Entries for settings pages' },
|
|
389
|
+
layoutBridgeHint: {
|
|
390
|
+
zh: '第三方插件只做了设置页、没做侧栏入口时,替它在侧栏补一个入口。关掉后这些入口全部移除,插件自己的设置页不受影响。',
|
|
391
|
+
en: 'When a third-party plugin ships a settings page but no sidebar entry, add one for it. Turning this off removes only those entries; the plugin keeps its settings page.',
|
|
392
|
+
},
|
|
393
|
+
bridgeBack: { zh: '返回对话', en: 'Back to conversation' },
|
|
394
|
+
bridgeTag: { zh: '设置页', en: 'Settings page' },
|
|
395
|
+
bridgeFrom: { zh: '来自 {p}', en: 'From {p}' },
|
|
396
|
+
bridgeMissing: { zh: '这个设置页已经不可用。', en: 'This settings page is no longer available.' },
|
|
397
|
+
diagBridge: { zh: '补全入口', en: 'bridged entries' },
|
|
380
398
|
layoutCollapseAll: { zh: '全部收起', en: 'Fold all' },
|
|
381
399
|
layoutExpandAll: { zh: '全部展开', en: 'Unfold all' },
|
|
382
400
|
layoutCollapseOne: { zh: '点此收起', en: 'click to fold' },
|
|
@@ -468,6 +486,7 @@ function build(require) {
|
|
|
468
486
|
collapsed: [],
|
|
469
487
|
guard: true,
|
|
470
488
|
collapse: true,
|
|
489
|
+
bridge: true,
|
|
471
490
|
panelMax: 45,
|
|
472
491
|
});
|
|
473
492
|
|
|
@@ -508,6 +527,9 @@ function build(require) {
|
|
|
508
527
|
// explicit false is the only way to turn the CSS off.
|
|
509
528
|
out.guard = raw.guard !== false;
|
|
510
529
|
out.collapse = raw.collapse !== false;
|
|
530
|
+
// Missing in files written before the bridge existed: those installs get the
|
|
531
|
+
// feature by default, exactly like a fresh one.
|
|
532
|
+
out.bridge = raw.bridge !== false;
|
|
511
533
|
const cap = Math.round(Number(raw.panelMax));
|
|
512
534
|
out.panelMax = Number.isFinite(cap) ? Math.min(70, Math.max(0, cap)) : 45;
|
|
513
535
|
if (Array.isArray(raw.groups)) {
|
|
@@ -559,7 +581,13 @@ function build(require) {
|
|
|
559
581
|
(prefs.groups || []).length > 0 ||
|
|
560
582
|
(prefs.collapsed || []).length > 0 ||
|
|
561
583
|
(prefs.autoFiled || []).length > 0 ||
|
|
562
|
-
prefs.auto === true
|
|
584
|
+
prefs.auto === true ||
|
|
585
|
+
// A file that only turned a switch off is still a file the reader edited;
|
|
586
|
+
// without this, the next load treats it as empty and drops the edit.
|
|
587
|
+
prefs.guard === false ||
|
|
588
|
+
prefs.collapse === false ||
|
|
589
|
+
prefs.bridge === false ||
|
|
590
|
+
(typeof prefs.panelMax === 'number' && prefs.panelMax !== 45));
|
|
563
591
|
|
|
564
592
|
/** One call to the Host half, or null when this Client cannot reach it. */
|
|
565
593
|
async function hostCall(method, payload) {
|
|
@@ -900,6 +928,339 @@ function build(require) {
|
|
|
900
928
|
left.length === right.length &&
|
|
901
929
|
left.every((row, index) => row.id === right[index].id && row.label === right[index].label);
|
|
902
930
|
|
|
931
|
+
/* -------------------------------------------------------------- bridges */
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Settings pages with no panel of their own.
|
|
935
|
+
*
|
|
936
|
+
* A Client plugin addresses its settings page with `settings.section`; the
|
|
937
|
+
* sidebar only lists `sidebar.panellist` entries, and the two registries are
|
|
938
|
+
* unrelated. A plugin that never registers a panel therefore has a reachable
|
|
939
|
+
* settings page and no way to open it from the navigation rail. This block
|
|
940
|
+
* gives each such page a panel of its own: a panellist row plus the `main`
|
|
941
|
+
* entry it addresses, whose body is the host's own renderer for that section.
|
|
942
|
+
*
|
|
943
|
+
* A slot entry's `registrant` names the Cordis fiber that registered it, but
|
|
944
|
+
* the client runner applies many plugins inside one fiber: in practice every
|
|
945
|
+
* plugin except the market reports the same name, so a registrant match cannot
|
|
946
|
+
* tell one plugin from another. Attribution is therefore by ownership of the
|
|
947
|
+
* navigation row: a settings id whose base name (with a trailing `-settings`
|
|
948
|
+
* removed) is an existing panel id, or whose label is an existing panel label,
|
|
949
|
+
* belongs to a plugin that already has a way in. Core settings ids are listed
|
|
950
|
+
* explicitly; they ship without a panel because that is the product decision,
|
|
951
|
+
* not a gap.
|
|
952
|
+
*/
|
|
953
|
+
const CORE_SECTION_IDS = new Set(['account', 'general', 'models', 'agent-presets', 'plugins']);
|
|
954
|
+
|
|
955
|
+
const bridgeState = {
|
|
956
|
+
entries: new Map(),
|
|
957
|
+
labels: new Map(),
|
|
958
|
+
timer: null,
|
|
959
|
+
retries: new Set(),
|
|
960
|
+
retriesStarted: false,
|
|
961
|
+
syncing: false,
|
|
962
|
+
scans: 0,
|
|
963
|
+
unsub: null,
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
function registrantOf(entry) {
|
|
967
|
+
const options = (entry && entry.options) || {};
|
|
968
|
+
const value = (entry && entry.registrant) || options.registrant;
|
|
969
|
+
return typeof value === 'string' ? value : '';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function readSections(slots) {
|
|
973
|
+
try {
|
|
974
|
+
const raw = slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_SETTINGS) || [] : [];
|
|
975
|
+
return raw
|
|
976
|
+
.map((entry) => {
|
|
977
|
+
const options = (entry && entry.options) || {};
|
|
978
|
+
return {
|
|
979
|
+
id: typeof options.id === 'string' ? options.id : '',
|
|
980
|
+
label: options.label,
|
|
981
|
+
registrant: registrantOf(entry),
|
|
982
|
+
component: entry && entry.component,
|
|
983
|
+
inject: options.inject,
|
|
984
|
+
store: options.store,
|
|
985
|
+
locale: options.locale,
|
|
986
|
+
};
|
|
987
|
+
})
|
|
988
|
+
.filter((section) => section.id !== '');
|
|
989
|
+
} catch {
|
|
990
|
+
return [];
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function panelEntries(slots) {
|
|
995
|
+
try {
|
|
996
|
+
return (slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_PANELS) || [] : [])
|
|
997
|
+
.map((entry) => entry && entry.options)
|
|
998
|
+
.filter((options) => options && typeof options.id === 'string' && options.id !== '')
|
|
999
|
+
.map((options) => ({ id: options.id, label: resolveLabel(options.label, options.id) }));
|
|
1000
|
+
} catch {
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function sectionBase(id) {
|
|
1006
|
+
return id.endsWith('-settings') ? id.slice(0, -'-settings'.length) : id;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Every settings page whose plugin owns no sidebar entry.
|
|
1011
|
+
*
|
|
1012
|
+
* A plugin may expose more than one settings page; each becomes its own row,
|
|
1013
|
+
* labelled with the plugin's own section label. A label already used by a real
|
|
1014
|
+
* panel is suffixed with the settings id so two rows never read the same.
|
|
1015
|
+
*/
|
|
1016
|
+
function bridgeCandidates(slots) {
|
|
1017
|
+
const rows = panelEntries(slots).filter((row) => !row.id.startsWith(BRIDGE_PREFIX));
|
|
1018
|
+
const panelIds = new Set(rows.map((row) => row.id));
|
|
1019
|
+
const panelLabels = new Set(rows.map((row) => row.label));
|
|
1020
|
+
// Real panel labels own their names; a bridged row must not steal one, and
|
|
1021
|
+
// must not collide with another bridged row either.
|
|
1022
|
+
const used = new Set(panelLabels);
|
|
1023
|
+
const out = [];
|
|
1024
|
+
const taken = new Set();
|
|
1025
|
+
for (const section of readSections(slots)) {
|
|
1026
|
+
if (CORE_SECTION_IDS.has(section.id)) continue;
|
|
1027
|
+
const base = sectionBase(section.id);
|
|
1028
|
+
let owned = panelIds.has(section.id) || panelIds.has(base);
|
|
1029
|
+
if (!owned) {
|
|
1030
|
+
for (const panelId of panelIds) {
|
|
1031
|
+
if (panelId.endsWith('-' + base)) {
|
|
1032
|
+
owned = true;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (owned) continue;
|
|
1038
|
+
const label = resolveLabel(section.label, section.id);
|
|
1039
|
+
if (panelLabels.has(label)) continue;
|
|
1040
|
+
if (typeof section.component !== 'function') continue;
|
|
1041
|
+
const bridgeId = BRIDGE_PREFIX + section.id;
|
|
1042
|
+
if (bridgeId.length > LIMITS.key || taken.has(bridgeId)) continue;
|
|
1043
|
+
taken.add(bridgeId);
|
|
1044
|
+
const chosen = used.has(label) ? `${label} · ${section.id}` : label;
|
|
1045
|
+
used.add(chosen);
|
|
1046
|
+
out.push({
|
|
1047
|
+
bridgeId,
|
|
1048
|
+
sectionId: section.id,
|
|
1049
|
+
label: chosen,
|
|
1050
|
+
component: section.component,
|
|
1051
|
+
inject: section.inject,
|
|
1052
|
+
store: section.store,
|
|
1053
|
+
locale: section.locale,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** The layout's live selection, read through the public store when it exposes one. */
|
|
1060
|
+
function activePanelId() {
|
|
1061
|
+
try {
|
|
1062
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
1063
|
+
const info = layout && layout.panelInfo;
|
|
1064
|
+
const snapshot = info && typeof info.getSnapshot === 'function' ? info.getSnapshot() : info;
|
|
1065
|
+
return snapshot && typeof snapshot.activePanelId === 'string' ? snapshot.activePanelId : null;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function disposeBridge(id, record) {
|
|
1072
|
+
bridgeState.entries.delete(id);
|
|
1073
|
+
for (const dispose of record.disposers.slice().reverse()) {
|
|
1074
|
+
try {
|
|
1075
|
+
dispose();
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
console.warn(`[${PACKAGE}] bridge cleanup failed for "${id}"`, error);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// The main key this row addressed is gone, so a reader sitting on it would
|
|
1081
|
+
// stare at an empty centre column. The Conversation is the only safe landing.
|
|
1082
|
+
if (activePanelId() === id) {
|
|
1083
|
+
try {
|
|
1084
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
1085
|
+
if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
|
|
1086
|
+
} catch {
|
|
1087
|
+
/* nothing was selected after all */
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function syncBridges() {
|
|
1093
|
+
const slots = ctxRef && ctxRef.slots;
|
|
1094
|
+
if (!slots || typeof slots.register !== 'function' || bridgeState.syncing) return;
|
|
1095
|
+
bridgeState.syncing = true;
|
|
1096
|
+
bridgeState.scans += 1;
|
|
1097
|
+
let changed = false;
|
|
1098
|
+
try {
|
|
1099
|
+
const enabled = prefsNow().bridge !== false;
|
|
1100
|
+
watchSettings();
|
|
1101
|
+
const sections = readSections(slots);
|
|
1102
|
+
const panelIds = panelEntries(slots).map((row) => row.id);
|
|
1103
|
+
const candidates = enabled ? bridgeCandidates(slots) : [];
|
|
1104
|
+
const desired = new Set(candidates.map((candidate) => candidate.bridgeId));
|
|
1105
|
+
bridgeState.labels = new Map(candidates.map((candidate) => [candidate.bridgeId, candidate.label]));
|
|
1106
|
+
for (const [id, record] of Array.from(bridgeState.entries)) {
|
|
1107
|
+
if (desired.has(id)) continue;
|
|
1108
|
+
disposeBridge(id, record);
|
|
1109
|
+
changed = true;
|
|
1110
|
+
}
|
|
1111
|
+
for (const candidate of candidates) {
|
|
1112
|
+
if (bridgeState.entries.has(candidate.bridgeId)) continue;
|
|
1113
|
+
const slotName = BRIDGE_SLOT_PREFIX + candidate.sectionId;
|
|
1114
|
+
const record = { disposers: [] };
|
|
1115
|
+
try {
|
|
1116
|
+
record.disposers.push(
|
|
1117
|
+
slots.register(
|
|
1118
|
+
{
|
|
1119
|
+
name: SLOT_MAIN,
|
|
1120
|
+
key: candidate.bridgeId,
|
|
1121
|
+
locale: NS,
|
|
1122
|
+
children: { [slotName]: { kind: 'single', scope: 'root' } },
|
|
1123
|
+
},
|
|
1124
|
+
makeBridgePanel(candidate, slotName),
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
// The settings shell owns the `settings.section` declaration, so its
|
|
1128
|
+
// props-face renderer is not ours to call. Declaring one child slot of
|
|
1129
|
+
// our own and re-registering the same component into it gives the
|
|
1130
|
+
// section the host kit it expects (inject, locale, store, boundary)
|
|
1131
|
+
// without touching the shell or the foreign registration.
|
|
1132
|
+
record.disposers.push(
|
|
1133
|
+
slots.register(
|
|
1134
|
+
{
|
|
1135
|
+
name: slotName,
|
|
1136
|
+
inject: candidate.inject,
|
|
1137
|
+
store: candidate.store,
|
|
1138
|
+
locale: candidate.locale,
|
|
1139
|
+
},
|
|
1140
|
+
candidate.component,
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
record.disposers.push(
|
|
1144
|
+
slots.register(
|
|
1145
|
+
{
|
|
1146
|
+
name: SLOT_PANELS,
|
|
1147
|
+
id: candidate.bridgeId,
|
|
1148
|
+
order: BRIDGE_ORDER,
|
|
1149
|
+
locale: NS,
|
|
1150
|
+
label: () => bridgeState.labels.get(candidate.bridgeId) || candidate.label,
|
|
1151
|
+
},
|
|
1152
|
+
BridgeIcon,
|
|
1153
|
+
),
|
|
1154
|
+
);
|
|
1155
|
+
bridgeState.entries.set(candidate.bridgeId, record);
|
|
1156
|
+
changed = true;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
for (const dispose of record.disposers.slice().reverse()) {
|
|
1159
|
+
try {
|
|
1160
|
+
dispose();
|
|
1161
|
+
} catch {
|
|
1162
|
+
/* the pair is already half gone */
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
console.warn(`[${PACKAGE}] could not add a panel for "${candidate.sectionId}"`, error);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Early scans run while plugins are still registering. Keeping a trace of
|
|
1169
|
+
// exactly what the slot returned tells a later reader whether a missing
|
|
1170
|
+
// row is a timing problem or an ownership guess that did not hold.
|
|
1171
|
+
if (bridgeState.scans <= 8 || changed) {
|
|
1172
|
+
reportHost({
|
|
1173
|
+
kind: 'bridge-scan',
|
|
1174
|
+
enabled,
|
|
1175
|
+
scans: bridgeState.scans,
|
|
1176
|
+
sections: sections.map((section) => `${section.id}:${section.registrant || '?'}`).join('|'),
|
|
1177
|
+
panels: panelIds.join('|'),
|
|
1178
|
+
ids: Array.from(bridgeState.entries.keys()).join(','),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (changed) {
|
|
1182
|
+
reportHost({
|
|
1183
|
+
kind: 'bridge',
|
|
1184
|
+
enabled,
|
|
1185
|
+
ids: Array.from(bridgeState.entries.keys()).join(','),
|
|
1186
|
+
sections: candidates.length,
|
|
1187
|
+
});
|
|
1188
|
+
// The diagnostics line in the settings card reads this count; without a
|
|
1189
|
+
// re-render it would keep showing the value from before the sync.
|
|
1190
|
+
emit();
|
|
1191
|
+
}
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
console.warn(`[${PACKAGE}] bridge sync failed`, error);
|
|
1194
|
+
} finally {
|
|
1195
|
+
bridgeState.syncing = false;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function scheduleBridgeSync() {
|
|
1200
|
+
if (bridgeState.timer) clearTimeout(bridgeState.timer);
|
|
1201
|
+
bridgeState.timer = setTimeout(() => {
|
|
1202
|
+
bridgeState.timer = null;
|
|
1203
|
+
syncBridges();
|
|
1204
|
+
}, BRIDGE_DEBOUNCE_MS);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Watch the section registry.
|
|
1209
|
+
*
|
|
1210
|
+
* `subscribe` is trustworthy only once the slot is declared. The plugin runs
|
|
1211
|
+
* before some of the plugins it bridges, so an early subscription can resolve
|
|
1212
|
+
* to nothing and never be retried; the declaration callback calls this again.
|
|
1213
|
+
*/
|
|
1214
|
+
function watchSettings() {
|
|
1215
|
+
if (bridgeState.unsub) return;
|
|
1216
|
+
const slots = ctxRef && ctxRef.slots;
|
|
1217
|
+
if (!slots || typeof slots.subscribe !== 'function') return;
|
|
1218
|
+
try {
|
|
1219
|
+
const dispose = slots.subscribe(SLOT_SETTINGS, () => scheduleBridgeSync());
|
|
1220
|
+
bridgeState.unsub = typeof dispose === 'function' ? dispose : () => {};
|
|
1221
|
+
} catch {
|
|
1222
|
+
/* not declared yet; the next scan or declaration retries */
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function unwatchSettings() {
|
|
1227
|
+
if (!bridgeState.unsub) return;
|
|
1228
|
+
try {
|
|
1229
|
+
bridgeState.unsub();
|
|
1230
|
+
} catch {
|
|
1231
|
+
/* the slot is already gone */
|
|
1232
|
+
}
|
|
1233
|
+
bridgeState.unsub = null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function scheduleBridgeRetry(delay) {
|
|
1237
|
+
const id = setTimeout(() => {
|
|
1238
|
+
bridgeState.retries.delete(id);
|
|
1239
|
+
if (ctxRef) syncBridges();
|
|
1240
|
+
}, delay);
|
|
1241
|
+
// Browsers return a number; Node returns an object. Letting the harness
|
|
1242
|
+
// exit without waiting out the last boot scan keeps `npm run check` quick.
|
|
1243
|
+
if (id && typeof id.unref === 'function') id.unref();
|
|
1244
|
+
bridgeState.retries.add(id);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function scheduleBridgeRetries() {
|
|
1248
|
+
if (bridgeState.retriesStarted) return;
|
|
1249
|
+
bridgeState.retriesStarted = true;
|
|
1250
|
+
for (const delay of BRIDGE_RETRY_MS) scheduleBridgeRetry(delay);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function disposeBridges() {
|
|
1254
|
+
if (bridgeState.timer) {
|
|
1255
|
+
clearTimeout(bridgeState.timer);
|
|
1256
|
+
bridgeState.timer = null;
|
|
1257
|
+
}
|
|
1258
|
+
for (const id of Array.from(bridgeState.retries)) clearTimeout(id);
|
|
1259
|
+
bridgeState.retries.clear();
|
|
1260
|
+
unwatchSettings();
|
|
1261
|
+
for (const [id, record] of Array.from(bridgeState.entries)) disposeBridge(id, record);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
903
1264
|
/** The stored order, extended with every entry that is new since it was saved. */
|
|
904
1265
|
function normalize(prefs, entries) {
|
|
905
1266
|
const known = new Set(entries.map((entry) => entry.id));
|
|
@@ -1447,6 +1808,9 @@ function build(require) {
|
|
|
1447
1808
|
scheduleSaveToHost();
|
|
1448
1809
|
emit();
|
|
1449
1810
|
if (engineRef) engineRef.schedule();
|
|
1811
|
+
// The bridge switch lives here too; toggling it must add or drop the rows
|
|
1812
|
+
// right away rather than on the next unrelated registration.
|
|
1813
|
+
scheduleBridgeSync();
|
|
1450
1814
|
}
|
|
1451
1815
|
|
|
1452
1816
|
function setDiag(patch) {
|
|
@@ -1477,6 +1841,8 @@ function build(require) {
|
|
|
1477
1841
|
if (!stored || !hasContent(stored)) await hostCall('save', { prefs: chosen });
|
|
1478
1842
|
}
|
|
1479
1843
|
setDiag({ prefsSource: prefsSource.value, hostFile });
|
|
1844
|
+
// The Host copy may carry the bridge switch with it.
|
|
1845
|
+
scheduleBridgeSync();
|
|
1480
1846
|
}
|
|
1481
1847
|
|
|
1482
1848
|
const prefsNow = () => store.state.prefs;
|
|
@@ -1747,6 +2113,7 @@ function build(require) {
|
|
|
1747
2113
|
plus: 'M12 6v12M6 12h12',
|
|
1748
2114
|
eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12Z',
|
|
1749
2115
|
eyeOff: 'M2.5 12S6 6.5 12 6.5c1.6 0 3 .45 4.2 1.1M21.5 12S18 17.5 12 17.5c-1.6 0-3-.45-4.2-1.1M4 4l16 16',
|
|
2116
|
+
back: 'M14.5 6.5 9 12l5.5 5.5',
|
|
1750
2117
|
};
|
|
1751
2118
|
|
|
1752
2119
|
/** One 16px line glyph, one 1.5 stroke, colours inherited from the text role. */
|
|
@@ -1807,6 +2174,105 @@ function build(require) {
|
|
|
1807
2174
|
|
|
1808
2175
|
const PanelIcon = SlidersIcon;
|
|
1809
2176
|
|
|
2177
|
+
/**
|
|
2178
|
+
* A page glyph with text lines: the settings page, not the plugin's own icon
|
|
2179
|
+
* (that one belongs to the plugin and is not available to the bridge).
|
|
2180
|
+
*/
|
|
2181
|
+
function BridgeIcon(props) {
|
|
2182
|
+
const size = props && typeof props.size === 'number' ? props.size : 18;
|
|
2183
|
+
const active = Boolean(props && props.active);
|
|
2184
|
+
return h(
|
|
2185
|
+
'svg',
|
|
2186
|
+
{
|
|
2187
|
+
width: size,
|
|
2188
|
+
height: size,
|
|
2189
|
+
viewBox: '0 0 24 24',
|
|
2190
|
+
fill: 'none',
|
|
2191
|
+
'aria-hidden': true,
|
|
2192
|
+
style: { display: 'block', opacity: active ? 1 : 0.78 },
|
|
2193
|
+
},
|
|
2194
|
+
h('rect', {
|
|
2195
|
+
x: 3.5,
|
|
2196
|
+
y: 4.5,
|
|
2197
|
+
width: 17,
|
|
2198
|
+
height: 15,
|
|
2199
|
+
rx: 2.5,
|
|
2200
|
+
stroke: 'currentColor',
|
|
2201
|
+
strokeWidth: 1.6,
|
|
2202
|
+
}),
|
|
2203
|
+
h('path', {
|
|
2204
|
+
d: 'M8 9.5h8M8 12h5M8 14.5h8',
|
|
2205
|
+
stroke: 'currentColor',
|
|
2206
|
+
strokeWidth: 1.5,
|
|
2207
|
+
strokeLinecap: 'round',
|
|
2208
|
+
}),
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* One bridged page: a back bar, then the host's renderer for the section.
|
|
2214
|
+
*
|
|
2215
|
+
* The settings shell owns the `settings.section` declaration, so the ctx-level
|
|
2216
|
+
* `slots.renderSlot` cannot reach it: that entry point only renders `root`, and
|
|
2217
|
+
* a child slot is renderable only through the props face of the entry that
|
|
2218
|
+
* declared it. Each bridge therefore declares its own single-child slot and
|
|
2219
|
+
* re-registers the section component into it (see `syncBridges`), and renders
|
|
2220
|
+
* that slot through the `renderSlot` prop the host hands this component. The
|
|
2221
|
+
* host still builds the section's kit — inject, locale, store, boundary and
|
|
2222
|
+
* hooks all stay its own. The owner prop is the same `close` the settings shell
|
|
2223
|
+
* passes, wired to the Conversation because this page has no dialog to close.
|
|
2224
|
+
*/
|
|
2225
|
+
function makeBridgePanel(candidate, slotName) {
|
|
2226
|
+
const back = () => {
|
|
2227
|
+
try {
|
|
2228
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
2229
|
+
if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
|
|
2230
|
+
} catch {
|
|
2231
|
+
/* the Conversation is already showing */
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
return function BridgePanel(props) {
|
|
2235
|
+
const renderSlot = props && typeof props.renderSlot === 'function' ? props.renderSlot : null;
|
|
2236
|
+
let node = null;
|
|
2237
|
+
try {
|
|
2238
|
+
if (renderSlot) node = renderSlot(slotName, { close: back }, {});
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
console.warn(`[${PACKAGE}] could not render settings page "${candidate.sectionId}"`, error);
|
|
2241
|
+
}
|
|
2242
|
+
return h(
|
|
2243
|
+
'div',
|
|
2244
|
+
{ className: 'dpc-root dpc-bridge' },
|
|
2245
|
+
h(
|
|
2246
|
+
'div',
|
|
2247
|
+
{ className: 'dpc-page dpc-bridgePage' },
|
|
2248
|
+
h(
|
|
2249
|
+
'div',
|
|
2250
|
+
{ className: 'dpc-bridgeBar' },
|
|
2251
|
+
h(
|
|
2252
|
+
'button',
|
|
2253
|
+
{ type: 'button', className: 'dpc-link dpc-bridgeBack', onClick: back },
|
|
2254
|
+
h(Icon, { name: 'back' }),
|
|
2255
|
+
h('span', null, t('bridgeBack')),
|
|
2256
|
+
),
|
|
2257
|
+
h(
|
|
2258
|
+
'span',
|
|
2259
|
+
{ className: 'dpc-bridgeTags' },
|
|
2260
|
+
h('span', { className: 'dpc-bridgeTag' }, t('bridgeTag')),
|
|
2261
|
+
h('span', { className: 'dpc-bridgeFrom' }, fill(t('bridgeFrom'), { p: candidate.sectionId })),
|
|
2262
|
+
),
|
|
2263
|
+
),
|
|
2264
|
+
node
|
|
2265
|
+
? h('div', { className: 'dpc-bridgeBody' }, node)
|
|
2266
|
+
: h(
|
|
2267
|
+
'section',
|
|
2268
|
+
{ className: 'dpc-card dpc-empty' },
|
|
2269
|
+
h('p', { className: 'dpc-emptyTitle' }, t('bridgeMissing')),
|
|
2270
|
+
),
|
|
2271
|
+
),
|
|
2272
|
+
);
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1810
2276
|
/** The entry's own glyph, borrowed from the slot it registered. */
|
|
1811
2277
|
function Glyph(props) {
|
|
1812
2278
|
const [node, setNode] = React.useState(null);
|
|
@@ -2209,6 +2675,7 @@ function build(require) {
|
|
|
2209
2675
|
// is the only place the guard, the cap and the fold switch are explained.
|
|
2210
2676
|
const layoutOn = prefs.guard !== false;
|
|
2211
2677
|
const foldingOn = prefs.collapse !== false;
|
|
2678
|
+
const bridgeOn = prefs.bridge !== false;
|
|
2212
2679
|
const capNow = typeof prefs.panelMax === 'number' ? prefs.panelMax : 45;
|
|
2213
2680
|
const setLayout = (patch) => commit({ ...prefsNow(), ...patch });
|
|
2214
2681
|
const layoutSwitch = (on, label, onClick) =>
|
|
@@ -2251,6 +2718,14 @@ function build(require) {
|
|
|
2251
2718
|
t('layoutCollapseHint'),
|
|
2252
2719
|
layoutSwitch(foldingOn, t('layoutCollapse'), () => setLayout({ collapse: !foldingOn })),
|
|
2253
2720
|
),
|
|
2721
|
+
// The switch is the only way back for a reader who does not want the
|
|
2722
|
+
// extra rows: turning it off also disposes the registrations, not just
|
|
2723
|
+
// the display, so the host list and this page agree.
|
|
2724
|
+
layoutRow(
|
|
2725
|
+
t('layoutBridge'),
|
|
2726
|
+
t('layoutBridgeHint'),
|
|
2727
|
+
layoutSwitch(bridgeOn, t('layoutBridge'), () => setLayout({ bridge: !bridgeOn })),
|
|
2728
|
+
),
|
|
2254
2729
|
layoutRow(
|
|
2255
2730
|
t('layoutMax'),
|
|
2256
2731
|
null,
|
|
@@ -2350,6 +2825,7 @@ function build(require) {
|
|
|
2350
2825
|
state.inventory && state.inventory.repeated ? t('diagYes') : t('diagNo'),
|
|
2351
2826
|
true,
|
|
2352
2827
|
),
|
|
2828
|
+
diagRow(t('diagBridge'), String(bridgeState.entries.size), true),
|
|
2353
2829
|
diagnostics && diagnostics.error ? h('dt', { key: 'et' }, 'error') : null,
|
|
2354
2830
|
diagnostics && diagnostics.error
|
|
2355
2831
|
? h('dd', { key: 'ed', className: 'dpc-error' }, diagnostics.error)
|
|
@@ -2525,6 +3001,16 @@ function build(require) {
|
|
|
2525
3001
|
.dpc-segmentBtn--on{background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.22));color:var(--dsw-alias-label-primary,#8a8a8a)}
|
|
2526
3002
|
.dpc-segmentBtn:disabled{opacity:.4;cursor:default}
|
|
2527
3003
|
|
|
3004
|
+
/* A bridged settings page: its own bar, then the host-rendered section. */
|
|
3005
|
+
.dpc-bridgePage{gap:16px}
|
|
3006
|
+
.dpc-bridgeBar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding-bottom:8px;border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
|
|
3007
|
+
.dpc-bridgeBack{display:inline-flex;align-items:center;gap:6px;margin-left:-8px}
|
|
3008
|
+
.dpc-bridgeBack .dpc-ico{width:14px;height:14px}
|
|
3009
|
+
.dpc-bridgeTags{display:flex;align-items:center;gap:8px;min-width:0}
|
|
3010
|
+
.dpc-bridgeTag{flex:none;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:999px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
|
|
3011
|
+
.dpc-bridgeFrom{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
|
|
3012
|
+
.dpc-bridgeBody{min-width:0}
|
|
3013
|
+
|
|
2528
3014
|
@media (max-width:1000px){
|
|
2529
3015
|
/* Below the column width the frame becomes the gutter instead of pushing
|
|
2530
3016
|
content past the window edge. */
|
|
@@ -2534,6 +3020,7 @@ function build(require) {
|
|
|
2534
3020
|
.dpc-row{flex-wrap:wrap;gap:8px}
|
|
2535
3021
|
.dpc-rowText{flex:1 1 120px}
|
|
2536
3022
|
.dpc-tags{order:8}
|
|
3023
|
+
.dpc-bridgeFrom{display:none}
|
|
2537
3024
|
}
|
|
2538
3025
|
/* The settings column is narrower than the page column: drop what is only
|
|
2539
3026
|
nice-to-have there instead of letting the row overflow. */
|
|
@@ -2672,10 +3159,24 @@ function build(require) {
|
|
|
2672
3159
|
// A registration, a rename or a locale switch changes the list this plugin
|
|
2673
3160
|
// lays out, so every one of them re-runs the pass.
|
|
2674
3161
|
if (typeof ctx.slots.subscribe === 'function') {
|
|
2675
|
-
ctx.effect(
|
|
3162
|
+
ctx.effect(
|
|
3163
|
+
() =>
|
|
3164
|
+
ctx.slots.subscribe(SLOT_PANELS, () => {
|
|
3165
|
+
if (engineRef) engineRef.schedule();
|
|
3166
|
+
scheduleBridgeSync();
|
|
3167
|
+
}),
|
|
3168
|
+
`${PACKAGE}: panel entries`,
|
|
3169
|
+
);
|
|
2676
3170
|
}
|
|
2677
3171
|
if (locale && typeof locale.subscribe === 'function') {
|
|
2678
|
-
ctx.effect(
|
|
3172
|
+
ctx.effect(
|
|
3173
|
+
() =>
|
|
3174
|
+
locale.subscribe(() => {
|
|
3175
|
+
if (engineRef) engineRef.schedule();
|
|
3176
|
+
scheduleBridgeSync();
|
|
3177
|
+
}),
|
|
3178
|
+
`${PACKAGE}: labels`,
|
|
3179
|
+
);
|
|
2679
3180
|
}
|
|
2680
3181
|
|
|
2681
3182
|
ctx.slots.inject(SLOT_MAIN, () => {
|
|
@@ -2714,6 +3215,35 @@ function build(require) {
|
|
|
2714
3215
|
return undefined;
|
|
2715
3216
|
}
|
|
2716
3217
|
});
|
|
3218
|
+
|
|
3219
|
+
// A bridged page follows three registries: the settings sections it
|
|
3220
|
+
// mirrors, the panels it must not duplicate, and the locale that names
|
|
3221
|
+
// both. Each declaration is its own readiness signal, so the sync runs
|
|
3222
|
+
// once the slots exist and again on every later change.
|
|
3223
|
+
const kickBridge = () => {
|
|
3224
|
+
try {
|
|
3225
|
+
watchSettings();
|
|
3226
|
+
scheduleBridgeSync();
|
|
3227
|
+
scheduleBridgeRetries();
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
console.warn(`[${PACKAGE}] bridge scheduling failed`, error);
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
try {
|
|
3233
|
+
// Re-install the watch on every declaration: before the slot exists
|
|
3234
|
+
// `subscribe` cannot observe the section registry, and a stale
|
|
3235
|
+
// subscription would miss the pages that register after us.
|
|
3236
|
+
ctx.slots.inject(SLOT_SETTINGS, () => {
|
|
3237
|
+
unwatchSettings();
|
|
3238
|
+
watchSettings();
|
|
3239
|
+
kickBridge();
|
|
3240
|
+
});
|
|
3241
|
+
ctx.slots.inject(SLOT_PANELS, kickBridge);
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
console.warn(`[${PACKAGE}] bridge slots unavailable`, error);
|
|
3244
|
+
}
|
|
3245
|
+
ctx.effect(() => () => disposeBridges(), `${PACKAGE}: bridge cleanup`);
|
|
3246
|
+
kickBridge();
|
|
2717
3247
|
try { window.__dpcApplied = true; } catch { /* ignore */ }
|
|
2718
3248
|
}
|
|
2719
3249
|
|
|
@@ -2736,7 +3266,9 @@ function build(require) {
|
|
|
2736
3266
|
}
|
|
2737
3267
|
|
|
2738
3268
|
return {
|
|
2739
|
-
|
|
3269
|
+
// `layout` is only read (never patched): the bridge's back button and its
|
|
3270
|
+
// selection fallback call the public selectPanel action.
|
|
3271
|
+
inject: ['slots', 'layout'],
|
|
2740
3272
|
// Pure helpers the browser harness drives directly, without a Cordis context.
|
|
2741
3273
|
testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
|
|
2742
3274
|
apply(ctx) {
|
|
@@ -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`。同一个包注册多个设置页会补多行,这是有意的。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-panel-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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
6
|
"keywords": [
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"url": "https://github.com/QIUZAIYOU/dsh-panel-control/issues"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
|
-
"check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs && node scripts/layout-smoke.mjs",
|
|
53
|
+
"check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs && node scripts/layout-smoke.mjs && node scripts/bridge-smoke.mjs",
|
|
54
54
|
"smoke": "node scripts/host-smoke.mjs",
|
|
55
55
|
"doctor": "node scripts/profile-doctor.mjs",
|
|
56
56
|
"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);
|
package/scripts/layout-smoke.mjs
CHANGED
|
@@ -27,12 +27,28 @@ for (const marker of [
|
|
|
27
27
|
'layoutGuard',
|
|
28
28
|
'layoutMaxNone',
|
|
29
29
|
'toggleCollapse',
|
|
30
|
+
'layoutBridge',
|
|
31
|
+
'dpc-bridgeBar',
|
|
32
|
+
'registrantOf',
|
|
30
33
|
]) {
|
|
31
34
|
expect(client.includes(marker), 'client.js lost ' + marker);
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
// A bridged row is only as good as the two halves it registers: a keyed main
|
|
38
|
+
// entry the layout can select, and the host-rendered settings section it shows.
|
|
39
|
+
expect(client.includes('BRIDGE_ORDER = 85'), 'bridged rows lost their order slot');
|
|
40
|
+
expect(client.includes('out.bridge = raw.bridge !== false'), 'the bridge switch is not persisted');
|
|
41
|
+
expect(
|
|
42
|
+
client.includes('BRIDGE_SLOT_PREFIX') &&
|
|
43
|
+
client.includes('children: { [slotName]: { kind: \'single\', scope: \'root\' } }') &&
|
|
44
|
+
client.includes('renderSlot(slotName, { close: back }, {})'),
|
|
45
|
+
'the bridged page no longer renders through its own child slot',
|
|
46
|
+
);
|
|
47
|
+
expect(/\.dpc-bridgeBar\{[^}]*min-height:40px/.test(client), 'the bridge bar lost its 40px row height');
|
|
48
|
+
|
|
34
49
|
expect(client.includes("const BUILD_STAMP = 'dsh-panel-control/" + pkg.version + "'"), 'BUILD_STAMP and package.json version disagree');
|
|
35
50
|
expect(pkg.scripts && String(pkg.scripts.check).includes('layout-smoke'), 'npm run check no longer runs this gate');
|
|
51
|
+
expect(pkg.scripts && String(pkg.scripts.check).includes('bridge-smoke'), 'npm run check no longer runs the bridge gate');
|
|
36
52
|
expect(/\[data-dpc-list\]\{[^}]*overflow-y:auto/.test(client), 'the panel list is not scrollable');
|
|
37
53
|
expect(/\[data-dpc-list\]\{[^}]*max-height:max\(120px,var\(--dpc-panellist-max,45vh\)\)/.test(client), 'the panel list lost its height cap');
|
|
38
54
|
expect(/\[data-dpc-region\]\{[^}]*min-height/.test(client), 'the region below the list lost its floor');
|