dsh-declutter 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,1689 @@
1
+ // dsh-sidebar-declutter — client half.
2
+ //
3
+ // Each row is rendered straight into the layout by a shipped package, so none of
4
+ // them is a slot registration and no config switch exists for any of them:
5
+ //
6
+ // * `_newSession` — the sidebar's "New session" button (ui-sidebar). It sits in
7
+ // the normal column flow with no wrapper (its Tooltip clones the button), so
8
+ // `display:none` takes it — and its margin — out of layout and the panel list
9
+ // below moves up into the freed row.
10
+ // * `_moreAnchor` in `_panelList` — the sidebar panel list's "More" launcher. It
11
+ // renders only while `sidebar.panellist.actions` has registrations (today one,
12
+ // from entry-customization), and that menu is the only place a panel can be
13
+ // pinned or unpinned, so hiding it retires that control too.
14
+ // * `_moreAnchor` in `[data-sidebar-right-guide]` — the right column's start-page
15
+ // "More". Same local class name as the sidebar's, in a different CSS module
16
+ // (`F6Huca_moreAnchor` vs `kB3qhq_moreAnchor`), which is why every rule here is
17
+ // scoped by an ancestor instead of matching the bare suffix.
18
+ // * `_viewsMore` — the Session header View row's launcher; an ellipsis icon, so
19
+ // its aria-label says "More" while the page shows no such text.
20
+ //
21
+ // Selectors key on CSS-module local suffixes, never on the build-hashed prefix
22
+ // (`kB3qhq_newSession` today): the prefix changes on every build.
23
+ //
24
+ // Settings live in localStorage rather than the loader Config: the entry needs no
25
+ // Host schema, changes apply without a restart, and every surface reads one store.
26
+ window.__ModuleLoader__.load({
27
+ id: 'dsh-sidebar-declutter',
28
+ factory: (require) => {
29
+ const React = require('react')
30
+ const h = React.createElement
31
+
32
+ /**
33
+ * Bisect switch: the settings card is the only thing this bundle contributes
34
+ * to the plugins page, so it can be taken out while that page is investigated.
35
+ */
36
+ const ENABLE_ROW_CONFIG = true
37
+
38
+ /** Row-config key: `<bundle package>#<row id from the bundle patch>`. */
39
+ /** Sidebar entry that opens this plugin's settings page. */
40
+ const PANEL_ENTRY_LABEL = '隐藏与整理'
41
+ /** Panel id: the URL segment (#/panel/<id>) and the main slot key. */
42
+ const PANEL_ENTRY_ID = 'hide-rows'
43
+ /** The sidebar draws the row; this slot contributes the icon in front of it. */
44
+ function PanelEntry() {
45
+ return h(
46
+ 'svg',
47
+ {
48
+ width: 16,
49
+ height: 16,
50
+ viewBox: '0 0 16 16',
51
+ fill: 'none',
52
+ 'aria-hidden': 'true',
53
+ },
54
+ h('path', {
55
+ d: 'M1.8 8c1.6-2.5 3.8-3.9 6.2-3.9S12.6 5.5 14.2 8c-1.6 2.5-3.8 3.9-6.2 3.9S3.4 10.5 1.8 8Z',
56
+ stroke: 'currentColor',
57
+ strokeWidth: 1.3,
58
+ strokeLinejoin: 'round',
59
+ }),
60
+ h('circle', { cx: 8, cy: 8, r: 1.9, stroke: 'currentColor', strokeWidth: 1.3 }),
61
+ h('path', {
62
+ d: 'M3 13 13 3',
63
+ stroke: 'currentColor',
64
+ strokeWidth: 1.3,
65
+ strokeLinecap: 'round',
66
+ }),
67
+ )
68
+ }
69
+
70
+ const ROW_CONFIG_KEY = 'dsh-declutter#hide-rows'
71
+ const STORE_KEY = 'dsh-sidebar-declutter'
72
+ /** Key this bundle stored its settings under before the rename. */
73
+ const LEGACY_STORE_KEY = 'dsh-hide-new-session'
74
+ const STYLE_ATTR = 'data-dsh-sidebar-declutter'
75
+
76
+ /**
77
+ * The page groups its switches under headings; the flat `FIELDS` below is
78
+ * derived from them, so the store, the summary and the rules keep iterating
79
+ * one list while the page renders sections.
80
+ */
81
+ const SECTIONS = [
82
+ {
83
+ kind: 'hide',
84
+ title: '左栏',
85
+ panels: true,
86
+ columns: 4,
87
+ fields: [
88
+ { key: 'brand', title: '顶部 logo' },
89
+ { key: 'newSession', title: '「新会话」按钮' },
90
+ { key: 'sidebarMore', title: '「更多」按钮' },
91
+ { key: 'topGap', title: '顶部空隙' },
92
+ ],
93
+ },
94
+ {
95
+ kind: 'hide',
96
+ title: '其它位置',
97
+ columns: 2,
98
+ fields: [
99
+ { key: 'rightMore', title: '右侧栏起始页的「更多」' },
100
+ { key: 'headerMore', title: '会话头部 View 行的「更多」' },
101
+ ],
102
+ },
103
+ {
104
+ kind: 'layout',
105
+ title: '排布',
106
+ fields: [
107
+ {
108
+ key: 'panelLayout',
109
+ type: 'choice',
110
+ inline: true,
111
+ options: [
112
+ { value: 'stack', title: '一列' },
113
+ { value: 'two', title: '两列' },
114
+ { value: 'three', title: '三列' },
115
+ { value: 'wrap', title: '自动紧凑' },
116
+ { value: 'icon', title: '图标显示' },
117
+ ],
118
+ },
119
+ ],
120
+ },
121
+ {
122
+ kind: 'menu',
123
+ title: '会话菜单',
124
+ hint: '打开一次会话菜单(右键会话行)后,这里会列出这份构建真正渲染过的条目;勾上即隐藏,取消即恢复。',
125
+ fields: [],
126
+ },
127
+ {
128
+ kind: 'picked',
129
+ title: '自由隐藏',
130
+ hint: '点「开始点选」,再点界面上任意一个元素——它会作为一个条目出现在下面,勾上才隐藏。',
131
+ fields: [],
132
+ },
133
+ ]
134
+
135
+ /** Flat view of every switch, in page order, tagged with its section. */
136
+ const FIELDS = SECTIONS.flatMap((section) =>
137
+ section.fields.map((field) => ({ ...field, kind: section.kind })),
138
+ )
139
+
140
+ /** Defaults match the state this plugin shipped with before the switches. */
141
+ const DEFAULTS = {
142
+ brand: true,
143
+ newSession: true,
144
+ panelLayout: 'two',
145
+ sidebarMore: true,
146
+ rightMore: true,
147
+ headerMore: false,
148
+ /** The bulk layer the footer button drives; never persisted. */
149
+ collapsedAll: false,
150
+ /** Elements picked by hand: `[{ selector, label }]`. */
151
+ picked: [],
152
+ /** Panel ids (from `sidebar.panellist`) the user ticked off. */
153
+ hiddenPanels: [],
154
+ /** Panel ids in the order the user dragged them into. */
155
+ panelOrder: [],
156
+ }
157
+
158
+ /** Accepted values of the layout choice, keyed by field. */
159
+ const CHOICES = {}
160
+ for (const field of FIELDS) {
161
+ if (field.type === 'choice') CHOICES[field.key] = field.options.map((option) => option.value)
162
+ }
163
+
164
+ let values = { ...DEFAULTS }
165
+ const listeners = new Set()
166
+
167
+ function readStore() {
168
+ const next = { ...DEFAULTS }
169
+ try {
170
+ const raw = localStorage.getItem(STORE_KEY) ?? localStorage.getItem(LEGACY_STORE_KEY)
171
+ if (raw === null) return next
172
+ const parsed = JSON.parse(raw)
173
+ for (const field of FIELDS) {
174
+ const stored = parsed?.[field.key]
175
+ if (field.type === 'choice') {
176
+ if (CHOICES[field.key].includes(stored)) next[field.key] = stored
177
+ continue
178
+ }
179
+ if (typeof stored === 'boolean') next[field.key] = stored
180
+ }
181
+ // `panelGrid` was the boolean this setting shipped as before it became a
182
+ // three-way choice; keep an existing preference instead of resetting it.
183
+ if (Array.isArray(parsed?.picked)) {
184
+ next.picked = parsed.picked.filter(
185
+ (one) => one && typeof one.selector === 'string' && one.selector.trim() !== '',
186
+ )
187
+ }
188
+ if (Array.isArray(parsed?.hiddenPanels)) {
189
+ next.hiddenPanels = parsed.hiddenPanels.filter((one) => typeof one === 'string')
190
+ }
191
+ if (Array.isArray(parsed?.panelOrder)) {
192
+ next.panelOrder = parsed.panelOrder.filter((one) => typeof one === 'string')
193
+ }
194
+ if (parsed?.panelLayout === undefined && typeof parsed?.panelGrid === 'boolean') {
195
+ // `panelGrid` was introduced meaning "two per row" before it briefly
196
+ // meant "wrap"; `true` keeps the two-column reading.
197
+ next.panelLayout = parsed.panelGrid ? 'two' : 'stack'
198
+ }
199
+ } catch {
200
+ // Stored junk falls back to the defaults instead of breaking boot.
201
+ }
202
+ return next
203
+ }
204
+
205
+ function writeStore() {
206
+ try {
207
+ localStorage.setItem(STORE_KEY, JSON.stringify(values))
208
+ } catch {
209
+ // Private mode: the switch still applies for this page.
210
+ }
211
+ }
212
+
213
+ // --- menu rows: free hiding -------------------------------------------------
214
+ //
215
+ // Menu rows are not slots a plugin can drop; the shipped ones are rendered by
216
+ // the product and only `display: none` takes them away. Which rows exist is
217
+ // discovered from real menu opens (label + accelerator), so the page lists
218
+ // what this build really renders. Nothing is hidden until it is ticked.
219
+
220
+ const MENU_STORE_KEY = 'dsh-sidebar-declutter.menu'
221
+ const MENU_STORE_VERSION = 2
222
+ const MENU_HIDDEN_ATTR = 'data-dsd-menu-hidden'
223
+
224
+ /**
225
+ * The Session menu's shipped rows, listed up front so nothing has to be
226
+ * opened once before it can be ticked. Each rule matches the row's text at
227
+ * its start, so a trailing accelerator or a language switch still hits.
228
+ * Rows this build renders that are not listed here are appended by discovery.
229
+ */
230
+ const MENU_PRESETS = [
231
+ { key: '重命名', accel: 'Ctrl+Alt+G', rule: /^(重命名|Rename)/, aria: ['Control+Alt+G', 'Ctrl+Alt+G', '⌘+⌥+G'] },
232
+ { key: '归档会话', accel: '', rule: /^(归档会话|Archive)/, aria: ['Control+Shift+A', 'Ctrl+Shift+A', '⌘+⇧+A'] },
233
+ { key: '取消归档', accel: '', rule: /^(取消归档|Unarchive)/, aria: [] },
234
+ { key: '置顶会话', accel: '', rule: /^(置顶会话|Pin)/, aria: [] },
235
+ { key: '取消置顶', accel: '', rule: /^(取消置顶|Unpin)/, aria: [] },
236
+ // Also shipped by the product: menu.fork / menu.sessionId / menu.openIn
237
+ // in the shell's own locale. They used to fall through to discovery and
238
+ // were mislabelled as community rows.
239
+ { key: '分叉会话', accel: 'Alt+Meta+F', rule: /^(分叉会话|Fork)/, aria: ['Alt+Meta+F'] },
240
+ { key: '复制 Session ID', accel: '', rule: /^(复制 Session ID|Copy Session ID)/, aria: [] },
241
+ { key: '在应用中打开', accel: '', rule: /^(在应用中打开|Open in App)/, aria: [] },
242
+ ]
243
+
244
+ /** Fold a stored/discovered label onto its official key when it matches. */
245
+ function canonicalMenuKey(key) {
246
+ const preset = MENU_PRESETS.find((one) => one.rule.test(key))
247
+ return preset === undefined ? key : preset.key
248
+ }
249
+
250
+ /**
251
+ * Rows that carry an accelerator can be hidden by CSS, before the menu is ever
252
+ * painted — no timing to lose. Rows without one still go through the DOM sweep.
253
+ */
254
+ function menuCssRules() {
255
+ const rules = []
256
+ for (const preset of MENU_PRESETS) {
257
+ if (!menuState.hidden.includes(preset.key)) continue
258
+ for (const aria of preset.aria) {
259
+ rules.push(`[role="menuitem"][aria-keyshortcuts="${aria}"]{display:none!important}`)
260
+ }
261
+ }
262
+ return rules
263
+ }
264
+
265
+ /** @type {{ hidden: string[], seen: { key: string, accel: string }[] }} */
266
+ let menuState = { hidden: [], seen: [] }
267
+
268
+ function readMenuState() {
269
+ try {
270
+ const parsed = JSON.parse(localStorage.getItem(MENU_STORE_KEY) || 'null')
271
+ if (!parsed) return
272
+ const hidden = Array.isArray(parsed.hidden)
273
+ ? [...new Set(parsed.hidden.filter((one) => typeof one === 'string').map(canonicalMenuKey))]
274
+ : []
275
+ // v1 recorded rows from every menu on the desktop — including the system
276
+ // "open with" list — so its `seen` is thrown away, keeping the switches.
277
+ const seen = parsed.version === MENU_STORE_VERSION && Array.isArray(parsed.seen)
278
+ ? parsed.seen
279
+ .filter((one) => one && typeof one.key === 'string')
280
+ .reduce((list, one) => {
281
+ const key = canonicalMenuKey(one.key)
282
+ return list.some((entry) => entry.key === key) ? list : [...list, { ...one, key }]
283
+ }, [])
284
+ : []
285
+ menuState = { hidden, seen }
286
+ if (parsed.version !== MENU_STORE_VERSION) writeMenuState()
287
+ } catch {
288
+ // Private mode: the switches still apply for this page.
289
+ }
290
+ }
291
+
292
+ function writeMenuState() {
293
+ try {
294
+ localStorage.setItem(
295
+ MENU_STORE_KEY,
296
+ JSON.stringify({ version: MENU_STORE_VERSION, hidden: menuState.hidden, seen: menuState.seen }),
297
+ )
298
+ } catch {
299
+ // Same.
300
+ }
301
+ }
302
+
303
+ /** True when a stored row id is really one of the presets (a legacy record). */
304
+ function isPresetMenuKey(key) {
305
+ return MENU_PRESETS.some((one) => one.rule.test(key))
306
+ }
307
+
308
+ /** A row's identity: a preset name when one matches, else its normalized text. */
309
+ function menuRowKey(item) {
310
+ const text = (item.textContent || '').replace(/\s+/g, ' ').trim()
311
+ for (const preset of MENU_PRESETS) {
312
+ if (preset.rule.test(text)) return preset.key
313
+ }
314
+ return text
315
+ }
316
+
317
+ /** True when this row is one of the shipped Session actions. */
318
+ function isSessionMenuItem(item) {
319
+ const text = (item.textContent || '').replace(/\s+/g, ' ').trim()
320
+ return MENU_PRESETS.some((one) => one.rule.test(text))
321
+ }
322
+
323
+ function menuSweep() {
324
+ // `[role="menuitem"]` is used by every menu on the desktop — the system
325
+ // "open with" list included. A menu only counts when it carries a shipped
326
+ // Session action, so unrelated lists are never read and never recorded.
327
+ const groups = new Map()
328
+ for (const item of document.querySelectorAll('[role="menuitem"]')) {
329
+ const root = item.closest('[role="menu"]') || item.parentElement
330
+ if (root === null) continue
331
+ const list = groups.get(root)
332
+ if (list === undefined) groups.set(root, [item])
333
+ else list.push(item)
334
+ }
335
+ let added = false
336
+ for (const items of groups.values()) {
337
+ if (!items.some((item) => isSessionMenuItem(item))) continue
338
+ for (const item of items) {
339
+ // A marked node was already handled in this batch. The next menu open
340
+ // mounts a fresh set, so "unhide after unticking" needs no bookkeeping.
341
+ if (item.hasAttribute(MENU_HIDDEN_ATTR)) continue
342
+ const key = menuRowKey(item)
343
+ if (!key) continue
344
+ // Only a menu opened from a Session row is recorded: that is what keeps
345
+ // unrelated lists (the system "open with" menu) out of the settings page.
346
+ if (menuFromSessionRow && !menuState.seen.some((one) => one.key === key)) {
347
+ menuState.seen.push({ key, accel: item.getAttribute('aria-keyshortcuts') || '' })
348
+ added = true
349
+ }
350
+ if (!menuState.hidden.includes(key)) continue
351
+ item.setAttribute(MENU_HIDDEN_ATTR, '')
352
+ item.style.display = 'none'
353
+ }
354
+ }
355
+ if (added) {
356
+ writeMenuState()
357
+ notify()
358
+ }
359
+ }
360
+
361
+ let menuObserver = null
362
+ let menuObserverTimer = 0
363
+ let menuSweepQueued = false
364
+
365
+ /** Coalesce a burst of mutations into one sweep, before the next paint. */
366
+ function menuQueueSweep(records) {
367
+ // The 400ms watch overlaps the chat stream. Only a mutation that actually
368
+ // mounted a menu node deserves a document-wide sweep; per-token text and
369
+ // message-row updates do not. The timed sweeps below still cover menus
370
+ // that mount without adding a role-bearing node.
371
+ if (records && records.length) {
372
+ let menuish = false
373
+ for (const rec of records) {
374
+ for (const node of rec.addedNodes || []) {
375
+ if (node.nodeType !== 1) continue
376
+ if (node.matches?.('[role="menu"],[role="menuitem"]') ||
377
+ node.querySelector?.('[role="menu"],[role="menuitem"]')) {
378
+ menuish = true
379
+ break
380
+ }
381
+ }
382
+ if (menuish) break
383
+ }
384
+ if (!menuish) return
385
+ }
386
+ if (menuSweepQueued) return
387
+ menuSweepQueued = true
388
+ const run = () => {
389
+ menuSweepQueued = false
390
+ menuSweep()
391
+ }
392
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
393
+ else setTimeout(run, 0)
394
+ }
395
+
396
+ /**
397
+ * Watch for the menu's first insertion instead of waiting for it. The observer
398
+ * runs only inside a short window after a click, and the sweep itself is queued
399
+ * on the next frame — which lands before that frame paints, so the rows never
400
+ * appear unhidden first. A standing body-wide observer is what would be
401
+ * expensive; a 400ms window is not.
402
+ */
403
+ function menuWatchForMount() {
404
+ if (typeof MutationObserver !== 'function') return
405
+ if (menuObserver === null) {
406
+ menuObserver = new MutationObserver(menuQueueSweep)
407
+ menuObserver.observe(document.documentElement, { childList: true, subtree: true })
408
+ }
409
+ clearTimeout(menuObserverTimer)
410
+ menuObserverTimer = setTimeout(() => {
411
+ menuObserver.disconnect()
412
+ menuObserver = null
413
+ }, 400)
414
+ }
415
+
416
+ /** Set while the click that opens a menu came from a Session row. */
417
+ let menuFromSessionRow = false
418
+
419
+ /** Menus mount a frame or two after the click that opens them. */
420
+ function menuScheduleSweep(fromSessionRow) {
421
+ // A click that cannot open a session menu and has no hidden rows to
422
+ // enforce makes menuSweep() a no-op: added stays false and every item
423
+ // hits the hidden guard. Skip the four document-wide scans, the 400ms
424
+ // watch and all timers -- this is the hot path on every ordinary click.
425
+ if (!fromSessionRow && !menuFromSessionRow && menuState.hidden.length === 0) return
426
+ if (fromSessionRow) menuFromSessionRow = true
427
+ menuSweep()
428
+ menuWatchForMount()
429
+ for (const delay of [40, 120, 260]) setTimeout(menuSweep, delay)
430
+ setTimeout(() => {
431
+ menuFromSessionRow = false
432
+ }, 600)
433
+ }
434
+
435
+ /** Drop the watch when the plugin goes away. */
436
+ function menuStopWatch() {
437
+ clearTimeout(menuObserverTimer)
438
+ if (menuObserver !== null) {
439
+ menuObserver.disconnect()
440
+ menuObserver = null
441
+ }
442
+ }
443
+
444
+ function setMenuRowHidden(key, hide) {
445
+ const next = menuState.hidden.filter((one) => one !== key)
446
+ if (hide) next.push(key)
447
+ menuState = { ...menuState, hidden: next }
448
+ writeMenuState()
449
+ syncStyle()
450
+ notify()
451
+ menuSweep()
452
+ }
453
+
454
+ function setValue(key, value) {
455
+ if (values[key] === value) return
456
+ values = { ...values, [key]: value }
457
+ writeStore()
458
+ syncStyle()
459
+ for (const listener of [...listeners]) {
460
+ try {
461
+ listener()
462
+ } catch {
463
+ // One broken subscriber must not stop the rest.
464
+ }
465
+ }
466
+ }
467
+
468
+ function subscribe(listener) {
469
+ listeners.add(listener)
470
+ return () => {
471
+ listeners.delete(listener)
472
+ }
473
+ }
474
+
475
+ function hiddenRules() {
476
+ const rules = []
477
+ /**
478
+ * Platform split. The desktop shell marks <html data-platform>: darwin on
479
+ * macOS, win32 on Windows; a plain browser carries no marker at all (see
480
+ * the shell's own macDesktopWindow / dsh-power-menu-mac). Linux desktop is
481
+ * neither darwin nor a browser and stays on the generic branch. macOS is
482
+ * the only build with native traffic lights floating over the sidebar and
483
+ * no in-page title bar strip.
484
+ */
485
+ const isMacDesktop = () =>
486
+ typeof document !== 'undefined' &&
487
+ document.documentElement?.getAttribute('data-platform') === 'darwin'
488
+ if (values.brand) {
489
+ if (isMacDesktop()) {
490
+ // macOS has native traffic lights and no in-page title bar: the
491
+ // _topStrip is the only thing keeping sidebar content below them.
492
+ // Collapse the _logoRow (that is the space we reclaim) but leave the
493
+ // strip alone -- dropping both, as the Windows/browser branch does,
494
+ // is what pulled "New session" into the traffic lights. No global
495
+ // padding fallback here: [class*="_root"] is reused by many shell
496
+ // components and padding every one of them tears the column apart.
497
+ rules.push(
498
+ '[class*="_logoRow"] [class*="_brand"]{display:none!important}',
499
+ '[class*="_topStrip"] [class*="_brand"]{display:none!important}',
500
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_logoRow"]:not(:has([data-sidebar-toggle]))'
501
+ + '{display:none!important}',
502
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_logoRow"]:has([data-sidebar-toggle])'
503
+ + '{height:0!important;min-height:0!important;padding:0!important;overflow:visible!important}',
504
+ )
505
+ } else {
506
+ // The logo row is a flex child of the column, so dropping it closes the
507
+ // gap. It holds the collapse toggle on Windows and Linux, though, and that
508
+ // toggle must survive: hide the whole row only where it has no toggle, and
509
+ // otherwise hide just the brand inside it. `data-sidebar-toggle` is the
510
+ // sidebar's own attribute, stabler than any CSS-module class name.
511
+ rules.push(
512
+ '[class*="_logoRow"]:not(:has([data-sidebar-toggle])){display:none!important}',
513
+ '[class*="_logoRow"]:has([data-sidebar-toggle]) [class*="_brand"]{display:none!important}',
514
+ // Where the row does hold the toggle, drop its height instead of the
515
+ // row: zero height, no padding, and the button keeps the same place,
516
+ // drawn on top of whatever now starts where the row used to be. Pure
517
+ // CSS — no probe, no timer, no forced layout.
518
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_logoRow"]:has([data-sidebar-toggle])'
519
+ + '{height:0!important;min-height:0!important;padding:0!important;overflow:visible!important'
520
+ + ';align-items:flex-start!important;justify-content:flex-end!important}',
521
+ // Never macOS: its native traffic lights sit over the sidebar, so
522
+ // the toggle must stay in the shell's own layout. Shells that draw
523
+ // a Windows title bar are excluded by the first guard; everything
524
+ // else keeps the gutter behaviour.
525
+ 'html:not([data-windows-titlebar]):not([data-platform="darwin"]) [class*="_root"]:not([class*="_collapsed"])'
526
+ + ' [class*="_logoRow"]:has([data-sidebar-toggle]) [class*="_toggle"]'
527
+ + '{position:fixed;top:6px;left:calc(var(--dsh-sidebar-width,280px) + 8px);z-index:30;border-radius:8px}',
528
+ // The collapsed rail keeps a normal row, or there is no way back out.
529
+ '[class*="_root"][class*="_collapsed"] [class*="_logoRow"]'
530
+ + '{height:28px!important;min-height:0!important;padding-block:0!important;align-items:center!important}',
531
+ // Newer builds put the collapse toggle in a 52px `topStrip` above the
532
+ // logo row, so hiding the row alone leaves that strip showing as a band
533
+ // of empty space. Drop it too: on Windows the toggle is fixed inside the
534
+ // title bar anyway, and the browser build re-parks it in the gutter.
535
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_topStrip"]'
536
+ + '{height:0!important;min-height:0!important;margin:0!important;padding:0!important'
537
+ + ';overflow:visible!important;border:0!important}',
538
+ 'html:not([data-windows-titlebar]):not([data-platform="darwin"]) [class*="_root"]:not([class*="_collapsed"])'
539
+ + ' [class*="_topStrip"] [class*="_toggle"]'
540
+ + '{position:fixed;top:6px;left:calc(var(--dsh-sidebar-width,280px) + 8px);z-index:30;border-radius:8px}',
541
+ )
542
+ }
543
+ }
544
+ // 顶部空隙: ONE checkbox on all three platforms, different behaviour.
545
+ // Windows / web keep the original three rules verbatim. On macOS the
546
+ // topStrip is 52px tall with a 28px toggle centred in it, so its lower
547
+ // part is empty: shrink it to 38px (reclaim 14px) and keep the toggle
548
+ // 11px from the top -- the same offset centring gave it -- so the button
549
+ // stays at exactly the same screen position while everything below moves
550
+ // up 14px. Zeroing _root padding is never done on macOS: that would
551
+ // shove the toggle into the native traffic lights.
552
+ if (values.topGap) {
553
+ if (isMacDesktop()) {
554
+ rules.push(
555
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_topStrip"]'
556
+ + '{height:38px!important;min-height:38px!important;align-items:flex-start!important'
557
+ + ';padding-top:11px!important;padding-bottom:0!important;box-sizing:border-box!important}',
558
+ )
559
+ } else {
560
+ rules.push(
561
+ '[class*="_root"]{padding-top:0!important}',
562
+ '[class*="_scrollRegion"]{margin-top:0!important}',
563
+ '[class*="_scrollArea"]{grid-template-rows:0 max-content 2px max-content!important}',
564
+ )
565
+ }
566
+ }
567
+ if (values.newSession) {
568
+ rules.push('button[class*="_newSession"]{display:none!important}')
569
+ }
570
+ if (values.panelLayout === 'two') {
571
+ // Fixed two-column grid: columns share one width, so rows line up
572
+ // exactly, and a longer label is truncated by the shipped ellipsis.
573
+ // The `:not([class*="_collapsed"])` guard is load-bearing here too — the
574
+ // shipped stylesheet hides the whole panel list in the collapsed rail
575
+ // (`.collapsed .panelList{display:none}`), and an unscoped `!important`
576
+ // grid would resurrect it there.
577
+ rules.push(
578
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
579
+ + '{display:grid!important;grid-template-columns:1fr 1fr;gap:2px}',
580
+ )
581
+ }
582
+ if (values.panelLayout === 'three') {
583
+ // Three fixed columns: same idea as the two-column grid, one more lane.
584
+ // Long labels keep the shipped ellipsis, and rows still line up.
585
+ rules.push(
586
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
587
+ + '{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px}',
588
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelEntry"]'
589
+ + '{width:auto!important;min-width:0!important;max-width:100%}',
590
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelRow"]'
591
+ + '{padding-left:6px!important;padding-right:6px!important}',
592
+ )
593
+ }
594
+ if (values.panelLayout === 'icon') {
595
+ // Icons only: the label is dropped, rows shrink to their glyph, and the
596
+ // list packs them side by side. The unpin control is hidden too, or it
597
+ // would show up as a second glyph per entry.
598
+ rules.push(
599
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
600
+ + '{flex-direction:row!important;flex-wrap:wrap!important;gap:4px!important}',
601
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelEntry"]'
602
+ + '{flex:0 0 auto!important;width:auto!important;min-width:0!important;max-width:100%}',
603
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelTitle"]{display:none!important}',
604
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelRow"]'
605
+ + '{width:auto!important;min-width:0!important;padding-left:8px!important;'
606
+ + 'padding-right:8px!important;justify-content:center!important;gap:0!important}',
607
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelUnpin"]{display:none!important}',
608
+ )
609
+ }
610
+ if (values.panelLayout === 'wrap') {
611
+ // Content-sized chips that wrap greedily, not fixed columns: two short
612
+ // names (插件 / 鱼塘) pack three or four to a line while a long one
613
+ // (自动化任务) takes what it needs, and nothing is ever truncated. Fixed
614
+ // `grid-template-columns` cannot do this — every column shares one width.
615
+ rules.push(
616
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
617
+ + '{flex-direction:row!important;flex-wrap:wrap!important;'
618
+ + 'column-gap:6px!important;row-gap:2px!important}',
619
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
620
+ + ' > [class*="_panelRow"]{flex:0 0 auto!important;width:auto!important;'
621
+ + 'max-width:100%}',
622
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
623
+ + ' > button:nth-of-type(n+3){order:1}',
624
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
625
+ + ' > [class*="_moreAnchor"]{order:0}',
626
+ )
627
+ }
628
+ if (values.panelOrder.length > 0) {
629
+ // The user's arrangement becomes the real order, and the More launcher
630
+ // keeps trailing it. `!important` beats the wrap-mode rule above.
631
+ const ordered = orderedPanelEntries()
632
+ ordered.forEach((entry, index) => {
633
+ rules.push(`[class*="_panelEntry"]:has([${PANEL_ATTR}="${entry.id}"]){order:${index + 1}!important}`)
634
+ })
635
+ rules.push(
636
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
637
+ + ` > [class*="_moreAnchor"]{order:${ordered.length + 1}!important}`,
638
+ )
639
+ }
640
+ if (values.sidebarMore) {
641
+ rules.push(
642
+ '[class*="_panelList"] [class*="_moreAnchor"]{display:none!important}',
643
+ '[class*="_panelList"] button[aria-haspopup="menu"]{display:none!important}',
644
+ )
645
+ }
646
+ if (values.rightMore) {
647
+ rules.push(
648
+ '[data-sidebar-right-guide] [class*="_moreAnchor"]{display:none!important}',
649
+ '[data-sidebar-right-guide] button[aria-haspopup="menu"]{display:none!important}',
650
+ )
651
+ }
652
+ if (values.headerMore) {
653
+ rules.push(
654
+ '[class*="_viewsMoreAnchor"]{display:none!important}',
655
+ 'button[class*="_viewsMore"]{display:none!important}',
656
+ )
657
+ }
658
+ if (values.collapsedAll === true) {
659
+ // The bulk layer only: every panel row plus the list's More launcher. The
660
+ // individual switches and the checkbox list stay untouched.
661
+ rules.push(
662
+ '[class*="_panelList"] [class*="_panelEntry"]{display:none!important}',
663
+ '[class*="_panelList"] > button[aria-label]{display:none!important}',
664
+ '[class*="_panelList"] [class*="_moreAnchor"]{display:none!important}',
665
+ '[class*="_panelList"] button[aria-haspopup="menu"]{display:none!important}',
666
+ )
667
+ }
668
+ for (const id of values.hiddenPanels) {
669
+ const entry = panelEntries.find((one) => one.id === id)
670
+ if (entry === undefined) continue
671
+ // Primary: the id stamped on the row, which keeps two same-named panels
672
+ // apart. Fallback: the name, used only before the first stamping pass and
673
+ // only when no other panel carries it.
674
+ // Newer builds wrap every row in a `_panelEntry` box; hiding only the
675
+ // button leaves that box occupying its grid cell, which is the empty slot
676
+ // seen when a two-column list loses one panel. Hide the box itself.
677
+ rules.push(
678
+ '[class*="_panelEntry"]:has(button[' + PANEL_ATTR + '="' + cssAttrValue(id) + '"])'
679
+ + '{display:none!important}',
680
+ 'button[' + PANEL_ATTR + '="' + cssAttrValue(id) + '"]{display:none!important}',
681
+ )
682
+ const shared = panelEntries.some((one) => one.id !== id && one.label === entry.label)
683
+ if (!shared) {
684
+ rules.push(
685
+ '[class*="_panelEntry"]:has(button[aria-label="' + cssAttrValue(entry.label) + '"])'
686
+ + '{display:none!important}',
687
+ )
688
+ }
689
+ }
690
+ for (const one of values.picked) {
691
+ // Entries written before this flag existed were already hidden; keep them.
692
+ if (one.enabled === false) continue
693
+ rules.push(one.selector + '{display:none!important}')
694
+ }
695
+ return rules
696
+ }
697
+
698
+ /** Styles for the plugin-page card; static, kept beside the hide rules. */
699
+ const FORM_CSS = [
700
+ '.dhns-form{display:flex;flex-direction:column;gap:22px;padding:12px 0}',
701
+ '.dhns-section{display:flex;flex-direction:column;gap:8px}',
702
+ '.dhns-sectionHead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}',
703
+ '.dhns-sectionTitle{margin:0;font-size:13px;font-weight:600;',
704
+ 'letter-spacing:.02em;color:var(--dsw-alias-label-primary)}',
705
+ '.dhns-sectionHint{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary)}',
706
+ '.dhns-rows{display:flex;flex-direction:column;gap:8px}',
707
+ '.dhns-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}',
708
+ '.dhns-grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}',
709
+ '.dhns-grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}',
710
+ '.dhns-grid .dhns-row{padding:6px 8px;align-items:center;border-radius:6px}',
711
+ '.dhns-grid .dhns-title{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
712
+ '.dhns-grid .dhns-hint{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
713
+ '.dhns-grid .dhns-text{gap:0}',
714
+ '.dhns-footer-btn{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;',
715
+ 'padding:7px 8px;margin:0 0 2px;border:0;border-radius:var(--dsw-radius-md,8px);',
716
+ 'background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}',
717
+ '.dhns-footer-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
718
+ '.dhns-footer-btn[data-rail=true]{justify-content:center;width:36px;height:36px;padding:0;margin:0 auto 2px}',
719
+ '.dhns-footer-icon{flex:none;width:14px;text-align:center;opacity:.75}',
720
+ '.dhns-footer-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
721
+ '.dhns-row{display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:8px 10px;',
722
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.18))}',
723
+ '.dhns-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
724
+ '.dhns-row input{margin:2px 0 0;flex:none}',
725
+ '.dhns-grip{flex:none;cursor:grab;user-select:none;line-height:1;font-size:13px;',
726
+ 'color:var(--dsw-alias-label-tertiary,#9aa0a6);padding:1px 0}',
727
+ '.dhns-grip:active{cursor:grabbing}',
728
+ '.dhns-rowDragging{opacity:.45}',
729
+ '.dhns-choice{margin:0;padding:0;border:0;display:flex;flex-direction:column;gap:8px}',
730
+ '.dhns-choiceInline{flex-direction:row;flex-wrap:wrap;gap:6px}',
731
+ '.dhns-choiceInline .dhns-row{flex:1 1 0;min-width:0;padding:6px 10px}',
732
+ '.dhns-choiceInline .dhns-title{white-space:nowrap}',
733
+ '.dhns-legend{padding:0;font-size:13px;font-weight:500;',
734
+ 'color:var(--dsw-alias-label-primary);margin-bottom:2px}',
735
+ '.dhns-option[data-selected=true]{border-color:var(--dsw-alias-state-business-primary,rgba(44,123,229,.55));',
736
+ 'background:var(--dsw-alias-interactive-bg-hover)}',
737
+ '.dhns-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}',
738
+ '.dhns-title{font-size:13px;color:var(--dsw-alias-label-primary)}',
739
+ '.dhns-hint{font-size:11px;line-height:1.45;color:var(--dsw-alias-label-tertiary)}',
740
+ '.dhns-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}',
741
+ '.dhns-picked{display:flex;flex-direction:column;gap:6px;margin-top:4px}',
742
+ '.dhns-pickedRow{display:flex;align-items:center;gap:10px;padding:6px 10px;',
743
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.18))}',
744
+ '.dhns-pickedRow .dhns-hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
745
+ '.dhns-sel{font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;',
746
+ 'color:var(--dsw-alias-label-secondary);max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
747
+ '.dhns-btn{font:inherit;font-size:12px;line-height:18px;padding:5px 12px;cursor:pointer;',
748
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));',
749
+ 'background:transparent;color:var(--dsw-alias-label-primary)}',
750
+ '.dhns-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
751
+ '.dhns-btn[data-tone=primary]{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-primary,#fff);',
752
+ 'border-color:var(--dsw-alias-label-primary)}',
753
+ '.dhns-footer-btn{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;',
754
+ 'padding:7px 8px;margin:0 0 2px;border:0;border-radius:var(--dsw-radius-md,8px);',
755
+ 'background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}',
756
+ '.dhns-footer-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
757
+ '.dhns-footer-btn[data-rail=true]{justify-content:center;width:36px;height:36px;padding:0;margin:0 auto 2px}',
758
+ '.dhns-footer-icon{flex:none;width:14px;text-align:center;opacity:.75}',
759
+ '.dhns-footer-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
760
+ '.dhns-subhead{font-size:12px;font-weight:500;letter-spacing:.02em;',
761
+ 'color:var(--dsw-alias-label-secondary);margin-top:4px}',
762
+ '.dhns-subheadRow{display:flex;align-items:center;justify-content:space-between;',
763
+ 'gap:8px;margin-top:4px}',
764
+ '.dhns-subheadRow .dhns-subhead{margin-top:0}',
765
+ '.dhns-subheadBtn{font:inherit;font-size:11px;line-height:16px;padding:1px 8px;cursor:pointer;',
766
+ 'border-radius:6px;border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));',
767
+ 'background:transparent;color:var(--dsw-alias-label-secondary)}',
768
+ '.dhns-subheadBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
769
+ '.dhns-subheadBtn:disabled{opacity:.4;cursor:default}',
770
+ '.dhns-page{padding:24px 28px;max-width:820px}',
771
+ '.dhns-pageTitle{margin:0 0 16px;font-size:16px;font-weight:600}',
772
+ ].join('')
773
+
774
+ function syncStyle() {
775
+ if (typeof document === 'undefined') return null
776
+ let tag = document.querySelector('style[' + STYLE_ATTR + ']')
777
+ if (tag === null) {
778
+ tag = document.createElement('style')
779
+ tag.setAttribute(STYLE_ATTR, '1')
780
+ document.head.appendChild(tag)
781
+ }
782
+ const css = '/* dsh-sidebar-declutter */\n'
783
+ + hiddenRules().concat(menuCssRules()).join('\n')
784
+ + '\n' + FORM_CSS
785
+ // The tag outlives the module instance: a newer build re-executing in the
786
+ // same document must rewrite it rather than keep the old stylesheet.
787
+ if (tag.textContent !== css) tag.textContent = css
788
+ return tag
789
+ }
790
+
791
+ /** Live view of what is registered into the sidebar's panel list. */
792
+ let panelEntries = []
793
+
794
+ /** Escape a value for a double-quoted CSS attribute selector. */
795
+ function cssAttrValue(value) {
796
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
797
+ }
798
+
799
+ // --- collapse control -------------------------------------------------------
800
+ // `sidebar.panellist` rows are navigation by contract, so an action has no
801
+ // seat there. `sidebar.footer.action` is an official list slot the sidebar
802
+ // renders itself — no DOM injection, and a re-render cannot remove it.
803
+
804
+ function toggleCollapse() {
805
+ values = { ...values, collapsedAll: values.collapsedAll !== true }
806
+ writeStore()
807
+ syncStyle()
808
+ notify()
809
+ }
810
+
811
+ function collapseLabel() {
812
+ return values.collapsedAll === true ? '展开面板列表' : '收起面板列表'
813
+ }
814
+
815
+ /**
816
+ * The footer action: an official list slot the sidebar renders itself, so it
817
+ * cannot be removed by a re-render. `wide` collapses it to an icon in the rail.
818
+ */
819
+ function CollapseAction(props) {
820
+ const [, force] = React.useReducer((count) => count + 1, 0)
821
+ React.useEffect(() => subscribe(force), [])
822
+ const wide = props?.wide !== false
823
+ const label = collapseLabel()
824
+ return h(
825
+ 'button',
826
+ {
827
+ type: 'button',
828
+ className: 'dhns-footer-btn',
829
+ 'data-rail': wide ? undefined : 'true',
830
+ 'aria-label': label,
831
+ 'aria-pressed': values.collapsedAll === true ? 'true' : 'false',
832
+ title: label,
833
+ onClick: () => toggleCollapse(),
834
+ },
835
+ h('span', { className: 'dhns-footer-icon', 'aria-hidden': 'true' }, values.collapsedAll ? '▸' : '▾'),
836
+ wide ? h('span', { className: 'dhns-footer-label' }, label) : null,
837
+ )
838
+ }
839
+
840
+ // --- per-row id stamping -----------------------------------------------------
841
+ // The list exposes no id in the DOM, only the accessible name, so two panels
842
+ // sharing a name would hide together. Each rendered row is therefore stamped
843
+ // with the registration id it belongs to, and the hide rule names the id.
844
+ // Stamping only sets attributes; it never inserts or moves a node.
845
+
846
+ const PANEL_ATTR = 'data-dsh-panel'
847
+
848
+ /** The panel list inside the sidebar column, located from its own anchor. */
849
+ function findPanelNav() {
850
+ if (typeof document === 'undefined') return null
851
+ const toggle = document.querySelector('[data-sidebar-toggle]')
852
+ if (toggle === null) return null
853
+ let node = toggle.parentElement
854
+ while (node !== null && node !== document.body) {
855
+ const nav = node.querySelector('[class*="_panelList"]')
856
+ if (nav !== null) {
857
+ const rect = nav.getBoundingClientRect()
858
+ // A sidebar column is a narrow strip; anything wider is another
859
+ // component whose CSS-module local name happens to match.
860
+ if (rect.width > 0 && rect.width < 520) return nav
861
+ }
862
+ node = node.parentElement
863
+ }
864
+ return null
865
+ }
866
+
867
+ /**
868
+ * Pair each rendered row with a registered entry: by name first, then by
869
+ * render order among the rest. A failed pass hides nothing, which is the safe
870
+ * direction — every panel stays visible and can be re-ticked.
871
+ */
872
+ /**
873
+ * Registered panels in the order the user dragged them into; anything they
874
+ * have not arranged yet keeps its registration order behind them.
875
+ */
876
+ function orderedPanelEntries() {
877
+ if (values.panelOrder.length === 0) return [...panelEntries]
878
+ const rank = new Map(values.panelOrder.map((id, index) => [id, index]))
879
+ const base = new Map(panelEntries.map((entry, index) => [entry.id, index]))
880
+ return [...panelEntries].sort((a, b) => {
881
+ const ra = rank.has(a.id) ? rank.get(a.id) : Number.MAX_SAFE_INTEGER
882
+ const rb = rank.has(b.id) ? rank.get(b.id) : Number.MAX_SAFE_INTEGER
883
+ if (ra !== rb) return ra - rb
884
+ return (base.get(a.id) ?? 0) - (base.get(b.id) ?? 0)
885
+ })
886
+ }
887
+
888
+ function stampPanelRows() {
889
+ try {
890
+ const nav = findPanelNav()
891
+ if (nav === null) return
892
+ const rows = Array.from(nav.querySelectorAll('button[aria-label]'))
893
+ if (rows.length === 0) return
894
+ const claimed = new Set()
895
+ const take = (label) => {
896
+ const hit = panelEntries.find((entry) => entry.label === label && !claimed.has(entry.id))
897
+ if (hit === undefined) return null
898
+ claimed.add(hit.id)
899
+ return hit.id
900
+ }
901
+ for (const row of rows) {
902
+ const id = take(row.getAttribute('aria-label') ?? '')
903
+ if (id === null) {
904
+ row.removeAttribute(PANEL_ATTR)
905
+ continue
906
+ }
907
+ if (row.getAttribute(PANEL_ATTR) !== id) row.setAttribute(PANEL_ATTR, id)
908
+ }
909
+ } catch (error) {
910
+ console.warn('[dsh-sidebar-declutter] 面板打标失败:', error)
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Re-stamp whenever the sidebar re-renders its list. The observer watches the
916
+ * list node only — watching the whole document would fire on every chat
917
+ * token. Until the sidebar exists, a short poll looks for it and then stops.
918
+ */
919
+ function watchPanelRows() {
920
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return null
921
+ let observer = null
922
+ let target = null
923
+ let scheduled = false
924
+ let poll = null
925
+
926
+ const stampSoon = () => {
927
+ if (scheduled) return
928
+ scheduled = true
929
+ setTimeout(() => {
930
+ scheduled = false
931
+ stampPanelRows()
932
+ }, 16)
933
+ }
934
+
935
+ const attach = () => {
936
+ const nav = findPanelNav()
937
+ if (nav === null) return false
938
+ if (nav === target) return true
939
+ observer?.disconnect()
940
+ target = nav
941
+ observer = new MutationObserver(stampSoon)
942
+ observer.observe(nav, { childList: true })
943
+ stampPanelRows()
944
+ return true
945
+ }
946
+
947
+ if (!attach()) {
948
+ poll = setInterval(() => {
949
+ if (attach() && poll !== null) {
950
+ clearInterval(poll)
951
+ poll = null
952
+ }
953
+ }, 500)
954
+ }
955
+
956
+ return () => {
957
+ observer?.disconnect()
958
+ if (poll !== null) clearInterval(poll)
959
+ }
960
+ }
961
+
962
+ // --- pick mode -------------------------------------------------------------
963
+ // User-started only: capture one click, turn the target into a selector and
964
+ // keep it in `picked`. Nothing here runs until the button is pressed.
965
+
966
+ let picker = null
967
+
968
+ /**
969
+ * Teardown handles that outlive one module instance. A hot swap re-runs this
970
+ * module with fresh state while the previous instance's document listeners
971
+ * are still attached; without this the old capture handler keeps swallowing
972
+ * every click in the page.
973
+ */
974
+ const TEARDOWN_KEY = '__dshSidebarDeclutterTeardown'
975
+
976
+ function registerTeardown(fn) {
977
+ if (typeof window === 'undefined') return
978
+ const previous = window[TEARDOWN_KEY]
979
+ if (typeof previous === 'function') {
980
+ try {
981
+ previous()
982
+ } catch {
983
+ // A broken previous instance must not stop this one.
984
+ }
985
+ }
986
+ window[TEARDOWN_KEY] = fn
987
+ }
988
+
989
+ /**
990
+ * Live view of what is registered into the sidebar's panel list, mirrored
991
+ * from the slot registry the sidebar itself renders from. Each entry is
992
+ * `{ id, order, label }`; the label is what the rendered button carries as
993
+ * its accessible name.
994
+ */
995
+ /** Escape a value for a double-quoted CSS attribute selector. */
996
+ /** `kB3qhq_panelRow` → `panelRow`; a class without a module hash stays whole. */
997
+ function localName(cls) {
998
+ const match = /^[A-Za-z0-9_-]+_([A-Za-z][A-Za-z0-9-]*)$/.exec(cls)
999
+ return match === null ? null : match[1]
1000
+ }
1001
+
1002
+ function escapeIdent(value) {
1003
+ return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
1004
+ ? CSS.escape(value)
1005
+ : value.replace(/[^A-Za-z0-9_-]/g, (ch) => '\\' + ch)
1006
+ }
1007
+
1008
+ /**
1009
+ * A selector that survives the next build. CSS-module class names carry a
1010
+ * per-build hash (`kB3qhq_panelRow`), so they are matched by their local
1011
+ * suffix; an id or a `data-` attribute is preferred when the element has one.
1012
+ */
1013
+ function buildSelector(el) {
1014
+ if (el.id !== '') return '#' + escapeIdent(el.id)
1015
+ const classes = Array.from(el.classList)
1016
+ const locals = classes.map(localName).filter((one) => one !== null)
1017
+ const plain = classes.filter((one) => localName(one) === null)
1018
+ if (plain.length > 0 || locals.length > 0) {
1019
+ let sel = el.tagName.toLowerCase()
1020
+ if (plain.length > 0) sel += '.' + escapeIdent(plain[0])
1021
+ if (locals.length > 0) sel += '[class*="_' + locals[0] + '"]'
1022
+ return sel
1023
+ }
1024
+ for (const attr of Array.from(el.attributes)) {
1025
+ if (attr.name.startsWith('data-') && attr.name !== 'data-dsh-sidebar-declutter') {
1026
+ return '[' + attr.name + ']'
1027
+ }
1028
+ }
1029
+ const path = []
1030
+ let node = el
1031
+ for (let depth = 0; depth < 5 && node && node !== document.body; depth += 1) {
1032
+ let sel = node.tagName.toLowerCase()
1033
+ const parent = node.parentElement
1034
+ if (parent !== null) sel += ':nth-child(' + (Array.from(parent.children).indexOf(node) + 1) + ')'
1035
+ path.unshift(sel)
1036
+ node = node.parentElement
1037
+ }
1038
+ return path.join(' > ')
1039
+ }
1040
+
1041
+ function labelFor(el) {
1042
+ const named = el.getAttribute('aria-label') ?? el.getAttribute('title') ?? ''
1043
+ const text = (named !== '' ? named : el.textContent ?? '').replace(/\s+/g, ' ').trim()
1044
+ return text === '' ? el.tagName.toLowerCase() : text.slice(0, 24)
1045
+ }
1046
+
1047
+ /** Guards that keep the way back open. */
1048
+ function isProtected(el) {
1049
+ if (typeof document === 'undefined') return true
1050
+ if (el === document.body || el === document.documentElement) return true
1051
+ if (el.closest('.dhns-form') !== null) return true
1052
+ // The settings entry is the only door to the plugin page that holds the
1053
+ // restore list; hiding it — directly or through an ancestor — locks the
1054
+ // user out of the very screen that could undo it.
1055
+ if (el.matches('[class*="_triggerRow"]') || el.querySelector('[class*="_triggerRow"]') !== null) return true
1056
+ // A container, not a row. One stray click on a panel-sized box can blank a
1057
+ // whole area — the plugin page itself was hidden this way once — so
1058
+ // anything covering a large share of the viewport is refused.
1059
+ const rect = el.getBoundingClientRect()
1060
+ const viewport = window.innerWidth * window.innerHeight
1061
+ if (viewport > 0 && rect.width * rect.height > viewport * 0.4) return true
1062
+ return false
1063
+ }
1064
+
1065
+ function addPicked(el) {
1066
+ const selector = buildSelector(el)
1067
+ if (selector.trim() === '') return null
1068
+ if (values.picked.some((one) => one.selector === selector)) return { selector, duplicate: true }
1069
+ // Added as a candidate: it hides only once its checkbox is ticked, so a
1070
+ // stray click can never take something off the screen by itself.
1071
+ const entry = { selector, label: labelFor(el), enabled: false }
1072
+ values = { ...values, picked: [...values.picked, entry] }
1073
+ writeStore()
1074
+ syncStyle()
1075
+ notify()
1076
+ return { selector, duplicate: false }
1077
+ }
1078
+
1079
+ function removePickedAt(index) {
1080
+ const next = values.picked.filter((_, position) => position !== index)
1081
+ values = { ...values, picked: next }
1082
+ writeStore()
1083
+ syncStyle()
1084
+ notify()
1085
+ }
1086
+
1087
+ function stopPicker() {
1088
+ if (picker === null) return
1089
+ document.removeEventListener('click', picker.onClick, true)
1090
+ document.removeEventListener('mousemove', picker.onMove, true)
1091
+ document.removeEventListener('keydown', picker.onKeyDown, true)
1092
+ if (picker.outlined !== null) picker.outlined.style.outline = picker.savedOutline
1093
+ picker.bar.remove()
1094
+ picker = null
1095
+ notify()
1096
+ }
1097
+
1098
+ function startPicker() {
1099
+ if (typeof document === 'undefined' || picker !== null) return
1100
+ const bar = document.createElement('div')
1101
+ bar.setAttribute('data-dsh-picker', '1')
1102
+ bar.style.cssText = [
1103
+ 'position:fixed', 'left:50%', 'bottom:24px', 'transform:translateX(-50%)',
1104
+ 'z-index:2147483000', 'display:flex', 'align-items:center', 'gap:10px',
1105
+ 'padding:8px 12px', 'border-radius:12px', 'font:13px/1.4 system-ui,sans-serif',
1106
+ 'background:#1f2328', 'color:#fff', 'box-shadow:0 8px 24px rgba(0,0,0,.35)',
1107
+ ].join(';')
1108
+
1109
+ const text = document.createElement('span')
1110
+ text.setAttribute('data-role', 'text')
1111
+ const undo = document.createElement('button')
1112
+ undo.type = 'button'
1113
+ undo.textContent = '撤销'
1114
+ undo.style.cssText = 'font:inherit;padding:4px 10px;border-radius:8px;border:1px solid #ffffff44;background:transparent;color:#fff;cursor:pointer'
1115
+ const done = document.createElement('button')
1116
+ done.type = 'button'
1117
+ done.textContent = '完成'
1118
+ done.style.cssText = 'font:inherit;padding:4px 10px;border-radius:8px;border:1px solid #ffffff44;background:#fff;color:#1f2328;cursor:pointer'
1119
+ bar.append(text, undo, done)
1120
+ document.body.appendChild(bar)
1121
+
1122
+ const refreshBar = () => {
1123
+ text.textContent = '点选要隐藏的元素 · 已隐藏 ' + values.picked.length + ' 个'
1124
+ undo.disabled = values.picked.length === 0
1125
+ }
1126
+ refreshBar()
1127
+
1128
+ undo.addEventListener('click', () => {
1129
+ if (values.picked.length === 0) return
1130
+ removePickedAt(values.picked.length - 1)
1131
+ refreshBar()
1132
+ })
1133
+ done.addEventListener('click', () => stopPicker())
1134
+
1135
+ const state = { bar, onClick: null, onMove: null, outlined: null, savedOutline: '' }
1136
+
1137
+ state.onMove = (event) => {
1138
+ const el = event.target instanceof Element ? event.target : null
1139
+ if (el === null || el === state.outlined) return
1140
+ if (state.outlined !== null) state.outlined.style.outline = state.savedOutline
1141
+ state.outlined = null
1142
+ if (el === document.body || el === document.documentElement) return
1143
+ if (bar.contains(el)) return
1144
+ state.outlined = el
1145
+ state.savedOutline = el.style.outline
1146
+ el.style.outline = isProtected(el) ? '2px dashed #e5484d' : '2px solid #2c7be5'
1147
+ }
1148
+
1149
+ state.onClick = (event) => {
1150
+ const el = event.target instanceof Element ? event.target : null
1151
+ if (el === null || bar.contains(el)) return
1152
+ // Clicks on this plugin's own card must keep working — that is where
1153
+ // 「结束点选」and the restore rows live.
1154
+ if (el.closest('.dhns-form') !== null) return
1155
+ event.preventDefault()
1156
+ event.stopPropagation()
1157
+ if (isProtected(el)) {
1158
+ text.textContent = '这一项不能隐藏:它会挡住设置入口'
1159
+ setTimeout(refreshBar, 1800)
1160
+ return
1161
+ }
1162
+ const result = addPicked(el)
1163
+ text.textContent =
1164
+ result !== null && result.duplicate
1165
+ ? '这一项已经在列表里了'
1166
+ : '已加入列表:' + labelFor(el) + '(去设置里勾选才会隐藏)'
1167
+ setTimeout(refreshBar, 2000)
1168
+ if (state.outlined !== null) {
1169
+ state.outlined.style.outline = state.savedOutline
1170
+ state.outlined = null
1171
+ }
1172
+ }
1173
+
1174
+ state.onKeyDown = (event) => {
1175
+ if (event.key === 'Escape') {
1176
+ event.preventDefault()
1177
+ event.stopPropagation()
1178
+ stopPicker()
1179
+ }
1180
+ }
1181
+
1182
+ document.addEventListener('mousemove', state.onMove, true)
1183
+ document.addEventListener('click', state.onClick, true)
1184
+ document.addEventListener('keydown', state.onKeyDown, true)
1185
+ picker = state
1186
+ notify()
1187
+ }
1188
+
1189
+ function notify() {
1190
+ for (const listener of [...listeners]) {
1191
+ try {
1192
+ listener()
1193
+ } catch {
1194
+ // One broken subscriber must not stop the rest.
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * The plugin page card, registered at `plugins.row.config` for this bundle's
1201
+ * row. `summary` is the one-liner the row shows when collapsed; `page` is the
1202
+ * form the row's configure control opens.
1203
+ */
1204
+ /** The page #/panel/hide-rows renders: the same form as the plugin row card. */
1205
+ function SettingsPage() {
1206
+ return h(
1207
+ 'div',
1208
+ { className: 'dhns-page' },
1209
+ h('h2', { className: 'dhns-pageTitle' }, PANEL_ENTRY_LABEL),
1210
+ h(SettingsCard, { view: 'full' }),
1211
+ )
1212
+ }
1213
+
1214
+ function SettingsCard(props) {
1215
+ const view = props?.view
1216
+ const [, forceRender] = React.useReducer((count) => count + 1, 0)
1217
+ const [dragId, setDragId] = React.useState(null)
1218
+ React.useEffect(() => subscribe(forceRender), [])
1219
+
1220
+ // The host renders this card with view="summary" as the one-line
1221
+ // description under the plugin row. Return nothing so no description
1222
+ // is drawn at all; the full form is unchanged.
1223
+ if (view === 'summary') return null
1224
+
1225
+ const rowFor = (field) =>
1226
+ h(
1227
+ 'label',
1228
+ { key: field.key, className: 'dhns-row' },
1229
+ h('input', {
1230
+ type: 'checkbox',
1231
+ checked: values[field.key] === true,
1232
+ onChange: (event) => setValue(field.key, event.target.checked === true),
1233
+ }),
1234
+ h(
1235
+ 'span',
1236
+ { className: 'dhns-text' },
1237
+ h('span', { className: 'dhns-title' }, field.title),
1238
+ h('span', { className: 'dhns-hint' }, field.hint),
1239
+ ),
1240
+ )
1241
+
1242
+ /**
1243
+ * A mutually exclusive group. One shared `name` is what makes the browser
1244
+ * keep exactly one option selected, so the three layouts can never be
1245
+ * combined — the CSS rules are all derived from the single stored value.
1246
+ */
1247
+ const choiceFor = (field) =>
1248
+ h(
1249
+ 'fieldset',
1250
+ {
1251
+ key: field.key,
1252
+ className: field.inline === true ? 'dhns-choice dhns-choiceInline' : 'dhns-choice',
1253
+ 'aria-label': field.title ?? field.key,
1254
+ },
1255
+ field.title === undefined
1256
+ ? null
1257
+ : h('legend', { className: 'dhns-legend' }, field.title),
1258
+ field.options.map((option) =>
1259
+ h(
1260
+ 'label',
1261
+ {
1262
+ key: option.value,
1263
+ className: 'dhns-row dhns-option',
1264
+ 'data-selected': values[field.key] === option.value ? 'true' : undefined,
1265
+ },
1266
+ h('input', {
1267
+ type: 'radio',
1268
+ name: 'dhns-' + field.key,
1269
+ checked: values[field.key] === option.value,
1270
+ onChange: () => setValue(field.key, option.value),
1271
+ }),
1272
+ h(
1273
+ 'span',
1274
+ { className: 'dhns-text' },
1275
+ h('span', { className: 'dhns-title' }, option.title),
1276
+ h('span', { className: 'dhns-hint' }, option.hint),
1277
+ ),
1278
+ ),
1279
+ ),
1280
+ )
1281
+
1282
+ const renderField = (field) => (field.type === 'choice' ? choiceFor(field) : rowFor(field))
1283
+
1284
+ const togglePanel = (id, on) => {
1285
+ const next = on
1286
+ ? values.hiddenPanels.includes(id) ? values.hiddenPanels : [...values.hiddenPanels, id]
1287
+ : values.hiddenPanels.filter((one) => one !== id)
1288
+ values = { ...values, hiddenPanels: next }
1289
+ writeStore()
1290
+ syncStyle()
1291
+ notify()
1292
+ }
1293
+
1294
+ /** Drop `fromId` where `toId` sits, then persist the whole arrangement. */
1295
+ const movePanel = (fromId, toId) => {
1296
+ const ids = orderedPanelEntries().map((one) => one.id)
1297
+ const from = ids.indexOf(fromId)
1298
+ const to = ids.indexOf(toId)
1299
+ if (from < 0 || to < 0 || from === to) return
1300
+ const [moved] = ids.splice(from, 1)
1301
+ ids.splice(to, 0, moved)
1302
+ values = { ...values, panelOrder: ids }
1303
+ writeStore()
1304
+ syncStyle()
1305
+ notify()
1306
+ }
1307
+
1308
+ /** Drop the arrangement and fall back to the registration order. */
1309
+ const resetPanelOrder = () => {
1310
+ if (values.panelOrder.length === 0) return
1311
+ values = { ...values, panelOrder: [] }
1312
+ writeStore()
1313
+ syncStyle()
1314
+ notify()
1315
+ }
1316
+
1317
+ const panelBlock = () => {
1318
+ if (panelEntries.length === 0) {
1319
+ return h('p', { className: 'dhns-hint' }, '当前没有检测到已注册的面板。')
1320
+ }
1321
+ return h(
1322
+ 'div',
1323
+ null,
1324
+ h(
1325
+ 'div',
1326
+ { className: 'dhns-subheadRow', key: 'subhead' },
1327
+ h('span', { className: 'dhns-subhead' }, '面板'),
1328
+ h(
1329
+ 'button',
1330
+ {
1331
+ type: 'button',
1332
+ className: 'dhns-subheadBtn',
1333
+ disabled: values.panelOrder.length === 0,
1334
+ onClick: resetPanelOrder,
1335
+ },
1336
+ '还原默认',
1337
+ ),
1338
+ ),
1339
+ h(
1340
+ 'div',
1341
+ { className: 'dhns-grid', key: 'grid' },
1342
+ orderedPanelEntries().map((entry) =>
1343
+ h(
1344
+ 'label',
1345
+ {
1346
+ key: entry.id,
1347
+ className: dragId === entry.id ? 'dhns-row dhns-rowDragging' : 'dhns-row',
1348
+ onDragOver: (event) => {
1349
+ if (dragId === null) return
1350
+ event.preventDefault()
1351
+ event.dataTransfer.dropEffect = 'move'
1352
+ },
1353
+ onDrop: (event) => {
1354
+ if (dragId === null) return
1355
+ event.preventDefault()
1356
+ movePanel(dragId, entry.id)
1357
+ setDragId(null)
1358
+ },
1359
+ },
1360
+ h(
1361
+ 'span',
1362
+ {
1363
+ className: 'dhns-grip',
1364
+ title: '拖动排序',
1365
+ draggable: true,
1366
+ onDragStart: (event) => {
1367
+ setDragId(entry.id)
1368
+ event.dataTransfer.effectAllowed = 'move'
1369
+ event.dataTransfer.setData('text/plain', entry.id)
1370
+ },
1371
+ onDragEnd: () => setDragId(null),
1372
+ },
1373
+ '⠿',
1374
+ ),
1375
+ h('input', {
1376
+ type: 'checkbox',
1377
+ checked: values.hiddenPanels.includes(entry.id),
1378
+ onChange: (event) => togglePanel(entry.id, event.target.checked === true),
1379
+ }),
1380
+ h(
1381
+ 'span',
1382
+ { className: 'dhns-text' },
1383
+ h('span', { className: 'dhns-title' }, entry.label),
1384
+ h('span', { className: 'dhns-hint' }, entry.id),
1385
+ ),
1386
+ ),
1387
+ ),
1388
+ ),
1389
+ )
1390
+ }
1391
+
1392
+ const togglePicked = (index, on) => {
1393
+ const next = values.picked.map((one, position) =>
1394
+ position === index ? { ...one, enabled: on } : one,
1395
+ )
1396
+ values = { ...values, picked: next }
1397
+ writeStore()
1398
+ syncStyle()
1399
+ notify()
1400
+ }
1401
+
1402
+ const pickedBlock = () => {
1403
+ const running = picker !== null
1404
+ return [
1405
+ h(
1406
+ 'div',
1407
+ { className: 'dhns-actions', key: 'actions' },
1408
+ h(
1409
+ 'button',
1410
+ {
1411
+ type: 'button',
1412
+ className: 'dhns-btn',
1413
+ 'data-tone': running ? undefined : 'primary',
1414
+ onClick: () => (running ? stopPicker() : startPicker()),
1415
+ },
1416
+ running ? '结束点选' : '开始点选',
1417
+ ),
1418
+ values.picked.length === 0
1419
+ ? null
1420
+ : h(
1421
+ 'button',
1422
+ {
1423
+ type: 'button',
1424
+ className: 'dhns-btn',
1425
+ onClick: () => {
1426
+ values = { ...values, picked: [] }
1427
+ writeStore()
1428
+ syncStyle()
1429
+ notify()
1430
+ },
1431
+ },
1432
+ '清空列表',
1433
+ ),
1434
+ ),
1435
+ values.picked.length === 0
1436
+ ? null
1437
+ : h(
1438
+ 'div',
1439
+ { className: 'dhns-rows', key: 'list' },
1440
+ values.picked.map((one, index) =>
1441
+ h(
1442
+ 'label',
1443
+ { className: 'dhns-row', key: one.selector + ':' + index },
1444
+ h('input', {
1445
+ type: 'checkbox',
1446
+ checked: one.enabled !== false,
1447
+ onChange: (event) => togglePicked(index, event.target.checked === true),
1448
+ }),
1449
+ h(
1450
+ 'span',
1451
+ { className: 'dhns-text' },
1452
+ h('span', { className: 'dhns-title' }, one.label),
1453
+ h(
1454
+ 'span',
1455
+ { className: 'dhns-hint dhns-sel', title: one.selector },
1456
+ one.selector,
1457
+ ),
1458
+ ),
1459
+ h(
1460
+ 'button',
1461
+ {
1462
+ type: 'button',
1463
+ className: 'dhns-btn',
1464
+ onClick: (event) => {
1465
+ event.preventDefault()
1466
+ removePickedAt(index)
1467
+ },
1468
+ },
1469
+ '移除',
1470
+ ),
1471
+ ),
1472
+ ),
1473
+ ),
1474
+ ]
1475
+ }
1476
+
1477
+ const menuBlock = () => {
1478
+ const presetKeys = MENU_PRESETS.map((one) => one.key)
1479
+ const other = menuState.seen
1480
+ .filter((one) => !presetKeys.includes(one.key) && !isPresetMenuKey(one.key))
1481
+ .sort((a, b) => a.key.localeCompare(b.key, 'zh'))
1482
+ const rowOf = (one) =>
1483
+ h(
1484
+ 'label',
1485
+ { key: one.key, className: 'dhns-row' },
1486
+ h('input', {
1487
+ type: 'checkbox',
1488
+ checked: menuState.hidden.includes(one.key),
1489
+ onChange: (event) => setMenuRowHidden(one.key, event.target.checked === true),
1490
+ }),
1491
+ h(
1492
+ 'span',
1493
+ { className: 'dhns-text' },
1494
+ h('span', { className: 'dhns-title' }, one.key),
1495
+ one.accel === '' ? null : h('span', { className: 'dhns-hint' }, one.accel),
1496
+ ),
1497
+ )
1498
+ return h(
1499
+ 'div',
1500
+ null,
1501
+ h('div', { className: 'dhns-subhead', key: 'official' }, '官方'),
1502
+ h('div', { className: 'dhns-grid', key: 'official-grid' },
1503
+ MENU_PRESETS.map((one) => rowOf({ key: one.key, accel: one.accel }))),
1504
+ other.length === 0
1505
+ ? null
1506
+ : h('div', { className: 'dhns-subhead', key: 'other' }, '社区'),
1507
+ other.length === 0
1508
+ ? null
1509
+ : h('div', { className: 'dhns-grid', key: 'other-grid' }, other.map(rowOf)),
1510
+ )
1511
+ }
1512
+
1513
+ return h(
1514
+ 'div',
1515
+ { className: 'dhns-form' },
1516
+ SECTIONS.map((section) =>
1517
+ h(
1518
+ 'section',
1519
+ { key: section.title, className: 'dhns-section' },
1520
+ h(
1521
+ 'div',
1522
+ { className: 'dhns-sectionHead' },
1523
+ h('h4', { className: 'dhns-sectionTitle' }, section.title),
1524
+ section.hint === undefined
1525
+ ? null
1526
+ : h('span', { className: 'dhns-sectionHint' }, section.hint),
1527
+ ),
1528
+ section.fields.length === 0
1529
+ ? null
1530
+ : h(
1531
+ 'div',
1532
+ {
1533
+ className: section.columns === 2
1534
+ ? 'dhns-grid dhns-grid-2'
1535
+ : section.columns === 3
1536
+ ? 'dhns-grid'
1537
+ : section.columns === 4
1538
+ ? 'dhns-grid dhns-grid-4'
1539
+ : 'dhns-rows',
1540
+ },
1541
+ section.fields.map(renderField),
1542
+ ),
1543
+ section.kind === 'picked' ? pickedBlock() : null,
1544
+ section.kind === 'menu' ? menuBlock() : null,
1545
+ section.panels === true ? panelBlock() : null,
1546
+ ),
1547
+ ),
1548
+ )
1549
+ }
1550
+
1551
+ return {
1552
+ inject: ['slots'],
1553
+ apply(ctx) {
1554
+ values = readStore()
1555
+ readMenuState()
1556
+
1557
+ // Mirror the panel registry the sidebar renders from: it is the only
1558
+ // place that knows every left-column button and its display name, so the
1559
+ // checkboxes follow newly installed plugins without any code change.
1560
+ const syncPanels = () => {
1561
+ try {
1562
+ panelEntries = ctx.slots
1563
+ .entriesOfSlot('sidebar.panellist')
1564
+ .map(({ options }) => ({
1565
+ id: options.id,
1566
+ order: options.order ?? 0,
1567
+ label:
1568
+ typeof options.label === 'function'
1569
+ ? options.label()
1570
+ : options.label ?? options.id,
1571
+ }))
1572
+ .sort((a, b) => a.order - b.order)
1573
+ } catch {
1574
+ panelEntries = []
1575
+ }
1576
+ stampPanelRows()
1577
+ syncStyle()
1578
+ notify()
1579
+ }
1580
+
1581
+ ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'dsh-sidebar-declutter: panel registry')
1582
+ const locale = (() => {
1583
+ try {
1584
+ return ctx.get('locale')
1585
+ } catch {
1586
+ return null
1587
+ }
1588
+ })()
1589
+ if (locale !== null && typeof locale?.subscribe === 'function') {
1590
+ ctx.effect(() => locale.subscribe(syncPanels), 'dsh-sidebar-declutter: panel labels')
1591
+ }
1592
+ syncPanels()
1593
+ ctx.effect(() => watchPanelRows(), 'dsh-sidebar-declutter: panel rows')
1594
+
1595
+ ctx.effect(() => {
1596
+ registerTeardown(() => {
1597
+ if (picker !== null) stopPicker()
1598
+ })
1599
+ return () => {
1600
+ if (picker !== null) stopPicker()
1601
+ }
1602
+ }, 'dsh-sidebar-declutter: pick teardown')
1603
+
1604
+ ctx.effect(() => {
1605
+ const tag = syncStyle()
1606
+
1607
+ // Diagnose selector drift after an upgrade instead of failing silently:
1608
+ // the sidebar is on screen but nothing matches the button.
1609
+ let timer = null
1610
+ if (typeof document !== 'undefined' && values.newSession) {
1611
+ timer = setTimeout(() => {
1612
+ timer = null
1613
+ if (document.querySelector('[class*="_panelList"]') === null) return
1614
+ if (document.querySelector('button[class*="_newSession"]') !== null) return
1615
+ console.warn(
1616
+ '[dsh-sidebar-declutter] 侧栏已渲染但没匹配到新会话按钮,选择器可能随版本变化。',
1617
+ )
1618
+ }, 2000)
1619
+ }
1620
+
1621
+ return () => {
1622
+ if (timer !== null) clearTimeout(timer)
1623
+ if (tag !== null) tag.remove()
1624
+ }
1625
+ }, 'dsh-sidebar-declutter: stylesheet')
1626
+
1627
+
1628
+ ctx.effect(() =>
1629
+ ctx.slots.inject('sidebar.footer.action', () =>
1630
+ ctx.slots.register(
1631
+ { name: 'sidebar.footer.action', id: 'sidebar-declutter-collapse', order: 10 },
1632
+ CollapseAction,
1633
+ ),
1634
+ ),
1635
+ )
1636
+
1637
+ // Same two-slot pattern the shipped panels use: the sidebar row only
1638
+ // carries the icon and label, and the main slot with the same key is the
1639
+ // page the shell routes #/panel/<id> to. Without the second registration
1640
+ // the click falls back to whatever panel was open before.
1641
+ if (ctx.slots && typeof ctx.slots.inject === 'function') {
1642
+ ctx.effect(() =>
1643
+ ctx.slots.inject('sidebar.panellist', () =>
1644
+ ctx.slots.register(
1645
+ {
1646
+ name: 'sidebar.panellist',
1647
+ id: PANEL_ENTRY_ID,
1648
+ order: 20,
1649
+ label: () => PANEL_ENTRY_LABEL,
1650
+ },
1651
+ PanelEntry,
1652
+ ),
1653
+ ),
1654
+ )
1655
+ ctx.effect(() =>
1656
+ ctx.slots.inject('main', () =>
1657
+ ctx.slots.register({ name: 'main', key: PANEL_ENTRY_ID }, SettingsPage),
1658
+ ),
1659
+ )
1660
+ }
1661
+
1662
+ if (ENABLE_ROW_CONFIG) {
1663
+ ctx.effect(() =>
1664
+ ctx.slots.inject('plugins.row.config', () =>
1665
+ ctx.slots.register({ name: 'plugins.row.config', key: ROW_CONFIG_KEY }, SettingsCard),
1666
+ ),
1667
+ )
1668
+ }
1669
+
1670
+ // Menu rows are plain DOM: sweep after every click that could have opened
1671
+ // one, never with a body-level observer (the chat stream mutates per token).
1672
+ ctx.effect(() => {
1673
+ const sweep = (event) => {
1674
+ const target = event && event.target
1675
+ const fromRow = !!(target && target.closest && target.closest('[class*="_sessionRow"]'))
1676
+ menuScheduleSweep(fromRow)
1677
+ }
1678
+ document.addEventListener('click', sweep, true)
1679
+ document.addEventListener('contextmenu', sweep, true)
1680
+ return () => {
1681
+ document.removeEventListener('click', sweep, true)
1682
+ document.removeEventListener('contextmenu', sweep, true)
1683
+ menuStopWatch()
1684
+ }
1685
+ }, 'dsh-sidebar-declutter: menu rows')
1686
+ },
1687
+ }
1688
+ },
1689
+ })