dsh-sidebar-suite 0.2.9 → 0.4.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/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.4.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.',
@@ -95,6 +106,15 @@ window.__ModuleLoader__.load({
95
106
  'temp.feedbackHint': 'Report it on the plugin detail page.',
96
107
  'temp.restartHint': 'Fully quit DSH (tray icon → Quit) and reopen it — a hot reload does not reload the plugin host.',
97
108
  'temp.badge': 'TEMP',
109
+ 'temp.menu.label': 'Temporary session actions',
110
+ 'temp.menu.keep': 'Keep as a normal session',
111
+ 'temp.menu.keep.hint': 'It will no longer be deleted when DSH quits.',
112
+ 'temp.menu.delete': 'Delete now',
113
+ 'temp.menu.delete.hint': 'Removes this session from disk. It cannot be undone.',
114
+ 'temp.menu.kept': 'Kept as a normal session — it now survives quitting DSH.',
115
+ 'temp.menu.deleted': 'Temporary session deleted.',
116
+ 'temp.menu.hidden': 'Files were locked, so the row was hidden instead. Deletion is retried next launch.',
117
+ 'temp.menu.failed': 'Action failed: ',
98
118
  'policy.title': 'Temporary session cleanup',
99
119
  'policy.help': 'Choose when temporary sessions are removed. Defaults: delete on quit = on, daily cleanup = off. Saved to the plugin settings file and applied immediately — no restart.',
100
120
  'policy.onQuit': 'Delete when DSH quits',
@@ -129,6 +149,10 @@ window.__ModuleLoader__.load({
129
149
  'tab.to.ext.hint': '把共用的一栏切换到扩展区。',
130
150
  'tab.to.ws': '显示工作区',
131
151
  'tab.to.ws.hint': '把共用的一栏切换到工作区。',
152
+ 'tab.ext': '扩展',
153
+ 'tab.ws': '工作区',
154
+ 'tabs.label': '侧栏页签',
155
+ 'tabs.own': '这一页由 {name} 提供。',
132
156
  'pop.open': '展开扩展',
133
157
  'pop.close': '收起扩展',
134
158
  'pop.hint': '打开或收起扩展浮层。',
@@ -145,6 +169,15 @@ window.__ModuleLoader__.load({
145
169
  'temp.feedbackHint': '可在插件详情页反馈。',
146
170
  'temp.restartHint': '请从托盘图标彻底退出 DSH 再打开 —— 热重载不会重载插件后端。',
147
171
  'temp.badge': '临时',
172
+ 'temp.menu.label': '临时会话操作',
173
+ 'temp.menu.keep': '保留为正式会话',
174
+ 'temp.menu.keep.hint': '退出 DSH 时不再删除它。',
175
+ 'temp.menu.delete': '立即删除',
176
+ 'temp.menu.delete.hint': '把这个会话从磁盘删掉,无法恢复。',
177
+ 'temp.menu.kept': '已保留为正式会话:退出 DSH 时不会再删除它。',
178
+ 'temp.menu.deleted': '临时会话已删除。',
179
+ 'temp.menu.hidden': '文件被占用,已先把它从列表里隐藏,下次启动再删。',
180
+ 'temp.menu.failed': '操作失败:',
148
181
  'policy.title': '临时会话自动删除',
149
182
  'policy.help': '决定临时会话什么时候被清掉。默认:退出 DSH 时删除=开,每天定时清理=关。写入插件设置文件,改完立即生效,不用重启。',
150
183
  'policy.onQuit': '退出 DSH 时删除',
@@ -191,6 +224,8 @@ window.__ModuleLoader__.load({
191
224
  'options.styleOwn': '独占一行 / Own row',
192
225
  'options.expandAll': '会话默认全展开 / Always expand sessions',
193
226
  'options.expandHint': '关掉后恢复 DSH 原来的折叠行为;已经展开的列表要切换一次工作区才会收回去。 / Turning it off restores host folding; already-expanded groups collapse after you switch workspaces once.',
227
+ 'options.rowMenu': '临时会话右键菜单 / Right-click menu on temp sessions',
228
+ 'options.rowMenuHint': '右键临时会话可「保留为正式会话」或「立即删除」。若和别的插件抢右键菜单,关掉这一项即可。 / Right-clicking a temp session offers "keep as normal" or "delete now". Turn this off if another plugin owns the context menu.',
194
229
  'options.maxKept': '最多保留 / Keep at most',
195
230
  'options.maxKeptHint': '个临时会话(1–200) / temp sessions (1–200)',
196
231
  'options.purge': '立即清理临时会话 / Clean up now',
@@ -299,6 +334,19 @@ window.__ModuleLoader__.load({
299
334
  // In the collapsed rail there is no horizontal room for two buttons.
300
335
  'html[data-dsbs-temp-solo="true"] [data-dsbs-temp-slot]{display:none!important}',
301
336
  'html[data-dsbs-temp-solo="true"]:not([data-dsbs-chat-mode]) [data-dsbs-newchat] button[class*="newSession"]{width:auto!important;height:38px!important;font-size:14px!important;line-height:22px!important;margin:0 calc(var(--dsh-sidebar-paint-inset,3px) + 1px) 10px!important}',
337
+ // ---- per-row menu on temporary sessions (0.4.0) -------------------
338
+ // Right-clicking a temporary row offers "keep as normal" and "delete
339
+ // now". Styled from host tokens like every other surface here; the row
340
+ // itself gains no children, so React re-renders cannot wipe it.
341
+ '[data-dsbs-temp-menu]{position:fixed;z-index:9400;min-width:196px;max-width:min(280px,calc(100vw - 16px));box-sizing:border-box;padding:4px;display:flex;flex-direction:column;gap:1px;border-radius:10px;background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-base,#1f1f1f));color:var(--dsw-alias-label-primary,inherit);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));box-shadow:0 12px 32px rgba(0,0,0,.32)}',
342
+ '[data-dsbs-temp-menu][hidden]{display:none!important}',
343
+ '[data-dsbs-temp-menu-head]{padding:4px 8px 6px;margin-bottom:2px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.9));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
344
+ '[data-dsbs-temp-menu] button{appearance:none;-webkit-appearance:none;display:block;width:100%;box-sizing:border-box;border:0;background:transparent;color:inherit;font:inherit;font-size:12px;line-height:1.45;text-align:left;padding:6px 8px;border-radius:7px;cursor:pointer;white-space:normal}',
345
+ '[data-dsbs-temp-menu] button:hover,[data-dsbs-temp-menu] button:focus-visible{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));outline:none}',
346
+ '[data-dsbs-temp-menu] button:focus-visible{outline:2px solid var(--dsw-alias-label-primary,currentColor);outline-offset:-2px}',
347
+ '[data-dsbs-temp-menu] button[data-dsbs-temp-action="delete"]{color:var(--dsw-alias-state-business-danger,#d9534f)}',
348
+ '[data-dsbs-temp-menu-sub]{display:block;font-size:10px;line-height:1.4;opacity:.68}',
349
+ 'html[data-dsbs-temp-menu="false"] [data-dsbs-temp-menu]{display:none!important}',
302
350
  // A temporary session is a NORMAL DSH session, so its sidebar row is the
303
351
  // host's own row. Nothing is added to the DOM: we tag the row and let CSS
304
352
  // draw the badge, which survives every React re-render of the list.
@@ -356,6 +404,34 @@ window.__ModuleLoader__.load({
356
404
  '[data-row-key^="session:"]{transition:background-color .13s ease,box-shadow .13s ease,color .13s ease;font-variant-numeric:tabular-nums}',
357
405
  '[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
406
  'html[data-dsbs-rail="true"] [data-row-key^="session:"][aria-selected="true"]{box-shadow:none!important}',
407
+
408
+ // ---- third-party page strip (0.3.0) ------------------------------
409
+ // The strip only exists while at least one plugin registered a tab, so a
410
+ // stock profile never sees it. Everything inherits currentColor, which
411
+ // is what keeps it legible in both host themes without a palette here.
412
+ '[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}',
413
+ '[data-dsbs-tabs][hidden]{display:none!important}',
414
+ '[data-dsbs-tabs]::-webkit-scrollbar{display:none}',
415
+ '[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}',
416
+ '[data-dsbs-tabs] button:hover{opacity:.92;background:color-mix(in srgb,currentColor 8%,transparent)}',
417
+ '[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)}',
418
+ '[data-dsbs-tablabel]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}',
419
+ '[data-dsbs-tabicon]{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex:none}',
420
+ '[data-dsbs-tabicon] svg{width:14px;height:14px;display:block;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}',
421
+ '[data-dsbs-tabicon-text]{font-size:12px;line-height:1}',
422
+ '[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}',
423
+ // The tab's own container inherits the column the two groups gave up.
424
+ '[data-dsbs-tabpanel]{display:none;flex:1 1 auto;min-height:0;min-width:0;overflow:auto;flex-direction:column}',
425
+ '[data-dsbs-tabpanel][data-dsbs-visible="true"]{display:flex}',
426
+ 'html[data-dsbs-ptab="true"] nav[data-dsbs-ext]{display:none!important}',
427
+ 'html[data-dsbs-ptab="true"] div:has(> [data-slot="sidebar.workspaces"]){display:none!important}',
428
+ // Narrow: icon-only pages, the same rule the control bar follows. A tab
429
+ // without an icon keeps its label instead of becoming an empty box.
430
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button{max-width:64px;padding:4px 6px}',
431
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tablabel]{display:none}',
432
+ 'html[data-dsbs-narrow="true"] [data-dsbs-tabs] button[data-dsbs-hasicon] [data-dsbs-tabbadge]{display:none}',
433
+ 'html[data-dsbs-rail="true"] [data-dsbs-tabs]{padding:2px 2px 0}',
434
+ 'html[data-dsbs-rail="true"] [data-dsbs-tabs] button{max-width:40px;padding:4px 5px}',
359
435
  ].join('\n')
360
436
 
361
437
  function assign(target) {
@@ -504,6 +580,11 @@ window.__ModuleLoader__.load({
504
580
  return DICTS[lang][key] || DICTS.en[key] || key
505
581
  }
506
582
  t.refresh = refresh
583
+ // Third-party tab titles may arrive as `{ zh, en }`; resolving them needs
584
+ // the same detector the dictionaries use, not a second guess.
585
+ t.langName = function () {
586
+ return lang
587
+ }
507
588
  return t
508
589
  }
509
590
 
@@ -559,6 +640,100 @@ window.__ModuleLoader__.load({
559
640
  resizeObserver: null,
560
641
  mutationObserver: null,
561
642
  raf: 0,
643
+ // 0.3.0 — third-party tabs. `activeTab` holds a registered id (or null
644
+ // for the built-in groups); `tabPanels` caches one live container per
645
+ // tab so a mount callback runs once and its disposer stays reachable.
646
+ activeTab: readActiveTab(),
647
+ seenTabs: Object.create(null),
648
+ tabStrip: null,
649
+ tabPanels: Object.create(null),
650
+ tabSignature: '',
651
+ }
652
+
653
+ // The public surface other plugins see. Everything here is either a
654
+ // function declaration (hoisted, so the object can be built before the
655
+ // implementations read below) or a getter that touches live state only
656
+ // when called.
657
+ var sidebarService = {
658
+ version: VERSION,
659
+ // Returns its own disposer, which is exactly what `ctx.effect(() => …)`
660
+ // wants, so a consumer never has to remember to unregister.
661
+ registerTab: function (descriptor) {
662
+ return registerTab(descriptor)
663
+ },
664
+ unregisterTab: function (id) {
665
+ return unregisterTab(id)
666
+ },
667
+ getTabs: function () {
668
+ return tabSnapshot()
669
+ },
670
+ activateTab: function (id) {
671
+ return activateTab(id)
672
+ },
673
+ getActiveTab: function () {
674
+ return runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
675
+ },
676
+ // A consumer can follow the strip instead of polling it: called with
677
+ // (tabs, reason, activeId) whenever the list or the selection changes.
678
+ subscribe: function (listener) {
679
+ if (typeof listener !== 'function') return function () {}
680
+ tabListeners.push(listener)
681
+ var off = false
682
+ return function () {
683
+ if (off) return
684
+ off = true
685
+ var at = tabListeners.indexOf(listener)
686
+ if (at >= 0) tabListeners.splice(at, 1)
687
+ }
688
+ },
689
+ // Reading the current shape lets a consumer align with the layout
690
+ // instead of sniffing classes off <html>.
691
+ getLayout: function () {
692
+ return {
693
+ mode: config.values.mode,
694
+ tab: config.values.tab,
695
+ activeTab: runtime.activeTab,
696
+ narrow: runtime.narrow === true,
697
+ rail: runtime.rail === true,
698
+ }
699
+ },
700
+ }
701
+
702
+ // Publish the service once the slot system is live.
703
+ //
704
+ // Two dead ends measured against this client context, both of which take
705
+ // the whole sidebar down:
706
+ // * ctx.provide() directly inside apply() stalls the plugin loader on
707
+ // "Loading plugins..." forever;
708
+ // * a static `inject: ['slots']` never activates, so apply() never runs.
709
+ // Publishing from inside a dependency callback is the shape that works.
710
+ function publishService() {
711
+ try {
712
+ if (typeof ctx.provide === 'function') ctx.provide('sidebarSuite', sidebarService)
713
+ } catch (error) {
714
+ try {
715
+ console.warn('[dsh-sidebar-suite] could not publish the sidebarSuite service:', error)
716
+ } catch (inner) {
717
+ void inner
718
+ }
719
+ }
720
+ }
721
+ if (typeof ctx.inject === 'function') {
722
+ try {
723
+ ctx.inject(['slots'], function () { publishService() })
724
+ } catch (error) {
725
+ publishService()
726
+ }
727
+ } else {
728
+ publishService()
729
+ }
730
+ // One window handle for the same object. Not a second API: it exists so a
731
+ // consumer can reach the service from a plain browser context (and so the
732
+ // release checks can drive the strip over CDP).
733
+ try {
734
+ window.__dshSidebarSuite = sidebarService
735
+ } catch (error) {
736
+ void error
562
737
  }
563
738
 
564
739
  var manualCleanups = []
@@ -572,6 +747,12 @@ window.__ModuleLoader__.load({
572
747
  var configPollPending = false
573
748
  var tempChatGuard = null
574
749
  var tempIds = [] // session ids the Host half will delete on quit
750
+ // 0.4.0 — per-row menu. `tempMenuEnabled` mirrors TEMP_ROW_MENU and is the
751
+ // escape hatch if another plugin wants the context menu on these rows.
752
+ var tempMenuEnabled = true
753
+ var tempRowMenu = null
754
+ var tempMenuRow = null
755
+ var tempMenuDismiss = null
575
756
  var tempBusy = false
576
757
  var tempToastTimer = 0
577
758
 
@@ -840,6 +1021,275 @@ window.__ModuleLoader__.load({
840
1021
  }
841
1022
  }
842
1023
 
1024
+ // ---- per-row menu on temporary rows (0.4.0) -----------------------
1025
+ // A temporary session used to be all-or-nothing: either it vanished at
1026
+ // quit, or you turned DELETE_ON_EXIT off and every temp session became a
1027
+ // normal one. Now a single row can be promoted ("keep as a normal
1028
+ // session") or deleted on the spot.
1029
+ //
1030
+ // Both actions already existed on the Host half — `untrack` and the new
1031
+ // `remove` route — so this is purely a missing entrance. It never touches
1032
+ // rows that are not temporary: no preventDefault, no stopPropagation, the
1033
+ // host's own context menu keeps working everywhere else.
1034
+
1035
+ /** Nearest ancestor carrying data-row-key="session:<id>". */
1036
+ function tempRowOf(node) {
1037
+ var el = node
1038
+ var guard = 0
1039
+ while (el && el !== document && guard < 60) {
1040
+ guard += 1
1041
+ var key = el.getAttribute ? el.getAttribute('data-row-key') : ''
1042
+ if (key && key.indexOf('session:') === 0) return el
1043
+ el = el.parentNode
1044
+ }
1045
+ return null
1046
+ }
1047
+
1048
+ function tempMenuButtons() {
1049
+ var out = []
1050
+ if (!tempRowMenu) return out
1051
+ var list = null
1052
+ try { list = tempRowMenu.querySelectorAll('button[data-dsbs-temp-action]') } catch (error) { void error }
1053
+ if (!list) return out
1054
+ for (var i = 0; i < list.length; i++) out.push(list[i])
1055
+ return out
1056
+ }
1057
+
1058
+ function closeTempMenu(restoreFocus) {
1059
+ if (!tempRowMenu) return false
1060
+ var row = tempMenuRow
1061
+ try { if (tempRowMenu.parentNode) tempRowMenu.parentNode.removeChild(tempRowMenu) } catch (error) { void error }
1062
+ tempRowMenu = null
1063
+ tempMenuRow = null
1064
+ detachTempMenuDismiss()
1065
+ if (restoreFocus && row && typeof row.focus === 'function') {
1066
+ try { row.focus() } catch (error) { void error }
1067
+ }
1068
+ return true
1069
+ }
1070
+
1071
+ function detachTempMenuDismiss() {
1072
+ var handlers = tempMenuDismiss
1073
+ if (!handlers) return
1074
+ tempMenuDismiss = null
1075
+ try { document.removeEventListener('pointerdown', handlers.onDown, true) } catch (error) { void error }
1076
+ try { document.removeEventListener('keydown', handlers.onKey, true) } catch (error) { void error }
1077
+ try { window.removeEventListener('resize', handlers.onViewport) } catch (error) { void error }
1078
+ try { window.removeEventListener('scroll', handlers.onViewport, true) } catch (error) { void error }
1079
+ }
1080
+
1081
+ function attachTempMenuDismiss() {
1082
+ if (tempMenuDismiss) return
1083
+ var handlers = {
1084
+ onDown: function (event) {
1085
+ if (!tempRowMenu) return
1086
+ var target = event && event.target
1087
+ if (target && tempRowMenu.contains && tempRowMenu.contains(target)) return
1088
+ closeTempMenu(false)
1089
+ },
1090
+ onKey: function (event) {
1091
+ if (!tempRowMenu || !event) return
1092
+ var key = event.key
1093
+ if (key === 'Escape') {
1094
+ event.preventDefault()
1095
+ event.stopPropagation()
1096
+ closeTempMenu(true)
1097
+ return
1098
+ }
1099
+ if (key === 'Tab') { closeTempMenu(false); return }
1100
+ if (key !== 'ArrowDown' && key !== 'ArrowUp') return
1101
+ var buttons = tempMenuButtons()
1102
+ if (!buttons.length) return
1103
+ event.preventDefault()
1104
+ var active = document.activeElement
1105
+ var at = buttons.indexOf(active)
1106
+ var next
1107
+ if (key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % buttons.length
1108
+ else next = at <= 0 ? buttons.length - 1 : at - 1
1109
+ try { buttons[next].focus() } catch (error) { void error }
1110
+ },
1111
+ // The menu is positioned in viewport coordinates, so any scroll or
1112
+ // resize would leave it floating next to the wrong row.
1113
+ onViewport: function () { closeTempMenu(false) },
1114
+ }
1115
+ tempMenuDismiss = handlers
1116
+ try { document.addEventListener('pointerdown', handlers.onDown, true) } catch (error) { void error }
1117
+ try { document.addEventListener('keydown', handlers.onKey, true) } catch (error) { void error }
1118
+ try { window.addEventListener('resize', handlers.onViewport) } catch (error) { void error }
1119
+ try { window.addEventListener('scroll', handlers.onViewport, true) } catch (error) { void error }
1120
+ }
1121
+
1122
+ function makeTempMenuItem(action, labelKey, hintKey, onClick) {
1123
+ var button = document.createElement('button')
1124
+ button.type = 'button'
1125
+ button.setAttribute('role', 'menuitem')
1126
+ button.setAttribute('data-dsbs-temp-action', action)
1127
+ var label = document.createElement('span')
1128
+ label.textContent = t(labelKey)
1129
+ button.appendChild(label)
1130
+ var hint = t(hintKey)
1131
+ if (hint) {
1132
+ var sub = document.createElement('span')
1133
+ sub.setAttribute('data-dsbs-temp-menu-sub', '')
1134
+ sub.textContent = hint
1135
+ button.appendChild(sub)
1136
+ }
1137
+ button.addEventListener('click', function (event) {
1138
+ if (event) {
1139
+ event.preventDefault()
1140
+ event.stopPropagation()
1141
+ }
1142
+ closeTempMenu(false)
1143
+ onClick()
1144
+ })
1145
+ return button
1146
+ }
1147
+
1148
+ function openTempMenu(row, id, x, y) {
1149
+ closeTempMenu(false)
1150
+ var menu = document.createElement('div')
1151
+ menu.setAttribute('data-dsbs-temp-menu', '')
1152
+ menu.setAttribute('role', 'menu')
1153
+ menu.setAttribute('aria-label', t('temp.menu.label'))
1154
+ // The row's own text is the only title available (the host owns the
1155
+ // session list), and it is what the user just pointed at.
1156
+ var text = (row.textContent || '').replace(/\s+/g, ' ').trim()
1157
+ if (text) {
1158
+ var head = document.createElement('div')
1159
+ head.setAttribute('data-dsbs-temp-menu-head', '')
1160
+ head.textContent = text.length > 48 ? text.slice(0, 48) + '…' : text
1161
+ menu.appendChild(head)
1162
+ }
1163
+ menu.appendChild(makeTempMenuItem('keep', 'temp.menu.keep', 'temp.menu.keep.hint', function () {
1164
+ keepTempSession(id)
1165
+ }))
1166
+ menu.appendChild(makeTempMenuItem('delete', 'temp.menu.delete', 'temp.menu.delete.hint', function () {
1167
+ deleteTempSession(id)
1168
+ }))
1169
+ document.body.appendChild(menu)
1170
+
1171
+ // Measure first, then flip near the viewport edges. Both reads are
1172
+ // wrapped: a detached or unmeasured node must not break the menu.
1173
+ var width = 0
1174
+ var height = 0
1175
+ try {
1176
+ var rect = menu.getBoundingClientRect ? menu.getBoundingClientRect() : null
1177
+ width = (rect && rect.width) || menu.offsetWidth || 0
1178
+ height = (rect && rect.height) || menu.offsetHeight || 0
1179
+ } catch (error) { void error }
1180
+ if (!width) width = 220
1181
+ if (!height) height = 96
1182
+ var vw = window.innerWidth || 1024
1183
+ var vh = window.innerHeight || 768
1184
+ var left = Number.isFinite(x) ? x : 0
1185
+ var top = Number.isFinite(y) ? y : 0
1186
+ if (left + width > vw - 8) left = Math.max(8, vw - width - 8)
1187
+ if (top + height > vh - 8) top = Math.max(8, vh - height - 8)
1188
+ menu.style.left = Math.round(left) + 'px'
1189
+ menu.style.top = Math.round(top) + 'px'
1190
+
1191
+ tempRowMenu = menu
1192
+ tempMenuRow = row
1193
+ attachTempMenuDismiss()
1194
+ var buttons = tempMenuButtons()
1195
+ if (buttons.length) {
1196
+ try { buttons[0].focus() } catch (error) { void error }
1197
+ }
1198
+ }
1199
+
1200
+ /** Promote one session: it stops being temporary, nothing is deleted. */
1201
+ function keepTempSession(id) {
1202
+ tempCall('api/dsh-temp-session.untrack?sessionId=' + encodeURIComponent(id), { method: 'POST' })
1203
+ .then(function (data) {
1204
+ if (!data || data.ok !== true) throw new Error((data && data.error) || 'untrack failed')
1205
+ applyTempIdList(data.ids, id)
1206
+ toast(t('temp.menu.kept'), false)
1207
+ scheduleSync()
1208
+ })
1209
+ .catch(function (error) {
1210
+ toast(t('temp.menu.failed') + String((error && error.message) || error), true)
1211
+ })
1212
+ }
1213
+
1214
+ /** Delete one session now, with the same fallback `purgeAll` uses. */
1215
+ function deleteTempSession(id) {
1216
+ tempCall('api/dsh-temp-session.remove?sessionId=' + encodeURIComponent(id), { method: 'POST' })
1217
+ .then(function (data) {
1218
+ if (!data || data.ok !== true) throw new Error((data && data.error) || 'delete failed')
1219
+ applyTempIdList(data.ids, id)
1220
+ toast(t(data.hidden ? 'temp.menu.hidden' : 'temp.menu.deleted'), false)
1221
+ refreshHostSessions()
1222
+ scheduleSync()
1223
+ })
1224
+ .catch(function (error) {
1225
+ toast(t('temp.menu.failed') + String((error && error.message) || error), true)
1226
+ })
1227
+ }
1228
+
1229
+ /** Adopt the id list the Host returned, or just drop the one id. */
1230
+ function applyTempIdList(ids, removedId) {
1231
+ if (Array.isArray(ids)) tempIds = ids.slice()
1232
+ else tempIds = tempIds.filter(function (item) { return item !== removedId })
1233
+ applyTempRows()
1234
+ if (tempRowMenu && tempMenuRow) {
1235
+ var key = tempMenuRow.getAttribute('data-row-key') || ''
1236
+ if (tempIds.indexOf(key.slice('session:'.length)) < 0) closeTempMenu(false)
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * Ask the host's own session list to re-read. There is no documented API
1242
+ * for this, so every common name is tried and failure is silent: a row
1243
+ * that outlives its files disappears on the next list refresh anyway.
1244
+ */
1245
+ function refreshHostSessions() {
1246
+ try {
1247
+ var ui = uiWorkspace()
1248
+ var sessions = ui && ui.sessions
1249
+ var names = ['reload', 'refresh', 'reloadSessions', 'invalidate']
1250
+ for (var i = 0; i < names.length; i++) {
1251
+ if (sessions && typeof sessions[names[i]] === 'function') {
1252
+ sessions[names[i]]()
1253
+ return true
1254
+ }
1255
+ }
1256
+ } catch (error) {
1257
+ void error
1258
+ }
1259
+ return false
1260
+ }
1261
+
1262
+ function onTempContextMenu(event) {
1263
+ if (runtime.disposed || !tempMenuEnabled || !event) return
1264
+ var row = tempRowOf(event.target)
1265
+ if (!row) return
1266
+ var key = row.getAttribute('data-row-key') || ''
1267
+ var id = key.slice('session:'.length)
1268
+ // Not one of ours: leave the event completely alone so the host (or
1269
+ // whichever plugin owns that row) still gets its own menu.
1270
+ if (!id || tempIds.indexOf(id) < 0) return
1271
+ event.preventDefault()
1272
+ event.stopPropagation()
1273
+ openTempMenu(row, id, event.clientX, event.clientY)
1274
+ }
1275
+
1276
+ var tempMenuUnmount = null
1277
+ function mountTempRowMenu() {
1278
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
1279
+ document.addEventListener('contextmenu', onTempContextMenu)
1280
+ tempMenuUnmount = function () {
1281
+ try { document.removeEventListener('contextmenu', onTempContextMenu) } catch (error) { void error }
1282
+ closeTempMenu(false)
1283
+ }
1284
+ }
1285
+
1286
+ function applyTempMenuSetting(data) {
1287
+ var next = !data || data.rowMenu !== false
1288
+ if (next === tempMenuEnabled) return
1289
+ tempMenuEnabled = next
1290
+ if (!next) closeTempMenu(false)
1291
+ }
1292
+
843
1293
  function refreshTempIds() {
844
1294
  return tempCall('api/dsh-temp-session.list')
845
1295
  .then(function (data) {
@@ -947,6 +1397,8 @@ window.__ModuleLoader__.load({
947
1397
  expandAll = !data || data.expandAllSessions !== false
948
1398
  expandAllKnown = true
949
1399
  applyExpandFlag()
1400
+ // 0.4.0 — same treatment: a Host without this key keeps the menu on.
1401
+ applyTempMenuSetting(data)
950
1402
  syncChatMode()
951
1403
  expandAllSessions()
952
1404
  scheduleSync()
@@ -1140,11 +1592,32 @@ window.__ModuleLoader__.load({
1140
1592
  return bar
1141
1593
  }
1142
1594
 
1595
+ // A paint must be able to leave the tree exactly as it found it. Asking
1596
+ // for an exact neighbour is not enough: the 0.3.0 tab strip (and an
1597
+ // active plugin's panel) legitimately sit between the control bar and
1598
+ // the host's panels nav, so `bar.nextSibling === nav` can never be true
1599
+ // once those exist. Moving the bar on every paint mutates the observed
1600
+ // root, which fires the MutationObserver, which schedules another paint
1601
+ // -- a microtask loop that never returns to the event loop and hangs DSH
1602
+ // on "Loading plugins..." with the renderer at 100%. Compare ORDER, not
1603
+ // adjacency.
1604
+ function isBefore(node, reference) {
1605
+ if (!node || !reference || !node.parentNode || node.parentNode !== reference.parentNode) return false
1606
+ var list = node.parentNode.children
1607
+ var at = -1
1608
+ var before = -1
1609
+ for (var i = 0; i < list.length; i++) {
1610
+ if (list[i] === node) at = i
1611
+ if (list[i] === reference) before = i
1612
+ }
1613
+ return at >= 0 && before >= 0 && at < before
1614
+ }
1615
+
1143
1616
  function ensureBar(fx) {
1144
1617
  var bar = runtime.bar
1145
1618
  if (bar && bar.parentNode !== fx.root) {
1146
1619
  try {
1147
- bar.parentNode.removeChild(bar)
1620
+ if (bar.parentNode) bar.parentNode.removeChild(bar)
1148
1621
  } catch (error) {
1149
1622
  void error
1150
1623
  }
@@ -1156,13 +1629,442 @@ window.__ModuleLoader__.load({
1156
1629
  }
1157
1630
  var anchor = fx.nav || fx.regionArea
1158
1631
  if (anchor && anchor.parentNode === fx.root) {
1159
- if (bar.nextSibling !== anchor) fx.root.insertBefore(bar, anchor)
1632
+ if (bar.parentNode !== fx.root || !isBefore(bar, anchor)) fx.root.insertBefore(bar, anchor)
1160
1633
  } else if (bar.parentNode !== fx.root) {
1161
1634
  fx.root.appendChild(bar)
1162
1635
  }
1163
1636
  return bar
1164
1637
  }
1165
1638
 
1639
+ // ---- third-party tabs (0.3.0) --------------------------------------
1640
+ // Another plugin cannot reach into this bundle, so the whole feature is
1641
+ // one service object handed to the client context (`ctx.provide` at the
1642
+ // bottom of this file). The contract is deliberately DOM-shaped rather
1643
+ // than React-shaped: a consumer receives a plain container and paints
1644
+ // into it, which works for every plugin no matter how it was built.
1645
+ //
1646
+ // Deliberately absent: any way for a tab to restructure the sidebar.
1647
+ // A tab owns its own container and nothing else.
1648
+ var TAB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/
1649
+ var tabRecords = []
1650
+ var tabListeners = []
1651
+ var tabSeq = 0
1652
+
1653
+ function readActiveTab() {
1654
+ try {
1655
+ var raw = localStorage.getItem(TAB_STORE_KEY)
1656
+ return typeof raw === 'string' && raw ? raw : null
1657
+ } catch (error) {
1658
+ return null
1659
+ }
1660
+ }
1661
+
1662
+ function writeActiveTab(id) {
1663
+ try {
1664
+ if (id) localStorage.setItem(TAB_STORE_KEY, id)
1665
+ else localStorage.removeItem(TAB_STORE_KEY)
1666
+ } catch (error) {
1667
+ void error
1668
+ }
1669
+ }
1670
+
1671
+ function findTab(id) {
1672
+ for (var i = 0; i < tabRecords.length; i++) {
1673
+ if (tabRecords[i].id === id) return tabRecords[i]
1674
+ }
1675
+ return null
1676
+ }
1677
+
1678
+ // `title` may be a plain string or `{ zh, en }`. Resolving it here keeps
1679
+ // the two languages in one descriptor instead of making every consumer
1680
+ // read the locale itself.
1681
+ function tabTitle(record) {
1682
+ var title = record.title
1683
+ if (title && typeof title === 'object') {
1684
+ var zh = typeof title.zh === 'string' ? title.zh : ''
1685
+ var en = typeof title.en === 'string' ? title.en : ''
1686
+ var preferred = t.langName() === 'zh' ? zh || en : en || zh
1687
+ return preferred || record.id
1688
+ }
1689
+ return typeof title === 'string' && title ? title : record.id
1690
+ }
1691
+
1692
+ function tabSnapshot() {
1693
+ var out = []
1694
+ for (var i = 0; i < tabRecords.length; i++) {
1695
+ var record = tabRecords[i]
1696
+ out.push({
1697
+ id: record.id,
1698
+ title: tabTitle(record),
1699
+ icon: record.icon,
1700
+ order: record.order,
1701
+ badge: record.badge,
1702
+ hidden: record.hidden,
1703
+ owner: record.owner,
1704
+ })
1705
+ }
1706
+ return out
1707
+ }
1708
+
1709
+ function notifyTabs(reason) {
1710
+ if (!tabListeners.length) return
1711
+ var list = tabSnapshot()
1712
+ var active = runtime.activeTab
1713
+ for (var i = 0; i < tabListeners.length; i++) {
1714
+ try {
1715
+ tabListeners[i](list, reason, active)
1716
+ } catch (error) {
1717
+ void error
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ function tabContext(record) {
1723
+ return {
1724
+ id: record.id,
1725
+ t: function (key) {
1726
+ return t(key)
1727
+ },
1728
+ lang: function () {
1729
+ return t.langName()
1730
+ },
1731
+ // Lets a consumer close its own tab once the user is done with it,
1732
+ // rather than leaving a dead page in the strip.
1733
+ close: function () {
1734
+ if (runtime.activeTab === record.id) activateTab(null)
1735
+ },
1736
+ }
1737
+ }
1738
+
1739
+ function createTabPanel(record) {
1740
+ var panel = document.createElement('div')
1741
+ panel.setAttribute('data-dsbs-tabpanel', record.id)
1742
+ panel.setAttribute('role', 'tabpanel')
1743
+ panel.setAttribute('aria-label', tabTitle(record))
1744
+ panel.setAttribute('data-dsbs-visible', 'false')
1745
+ var entry = { el: panel, cleanup: null, record: record, mounted: false }
1746
+ runtime.tabPanels[record.id] = entry
1747
+ return entry
1748
+ }
1749
+
1750
+ // Mounting is deferred until the first activation: a tab that is never
1751
+ // opened should cost nothing, and a container that is still `display:
1752
+ // none` has no geometry to measure.
1753
+ function mountTabPanel(entry) {
1754
+ if (!entry || entry.mounted) return
1755
+ entry.mounted = true
1756
+ var record = entry.record
1757
+ if (typeof record.mount !== 'function') return
1758
+ try {
1759
+ var result = record.mount(entry.el, tabContext(record))
1760
+ if (typeof result === 'function') entry.cleanup = result
1761
+ else if (result && typeof result.dispose === 'function') {
1762
+ entry.cleanup = function () {
1763
+ result.dispose()
1764
+ }
1765
+ }
1766
+ } catch (error) {
1767
+ // A broken consumer must never take the sidebar down with it: the
1768
+ // failure is reported inside its own tab and logged once.
1769
+ try {
1770
+ entry.el.textContent = '[dsh-sidebar-suite] tab "' + record.id + '" failed to mount: '
1771
+ + (error && error.message ? error.message : String(error))
1772
+ } catch (inner) {
1773
+ void inner
1774
+ }
1775
+ try {
1776
+ console.error('[dsh-sidebar-suite] tab mount failed:', record.id, error)
1777
+ } catch (inner) {
1778
+ void inner
1779
+ }
1780
+ }
1781
+ }
1782
+
1783
+ function disposeTabPanel(id) {
1784
+ var entry = runtime.tabPanels[id]
1785
+ if (!entry) return
1786
+ delete runtime.tabPanels[id]
1787
+ try {
1788
+ if (typeof entry.cleanup === 'function') entry.cleanup()
1789
+ } catch (error) {
1790
+ void error
1791
+ }
1792
+ try {
1793
+ if (entry.el && entry.el.parentNode) entry.el.parentNode.removeChild(entry.el)
1794
+ } catch (error) {
1795
+ void error
1796
+ }
1797
+ }
1798
+
1799
+ function disposeAllTabPanels() {
1800
+ var ids = []
1801
+ for (var id in runtime.tabPanels) ids.push(id)
1802
+ for (var i = 0; i < ids.length; i++) disposeTabPanel(ids[i])
1803
+ }
1804
+
1805
+ function unregisterTab(id) {
1806
+ var record = findTab(id)
1807
+ if (!record) return false
1808
+ tabRecords.splice(tabRecords.indexOf(record), 1)
1809
+ disposeTabPanel(id)
1810
+ if (runtime.activeTab === id) {
1811
+ runtime.activeTab = null
1812
+ writeActiveTab(null)
1813
+ }
1814
+ notifyTabs('unregister')
1815
+ scheduleSync()
1816
+ return true
1817
+ }
1818
+
1819
+ function registerTab(descriptor) {
1820
+ if (!descriptor || typeof descriptor !== 'object') {
1821
+ throw new TypeError('[dsh-sidebar-suite] registerTab(descriptor) needs an object')
1822
+ }
1823
+ var id = String(descriptor.id == null ? '' : descriptor.id).trim()
1824
+ if (!TAB_ID_RE.test(id)) {
1825
+ throw new TypeError('[dsh-sidebar-suite] registerTab: id must match ' + TAB_ID_RE + ', got "' + id + '"')
1826
+ }
1827
+ if (id === 'ext' || id === 'ws') {
1828
+ throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" is reserved for a built-in group')
1829
+ }
1830
+ var mount = descriptor.mount || descriptor.render
1831
+ if (typeof mount !== 'function') {
1832
+ throw new TypeError('[dsh-sidebar-suite] registerTab: "' + id + '" needs mount(container) or render(container)')
1833
+ }
1834
+ var record = {
1835
+ id: id,
1836
+ title: descriptor.title,
1837
+ icon: typeof descriptor.icon === 'string' ? descriptor.icon : null,
1838
+ order: typeof descriptor.order === 'number' && isFinite(descriptor.order) ? descriptor.order : 100,
1839
+ badge: descriptor.badge == null ? null : descriptor.badge,
1840
+ hidden: descriptor.hidden === true,
1841
+ mount: mount,
1842
+ owner: descriptor.owner == null ? null : String(descriptor.owner),
1843
+ seq: (tabSeq += 1),
1844
+ }
1845
+ // Re-registering the same id is exactly what a hot client reload looks
1846
+ // like, so it replaces in place instead of stacking a duplicate page.
1847
+ var previous = findTab(id)
1848
+ if (previous) {
1849
+ disposeTabPanel(id)
1850
+ tabRecords.splice(tabRecords.indexOf(previous), 1)
1851
+ }
1852
+ tabRecords.push(record)
1853
+ runtime.seenTabs[id] = true
1854
+ notifyTabs('register')
1855
+ scheduleSync()
1856
+ var disposed = false
1857
+ return function disposeTab() {
1858
+ if (disposed) return
1859
+ disposed = true
1860
+ unregisterTab(id)
1861
+ }
1862
+ }
1863
+
1864
+ // Built-in groups only appear as pages in layout b. Layout a folds the
1865
+ // two groups independently and layout c floats the panels, so a page
1866
+ // switcher for them there would contradict what those layouts mean --
1867
+ // third-party tabs still work in all three.
1868
+ function tabEntries() {
1869
+ var out = []
1870
+ var showBuiltin = config.values.mode === 'b' && !!runtime.nav
1871
+ if (showBuiltin) {
1872
+ out.push({ id: 'ext', builtin: true, title: t('tab.ext'), icon: null, order: -20, badge: null })
1873
+ out.push({ id: 'ws', builtin: true, title: t('tab.ws'), icon: null, order: -10, badge: null })
1874
+ }
1875
+ for (var i = 0; i < tabRecords.length; i++) {
1876
+ var record = tabRecords[i]
1877
+ if (record.hidden === true) continue
1878
+ out.push({
1879
+ id: record.id,
1880
+ builtin: false,
1881
+ title: tabTitle(record),
1882
+ icon: record.icon,
1883
+ order: record.order,
1884
+ seq: record.seq,
1885
+ badge: record.badge,
1886
+ })
1887
+ }
1888
+ out.sort(function (left, right) {
1889
+ if (left.builtin !== right.builtin) return left.builtin ? -1 : 1
1890
+ if (left.order !== right.order) return left.order - right.order
1891
+ return (left.seq || 0) - (right.seq || 0)
1892
+ })
1893
+ return out
1894
+ }
1895
+
1896
+ function ensureTabStrip(fx) {
1897
+ var strip = runtime.tabStrip
1898
+ if (strip && strip.parentNode !== fx.root) {
1899
+ try {
1900
+ if (strip.parentNode) strip.parentNode.removeChild(strip)
1901
+ } catch (error) {
1902
+ void error
1903
+ }
1904
+ strip = null
1905
+ }
1906
+ if (!strip) {
1907
+ strip = document.createElement('div')
1908
+ strip.setAttribute('data-dsbs-tabs', '')
1909
+ strip.setAttribute('role', 'tablist')
1910
+ strip.setAttribute('aria-label', t('tabs.label'))
1911
+ runtime.tabStrip = strip
1912
+ }
1913
+ // Sits under the control bar so the two read as one header. A plugin
1914
+ // panel may sit after it, so "immediately after the bar" is too strict
1915
+ // a condition for the steady state: demanding an exact previousSibling
1916
+ // moves the strip on every paint and spins the observer loop.
1917
+ if (strip.parentNode === fx.root && (!runtime.bar || isBefore(runtime.bar, strip))) return strip
1918
+ var anchor = runtime.bar && runtime.bar.parentNode === fx.root
1919
+ ? runtime.bar.nextSibling
1920
+ : (fx.nav || fx.regionArea)
1921
+ if (anchor === strip) return strip
1922
+ if (anchor && anchor.parentNode === fx.root) fx.root.insertBefore(strip, anchor)
1923
+ else fx.root.appendChild(strip)
1924
+ return strip
1925
+ }
1926
+
1927
+ function buildTabButton(entry, selected) {
1928
+ var button = document.createElement('button')
1929
+ button.setAttribute('type', 'button')
1930
+ button.setAttribute('data-dsbs-tabid', entry.id)
1931
+ button.setAttribute('role', 'tab')
1932
+ button.setAttribute('aria-selected', selected ? 'true' : 'false')
1933
+ button.setAttribute('title', entry.title)
1934
+ if (selected) button.setAttribute('data-dsbs-selected', 'true')
1935
+ if (entry.builtin) button.setAttribute('data-dsbs-builtin', entry.id)
1936
+
1937
+ if (typeof entry.icon === 'string' && entry.icon) {
1938
+ var glyph = document.createElement('span')
1939
+ glyph.setAttribute('data-dsbs-tabicon', '')
1940
+ glyph.setAttribute('aria-hidden', 'true')
1941
+ button.setAttribute('data-dsbs-hasicon', '')
1942
+ if (entry.icon.charAt(0) === '<') {
1943
+ // A consumer-supplied mark. Plugins already run arbitrary code in
1944
+ // this page, so this grants nothing new -- but the icon still gets
1945
+ // its own element so the label stays a text node.
1946
+ glyph.innerHTML = entry.icon
1947
+ } else {
1948
+ glyph.textContent = entry.icon
1949
+ glyph.setAttribute('data-dsbs-tabicon-text', '')
1950
+ }
1951
+ button.appendChild(glyph)
1952
+ }
1953
+
1954
+ var label = document.createElement('span')
1955
+ label.setAttribute('data-dsbs-tablabel', '')
1956
+ label.textContent = entry.title
1957
+ button.appendChild(label)
1958
+
1959
+ if (entry.badge != null && entry.badge !== '') {
1960
+ var badge = document.createElement('span')
1961
+ badge.setAttribute('data-dsbs-tabbadge', '')
1962
+ badge.textContent = String(entry.badge)
1963
+ button.appendChild(badge)
1964
+ }
1965
+
1966
+ button.addEventListener('click', function () {
1967
+ if (entry.builtin) {
1968
+ // Built-ins drive the existing group switcher, so a click keeps
1969
+ // meaning what the same click meant before 0.3.0.
1970
+ var tookOver = runtime.activeTab !== null
1971
+ runtime.activeTab = null
1972
+ writeActiveTab(null)
1973
+ setField('tab', entry.id)
1974
+ // A click here takes the column away from a consumer tab exactly
1975
+ // like clicking another tab does, so subscribers hear about it the
1976
+ // same way. Without this a consumer that trusts `subscribe` would
1977
+ // keep believing it is still on screen.
1978
+ if (tookOver) notifyTabs('active')
1979
+ } else {
1980
+ activateTab(entry.id)
1981
+ }
1982
+ })
1983
+ return button
1984
+ }
1985
+
1986
+ function renderTabStrip() {
1987
+ var strip = runtime.tabStrip
1988
+ if (!strip) return
1989
+ var entries = tabEntries()
1990
+ var hasPlugin = false
1991
+ for (var p = 0; p < entries.length; p++) {
1992
+ if (!entries[p].builtin) hasPlugin = true
1993
+ }
1994
+ // Nothing registered: stay out of the way entirely, so a profile with
1995
+ // no tab consumers looks exactly like 0.2.x.
1996
+ if (!hasPlugin) {
1997
+ strip.setAttribute('hidden', '')
1998
+ runtime.tabSignature = ''
1999
+ return
2000
+ }
2001
+ strip.removeAttribute('hidden')
2002
+
2003
+ var activePlugin = runtime.activeTab && findTab(runtime.activeTab) ? runtime.activeTab : null
2004
+ var signature = (activePlugin || '-') + '|' + config.values.mode + '|' + config.values.tab + '|'
2005
+ for (var s = 0; s < entries.length; s++) {
2006
+ signature += entries[s].id + ':' + entries[s].title + ':' + entries[s].icon + ':' + entries[s].badge + ';'
2007
+ }
2008
+ if (signature === runtime.tabSignature) return
2009
+ runtime.tabSignature = signature
2010
+
2011
+ while (strip.firstChild) strip.removeChild(strip.firstChild)
2012
+ for (var i = 0; i < entries.length; i++) {
2013
+ var entry = entries[i]
2014
+ var selected = entry.builtin
2015
+ ? (!activePlugin && config.values.tab === entry.id)
2016
+ : (activePlugin === entry.id)
2017
+ strip.appendChild(buildTabButton(entry, selected))
2018
+ }
2019
+ }
2020
+
2021
+ function placeTabPanel(panel, fx) {
2022
+ if (panel.parentNode === fx.root) return
2023
+ var anchor = fx.nav || fx.regionArea
2024
+ if (anchor && anchor.parentNode === fx.root) fx.root.insertBefore(panel, anchor)
2025
+ else fx.root.appendChild(panel)
2026
+ }
2027
+
2028
+ function syncTabPanels(fx, activeId) {
2029
+ var i
2030
+ // The container is created the first time its tab owns the column, not
2031
+ // at registration: a tab that is never opened should cost nothing, and
2032
+ // a display:none container has no geometry to measure anyway.
2033
+ if (activeId && !runtime.tabPanels[activeId]) {
2034
+ var owning = findTab(activeId)
2035
+ if (owning) createTabPanel(owning)
2036
+ }
2037
+ // Panels that are no longer registered are gone already; the ones that
2038
+ // remain are shown only while they own the column.
2039
+ for (var id in runtime.tabPanels) {
2040
+ var entry = runtime.tabPanels[id]
2041
+ if (!entry || !entry.el) continue
2042
+ if (id === activeId) {
2043
+ placeTabPanel(entry.el, fx)
2044
+ entry.el.setAttribute('data-dsbs-visible', 'true')
2045
+ entry.el.removeAttribute('aria-hidden')
2046
+ if (!entry.mounted) mountTabPanel(entry)
2047
+ } else {
2048
+ entry.el.setAttribute('data-dsbs-visible', 'false')
2049
+ entry.el.setAttribute('aria-hidden', 'true')
2050
+ }
2051
+ }
2052
+ void i
2053
+ }
2054
+
2055
+ function activateTab(id) {
2056
+ if (id != null && id !== '' && !findTab(id)) return false
2057
+ var next = id == null || id === '' ? null : String(id)
2058
+ // Clicking the tab that already owns the column returns to the groups,
2059
+ // which is the only way back out of a tab in layouts a and c.
2060
+ if (next && runtime.activeTab === next) next = null
2061
+ runtime.activeTab = next
2062
+ writeActiveTab(next)
2063
+ notifyTabs('active')
2064
+ scheduleSync()
2065
+ return true
2066
+ }
2067
+
1166
2068
  function barButton(key) {
1167
2069
  if (key === 'temp') return tempButton
1168
2070
  if (!runtime.bar) return null
@@ -1271,6 +2173,8 @@ window.__ModuleLoader__.load({
1271
2173
 
1272
2174
  if (fx.nav && !fx.nav.hasAttribute('data-dsbs-ext')) fx.nav.setAttribute('data-dsbs-ext', '')
1273
2175
  ensureBar(fx)
2176
+ ensureTabStrip(fx)
2177
+ renderTabStrip()
1274
2178
  placeTempButton()
1275
2179
  applyTempButtonVisibility()
1276
2180
  loadTempStatus()
@@ -1339,7 +2243,28 @@ window.__ModuleLoader__.load({
1339
2243
 
1340
2244
  var extHidden
1341
2245
  var wsHidden
1342
- if (state.mode === 'a') {
2246
+ // A registered tab owning the column outranks every layout rule: the
2247
+ // two groups step aside and the tab's own container takes the space.
2248
+ var pluginTab = null
2249
+ if (runtime.activeTab) {
2250
+ if (findTab(runtime.activeTab)) {
2251
+ pluginTab = runtime.activeTab
2252
+ } else if (runtime.seenTabs[runtime.activeTab]) {
2253
+ // It registered once and is gone now: its owner unloaded while the
2254
+ // page was open. Fall back instead of leaving an empty column with
2255
+ // no way out of it.
2256
+ runtime.activeTab = null
2257
+ writeActiveTab(null)
2258
+ notifyTabs('orphan')
2259
+ }
2260
+ // Not seen yet: another plugin may still be registering during
2261
+ // startup, so the stored id is kept and retried on the next paint.
2262
+ }
2263
+ setFlag(html, 'data-dsbs-ptab', !!pluginTab)
2264
+ if (pluginTab) {
2265
+ extHidden = true
2266
+ wsHidden = true
2267
+ } else if (state.mode === 'a') {
1343
2268
  extHidden = state.foldExt === true
1344
2269
  wsHidden = state.foldWs === true
1345
2270
  } else if (state.mode === 'b') {
@@ -1352,6 +2277,13 @@ window.__ModuleLoader__.load({
1352
2277
  }
1353
2278
  setGroupHidden(fx.nav, 'ext', extHidden)
1354
2279
  setGroupHidden(fx.regionArea, 'ws', wsHidden)
2280
+ syncTabPanels(fx, pluginTab)
2281
+ // The strip is the only affordance that survives a hidden bar, so it
2282
+ // is repainted on every sync rather than only when its content changed.
2283
+ if (runtime.tabStrip && pluginTab) {
2284
+ var selectedTab = runtime.tabStrip.querySelector('[data-dsbs-tabid="' + pluginTab + '"]')
2285
+ if (selectedTab) selectedTab.setAttribute('aria-selected', 'true')
2286
+ }
1355
2287
 
1356
2288
  observeRoot(fx.root)
1357
2289
  positionPop(fx)
@@ -1437,8 +2369,32 @@ window.__ModuleLoader__.load({
1437
2369
  }
1438
2370
 
1439
2371
  var pending = false
2372
+ var syncStreak = 0
2373
+ var streakTimer = null
2374
+ var streakDeferred = false
2375
+ // Safety net for the observer feedback loop: a paint that mutates the
2376
+ // observed root chains MutationObserver -> scheduleSync -> paint in
2377
+ // microtasks forever, so the renderer never gets a turn and DSH hangs on
2378
+ // "Loading plugins...". A healthy startup needs only a handful of syncs
2379
+ // between two macrotasks. Cap the streak, yield once, then resume; this
2380
+ // cannot lose a state change, and it keeps unrelated future regressions
2381
+ // from taking the whole app down.
1440
2382
  function scheduleSync() {
1441
2383
  if (runtime.disposed || pending) return
2384
+ if (!streakTimer) {
2385
+ streakTimer = setTimeout(function () {
2386
+ streakTimer = null
2387
+ syncStreak = 0
2388
+ var again = streakDeferred
2389
+ streakDeferred = false
2390
+ if (again && !runtime.disposed) scheduleSync()
2391
+ }, 0)
2392
+ }
2393
+ if (syncStreak >= 16) {
2394
+ streakDeferred = true
2395
+ return
2396
+ }
2397
+ syncStreak += 1
1442
2398
  pending = true
1443
2399
  Promise.resolve().then(function () {
1444
2400
  pending = false
@@ -1892,6 +2848,8 @@ window.__ModuleLoader__.load({
1892
2848
  inChatMode: data.inChatMode === 'show' ? 'show' : 'hide',
1893
2849
  buttonStyle: data.buttonStyle === 'own' ? 'own' : 'pair',
1894
2850
  expandAllSessions: data.expandAllSessions !== false,
2851
+ // Older Host halves have no rowMenu key; the menu ships on.
2852
+ rowMenu: data.rowMenu !== false,
1895
2853
  maxKept: kept > 0 ? Math.min(Math.round(kept), 200) : 20,
1896
2854
  configRevision: typeof data.configRevision === 'number' ? data.configRevision : null,
1897
2855
  configFile: typeof data.configFile === 'string' ? data.configFile : '',
@@ -2094,6 +3052,10 @@ window.__ModuleLoader__.load({
2094
3052
  persist({ expandAllSessions: next })
2095
3053
  }),
2096
3054
  h('div', { style: dim }, t('options.expandHint')),
3055
+ policyRow(t('options.rowMenu'), policy.rowMenu === true, function (next) {
3056
+ persist({ rowMenu: next })
3057
+ }),
3058
+ h('div', { style: dim }, t('options.rowMenuHint')),
2097
3059
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
2098
3060
  h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary,inherit)' } }, t('options.maxKept')),
2099
3061
  h('input', {
@@ -2397,6 +3359,14 @@ window.__ModuleLoader__.load({
2397
3359
  }
2398
3360
  }, 'sidebar-suite: dismissal')
2399
3361
 
3362
+ effect(function () {
3363
+ // 0.4.0 — right-click on a temporary row. One delegated listener on the
3364
+ // document, because the host recreates session rows on every list
3365
+ // refresh; per-row listeners would need re-binding after each render.
3366
+ mountTempRowMenu()
3367
+ return tempMenuUnmount
3368
+ }, 'sidebar-suite: temp row menu')
3369
+
2400
3370
  effect(function () {
2401
3371
  // Safety net: the sidebar root is created and destroyed by the host, so
2402
3372
  // a root replacement is invisible to an observer that watches the old
@@ -2442,6 +3412,13 @@ window.__ModuleLoader__.load({
2442
3412
  teardown = function () {
2443
3413
  if (runtime.disposed) return
2444
3414
  runtime.disposed = true
3415
+ try {
3416
+ if (streakTimer) clearTimeout(streakTimer)
3417
+ streakTimer = null
3418
+ streakDeferred = false
3419
+ } catch (error) {
3420
+ void error
3421
+ }
2445
3422
  try {
2446
3423
  clearTimeout(tempToastTimer)
2447
3424
  var toastNode = document.querySelector('[data-dsbs-temp-toast]')
@@ -2495,16 +3472,51 @@ window.__ModuleLoader__.load({
2495
3472
  } catch (error) {
2496
3473
  void error
2497
3474
  }
3475
+ try {
3476
+ // 0.4.0 — the row menu lives in <body> and its document listeners
3477
+ // outlive the sidebar if they are not dropped explicitly.
3478
+ closeTempMenu(false)
3479
+ detachTempMenuDismiss()
3480
+ if (tempMenuUnmount) { tempMenuUnmount(); tempMenuUnmount = null }
3481
+ tempRowMenu = null
3482
+ tempMenuRow = null
3483
+ } catch (error) {
3484
+ void error
3485
+ }
2498
3486
  try {
2499
3487
  if (runtime.bar && runtime.bar.parentNode) runtime.bar.parentNode.removeChild(runtime.bar)
2500
3488
  } catch (error) {
2501
3489
  void error
2502
3490
  }
3491
+ try {
3492
+ // Unmount every live tab first: a consumer's disposer may still
3493
+ // touch its container, so removing the DOM afterwards is safer
3494
+ // than yanking it out from under a cleanup callback.
3495
+ disposeAllTabPanels()
3496
+ if (runtime.tabStrip && runtime.tabStrip.parentNode) runtime.tabStrip.parentNode.removeChild(runtime.tabStrip)
3497
+ } catch (error) {
3498
+ void error
3499
+ }
3500
+ try {
3501
+ runtime.tabStrip = null
3502
+ runtime.tabSignature = ''
3503
+ tabListeners.length = 0
3504
+ tabRecords.length = 0
3505
+ } catch (error) {
3506
+ void error
3507
+ }
2503
3508
  try {
2504
3509
  if (window[OWNER_KEY] === owner) delete window[OWNER_KEY]
2505
3510
  } catch (error) {
2506
3511
  void error
2507
3512
  }
3513
+ try {
3514
+ // Only drop the handle if it is still ours: a newer generation may
3515
+ // already have replaced it, and that one must keep working.
3516
+ if (window.__dshSidebarSuite === sidebarService) delete window.__dshSidebarSuite
3517
+ } catch (error) {
3518
+ void error
3519
+ }
2508
3520
  try {
2509
3521
  if (runtime.root) runtime.root.style.removeProperty('--dsbs-pop-top')
2510
3522
  } catch (error) {
@@ -2538,6 +3550,7 @@ window.__ModuleLoader__.load({
2538
3550
  'data-dsbs-narrow',
2539
3551
  'data-dsbs-temp-solo',
2540
3552
  'data-dsbs-temp-style',
3553
+ 'data-dsbs-ptab',
2541
3554
  ]
2542
3555
  for (var i = 0; i < attributes.length; i++) {
2543
3556
  html.removeAttribute(attributes[i])
@@ -2564,7 +3577,6 @@ window.__ModuleLoader__.load({
2564
3577
  }, 'sidebar-suite: teardown')
2565
3578
  owner.teardown = teardown
2566
3579
  }
2567
-
2568
3580
  return { inject: ['locale'], apply: apply }
2569
3581
  },
2570
3582
  })