dsh-sidebar-suite 0.2.9 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,12 +1,29 @@
1
1
  # 更新日志 / Changelog
2
2
 
3
- > 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`)。
3
+ > 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`、`0.3.0`)。
4
4
  > `0.2.0`、`0.2.1`、`0.2.2`、`0.2.3` 是开发过程中的本地迭代号,**从未单独发布**;它们的功能一次性随 `0.2.4` 上线,所以在下面合并记录。
5
5
  >
6
6
  > Versions mirror the npm registry. `0.2.0`–`0.2.3` were local iteration numbers that were never published on their own; all of their features shipped together in `0.2.4`.
7
7
 
8
8
  ---
9
9
 
10
+ ## 0.3.0 — 第三方插件可以注册自己的页签 / Third-party tabs(2026-11-11)
11
+
12
+ **这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
13
+
14
+ - **侧栏变成可插拔的页容器** — 客户端通过 `ctx.provide('sidebarSuite', …)` 发布一个服务,别的插件 `inject = ['sidebarSuite']` 之后就能 `registerTab({ id, title, icon, order, mount })` 把自己的界面挂进侧栏。The sidebar is now a pluggable page container: a `sidebarSuite` service is published on the client context, and any plugin injecting it can register its own page.
15
+ - **给的是普通 DOM 容器,不是 React 组件** — `mount(container, tab)` 拿到一个空 `<div>`,原生 DOM / React / canvas 都行,消费方不必和本插件的框架版本对齐。Consumers get a plain container, so any stack works and no React version has to line up.
16
+ - **懒挂载 + 自带清理** — `mount` 只在页签第一次被打开时执行;返回的清理函数在页签移除、消费方卸载、DSH 退出时都会调用。`registerTab` 返回自己的 dispose 函数,正好能直接塞进 `ctx.effect`。Mount runs on first activation, and the returned disposer plugs straight into `ctx.effect`.
17
+ - **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换保证不会叠出两个同名页签,且旧页签的清理函数先跑。Re-registering an id replaces in place, which is what a hot reload needs.
18
+ - **失败只烂在自己那一页** — `mount` 抛异常时错误写进它自己的容器并 `console.error` 一次,侧栏其余部分照常工作。A throwing consumer is contained inside its own page.
19
+ - **页签条按布局出现** — 布局 B 下是 `[扩展][工作区][插件页…]` 并排;布局 A / C 下只有插件页进条(内置两组仍由原按钮控制,那两种布局不是页签模型)。**一个消费方都没有时这排页签是 `hidden` 的,不占高度**,看到的和 0.2.9 完全一样。Layout B lists the built-in groups alongside the plugin pages; layouts A and C list plugin pages only. With no consumers the strip is `hidden`: zero height, no visual change from 0.2.9.
20
+ - **选中态是宿主主题色** — 当前页签底部一条 2px 强调色(`--dsw-alias-state-business-primary`),跟随工作区主题;窄侧栏(< 300px)自动只留图标,没给图标的页签保留文字而不是变成空白方块。The active page carries a 2 px accent underline in the workspace's theme colour; narrow columns go icon-only, and a tab without an icon keeps its label.
21
+ - **选中哪一页跟着侧栏走** — 存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点一次当前页签退回原来的组。The selection lives in its own storage key, and re-clicking the active page returns to the groups.
22
+ - **点了内置页签也会通知订阅者** — 布局 B 下点「扩展 / 工作区」等于把这一列从插件页手里拿走,`subscribe` 现在会像其它切换一样发出 `('active', null)`;反过来,在两个内置组之间来回切是静默的,不会假装有插件页移动。没有这一条,靠订阅停表 / 停轮询 / 停画布的消费方会以为自己还挂在屏幕上。A click on a built-in page notifies subscribers exactly like any other switch; moving between two built-ins stays silent.
23
+ - **测试** — 新增两套:`tests/test_030.mjs` 在 vm 沙箱里按消费方的方式调服务(注册、列表、激活、订阅、注销、重复注册、非法入参,外加渲染与样式的源码契约、文档与版本联动,70 项);`tests/test_031.mjs` 自己搭了一个符合宿主契约的侧栏 DOM,跑真实 `lib/client.js`,然后像用户那样点页签、再点一次退回、切布局 A/C、注销、点内置页签看通知(53 项,含「连续 60 次重绘不新增节点」的防抖动断言——`paint()` 每次插入节点的话,它自己装的 `MutationObserver` 会把它重新触发成死循环)。`npm test` 链扩为 9 套。Two new suites: one drives the service the way a consumer would, the other runs the real bundle against a synthetic host sidebar and clicks through it, including a repaint-loop guard.
24
+
25
+ ---
26
+
10
27
  ## 0.2.9 — 布局栏重做 + 视觉精修 / Toolbar rebuilt as a segmented control(2026-11-11)
11
28
 
12
29
  **这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
package/DETAIL.md CHANGED
@@ -81,6 +81,45 @@ The toolbar is one segmented control: `[fold | tabs | overlay] │ actions`. The
81
81
 
82
82
  `换布局 / Cycle` 默认不出现(`showQuickSwitch`),打开后和其它动作按钮共用同一套图标。`Cycle` is off by default and shares the icon set when enabled.
83
83
 
84
+ ## 第三方页签 / Third-party tabs(0.3.0)
85
+
86
+ 侧栏从 0.3.0 起是一个**可插拔的页容器**:别的插件通过 `ctx.sidebarSuite` 注册自己的页,用户点一下就能在你的界面和你自己的界面之间切换。
87
+
88
+ ```js
89
+ export const inject = ['sidebarSuite']
90
+
91
+ export function apply(ctx) {
92
+ ctx.effect(() => ctx.sidebarSuite.registerTab({
93
+ id: 'my-plugin',
94
+ title: { zh: '我的面板', en: 'My panel' },
95
+ icon: '<svg viewBox="0 0 16 16">…</svg>', // 或一个 emoji
96
+ order: 100,
97
+ mount(container, tab) {
98
+ container.textContent = 'hello'
99
+ return () => { /* 清理 */ }
100
+ },
101
+ }))
102
+ }
103
+ ```
104
+
105
+ 设计取舍 / Why it looks like this:
106
+
107
+ - **给 DOM 容器,不给 React 组件** — 消费方可以是任何技术栈写的插件,不必和本插件的 React 版本对齐。A plain container works for every consumer regardless of framework.
108
+ - **懒挂载** — `mount` 只在页签第一次被打开时执行,没打开过的页签不跑任何代码。Mount runs on first activation only.
109
+ - **`registerTab` 返回自己的 dispose** — 正好是 `ctx.effect` 要的形状,消费方不用自己维护注销;消费方卸载时页签自动消失。The returned disposer plugs straight into `ctx.effect`.
110
+ - **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换能保证只有一个页签。Re-registering an id replaces in place, which is what a hot client reload needs.
111
+ - **失败只烂在自己那一页** — `mount` 抛异常时,错误写进它自己的容器并 `console.error` 一次,侧栏其余部分不受影响。A throwing consumer is contained inside its own page.
112
+ - **"失去这一列"只有一种通知方式** — 不管是被另一个插件页顶掉、被用户再点一次退回、还是布局 B 下点了「扩展 / 工作区」,走的都是同一次 `subscribe('active', null)`;在两个内置组之间来回切则不通知,因为对消费方来说什么都没变。订阅者不必自己推断"我现在还在不在屏幕上"。Losing the column always arrives as one notification, whoever took it; switching between two built-ins stays silent because nothing changed for the consumer.
113
+ - **进不去的边界** — 页签只拿到发给它的容器,没有能力移动、覆盖或重构宿主 DOM。A tab owns its container and nothing else.
114
+
115
+ 三种布局的差异 / Under the three layouts:
116
+
117
+ - B 标签页:`[扩展][工作区][你的页…]` 并排,都是页。
118
+ - A 折叠、C 浮层:**只有第三方页**进条;内置两组仍由原按钮控制,因为这两种布局不是页签模型。
119
+ - 没有任何第三方注册:页签条本身带 `hidden`,不占高度、不参与布局,看到的和 0.2.x 一模一样(只是多一个隐藏的空容器)。With no consumer the strip carries `hidden`: zero height, no layout impact — visually identical to 0.2.x.
120
+
121
+ 选中态存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点当前页签退回原来的组。
122
+
84
123
  ## 安装与生效 / Install & apply
85
124
 
86
125
  ```sh
package/README.md CHANGED
@@ -104,6 +104,53 @@ The toolbar is one segmented control: the three mutually exclusive layouts share
104
104
  `换布局 / Cycle` 默认不出现,可用 `showQuickSwitch: true` 打开;它和其余动作按钮一起走同一套图标。
105
105
  `Cycle` stays hidden unless `showQuickSwitch: true`; it uses the same icon set as the other action buttons.
106
106
 
107
+ ## 第三方插件接入:注册自己的页签 / Third-party tabs(0.3.0)
108
+
109
+ 别的插件可以往侧栏注册一页自己的界面。客户端会拿到一个 `sidebarSuite` 服务:
110
+
111
+ Any other plugin can put its own page in the sidebar through one client service.
112
+
113
+ ```js
114
+ // 在消费方插件里 / in the consumer plugin
115
+ export const inject = ['sidebarSuite']
116
+
117
+ export function apply(ctx) {
118
+ ctx.effect(() => ctx.sidebarSuite.registerTab({
119
+ id: 'my-plugin', // 必填,[A-Za-z0-9._-],≤48 字符
120
+ title: { zh: '我的面板', en: 'My panel' }, // 必填,或直接给字符串
121
+ icon: '<svg viewBox="0 0 16 16">…</svg>', // 可选,也可以是一个 emoji
122
+ order: 100, // 可选,越小越靠左,默认 100
123
+ mount(container, tab) { // 必填(别名 render)
124
+ container.textContent = 'hello'
125
+ return () => { /* 清理 */ } // 可选:移除/卸载时调用
126
+ },
127
+ }))
128
+ }
129
+ ```
130
+
131
+ - `mount` 拿到的是**一个空的普通 `<div>`**,原生 DOM、React、canvas 都行,不要求你用哪套框架。
132
+ - **首次点开才调用 `mount`**:没被打开过的页签不执行任何代码。
133
+ - 返回的清理函数在页签被移除、消费方插件卸载、DSH 退出这三种情况下都会执行。
134
+ - `registerTab` 返回它自己的 dispose 函数,所以 `ctx.effect(() => api.registerTab(...))` 就是完整写法,不用自己记着取消。
135
+ - 同一个 id 重复注册 = **热重载**,会原地替换而不是叠出两个页签。
136
+
137
+ 页签出现在哪儿 / Where the page shows up:
138
+
139
+ | 布局 | 表现 |
140
+ |---|---|
141
+ | B 标签页 | 和控制栏下面那排「扩展 / 工作区」并排 |
142
+ | A 折叠、C 浮层 | 只有插件页签进条;内置两组仍由原按钮控制(这两种布局本来就不是"页签"模型) |
143
+ | 没人注册 | 这排页签**完全不出现**,界面和 0.2.x 一模一样 |
144
+
145
+ 选中哪一页记在 `localStorage["dsh-sidebar-suite:active-tab"]`;**再点一次当前页签就退回原来的组**(布局 A / C 下这是唯一的退出口)。
146
+
147
+ 服务其余方法:`unregisterTab(id)`、`getTabs()`、`activateTab(id)`、`getActiveTab()`、`subscribe(fn)`(`fn(tabs, reason, activeId)`)、`getLayout()`。
148
+
149
+ `reason` 的取值是 `register` / `unregister` / `active` / `orphan`。**点内置页签(布局 B 下的「扩展 / 工作区」)也算一次 `active`**(`activeId` 为 `null`),所以靠订阅停表、停轮询、停动画的代码不需要为内置页签另写一套判断;反过来,在两个内置组之间切换是静默的。
150
+
151
+ > 边界是刻意的:一个页签只拥有发给它的那个容器,没有能力移动或重构侧栏,也不能覆盖宿主自己的界面。
152
+ > A tab owns its container and nothing else — it cannot restructure the sidebar.
153
+
107
154
  ## 实现要点 / How it works
108
155
 
109
156
  - 所有持久状态写在 `<html>` 属性上:`data-dsbs-mode`、`data-dsbs-tab`、`data-dsbs-fold-ext`、`data-dsbs-fold-ws`、`data-dsbs-pop`、`data-dsbs-quick`、`data-dsbs-rail`、`data-dsbs-narrow`。CSS 负责显隐,插件不重构宿主 DOM。All state lives in `<html>` attributes; CSS owns visibility.
package/lib/client.js CHANGED
@@ -19,8 +19,15 @@ window.__ModuleLoader__.load({
19
19
  'use strict'
20
20
 
21
21
  var ENTRY = 'dsh-sidebar-suite'
22
+ // Reported to tab consumers so they can feature-detect. Kept in lockstep
23
+ // with package.json by tests/test_030.mjs.
24
+ var VERSION = '0.3.0'
22
25
  var NS = 'sidebar-suite'
23
26
  var LS_KEY = 'dsh-sidebar-suite:state'
27
+ // Where the third-party tab choice lives. Kept out of LS_KEY on purpose:
28
+ // the config form describes the sidebar, this describes the user's
29
+ // current position inside it, and the two have different lifetimes.
30
+ var TAB_STORE_KEY = 'dsh-sidebar-suite:active-tab'
24
31
  var STYLE_ID = 'dsh-sidebar-suite-style'
25
32
  // Where feedback goes. A plugin bundle cannot open a mail client or post a
26
33
  // form without a server, so the entry point assembles a ready-to-send
@@ -79,6 +86,10 @@ window.__ModuleLoader__.load({
79
86
  'tab.to.ext.hint': 'Switch the shared space to the panels group.',
80
87
  'tab.to.ws': 'Show workspaces',
81
88
  'tab.to.ws.hint': 'Switch the shared space to the workspaces group.',
89
+ 'tab.ext': 'Panels',
90
+ 'tab.ws': 'Workspaces',
91
+ 'tabs.label': 'Sidebar pages',
92
+ 'tabs.own': 'This page is provided by {name}.',
82
93
  'pop.open': 'Open panels',
83
94
  'pop.close': 'Close panels',
84
95
  'pop.hint': 'Open or close the panels overlay.',
@@ -129,6 +140,10 @@ window.__ModuleLoader__.load({
129
140
  'tab.to.ext.hint': '把共用的一栏切换到扩展区。',
130
141
  'tab.to.ws': '显示工作区',
131
142
  'tab.to.ws.hint': '把共用的一栏切换到工作区。',
143
+ 'tab.ext': '扩展',
144
+ 'tab.ws': '工作区',
145
+ 'tabs.label': '侧栏页签',
146
+ 'tabs.own': '这一页由 {name} 提供。',
132
147
  'pop.open': '展开扩展',
133
148
  'pop.close': '收起扩展',
134
149
  'pop.hint': '打开或收起扩展浮层。',
@@ -356,6 +371,34 @@ window.__ModuleLoader__.load({
356
371
  '[data-row-key^="session:"]{transition:background-color .13s ease,box-shadow .13s ease,color .13s ease;font-variant-numeric:tabular-nums}',
357
372
  '[data-row-key^="session:"][aria-selected="true"]{box-shadow:inset 2px 0 0 var(--dsw-alias-state-business-primary,var(--dsw-alias-label-primary,#4d6bfe))!important}',
358
373
  'html[data-dsbs-rail="true"] [data-row-key^="session:"][aria-selected="true"]{box-shadow:none!important}',
374
+
375
+ // ---- third-party page strip (0.3.0) ------------------------------
376
+ // The strip only exists while at least one plugin registered a tab, so a
377
+ // stock profile never sees it. Everything inherits currentColor, which
378
+ // is what keeps it legible in both host themes without a palette here.
379
+ '[data-dsbs-tabs]{display:flex;align-items:center;gap:2px;padding:3px 4px 0;flex:none;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}',
380
+ '[data-dsbs-tabs][hidden]{display:none!important}',
381
+ '[data-dsbs-tabs]::-webkit-scrollbar{display:none}',
382
+ '[data-dsbs-tabs] button{position:relative;display:inline-flex;align-items:center;gap:5px;flex:none;min-width:0;max-width:150px;padding:4px 9px;border:0;border-radius:7px 7px 0 0;background:transparent;color:inherit;font:inherit;font-size:11px;line-height:1.3;opacity:.6;cursor:pointer;white-space:nowrap;transition:opacity .14s ease,background-color .14s ease}',
383
+ '[data-dsbs-tabs] button:hover{opacity:.92;background:color-mix(in srgb,currentColor 8%,transparent)}',
384
+ '[data-dsbs-tabs] button[aria-selected="true"]{opacity:1;font-weight:600;background:color-mix(in srgb,currentColor 12%,transparent);box-shadow:inset 0 -2px 0 var(--dsw-alias-state-business-primary,currentColor)}',
385
+ '[data-dsbs-tablabel]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}',
386
+ '[data-dsbs-tabicon]{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex:none}',
387
+ '[data-dsbs-tabicon] svg{width:14px;height:14px;display:block;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}',
388
+ '[data-dsbs-tabicon-text]{font-size:12px;line-height:1}',
389
+ '[data-dsbs-tabbadge]{flex:none;min-width:15px;padding:0 4px;border-radius:7px;font-size:10px;line-height:15px;text-align:center;background:color-mix(in srgb,currentColor 16%,transparent);font-variant-numeric:tabular-nums}',
390
+ // The tab's own container inherits the column the two groups gave up.
391
+ '[data-dsbs-tabpanel]{display:none;flex:1 1 auto;min-height:0;min-width:0;overflow:auto;flex-direction:column}',
392
+ '[data-dsbs-tabpanel][data-dsbs-visible="true"]{display:flex}',
393
+ 'html[data-dsbs-ptab="true"] nav[data-dsbs-ext]{display:none!important}',
394
+ 'html[data-dsbs-ptab="true"] div:has(> [data-slot="sidebar.workspaces"]){display:none!important}',
395
+ // Narrow: icon-only pages, the same rule the control bar follows. A tab
396
+ // without an icon keeps its label instead of becoming an empty box.
397
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button{max-width:64px;padding:4px 6px}',
398
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tablabel]{display:none}',
399
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tabbadge]{display:none}',
400
+ 'html[data-dsbs-rail="true"] [data-dsbs-tabs]{padding:2px 2px 0}',
401
+ 'html[data-dsbs-rail="true"] [data-dsbs-tabs] button{max-width:40px;padding:4px 5px}',
359
402
  ].join('\n')
360
403
 
361
404
  function assign(target) {
@@ -504,6 +547,11 @@ window.__ModuleLoader__.load({
504
547
  return DICTS[lang][key] || DICTS.en[key] || key
505
548
  }
506
549
  t.refresh = refresh
550
+ // Third-party tab titles may arrive as `{ zh, en }`; resolving them needs
551
+ // the same detector the dictionaries use, not a second guess.
552
+ t.langName = function () {
553
+ return lang
554
+ }
507
555
  return t
508
556
  }
509
557
 
@@ -559,6 +607,100 @@ window.__ModuleLoader__.load({
559
607
  resizeObserver: null,
560
608
  mutationObserver: null,
561
609
  raf: 0,
610
+ // 0.3.0 — third-party tabs. `activeTab` holds a registered id (or null
611
+ // for the built-in groups); `tabPanels` caches one live container per
612
+ // tab so a mount callback runs once and its disposer stays reachable.
613
+ activeTab: readActiveTab(),
614
+ seenTabs: Object.create(null),
615
+ tabStrip: null,
616
+ tabPanels: Object.create(null),
617
+ tabSignature: '',
618
+ }
619
+
620
+ // The public surface other plugins see. Everything here is either a
621
+ // function declaration (hoisted, so the object can be built before the
622
+ // implementations read below) or a getter that touches live state only
623
+ // when called.
624
+ var sidebarService = {
625
+ version: VERSION,
626
+ // Returns its own disposer, which is exactly what `ctx.effect(() => …)`
627
+ // wants, so a consumer never has to remember to unregister.
628
+ registerTab: function (descriptor) {
629
+ return registerTab(descriptor)
630
+ },
631
+ unregisterTab: function (id) {
632
+ return unregisterTab(id)
633
+ },
634
+ getTabs: function () {
635
+ return tabSnapshot()
636
+ },
637
+ activateTab: function (id) {
638
+ return activateTab(id)
639
+ },
640
+ getActiveTab: function () {
641
+ return runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
642
+ },
643
+ // A consumer can follow the strip instead of polling it: called with
644
+ // (tabs, reason, activeId) whenever the list or the selection changes.
645
+ subscribe: function (listener) {
646
+ if (typeof listener !== 'function') return function () {}
647
+ tabListeners.push(listener)
648
+ var off = false
649
+ return function () {
650
+ if (off) return
651
+ off = true
652
+ var at = tabListeners.indexOf(listener)
653
+ if (at >= 0) tabListeners.splice(at, 1)
654
+ }
655
+ },
656
+ // Reading the current shape lets a consumer align with the layout
657
+ // instead of sniffing classes off <html>.
658
+ getLayout: function () {
659
+ return {
660
+ mode: config.values.mode,
661
+ tab: config.values.tab,
662
+ activeTab: runtime.activeTab,
663
+ narrow: runtime.narrow === true,
664
+ rail: runtime.rail === true,
665
+ }
666
+ },
667
+ }
668
+
669
+ // Publish the service once the slot system is live.
670
+ //
671
+ // Two dead ends measured against this client context, both of which take
672
+ // the whole sidebar down:
673
+ // * ctx.provide() directly inside apply() stalls the plugin loader on
674
+ // "Loading plugins..." forever;
675
+ // * a static `inject: ['slots']` never activates, so apply() never runs.
676
+ // Publishing from inside a dependency callback is the shape that works.
677
+ function publishService() {
678
+ try {
679
+ if (typeof ctx.provide === 'function') ctx.provide('sidebarSuite', sidebarService)
680
+ } catch (error) {
681
+ try {
682
+ console.warn('[dsh-sidebar-suite] could not publish the sidebarSuite service:', error)
683
+ } catch (inner) {
684
+ void inner
685
+ }
686
+ }
687
+ }
688
+ if (typeof ctx.inject === 'function') {
689
+ try {
690
+ ctx.inject(['slots'], function () { publishService() })
691
+ } catch (error) {
692
+ publishService()
693
+ }
694
+ } else {
695
+ publishService()
696
+ }
697
+ // One window handle for the same object. Not a second API: it exists so a
698
+ // consumer can reach the service from a plain browser context (and so the
699
+ // release checks can drive the strip over CDP).
700
+ try {
701
+ window.__dshSidebarSuite = sidebarService
702
+ } catch (error) {
703
+ void error
562
704
  }
563
705
 
564
706
  var manualCleanups = []
@@ -1163,6 +1305,430 @@ window.__ModuleLoader__.load({
1163
1305
  return bar
1164
1306
  }
1165
1307
 
1308
+ // ---- third-party tabs (0.3.0) --------------------------------------
1309
+ // Another plugin cannot reach into this bundle, so the whole feature is
1310
+ // one service object handed to the client context (`ctx.provide` at the
1311
+ // bottom of this file). The contract is deliberately DOM-shaped rather
1312
+ // than React-shaped: a consumer receives a plain container and paints
1313
+ // into it, which works for every plugin no matter how it was built.
1314
+ //
1315
+ // Deliberately absent: any way for a tab to restructure the sidebar.
1316
+ // A tab owns its own container and nothing else.
1317
+ var TAB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/
1318
+ var tabRecords = []
1319
+ var tabListeners = []
1320
+ var tabSeq = 0
1321
+
1322
+ function readActiveTab() {
1323
+ try {
1324
+ var raw = localStorage.getItem(TAB_STORE_KEY)
1325
+ return typeof raw === 'string' && raw ? raw : null
1326
+ } catch (error) {
1327
+ return null
1328
+ }
1329
+ }
1330
+
1331
+ function writeActiveTab(id) {
1332
+ try {
1333
+ if (id) localStorage.setItem(TAB_STORE_KEY, id)
1334
+ else localStorage.removeItem(TAB_STORE_KEY)
1335
+ } catch (error) {
1336
+ void error
1337
+ }
1338
+ }
1339
+
1340
+ function findTab(id) {
1341
+ for (var i = 0; i < tabRecords.length; i++) {
1342
+ if (tabRecords[i].id === id) return tabRecords[i]
1343
+ }
1344
+ return null
1345
+ }
1346
+
1347
+ // `title` may be a plain string or `{ zh, en }`. Resolving it here keeps
1348
+ // the two languages in one descriptor instead of making every consumer
1349
+ // read the locale itself.
1350
+ function tabTitle(record) {
1351
+ var title = record.title
1352
+ if (title && typeof title === 'object') {
1353
+ var zh = typeof title.zh === 'string' ? title.zh : ''
1354
+ var en = typeof title.en === 'string' ? title.en : ''
1355
+ var preferred = t.langName() === 'zh' ? zh || en : en || zh
1356
+ return preferred || record.id
1357
+ }
1358
+ return typeof title === 'string' && title ? title : record.id
1359
+ }
1360
+
1361
+ function tabSnapshot() {
1362
+ var out = []
1363
+ for (var i = 0; i < tabRecords.length; i++) {
1364
+ var record = tabRecords[i]
1365
+ out.push({
1366
+ id: record.id,
1367
+ title: tabTitle(record),
1368
+ icon: record.icon,
1369
+ order: record.order,
1370
+ badge: record.badge,
1371
+ hidden: record.hidden,
1372
+ owner: record.owner,
1373
+ })
1374
+ }
1375
+ return out
1376
+ }
1377
+
1378
+ function notifyTabs(reason) {
1379
+ if (!tabListeners.length) return
1380
+ var list = tabSnapshot()
1381
+ var active = runtime.activeTab
1382
+ for (var i = 0; i < tabListeners.length; i++) {
1383
+ try {
1384
+ tabListeners[i](list, reason, active)
1385
+ } catch (error) {
1386
+ void error
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ function tabContext(record) {
1392
+ return {
1393
+ id: record.id,
1394
+ t: function (key) {
1395
+ return t(key)
1396
+ },
1397
+ lang: function () {
1398
+ return t.langName()
1399
+ },
1400
+ // Lets a consumer close its own tab once the user is done with it,
1401
+ // rather than leaving a dead page in the strip.
1402
+ close: function () {
1403
+ if (runtime.activeTab === record.id) activateTab(null)
1404
+ },
1405
+ }
1406
+ }
1407
+
1408
+ function createTabPanel(record) {
1409
+ var panel = document.createElement('div')
1410
+ panel.setAttribute('data-dsbs-tabpanel', record.id)
1411
+ panel.setAttribute('role', 'tabpanel')
1412
+ panel.setAttribute('aria-label', tabTitle(record))
1413
+ panel.setAttribute('data-dsbs-visible', 'false')
1414
+ var entry = { el: panel, cleanup: null, record: record, mounted: false }
1415
+ runtime.tabPanels[record.id] = entry
1416
+ return entry
1417
+ }
1418
+
1419
+ // Mounting is deferred until the first activation: a tab that is never
1420
+ // opened should cost nothing, and a container that is still `display:
1421
+ // none` has no geometry to measure.
1422
+ function mountTabPanel(entry) {
1423
+ if (!entry || entry.mounted) return
1424
+ entry.mounted = true
1425
+ var record = entry.record
1426
+ if (typeof record.mount !== 'function') return
1427
+ try {
1428
+ var result = record.mount(entry.el, tabContext(record))
1429
+ if (typeof result === 'function') entry.cleanup = result
1430
+ else if (result && typeof result.dispose === 'function') {
1431
+ entry.cleanup = function () {
1432
+ result.dispose()
1433
+ }
1434
+ }
1435
+ } catch (error) {
1436
+ // A broken consumer must never take the sidebar down with it: the
1437
+ // failure is reported inside its own tab and logged once.
1438
+ try {
1439
+ entry.el.textContent = '[dsh-sidebar-suite] tab "' + record.id + '" failed to mount: '
1440
+ + (error && error.message ? error.message : String(error))
1441
+ } catch (inner) {
1442
+ void inner
1443
+ }
1444
+ try {
1445
+ console.error('[dsh-sidebar-suite] tab mount failed:', record.id, error)
1446
+ } catch (inner) {
1447
+ void inner
1448
+ }
1449
+ }
1450
+ }
1451
+
1452
+ function disposeTabPanel(id) {
1453
+ var entry = runtime.tabPanels[id]
1454
+ if (!entry) return
1455
+ delete runtime.tabPanels[id]
1456
+ try {
1457
+ if (typeof entry.cleanup === 'function') entry.cleanup()
1458
+ } catch (error) {
1459
+ void error
1460
+ }
1461
+ try {
1462
+ if (entry.el && entry.el.parentNode) entry.el.parentNode.removeChild(entry.el)
1463
+ } catch (error) {
1464
+ void error
1465
+ }
1466
+ }
1467
+
1468
+ function disposeAllTabPanels() {
1469
+ var ids = []
1470
+ for (var id in runtime.tabPanels) ids.push(id)
1471
+ for (var i = 0; i < ids.length; i++) disposeTabPanel(ids[i])
1472
+ }
1473
+
1474
+ function unregisterTab(id) {
1475
+ var record = findTab(id)
1476
+ if (!record) return false
1477
+ tabRecords.splice(tabRecords.indexOf(record), 1)
1478
+ disposeTabPanel(id)
1479
+ if (runtime.activeTab === id) {
1480
+ runtime.activeTab = null
1481
+ writeActiveTab(null)
1482
+ }
1483
+ notifyTabs('unregister')
1484
+ scheduleSync()
1485
+ return true
1486
+ }
1487
+
1488
+ function registerTab(descriptor) {
1489
+ if (!descriptor || typeof descriptor !== 'object') {
1490
+ throw new TypeError('[dsh-sidebar-suite] registerTab(descriptor) needs an object')
1491
+ }
1492
+ var id = String(descriptor.id == null ? '' : descriptor.id).trim()
1493
+ if (!TAB_ID_RE.test(id)) {
1494
+ throw new TypeError('[dsh-sidebar-suite] registerTab: id must match ' + TAB_ID_RE + ', got "' + id + '"')
1495
+ }
1496
+ if (id === 'ext' || id === 'ws') {
1497
+ throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" is reserved for a built-in group')
1498
+ }
1499
+ var mount = descriptor.mount || descriptor.render
1500
+ if (typeof mount !== 'function') {
1501
+ throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" needs mount(container) or render(container)')
1502
+ }
1503
+ var record = {
1504
+ id: id,
1505
+ title: descriptor.title,
1506
+ icon: typeof descriptor.icon === 'string' ? descriptor.icon : null,
1507
+ order: typeof descriptor.order === 'number' && isFinite(descriptor.order) ? descriptor.order : 100,
1508
+ badge: descriptor.badge == null ? null : descriptor.badge,
1509
+ hidden: descriptor.hidden === true,
1510
+ mount: mount,
1511
+ owner: descriptor.owner == null ? null : String(descriptor.owner),
1512
+ seq: (tabSeq += 1),
1513
+ }
1514
+ // Re-registering the same id is exactly what a hot client reload looks
1515
+ // like, so it replaces in place instead of stacking a duplicate page.
1516
+ var previous = findTab(id)
1517
+ if (previous) {
1518
+ disposeTabPanel(id)
1519
+ tabRecords.splice(tabRecords.indexOf(previous), 1)
1520
+ }
1521
+ tabRecords.push(record)
1522
+ runtime.seenTabs[id] = true
1523
+ notifyTabs('register')
1524
+ scheduleSync()
1525
+ var disposed = false
1526
+ return function disposeTab() {
1527
+ if (disposed) return
1528
+ disposed = true
1529
+ unregisterTab(id)
1530
+ }
1531
+ }
1532
+
1533
+ // Built-in groups only appear as pages in layout b. Layout a folds the
1534
+ // two groups independently and layout c floats the panels, so a page
1535
+ // switcher for them there would contradict what those layouts mean --
1536
+ // third-party tabs still work in all three.
1537
+ function tabEntries() {
1538
+ var out = []
1539
+ var showBuiltin = config.values.mode === 'b' && !!runtime.nav
1540
+ if (showBuiltin) {
1541
+ out.push({ id: 'ext', builtin: true, title: t('tab.ext'), icon: null, order: -20, badge: null })
1542
+ out.push({ id: 'ws', builtin: true, title: t('tab.ws'), icon: null, order: -10, badge: null })
1543
+ }
1544
+ for (var i = 0; i < tabRecords.length; i++) {
1545
+ var record = tabRecords[i]
1546
+ if (record.hidden === true) continue
1547
+ out.push({
1548
+ id: record.id,
1549
+ builtin: false,
1550
+ title: tabTitle(record),
1551
+ icon: record.icon,
1552
+ order: record.order,
1553
+ seq: record.seq,
1554
+ badge: record.badge,
1555
+ })
1556
+ }
1557
+ out.sort(function (left, right) {
1558
+ if (left.builtin !== right.builtin) return left.builtin ? -1 : 1
1559
+ if (left.order !== right.order) return left.order - right.order
1560
+ return (left.seq || 0) - (right.seq || 0)
1561
+ })
1562
+ return out
1563
+ }
1564
+
1565
+ function ensureTabStrip(fx) {
1566
+ var strip = runtime.tabStrip
1567
+ if (strip && strip.parentNode !== fx.root) {
1568
+ try {
1569
+ strip.parentNode.removeChild(strip)
1570
+ } catch (error) {
1571
+ void error
1572
+ }
1573
+ strip = null
1574
+ }
1575
+ if (!strip) {
1576
+ strip = document.createElement('div')
1577
+ strip.setAttribute('data-dsbs-tabs', '')
1578
+ strip.setAttribute('role', 'tablist')
1579
+ strip.setAttribute('aria-label', t('tabs.label'))
1580
+ runtime.tabStrip = strip
1581
+ }
1582
+ // Sits under the control bar so the two read as one header. The
1583
+ // anchor matters: the host rebuilds the groups, never the bar.
1584
+ if (strip.parentNode === fx.root && runtime.bar && strip.previousSibling === runtime.bar) return strip
1585
+ var anchor = runtime.bar ? runtime.bar.nextSibling : (fx.nav || fx.regionArea)
1586
+ if (anchor && anchor.parentNode === fx.root && anchor !== strip) fx.root.insertBefore(strip, anchor)
1587
+ else if (strip.parentNode !== fx.root) fx.root.appendChild(strip)
1588
+ return strip
1589
+ }
1590
+
1591
+ function buildTabButton(entry, selected) {
1592
+ var button = document.createElement('button')
1593
+ button.setAttribute('type', 'button')
1594
+ button.setAttribute('data-dsbs-tabid', entry.id)
1595
+ button.setAttribute('role', 'tab')
1596
+ button.setAttribute('aria-selected', selected ? 'true' : 'false')
1597
+ button.setAttribute('title', entry.title)
1598
+ if (selected) button.setAttribute('data-dsbs-selected', 'true')
1599
+ if (entry.builtin) button.setAttribute('data-dsbs-builtin', entry.id)
1600
+
1601
+ if (typeof entry.icon === 'string' && entry.icon) {
1602
+ var glyph = document.createElement('span')
1603
+ glyph.setAttribute('data-dsbs-tabicon', '')
1604
+ glyph.setAttribute('aria-hidden', 'true')
1605
+ button.setAttribute('data-dsbs-hasicon', '')
1606
+ if (entry.icon.charAt(0) === '<') {
1607
+ // A consumer-supplied mark. Plugins already run arbitrary code in
1608
+ // this page, so this grants nothing new -- but the icon still gets
1609
+ // its own element so the label stays a text node.
1610
+ glyph.innerHTML = entry.icon
1611
+ } else {
1612
+ glyph.textContent = entry.icon
1613
+ glyph.setAttribute('data-dsbs-tabicon-text', '')
1614
+ }
1615
+ button.appendChild(glyph)
1616
+ }
1617
+
1618
+ var label = document.createElement('span')
1619
+ label.setAttribute('data-dsbs-tablabel', '')
1620
+ label.textContent = entry.title
1621
+ button.appendChild(label)
1622
+
1623
+ if (entry.badge != null && entry.badge !== '') {
1624
+ var badge = document.createElement('span')
1625
+ badge.setAttribute('data-dsbs-tabbadge', '')
1626
+ badge.textContent = String(entry.badge)
1627
+ button.appendChild(badge)
1628
+ }
1629
+
1630
+ button.addEventListener('click', function () {
1631
+ if (entry.builtin) {
1632
+ // Built-ins drive the existing group switcher, so a click keeps
1633
+ // meaning what the same click meant before 0.3.0.
1634
+ var tookOver = runtime.activeTab !== null
1635
+ runtime.activeTab = null
1636
+ writeActiveTab(null)
1637
+ setField('tab', entry.id)
1638
+ // A click here takes the column away from a consumer tab exactly
1639
+ // like clicking another tab does, so subscribers hear about it the
1640
+ // same way. Without this a consumer that trusts `subscribe` would
1641
+ // keep believing it is still on screen.
1642
+ if (tookOver) notifyTabs('active')
1643
+ } else {
1644
+ activateTab(entry.id)
1645
+ }
1646
+ })
1647
+ return button
1648
+ }
1649
+
1650
+ function renderTabStrip() {
1651
+ var strip = runtime.tabStrip
1652
+ if (!strip) return
1653
+ var entries = tabEntries()
1654
+ var hasPlugin = false
1655
+ for (var p = 0; p < entries.length; p++) {
1656
+ if (!entries[p].builtin) hasPlugin = true
1657
+ }
1658
+ // Nothing registered: stay out of the way entirely, so a profile with
1659
+ // no tab consumers looks exactly like 0.2.x.
1660
+ if (!hasPlugin) {
1661
+ strip.setAttribute('hidden', '')
1662
+ runtime.tabSignature = ''
1663
+ return
1664
+ }
1665
+ strip.removeAttribute('hidden')
1666
+
1667
+ var activePlugin = runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
1668
+ var signature = (activePlugin || '-') + '|' + config.values.mode + '|' + config.values.tab + '|'
1669
+ for (var s = 0; s < entries.length; s++) {
1670
+ signature += entries[s].id + ':' + entries[s].title + ':' + entries[s].icon + ':' + entries[s].badge + ';'
1671
+ }
1672
+ if (signature === runtime.tabSignature) return
1673
+ runtime.tabSignature = signature
1674
+
1675
+ while (strip.firstChild) strip.removeChild(strip.firstChild)
1676
+ for (var i = 0; i < entries.length; i++) {
1677
+ var entry = entries[i]
1678
+ var selected = entry.builtin
1679
+ ? (!activePlugin && config.values.tab === entry.id)
1680
+ : (activePlugin === entry.id)
1681
+ strip.appendChild(buildTabButton(entry, selected))
1682
+ }
1683
+ }
1684
+
1685
+ function placeTabPanel(panel, fx) {
1686
+ if (panel.parentNode === fx.root) return
1687
+ var anchor = fx.nav || fx.regionArea
1688
+ if (anchor && anchor.parentNode === fx.root) fx.root.insertBefore(panel, anchor)
1689
+ else fx.root.appendChild(panel)
1690
+ }
1691
+
1692
+ function syncTabPanels(fx, activeId) {
1693
+ var i
1694
+ // The container is created the first time its tab owns the column, not
1695
+ // at registration: a tab that is never opened should cost nothing, and
1696
+ // a display:none container has no geometry to measure anyway.
1697
+ if (activeId && !runtime.tabPanels[activeId]) {
1698
+ var owning = findTab(activeId)
1699
+ if (owning) createTabPanel(owning)
1700
+ }
1701
+ // Panels that are no longer registered are gone already; the ones that
1702
+ // remain are shown only while they own the column.
1703
+ for (var id in runtime.tabPanels) {
1704
+ var entry = runtime.tabPanels[id]
1705
+ if (!entry || !entry.el) continue
1706
+ if (id === activeId) {
1707
+ placeTabPanel(entry.el, fx)
1708
+ entry.el.setAttribute('data-dsbs-visible', 'true')
1709
+ entry.el.removeAttribute('aria-hidden')
1710
+ if (!entry.mounted) mountTabPanel(entry)
1711
+ } else {
1712
+ entry.el.setAttribute('data-dsbs-visible', 'false')
1713
+ entry.el.setAttribute('aria-hidden', 'true')
1714
+ }
1715
+ }
1716
+ void i
1717
+ }
1718
+
1719
+ function activateTab(id) {
1720
+ if (id != null && id !== '' && !findTab(id)) return false
1721
+ var next = id == null || id === '' ? null : String(id)
1722
+ // Clicking the tab that already owns the column returns to the groups,
1723
+ // which is the only way back out of a tab in layouts a and c.
1724
+ if (next && runtime.activeTab === next) next = null
1725
+ runtime.activeTab = next
1726
+ writeActiveTab(next)
1727
+ notifyTabs('active')
1728
+ scheduleSync()
1729
+ return true
1730
+ }
1731
+
1166
1732
  function barButton(key) {
1167
1733
  if (key === 'temp') return tempButton
1168
1734
  if (!runtime.bar) return null
@@ -1271,6 +1837,8 @@ window.__ModuleLoader__.load({
1271
1837
 
1272
1838
  if (fx.nav && !fx.nav.hasAttribute('data-dsbs-ext')) fx.nav.setAttribute('data-dsbs-ext', '')
1273
1839
  ensureBar(fx)
1840
+ ensureTabStrip(fx)
1841
+ renderTabStrip()
1274
1842
  placeTempButton()
1275
1843
  applyTempButtonVisibility()
1276
1844
  loadTempStatus()
@@ -1339,7 +1907,28 @@ window.__ModuleLoader__.load({
1339
1907
 
1340
1908
  var extHidden
1341
1909
  var wsHidden
1342
- if (state.mode === 'a') {
1910
+ // A registered tab owning the column outranks every layout rule: the
1911
+ // two groups step aside and the tab's own container takes the space.
1912
+ var pluginTab = null
1913
+ if (runtime.activeTab) {
1914
+ if (findTab(runtime.activeTab)) {
1915
+ pluginTab = runtime.activeTab
1916
+ } else if (runtime.seenTabs[runtime.activeTab]) {
1917
+ // It registered once and is gone now: its owner unloaded while the
1918
+ // page was open. Fall back instead of leaving an empty column with
1919
+ // no way out of it.
1920
+ runtime.activeTab = null
1921
+ writeActiveTab(null)
1922
+ notifyTabs('orphan')
1923
+ }
1924
+ // Not seen yet: another plugin may still be registering during
1925
+ // startup, so the stored id is kept and retried on the next paint.
1926
+ }
1927
+ setFlag(html, 'data-dsbs-ptab', !!pluginTab)
1928
+ if (pluginTab) {
1929
+ extHidden = true
1930
+ wsHidden = true
1931
+ } else if (state.mode === 'a') {
1343
1932
  extHidden = state.foldExt === true
1344
1933
  wsHidden = state.foldWs === true
1345
1934
  } else if (state.mode === 'b') {
@@ -1352,6 +1941,13 @@ window.__ModuleLoader__.load({
1352
1941
  }
1353
1942
  setGroupHidden(fx.nav, 'ext', extHidden)
1354
1943
  setGroupHidden(fx.regionArea, 'ws', wsHidden)
1944
+ syncTabPanels(fx, pluginTab)
1945
+ // The strip is the only affordance that survives a hidden bar, so it
1946
+ // is repainted on every sync rather than only when its content changed.
1947
+ if (runtime.tabStrip && pluginTab) {
1948
+ var selectedTab = runtime.tabStrip.querySelector('[data-dsbs-tabid="' + pluginTab + '"]')
1949
+ if (selectedTab) selectedTab.setAttribute('aria-selected', 'true')
1950
+ }
1355
1951
 
1356
1952
  observeRoot(fx.root)
1357
1953
  positionPop(fx)
@@ -2500,11 +3096,35 @@ window.__ModuleLoader__.load({
2500
3096
  } catch (error) {
2501
3097
  void error
2502
3098
  }
3099
+ try {
3100
+ // Unmount every live tab first: a consumer's disposer may still
3101
+ // touch its container, so removing the DOM afterwards is safer
3102
+ // than yanking it out from under a cleanup callback.
3103
+ disposeAllTabPanels()
3104
+ if (runtime.tabStrip && runtime.tabStrip.parentNode) runtime.tabStrip.parentNode.removeChild(runtime.tabStrip)
3105
+ } catch (error) {
3106
+ void error
3107
+ }
3108
+ try {
3109
+ runtime.tabStrip = null
3110
+ runtime.tabSignature = ''
3111
+ tabListeners.length = 0
3112
+ tabRecords.length = 0
3113
+ } catch (error) {
3114
+ void error
3115
+ }
2503
3116
  try {
2504
3117
  if (window[OWNER_KEY] === owner) delete window[OWNER_KEY]
2505
3118
  } catch (error) {
2506
3119
  void error
2507
3120
  }
3121
+ try {
3122
+ // Only drop the handle if it is still ours: a newer generation may
3123
+ // already have replaced it, and that one must keep working.
3124
+ if (window.__dshSidebarSuite === sidebarService) delete window.__dshSidebarSuite
3125
+ } catch (error) {
3126
+ void error
3127
+ }
2508
3128
  try {
2509
3129
  if (runtime.root) runtime.root.style.removeProperty('--dsbs-pop-top')
2510
3130
  } catch (error) {
@@ -2538,6 +3158,7 @@ window.__ModuleLoader__.load({
2538
3158
  'data-dsbs-narrow',
2539
3159
  'data-dsbs-temp-solo',
2540
3160
  'data-dsbs-temp-style',
3161
+ 'data-dsbs-ptab',
2541
3162
  ]
2542
3163
  for (var i = 0; i < attributes.length; i++) {
2543
3164
  html.removeAttribute(attributes[i])
@@ -2564,7 +3185,6 @@ window.__ModuleLoader__.load({
2564
3185
  }, 'sidebar-suite: teardown')
2565
3186
  owner.teardown = teardown
2566
3187
  }
2567
-
2568
3188
  return { inject: ['locale'], apply: apply }
2569
3189
  },
2570
3190
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-suite",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Yonan",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "type": "module",
29
29
  "scripts": {
30
- "test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs",
30
+ "test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs && node tests/test_030.mjs && node tests/test_031.mjs",
31
31
  "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/temp-session-host.mjs && npm pack --dry-run"
32
32
  },
33
33
  "main": "lib/index.js",
@@ -117,6 +117,29 @@
117
117
 
118
118
  ---
119
119
 
120
+ ### 3.4 第三方插件加的页签(0.3.0)
121
+
122
+ 这一版给**别的插件**开了一个口:它们可以往你侧栏里加自己的一页。
123
+
124
+ 你不需要做任何设置。什么时候会看到:
125
+
126
+ - 装了某个使用这个接口的插件之后,**控制栏下面会多出一排页签**,比如 `扩展 │ 工作区 │ 那个插件的名字`。
127
+ - 点哪个,侧栏中间那块就换成哪一页。
128
+ - **再点一次当前页签**,就退回原来的会话列表 / 扩展列表。
129
+ - 你在哪一页会被记住,下次打开还在那一页。
130
+
131
+ 三种布局下的差别:
132
+
133
+ | 你选的布局 | 页签条长什么样 |
134
+ |---|---|
135
+ | 标签页(默认) | `扩展 / 工作区 / 插件页` 三种并排 |
136
+ | 折叠 | 只有插件页进条;扩展和工作区还是用原来那两个折叠按钮 |
137
+ | 浮层 | 只有插件页进条;扩展还是用「浮出面板」按钮 |
138
+
139
+ **一个都没装的话,这排页签根本不会出现** —— 界面和 0.2.x 一模一样,不会平白多一行东西。
140
+
141
+ > 想自己写一个?接口是 `ctx.sidebarSuite.registerTab({ id, title, icon, mount })`,`mount` 拿到一个空容器,你爱画什么画什么。完整示例在 README 和 DETAIL 里。
142
+
120
143
  ## 四、临时会话(重点)
121
144
 
122
145
  ### 4.1 它到底是什么