dsh-panel-control 0.8.3 → 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.
@@ -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.8.3",
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 && node scripts/bridge-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"
@@ -56,8 +56,24 @@ function check(name, ok, detail) {
56
56
 
57
57
  // The registry currently holds revision "rev-now" for this package; the page
58
58
  // below asks for "rev-old", which is the 404 that silently drops a plugin.
59
+ const PKG_DIR = path.join(STATE, 'pkg-scan');
60
+ await fs.mkdir(PKG_DIR, { recursive: true });
61
+ const pkgFile = (name, body) => {
62
+ const file = path.join(PKG_DIR, name);
63
+ return fs.writeFile(file, body).then(() => file);
64
+ };
65
+ const pkgPaths = {
66
+ 'dsh-panel-control': await pkgFile(
67
+ 'panel.js',
68
+ "const SLOT = 'sidebar.panellist';\nconst KEY = 'panel-control';\nregister({ name: SLOT, id: KEY });\n",
69
+ ),
70
+ dshmarket: await pkgFile('market.js', "register({ name: 'sidebar.panellist', id: 'dsh-market' });\n"),
71
+ 'billion-context': await pkgFile('bili.js', "register({ name: 'settings.section', id: 'bili' });\n"),
72
+ };
73
+
59
74
  const clientModulesStub = {
60
75
  served: [],
76
+ clientPath: (id) => pkgPaths[id] || null,
61
77
  graph: () => ({
62
78
  rev: 'rev-test',
63
79
  release: 'rel',
@@ -65,6 +81,8 @@ const clientModulesStub = {
65
81
  { id: 'a' },
66
82
  { id: 'b' },
67
83
  { id: 'dsh-panel-control', rev: 'rev-now', url: 'plugins/??dsh-panel-control/client.js&rev=rev-now' },
84
+ { id: 'dshmarket', rev: 'rev-now', url: 'plugins/??dshmarket/client.js&rev=rev-now' },
85
+ { id: 'billion-context', rev: 'rev-now', url: 'plugins/??billion-context/client.js&rev=rev-now' },
68
86
  // The plugin that sits next to this one in the real profile: it carries no
69
87
  // fallback of its own, so a retired revision used to drop it silently
70
88
  // while the manager still reported it running.
@@ -185,11 +203,11 @@ if (route) {
185
203
  text: async () => JSON.stringify(payload),
186
204
  json: async () => payload,
187
205
  });
188
- const envelope = (method) => ({
206
+ const envelope = (method, payload = {}) => ({
189
207
  type: 'client-request',
190
208
  rpcId: 'r1',
191
209
  method: 'dsh-panel-control/classify',
192
- payload: { method, payload: {} },
210
+ payload: { method, payload },
193
211
  });
194
212
  try {
195
213
  const response = await route.fetch(request(envelope('inventory')));
@@ -247,9 +265,27 @@ if (route) {
247
265
  check(
248
266
  'inventory ids',
249
267
  Array.isArray(body.result.value.ids) &&
250
- body.result.value.ids.join() === 'a,b,dsh-panel-control,dsh-workspace-control',
268
+ body.result.value.ids.join() === 'a,b,dsh-panel-control,dshmarket,billion-context,dsh-workspace-control',
251
269
  body.result.value && body.result.value.ids.join(),
252
270
  );
271
+ const pkgResponse = await route.fetch(
272
+ request(
273
+ envelope('packages', {
274
+ ids: [
275
+ { id: 'panel-control', slot: 'sidebar.panellist' },
276
+ { id: 'dsh-market', slot: 'sidebar.panellist' },
277
+ { id: 'bili', slot: 'settings.section' },
278
+ { id: 'nobody', slot: 'sidebar.panellist' },
279
+ ],
280
+ }),
281
+ ),
282
+ );
283
+ const pkgBody = await pkgResponse.json();
284
+ const packages = (pkgBody.result && pkgBody.result.value && pkgBody.result.value.packages) || {};
285
+ check('packages maps panel id', packages['panel-control'] === 'dsh-panel-control', JSON.stringify(packages));
286
+ check('packages maps market id', packages['dsh-market'] === 'dshmarket', JSON.stringify(packages));
287
+ check('packages maps settings id', packages.bili === 'billion-context', JSON.stringify(packages));
288
+ check('packages leaves unknown ids unmapped', !packages.nobody, JSON.stringify(packages));
253
289
  } catch (error) {
254
290
  check('inventory answers', false, error.message);
255
291
  }
@@ -30,6 +30,10 @@ for (const marker of [
30
30
  'layoutBridge',
31
31
  'dpc-bridgeBar',
32
32
  'registrantOf',
33
+ 'dpc-nameTag',
34
+ 'data-dpc-label',
35
+ 'applyNames',
36
+ 'layoutTags',
33
37
  ]) {
34
38
  expect(client.includes(marker), 'client.js lost ' + marker);
35
39
  }
@@ -46,6 +50,13 @@ expect(
46
50
  );
47
51
  expect(/\.dpc-bridgeBar\{[^}]*min-height:40px/.test(client), 'the bridge bar lost its 40px row height');
48
52
 
53
+ // A renamed row keeps its host label addressable while the visible title changes;
54
+ // the original-name tag is opt-out, and the group menu must not sit on transparency.
55
+ expect(client.includes('out.tags = raw.tags !== false'), 'the original-name tag switch is not persisted');
56
+ expect(/\.dpc-nameTag\{[^}]*pointer-events:none/.test(client), 'the original-name tag can intercept clicks');
57
+ expect(/\.dpc-menu\{[^}]*background-color:var\(--dsw-alias-bg-layer-2/.test(client), 'the group menu lost its opaque base');
58
+ expect(pkg.scripts && String(pkg.scripts.check).includes('names-smoke'), 'npm run check no longer runs the names gate');
59
+
49
60
  expect(client.includes("const BUILD_STAMP = 'dsh-panel-control/" + pkg.version + "'"), 'BUILD_STAMP and package.json version disagree');
50
61
  expect(pkg.scripts && String(pkg.scripts.check).includes('layout-smoke'), 'npm run check no longer runs this gate');
51
62
  expect(pkg.scripts && String(pkg.scripts.check).includes('bridge-smoke'), 'npm run check no longer runs the bridge gate');