dsh-declutter 0.0.0-stage → 0.1.2

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,1611 @@
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
+ ]
237
+
238
+ /**
239
+ * Rows that carry an accelerator can be hidden by CSS, before the menu is ever
240
+ * painted — no timing to lose. Rows without one still go through the DOM sweep.
241
+ */
242
+ function menuCssRules() {
243
+ const rules = []
244
+ for (const preset of MENU_PRESETS) {
245
+ if (!menuState.hidden.includes(preset.key)) continue
246
+ for (const aria of preset.aria) {
247
+ rules.push(`[role="menuitem"][aria-keyshortcuts="${aria}"]{display:none!important}`)
248
+ }
249
+ }
250
+ return rules
251
+ }
252
+
253
+ /** @type {{ hidden: string[], seen: { key: string, accel: string }[] }} */
254
+ let menuState = { hidden: [], seen: [] }
255
+
256
+ function readMenuState() {
257
+ try {
258
+ const parsed = JSON.parse(localStorage.getItem(MENU_STORE_KEY) || 'null')
259
+ if (!parsed) return
260
+ const hidden = Array.isArray(parsed.hidden)
261
+ ? parsed.hidden.filter((one) => typeof one === 'string')
262
+ : []
263
+ // v1 recorded rows from every menu on the desktop — including the system
264
+ // "open with" list — so its `seen` is thrown away, keeping the switches.
265
+ const seen = parsed.version === MENU_STORE_VERSION && Array.isArray(parsed.seen)
266
+ ? parsed.seen.filter((one) => one && typeof one.key === 'string')
267
+ : []
268
+ menuState = { hidden, seen }
269
+ if (parsed.version !== MENU_STORE_VERSION) writeMenuState()
270
+ } catch {
271
+ // Private mode: the switches still apply for this page.
272
+ }
273
+ }
274
+
275
+ function writeMenuState() {
276
+ try {
277
+ localStorage.setItem(
278
+ MENU_STORE_KEY,
279
+ JSON.stringify({ version: MENU_STORE_VERSION, hidden: menuState.hidden, seen: menuState.seen }),
280
+ )
281
+ } catch {
282
+ // Same.
283
+ }
284
+ }
285
+
286
+ /** True when a stored row id is really one of the presets (a legacy record). */
287
+ function isPresetMenuKey(key) {
288
+ return MENU_PRESETS.some((one) => one.rule.test(key))
289
+ }
290
+
291
+ /** A row's identity: a preset name when one matches, else its normalized text. */
292
+ function menuRowKey(item) {
293
+ const text = (item.textContent || '').replace(/\s+/g, ' ').trim()
294
+ for (const preset of MENU_PRESETS) {
295
+ if (preset.rule.test(text)) return preset.key
296
+ }
297
+ return text
298
+ }
299
+
300
+ /** True when this row is one of the shipped Session actions. */
301
+ function isSessionMenuItem(item) {
302
+ const text = (item.textContent || '').replace(/\s+/g, ' ').trim()
303
+ return MENU_PRESETS.some((one) => one.rule.test(text))
304
+ }
305
+
306
+ function menuSweep() {
307
+ // `[role="menuitem"]` is used by every menu on the desktop — the system
308
+ // "open with" list included. A menu only counts when it carries a shipped
309
+ // Session action, so unrelated lists are never read and never recorded.
310
+ const groups = new Map()
311
+ for (const item of document.querySelectorAll('[role="menuitem"]')) {
312
+ const root = item.closest('[role="menu"]') || item.parentElement
313
+ if (root === null) continue
314
+ const list = groups.get(root)
315
+ if (list === undefined) groups.set(root, [item])
316
+ else list.push(item)
317
+ }
318
+ let added = false
319
+ for (const items of groups.values()) {
320
+ if (!items.some((item) => isSessionMenuItem(item))) continue
321
+ for (const item of items) {
322
+ // A marked node was already handled in this batch. The next menu open
323
+ // mounts a fresh set, so "unhide after unticking" needs no bookkeeping.
324
+ if (item.hasAttribute(MENU_HIDDEN_ATTR)) continue
325
+ const key = menuRowKey(item)
326
+ if (!key) continue
327
+ // Only a menu opened from a Session row is recorded: that is what keeps
328
+ // unrelated lists (the system "open with" menu) out of the settings page.
329
+ if (menuFromSessionRow && !menuState.seen.some((one) => one.key === key)) {
330
+ menuState.seen.push({ key, accel: item.getAttribute('aria-keyshortcuts') || '' })
331
+ added = true
332
+ }
333
+ if (!menuState.hidden.includes(key)) continue
334
+ item.setAttribute(MENU_HIDDEN_ATTR, '')
335
+ item.style.display = 'none'
336
+ }
337
+ }
338
+ if (added) {
339
+ writeMenuState()
340
+ notify()
341
+ }
342
+ }
343
+
344
+ let menuObserver = null
345
+ let menuObserverTimer = 0
346
+ let menuSweepQueued = false
347
+
348
+ /** Coalesce a burst of mutations into one sweep, before the next paint. */
349
+ function menuQueueSweep() {
350
+ if (menuSweepQueued) return
351
+ menuSweepQueued = true
352
+ const run = () => {
353
+ menuSweepQueued = false
354
+ menuSweep()
355
+ }
356
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
357
+ else setTimeout(run, 0)
358
+ }
359
+
360
+ /**
361
+ * Watch for the menu's first insertion instead of waiting for it. The observer
362
+ * runs only inside a short window after a click, and the sweep itself is queued
363
+ * on the next frame — which lands before that frame paints, so the rows never
364
+ * appear unhidden first. A standing body-wide observer is what would be
365
+ * expensive; a 400ms window is not.
366
+ */
367
+ function menuWatchForMount() {
368
+ if (typeof MutationObserver !== 'function') return
369
+ if (menuObserver === null) {
370
+ menuObserver = new MutationObserver(menuQueueSweep)
371
+ menuObserver.observe(document.documentElement, { childList: true, subtree: true })
372
+ }
373
+ clearTimeout(menuObserverTimer)
374
+ menuObserverTimer = setTimeout(() => {
375
+ menuObserver.disconnect()
376
+ menuObserver = null
377
+ }, 400)
378
+ }
379
+
380
+ /** Set while the click that opens a menu came from a Session row. */
381
+ let menuFromSessionRow = false
382
+
383
+ /** Menus mount a frame or two after the click that opens them. */
384
+ function menuScheduleSweep(fromSessionRow) {
385
+ if (fromSessionRow) menuFromSessionRow = true
386
+ menuSweep()
387
+ menuWatchForMount()
388
+ for (const delay of [40, 120, 260]) setTimeout(menuSweep, delay)
389
+ setTimeout(() => {
390
+ menuFromSessionRow = false
391
+ }, 600)
392
+ }
393
+
394
+ /** Drop the watch when the plugin goes away. */
395
+ function menuStopWatch() {
396
+ clearTimeout(menuObserverTimer)
397
+ if (menuObserver !== null) {
398
+ menuObserver.disconnect()
399
+ menuObserver = null
400
+ }
401
+ }
402
+
403
+ function setMenuRowHidden(key, hide) {
404
+ const next = menuState.hidden.filter((one) => one !== key)
405
+ if (hide) next.push(key)
406
+ menuState = { ...menuState, hidden: next }
407
+ writeMenuState()
408
+ syncStyle()
409
+ notify()
410
+ menuSweep()
411
+ }
412
+
413
+ function setValue(key, value) {
414
+ if (values[key] === value) return
415
+ values = { ...values, [key]: value }
416
+ writeStore()
417
+ syncStyle()
418
+ for (const listener of [...listeners]) {
419
+ try {
420
+ listener()
421
+ } catch {
422
+ // One broken subscriber must not stop the rest.
423
+ }
424
+ }
425
+ }
426
+
427
+ function subscribe(listener) {
428
+ listeners.add(listener)
429
+ return () => {
430
+ listeners.delete(listener)
431
+ }
432
+ }
433
+
434
+ function hiddenRules() {
435
+ const rules = []
436
+ if (values.brand) {
437
+ // The logo row is a flex child of the column, so dropping it closes the
438
+ // gap. It holds the collapse toggle on Windows and Linux, though, and that
439
+ // toggle must survive: hide the whole row only where it has no toggle, and
440
+ // otherwise hide just the brand inside it. `data-sidebar-toggle` is the
441
+ // sidebar's own attribute, stabler than any CSS-module class name.
442
+ rules.push(
443
+ '[class*="_logoRow"]:not(:has([data-sidebar-toggle])){display:none!important}',
444
+ '[class*="_logoRow"]:has([data-sidebar-toggle]) [class*="_brand"]{display:none!important}',
445
+ // Where the row does hold the toggle, drop its height instead of the
446
+ // row: zero height, no padding, and the button keeps the same place,
447
+ // drawn on top of whatever now starts where the row used to be. Pure
448
+ // CSS — no probe, no timer, no forced layout.
449
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_logoRow"]:has([data-sidebar-toggle])'
450
+ + '{height:0!important;min-height:0!important;padding:0!important;overflow:visible!important'
451
+ + ';align-items:flex-start!important;justify-content:flex-end!important}',
452
+ // Web only: a shell that draws a Windows title bar already pins this
453
+ // button up there (`[data-windows-titlebar] .toggle{position:fixed;
454
+ // left:12px}`), and this selector is more specific, so it has to stay
455
+ // out of that layout entirely. In the browser there is no title bar,
456
+ // and the button belongs in the gutter just right of the sidebar.
457
+ 'html:not([data-windows-titlebar]) [class*="_root"]:not([class*="_collapsed"])'
458
+ + ' [class*="_logoRow"]:has([data-sidebar-toggle]) [class*="_toggle"]'
459
+ + '{position:fixed;top:6px;left:calc(var(--dsh-sidebar-width,280px) + 8px);z-index:30;border-radius:8px}',
460
+ // The collapsed rail keeps a normal row, or there is no way back out.
461
+ '[class*="_root"][class*="_collapsed"] [class*="_logoRow"]'
462
+ + '{height:28px!important;min-height:0!important;padding-block:0!important;align-items:center!important}',
463
+ // Newer builds put the collapse toggle in a 52px `topStrip` above the
464
+ // logo row, so hiding the row alone leaves that strip showing as a band
465
+ // of empty space. Drop it too: on Windows the toggle is fixed inside the
466
+ // title bar anyway, and the browser build re-parks it in the gutter.
467
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_topStrip"]'
468
+ + '{height:0!important;min-height:0!important;margin:0!important;padding:0!important'
469
+ + ';overflow:visible!important;border:0!important}',
470
+ 'html:not([data-windows-titlebar]) [class*="_root"]:not([class*="_collapsed"])'
471
+ + ' [class*="_topStrip"] [class*="_toggle"]'
472
+ + '{position:fixed;top:6px;left:calc(var(--dsh-sidebar-width,280px) + 8px);z-index:30;border-radius:8px}',
473
+ )
474
+ }
475
+ // Tighten the 18px that the sidebar itself adds above the panel list:
476
+ // root padding (6), the scroll region's top margin (4) and the first grid
477
+ // row of the scroll area (8). The 40px above them belongs to the native
478
+ // window menu bar, so it is left alone.
479
+ if (values.topGap) {
480
+ rules.push(
481
+ '[class*="_root"]{padding-top:0!important}',
482
+ '[class*="_scrollRegion"]{margin-top:0!important}',
483
+ '[class*="_scrollArea"]{grid-template-rows:0 max-content 2px max-content!important}',
484
+ )
485
+ }
486
+ if (values.newSession) {
487
+ rules.push('button[class*="_newSession"]{display:none!important}')
488
+ }
489
+ if (values.panelLayout === 'two') {
490
+ // Fixed two-column grid: columns share one width, so rows line up
491
+ // exactly, and a longer label is truncated by the shipped ellipsis.
492
+ // The `:not([class*="_collapsed"])` guard is load-bearing here too — the
493
+ // shipped stylesheet hides the whole panel list in the collapsed rail
494
+ // (`.collapsed .panelList{display:none}`), and an unscoped `!important`
495
+ // grid would resurrect it there.
496
+ rules.push(
497
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
498
+ + '{display:grid!important;grid-template-columns:1fr 1fr;gap:2px}',
499
+ )
500
+ }
501
+ if (values.panelLayout === 'three') {
502
+ // Three fixed columns: same idea as the two-column grid, one more lane.
503
+ // Long labels keep the shipped ellipsis, and rows still line up.
504
+ rules.push(
505
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
506
+ + '{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px}',
507
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelEntry"]'
508
+ + '{width:auto!important;min-width:0!important;max-width:100%}',
509
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelRow"]'
510
+ + '{padding-left:6px!important;padding-right:6px!important}',
511
+ )
512
+ }
513
+ if (values.panelLayout === 'icon') {
514
+ // Icons only: the label is dropped, rows shrink to their glyph, and the
515
+ // list packs them side by side. The unpin control is hidden too, or it
516
+ // would show up as a second glyph per entry.
517
+ rules.push(
518
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
519
+ + '{flex-direction:row!important;flex-wrap:wrap!important;gap:4px!important}',
520
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelEntry"]'
521
+ + '{flex:0 0 auto!important;width:auto!important;min-width:0!important;max-width:100%}',
522
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelTitle"]{display:none!important}',
523
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelRow"]'
524
+ + '{width:auto!important;min-width:0!important;padding-left:8px!important;'
525
+ + 'padding-right:8px!important;justify-content:center!important;gap:0!important}',
526
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelUnpin"]{display:none!important}',
527
+ )
528
+ }
529
+ if (values.panelLayout === 'wrap') {
530
+ // Content-sized chips that wrap greedily, not fixed columns: two short
531
+ // names (插件 / 鱼塘) pack three or four to a line while a long one
532
+ // (自动化任务) takes what it needs, and nothing is ever truncated. Fixed
533
+ // `grid-template-columns` cannot do this — every column shares one width.
534
+ rules.push(
535
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
536
+ + '{flex-direction:row!important;flex-wrap:wrap!important;'
537
+ + 'column-gap:6px!important;row-gap:2px!important}',
538
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
539
+ + ' > [class*="_panelRow"]{flex:0 0 auto!important;width:auto!important;'
540
+ + 'max-width:100%}',
541
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
542
+ + ' > button:nth-of-type(n+3){order:1}',
543
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
544
+ + ' > [class*="_moreAnchor"]{order:0}',
545
+ )
546
+ }
547
+ if (values.panelOrder.length > 0) {
548
+ // The user's arrangement becomes the real order, and the More launcher
549
+ // keeps trailing it. `!important` beats the wrap-mode rule above.
550
+ const ordered = orderedPanelEntries()
551
+ ordered.forEach((entry, index) => {
552
+ rules.push(`[class*="_panelEntry"]:has([${PANEL_ATTR}="${entry.id}"]){order:${index + 1}!important}`)
553
+ })
554
+ rules.push(
555
+ '[class*="_root"]:not([class*="_collapsed"]) [class*="_panelList"]'
556
+ + ` > [class*="_moreAnchor"]{order:${ordered.length + 1}!important}`,
557
+ )
558
+ }
559
+ if (values.sidebarMore) {
560
+ rules.push(
561
+ '[class*="_panelList"] [class*="_moreAnchor"]{display:none!important}',
562
+ '[class*="_panelList"] button[aria-haspopup="menu"]{display:none!important}',
563
+ )
564
+ }
565
+ if (values.rightMore) {
566
+ rules.push(
567
+ '[data-sidebar-right-guide] [class*="_moreAnchor"]{display:none!important}',
568
+ '[data-sidebar-right-guide] button[aria-haspopup="menu"]{display:none!important}',
569
+ )
570
+ }
571
+ if (values.headerMore) {
572
+ rules.push(
573
+ '[class*="_viewsMoreAnchor"]{display:none!important}',
574
+ 'button[class*="_viewsMore"]{display:none!important}',
575
+ )
576
+ }
577
+ if (values.collapsedAll === true) {
578
+ // The bulk layer only: every panel row plus the list's More launcher. The
579
+ // individual switches and the checkbox list stay untouched.
580
+ rules.push(
581
+ '[class*="_panelList"] [class*="_panelEntry"]{display:none!important}',
582
+ '[class*="_panelList"] > button[aria-label]{display:none!important}',
583
+ '[class*="_panelList"] [class*="_moreAnchor"]{display:none!important}',
584
+ '[class*="_panelList"] button[aria-haspopup="menu"]{display:none!important}',
585
+ )
586
+ }
587
+ for (const id of values.hiddenPanels) {
588
+ const entry = panelEntries.find((one) => one.id === id)
589
+ if (entry === undefined) continue
590
+ // Primary: the id stamped on the row, which keeps two same-named panels
591
+ // apart. Fallback: the name, used only before the first stamping pass and
592
+ // only when no other panel carries it.
593
+ // Newer builds wrap every row in a `_panelEntry` box; hiding only the
594
+ // button leaves that box occupying its grid cell, which is the empty slot
595
+ // seen when a two-column list loses one panel. Hide the box itself.
596
+ rules.push(
597
+ '[class*="_panelEntry"]:has(button[' + PANEL_ATTR + '="' + cssAttrValue(id) + '"])'
598
+ + '{display:none!important}',
599
+ 'button[' + PANEL_ATTR + '="' + cssAttrValue(id) + '"]{display:none!important}',
600
+ )
601
+ const shared = panelEntries.some((one) => one.id !== id && one.label === entry.label)
602
+ if (!shared) {
603
+ rules.push(
604
+ '[class*="_panelEntry"]:has(button[aria-label="' + cssAttrValue(entry.label) + '"])'
605
+ + '{display:none!important}',
606
+ )
607
+ }
608
+ }
609
+ for (const one of values.picked) {
610
+ // Entries written before this flag existed were already hidden; keep them.
611
+ if (one.enabled === false) continue
612
+ rules.push(one.selector + '{display:none!important}')
613
+ }
614
+ return rules
615
+ }
616
+
617
+ /** Styles for the plugin-page card; static, kept beside the hide rules. */
618
+ const FORM_CSS = [
619
+ '.dhns-form{display:flex;flex-direction:column;gap:22px;padding:12px 0}',
620
+ '.dhns-section{display:flex;flex-direction:column;gap:8px}',
621
+ '.dhns-sectionHead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}',
622
+ '.dhns-sectionTitle{margin:0;font-size:13px;font-weight:600;',
623
+ 'letter-spacing:.02em;color:var(--dsw-alias-label-primary)}',
624
+ '.dhns-sectionHint{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary)}',
625
+ '.dhns-rows{display:flex;flex-direction:column;gap:8px}',
626
+ '.dhns-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}',
627
+ '.dhns-grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}',
628
+ '.dhns-grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}',
629
+ '.dhns-grid .dhns-row{padding:6px 8px;align-items:center;border-radius:6px}',
630
+ '.dhns-grid .dhns-title{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
631
+ '.dhns-grid .dhns-hint{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
632
+ '.dhns-grid .dhns-text{gap:0}',
633
+ '.dhns-footer-btn{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;',
634
+ 'padding:7px 8px;margin:0 0 2px;border:0;border-radius:var(--dsw-radius-md,8px);',
635
+ 'background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}',
636
+ '.dhns-footer-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
637
+ '.dhns-footer-btn[data-rail=true]{justify-content:center;width:36px;height:36px;padding:0;margin:0 auto 2px}',
638
+ '.dhns-footer-icon{flex:none;width:14px;text-align:center;opacity:.75}',
639
+ '.dhns-footer-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
640
+ '.dhns-row{display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:8px 10px;',
641
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.18))}',
642
+ '.dhns-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
643
+ '.dhns-row input{margin:2px 0 0;flex:none}',
644
+ '.dhns-grip{flex:none;cursor:grab;user-select:none;line-height:1;font-size:13px;',
645
+ 'color:var(--dsw-alias-label-tertiary,#9aa0a6);padding:1px 0}',
646
+ '.dhns-grip:active{cursor:grabbing}',
647
+ '.dhns-rowDragging{opacity:.45}',
648
+ '.dhns-choice{margin:0;padding:0;border:0;display:flex;flex-direction:column;gap:8px}',
649
+ '.dhns-choiceInline{flex-direction:row;flex-wrap:wrap;gap:6px}',
650
+ '.dhns-choiceInline .dhns-row{flex:1 1 0;min-width:0;padding:6px 10px}',
651
+ '.dhns-choiceInline .dhns-title{white-space:nowrap}',
652
+ '.dhns-legend{padding:0;font-size:13px;font-weight:500;',
653
+ 'color:var(--dsw-alias-label-primary);margin-bottom:2px}',
654
+ '.dhns-option[data-selected=true]{border-color:var(--dsw-alias-state-business-primary,rgba(44,123,229,.55));',
655
+ 'background:var(--dsw-alias-interactive-bg-hover)}',
656
+ '.dhns-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}',
657
+ '.dhns-title{font-size:13px;color:var(--dsw-alias-label-primary)}',
658
+ '.dhns-hint{font-size:11px;line-height:1.45;color:var(--dsw-alias-label-tertiary)}',
659
+ '.dhns-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}',
660
+ '.dhns-picked{display:flex;flex-direction:column;gap:6px;margin-top:4px}',
661
+ '.dhns-pickedRow{display:flex;align-items:center;gap:10px;padding:6px 10px;',
662
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.18))}',
663
+ '.dhns-pickedRow .dhns-hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
664
+ '.dhns-sel{font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;',
665
+ 'color:var(--dsw-alias-label-secondary);max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
666
+ '.dhns-btn{font:inherit;font-size:12px;line-height:18px;padding:5px 12px;cursor:pointer;',
667
+ 'border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));',
668
+ 'background:transparent;color:var(--dsw-alias-label-primary)}',
669
+ '.dhns-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
670
+ '.dhns-btn[data-tone=primary]{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-primary,#fff);',
671
+ 'border-color:var(--dsw-alias-label-primary)}',
672
+ '.dhns-footer-btn{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;',
673
+ 'padding:7px 8px;margin:0 0 2px;border:0;border-radius:var(--dsw-radius-md,8px);',
674
+ 'background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}',
675
+ '.dhns-footer-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
676
+ '.dhns-footer-btn[data-rail=true]{justify-content:center;width:36px;height:36px;padding:0;margin:0 auto 2px}',
677
+ '.dhns-footer-icon{flex:none;width:14px;text-align:center;opacity:.75}',
678
+ '.dhns-footer-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
679
+ '.dhns-subhead{font-size:12px;font-weight:500;letter-spacing:.02em;',
680
+ 'color:var(--dsw-alias-label-secondary);margin-top:4px}',
681
+ '.dhns-subheadRow{display:flex;align-items:center;justify-content:space-between;',
682
+ 'gap:8px;margin-top:4px}',
683
+ '.dhns-subheadRow .dhns-subhead{margin-top:0}',
684
+ '.dhns-subheadBtn{font:inherit;font-size:11px;line-height:16px;padding:1px 8px;cursor:pointer;',
685
+ 'border-radius:6px;border:1px solid var(--dsw-alias-border-secondary,rgba(128,128,128,.3));',
686
+ 'background:transparent;color:var(--dsw-alias-label-secondary)}',
687
+ '.dhns-subheadBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
688
+ '.dhns-subheadBtn:disabled{opacity:.4;cursor:default}',
689
+ '.dhns-page{padding:24px 28px;max-width:820px}',
690
+ '.dhns-pageTitle{margin:0 0 16px;font-size:16px;font-weight:600}',
691
+ ].join('')
692
+
693
+ function syncStyle() {
694
+ if (typeof document === 'undefined') return null
695
+ let tag = document.querySelector('style[' + STYLE_ATTR + ']')
696
+ if (tag === null) {
697
+ tag = document.createElement('style')
698
+ tag.setAttribute(STYLE_ATTR, '1')
699
+ document.head.appendChild(tag)
700
+ }
701
+ const css = '/* dsh-sidebar-declutter */\n'
702
+ + hiddenRules().concat(menuCssRules()).join('\n')
703
+ + '\n' + FORM_CSS
704
+ // The tag outlives the module instance: a newer build re-executing in the
705
+ // same document must rewrite it rather than keep the old stylesheet.
706
+ if (tag.textContent !== css) tag.textContent = css
707
+ return tag
708
+ }
709
+
710
+ /** Live view of what is registered into the sidebar's panel list. */
711
+ let panelEntries = []
712
+
713
+ /** Escape a value for a double-quoted CSS attribute selector. */
714
+ function cssAttrValue(value) {
715
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
716
+ }
717
+
718
+ // --- collapse control -------------------------------------------------------
719
+ // `sidebar.panellist` rows are navigation by contract, so an action has no
720
+ // seat there. `sidebar.footer.action` is an official list slot the sidebar
721
+ // renders itself — no DOM injection, and a re-render cannot remove it.
722
+
723
+ function toggleCollapse() {
724
+ values = { ...values, collapsedAll: values.collapsedAll !== true }
725
+ writeStore()
726
+ syncStyle()
727
+ notify()
728
+ }
729
+
730
+ function collapseLabel() {
731
+ return values.collapsedAll === true ? '展开面板列表' : '收起面板列表'
732
+ }
733
+
734
+ /**
735
+ * The footer action: an official list slot the sidebar renders itself, so it
736
+ * cannot be removed by a re-render. `wide` collapses it to an icon in the rail.
737
+ */
738
+ function CollapseAction(props) {
739
+ const [, force] = React.useReducer((count) => count + 1, 0)
740
+ React.useEffect(() => subscribe(force), [])
741
+ const wide = props?.wide !== false
742
+ const label = collapseLabel()
743
+ return h(
744
+ 'button',
745
+ {
746
+ type: 'button',
747
+ className: 'dhns-footer-btn',
748
+ 'data-rail': wide ? undefined : 'true',
749
+ 'aria-label': label,
750
+ 'aria-pressed': values.collapsedAll === true ? 'true' : 'false',
751
+ title: label,
752
+ onClick: () => toggleCollapse(),
753
+ },
754
+ h('span', { className: 'dhns-footer-icon', 'aria-hidden': 'true' }, values.collapsedAll ? '▸' : '▾'),
755
+ wide ? h('span', { className: 'dhns-footer-label' }, label) : null,
756
+ )
757
+ }
758
+
759
+ // --- per-row id stamping -----------------------------------------------------
760
+ // The list exposes no id in the DOM, only the accessible name, so two panels
761
+ // sharing a name would hide together. Each rendered row is therefore stamped
762
+ // with the registration id it belongs to, and the hide rule names the id.
763
+ // Stamping only sets attributes; it never inserts or moves a node.
764
+
765
+ const PANEL_ATTR = 'data-dsh-panel'
766
+
767
+ /** The panel list inside the sidebar column, located from its own anchor. */
768
+ function findPanelNav() {
769
+ if (typeof document === 'undefined') return null
770
+ const toggle = document.querySelector('[data-sidebar-toggle]')
771
+ if (toggle === null) return null
772
+ let node = toggle.parentElement
773
+ while (node !== null && node !== document.body) {
774
+ const nav = node.querySelector('[class*="_panelList"]')
775
+ if (nav !== null) {
776
+ const rect = nav.getBoundingClientRect()
777
+ // A sidebar column is a narrow strip; anything wider is another
778
+ // component whose CSS-module local name happens to match.
779
+ if (rect.width > 0 && rect.width < 520) return nav
780
+ }
781
+ node = node.parentElement
782
+ }
783
+ return null
784
+ }
785
+
786
+ /**
787
+ * Pair each rendered row with a registered entry: by name first, then by
788
+ * render order among the rest. A failed pass hides nothing, which is the safe
789
+ * direction — every panel stays visible and can be re-ticked.
790
+ */
791
+ /**
792
+ * Registered panels in the order the user dragged them into; anything they
793
+ * have not arranged yet keeps its registration order behind them.
794
+ */
795
+ function orderedPanelEntries() {
796
+ if (values.panelOrder.length === 0) return [...panelEntries]
797
+ const rank = new Map(values.panelOrder.map((id, index) => [id, index]))
798
+ const base = new Map(panelEntries.map((entry, index) => [entry.id, index]))
799
+ return [...panelEntries].sort((a, b) => {
800
+ const ra = rank.has(a.id) ? rank.get(a.id) : Number.MAX_SAFE_INTEGER
801
+ const rb = rank.has(b.id) ? rank.get(b.id) : Number.MAX_SAFE_INTEGER
802
+ if (ra !== rb) return ra - rb
803
+ return (base.get(a.id) ?? 0) - (base.get(b.id) ?? 0)
804
+ })
805
+ }
806
+
807
+ function stampPanelRows() {
808
+ try {
809
+ const nav = findPanelNav()
810
+ if (nav === null) return
811
+ const rows = Array.from(nav.querySelectorAll('button[aria-label]'))
812
+ if (rows.length === 0) return
813
+ const claimed = new Set()
814
+ const take = (label) => {
815
+ const hit = panelEntries.find((entry) => entry.label === label && !claimed.has(entry.id))
816
+ if (hit === undefined) return null
817
+ claimed.add(hit.id)
818
+ return hit.id
819
+ }
820
+ for (const row of rows) {
821
+ const id = take(row.getAttribute('aria-label') ?? '')
822
+ if (id === null) {
823
+ row.removeAttribute(PANEL_ATTR)
824
+ continue
825
+ }
826
+ if (row.getAttribute(PANEL_ATTR) !== id) row.setAttribute(PANEL_ATTR, id)
827
+ }
828
+ } catch (error) {
829
+ console.warn('[dsh-sidebar-declutter] 面板打标失败:', error)
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Re-stamp whenever the sidebar re-renders its list. The observer watches the
835
+ * list node only — watching the whole document would fire on every chat
836
+ * token. Until the sidebar exists, a short poll looks for it and then stops.
837
+ */
838
+ function watchPanelRows() {
839
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return null
840
+ let observer = null
841
+ let target = null
842
+ let scheduled = false
843
+ let poll = null
844
+
845
+ const stampSoon = () => {
846
+ if (scheduled) return
847
+ scheduled = true
848
+ setTimeout(() => {
849
+ scheduled = false
850
+ stampPanelRows()
851
+ }, 16)
852
+ }
853
+
854
+ const attach = () => {
855
+ const nav = findPanelNav()
856
+ if (nav === null) return false
857
+ if (nav === target) return true
858
+ observer?.disconnect()
859
+ target = nav
860
+ observer = new MutationObserver(stampSoon)
861
+ observer.observe(nav, { childList: true })
862
+ stampPanelRows()
863
+ return true
864
+ }
865
+
866
+ if (!attach()) {
867
+ poll = setInterval(() => {
868
+ if (attach() && poll !== null) {
869
+ clearInterval(poll)
870
+ poll = null
871
+ }
872
+ }, 500)
873
+ }
874
+
875
+ return () => {
876
+ observer?.disconnect()
877
+ if (poll !== null) clearInterval(poll)
878
+ }
879
+ }
880
+
881
+ // --- pick mode -------------------------------------------------------------
882
+ // User-started only: capture one click, turn the target into a selector and
883
+ // keep it in `picked`. Nothing here runs until the button is pressed.
884
+
885
+ let picker = null
886
+
887
+ /**
888
+ * Teardown handles that outlive one module instance. A hot swap re-runs this
889
+ * module with fresh state while the previous instance's document listeners
890
+ * are still attached; without this the old capture handler keeps swallowing
891
+ * every click in the page.
892
+ */
893
+ const TEARDOWN_KEY = '__dshSidebarDeclutterTeardown'
894
+
895
+ function registerTeardown(fn) {
896
+ if (typeof window === 'undefined') return
897
+ const previous = window[TEARDOWN_KEY]
898
+ if (typeof previous === 'function') {
899
+ try {
900
+ previous()
901
+ } catch {
902
+ // A broken previous instance must not stop this one.
903
+ }
904
+ }
905
+ window[TEARDOWN_KEY] = fn
906
+ }
907
+
908
+ /**
909
+ * Live view of what is registered into the sidebar's panel list, mirrored
910
+ * from the slot registry the sidebar itself renders from. Each entry is
911
+ * `{ id, order, label }`; the label is what the rendered button carries as
912
+ * its accessible name.
913
+ */
914
+ /** Escape a value for a double-quoted CSS attribute selector. */
915
+ /** `kB3qhq_panelRow` → `panelRow`; a class without a module hash stays whole. */
916
+ function localName(cls) {
917
+ const match = /^[A-Za-z0-9_-]+_([A-Za-z][A-Za-z0-9-]*)$/.exec(cls)
918
+ return match === null ? null : match[1]
919
+ }
920
+
921
+ function escapeIdent(value) {
922
+ return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
923
+ ? CSS.escape(value)
924
+ : value.replace(/[^A-Za-z0-9_-]/g, (ch) => '\\' + ch)
925
+ }
926
+
927
+ /**
928
+ * A selector that survives the next build. CSS-module class names carry a
929
+ * per-build hash (`kB3qhq_panelRow`), so they are matched by their local
930
+ * suffix; an id or a `data-` attribute is preferred when the element has one.
931
+ */
932
+ function buildSelector(el) {
933
+ if (el.id !== '') return '#' + escapeIdent(el.id)
934
+ const classes = Array.from(el.classList)
935
+ const locals = classes.map(localName).filter((one) => one !== null)
936
+ const plain = classes.filter((one) => localName(one) === null)
937
+ if (plain.length > 0 || locals.length > 0) {
938
+ let sel = el.tagName.toLowerCase()
939
+ if (plain.length > 0) sel += '.' + escapeIdent(plain[0])
940
+ if (locals.length > 0) sel += '[class*="_' + locals[0] + '"]'
941
+ return sel
942
+ }
943
+ for (const attr of Array.from(el.attributes)) {
944
+ if (attr.name.startsWith('data-') && attr.name !== 'data-dsh-sidebar-declutter') {
945
+ return '[' + attr.name + ']'
946
+ }
947
+ }
948
+ const path = []
949
+ let node = el
950
+ for (let depth = 0; depth < 5 && node && node !== document.body; depth += 1) {
951
+ let sel = node.tagName.toLowerCase()
952
+ const parent = node.parentElement
953
+ if (parent !== null) sel += ':nth-child(' + (Array.from(parent.children).indexOf(node) + 1) + ')'
954
+ path.unshift(sel)
955
+ node = node.parentElement
956
+ }
957
+ return path.join(' > ')
958
+ }
959
+
960
+ function labelFor(el) {
961
+ const named = el.getAttribute('aria-label') ?? el.getAttribute('title') ?? ''
962
+ const text = (named !== '' ? named : el.textContent ?? '').replace(/\s+/g, ' ').trim()
963
+ return text === '' ? el.tagName.toLowerCase() : text.slice(0, 24)
964
+ }
965
+
966
+ /** Guards that keep the way back open. */
967
+ function isProtected(el) {
968
+ if (typeof document === 'undefined') return true
969
+ if (el === document.body || el === document.documentElement) return true
970
+ if (el.closest('.dhns-form') !== null) return true
971
+ // The settings entry is the only door to the plugin page that holds the
972
+ // restore list; hiding it — directly or through an ancestor — locks the
973
+ // user out of the very screen that could undo it.
974
+ if (el.matches('[class*="_triggerRow"]') || el.querySelector('[class*="_triggerRow"]') !== null) return true
975
+ // A container, not a row. One stray click on a panel-sized box can blank a
976
+ // whole area — the plugin page itself was hidden this way once — so
977
+ // anything covering a large share of the viewport is refused.
978
+ const rect = el.getBoundingClientRect()
979
+ const viewport = window.innerWidth * window.innerHeight
980
+ if (viewport > 0 && rect.width * rect.height > viewport * 0.4) return true
981
+ return false
982
+ }
983
+
984
+ function addPicked(el) {
985
+ const selector = buildSelector(el)
986
+ if (selector.trim() === '') return null
987
+ if (values.picked.some((one) => one.selector === selector)) return { selector, duplicate: true }
988
+ // Added as a candidate: it hides only once its checkbox is ticked, so a
989
+ // stray click can never take something off the screen by itself.
990
+ const entry = { selector, label: labelFor(el), enabled: false }
991
+ values = { ...values, picked: [...values.picked, entry] }
992
+ writeStore()
993
+ syncStyle()
994
+ notify()
995
+ return { selector, duplicate: false }
996
+ }
997
+
998
+ function removePickedAt(index) {
999
+ const next = values.picked.filter((_, position) => position !== index)
1000
+ values = { ...values, picked: next }
1001
+ writeStore()
1002
+ syncStyle()
1003
+ notify()
1004
+ }
1005
+
1006
+ function stopPicker() {
1007
+ if (picker === null) return
1008
+ document.removeEventListener('click', picker.onClick, true)
1009
+ document.removeEventListener('mousemove', picker.onMove, true)
1010
+ document.removeEventListener('keydown', picker.onKeyDown, true)
1011
+ if (picker.outlined !== null) picker.outlined.style.outline = picker.savedOutline
1012
+ picker.bar.remove()
1013
+ picker = null
1014
+ notify()
1015
+ }
1016
+
1017
+ function startPicker() {
1018
+ if (typeof document === 'undefined' || picker !== null) return
1019
+ const bar = document.createElement('div')
1020
+ bar.setAttribute('data-dsh-picker', '1')
1021
+ bar.style.cssText = [
1022
+ 'position:fixed', 'left:50%', 'bottom:24px', 'transform:translateX(-50%)',
1023
+ 'z-index:2147483000', 'display:flex', 'align-items:center', 'gap:10px',
1024
+ 'padding:8px 12px', 'border-radius:12px', 'font:13px/1.4 system-ui,sans-serif',
1025
+ 'background:#1f2328', 'color:#fff', 'box-shadow:0 8px 24px rgba(0,0,0,.35)',
1026
+ ].join(';')
1027
+
1028
+ const text = document.createElement('span')
1029
+ text.setAttribute('data-role', 'text')
1030
+ const undo = document.createElement('button')
1031
+ undo.type = 'button'
1032
+ undo.textContent = '撤销'
1033
+ undo.style.cssText = 'font:inherit;padding:4px 10px;border-radius:8px;border:1px solid #ffffff44;background:transparent;color:#fff;cursor:pointer'
1034
+ const done = document.createElement('button')
1035
+ done.type = 'button'
1036
+ done.textContent = '完成'
1037
+ done.style.cssText = 'font:inherit;padding:4px 10px;border-radius:8px;border:1px solid #ffffff44;background:#fff;color:#1f2328;cursor:pointer'
1038
+ bar.append(text, undo, done)
1039
+ document.body.appendChild(bar)
1040
+
1041
+ const refreshBar = () => {
1042
+ text.textContent = '点选要隐藏的元素 · 已隐藏 ' + values.picked.length + ' 个'
1043
+ undo.disabled = values.picked.length === 0
1044
+ }
1045
+ refreshBar()
1046
+
1047
+ undo.addEventListener('click', () => {
1048
+ if (values.picked.length === 0) return
1049
+ removePickedAt(values.picked.length - 1)
1050
+ refreshBar()
1051
+ })
1052
+ done.addEventListener('click', () => stopPicker())
1053
+
1054
+ const state = { bar, onClick: null, onMove: null, outlined: null, savedOutline: '' }
1055
+
1056
+ state.onMove = (event) => {
1057
+ const el = event.target instanceof Element ? event.target : null
1058
+ if (el === null || el === state.outlined) return
1059
+ if (state.outlined !== null) state.outlined.style.outline = state.savedOutline
1060
+ state.outlined = null
1061
+ if (el === document.body || el === document.documentElement) return
1062
+ if (bar.contains(el)) return
1063
+ state.outlined = el
1064
+ state.savedOutline = el.style.outline
1065
+ el.style.outline = isProtected(el) ? '2px dashed #e5484d' : '2px solid #2c7be5'
1066
+ }
1067
+
1068
+ state.onClick = (event) => {
1069
+ const el = event.target instanceof Element ? event.target : null
1070
+ if (el === null || bar.contains(el)) return
1071
+ // Clicks on this plugin's own card must keep working — that is where
1072
+ // 「结束点选」and the restore rows live.
1073
+ if (el.closest('.dhns-form') !== null) return
1074
+ event.preventDefault()
1075
+ event.stopPropagation()
1076
+ if (isProtected(el)) {
1077
+ text.textContent = '这一项不能隐藏:它会挡住设置入口'
1078
+ setTimeout(refreshBar, 1800)
1079
+ return
1080
+ }
1081
+ const result = addPicked(el)
1082
+ text.textContent =
1083
+ result !== null && result.duplicate
1084
+ ? '这一项已经在列表里了'
1085
+ : '已加入列表:' + labelFor(el) + '(去设置里勾选才会隐藏)'
1086
+ setTimeout(refreshBar, 2000)
1087
+ if (state.outlined !== null) {
1088
+ state.outlined.style.outline = state.savedOutline
1089
+ state.outlined = null
1090
+ }
1091
+ }
1092
+
1093
+ state.onKeyDown = (event) => {
1094
+ if (event.key === 'Escape') {
1095
+ event.preventDefault()
1096
+ event.stopPropagation()
1097
+ stopPicker()
1098
+ }
1099
+ }
1100
+
1101
+ document.addEventListener('mousemove', state.onMove, true)
1102
+ document.addEventListener('click', state.onClick, true)
1103
+ document.addEventListener('keydown', state.onKeyDown, true)
1104
+ picker = state
1105
+ notify()
1106
+ }
1107
+
1108
+ function notify() {
1109
+ for (const listener of [...listeners]) {
1110
+ try {
1111
+ listener()
1112
+ } catch {
1113
+ // One broken subscriber must not stop the rest.
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * The plugin page card, registered at `plugins.row.config` for this bundle's
1120
+ * row. `summary` is the one-liner the row shows when collapsed; `page` is the
1121
+ * form the row's configure control opens.
1122
+ */
1123
+ /** The page #/panel/hide-rows renders: the same form as the plugin row card. */
1124
+ function SettingsPage() {
1125
+ return h(
1126
+ 'div',
1127
+ { className: 'dhns-page' },
1128
+ h('h2', { className: 'dhns-pageTitle' }, PANEL_ENTRY_LABEL),
1129
+ h(SettingsCard, { view: 'full' }),
1130
+ )
1131
+ }
1132
+
1133
+ function SettingsCard(props) {
1134
+ const view = props?.view
1135
+ const [, forceRender] = React.useReducer((count) => count + 1, 0)
1136
+ const [dragId, setDragId] = React.useState(null)
1137
+ React.useEffect(() => subscribe(forceRender), [])
1138
+
1139
+ if (view === 'summary') {
1140
+ // Only the hide section belongs in the one-liner; the layout switch is
1141
+ // not something the row can meaningfully report as "hidden".
1142
+ const on = FIELDS.filter((field) => field.kind === 'hide' && values[field.key] === true)
1143
+ .map((field) => field.title)
1144
+ return h('span', null, on.length === 0 ? '未隐藏任何项' : '已隐藏:' + on.join('、'))
1145
+ }
1146
+
1147
+ const rowFor = (field) =>
1148
+ h(
1149
+ 'label',
1150
+ { key: field.key, className: 'dhns-row' },
1151
+ h('input', {
1152
+ type: 'checkbox',
1153
+ checked: values[field.key] === true,
1154
+ onChange: (event) => setValue(field.key, event.target.checked === true),
1155
+ }),
1156
+ h(
1157
+ 'span',
1158
+ { className: 'dhns-text' },
1159
+ h('span', { className: 'dhns-title' }, field.title),
1160
+ h('span', { className: 'dhns-hint' }, field.hint),
1161
+ ),
1162
+ )
1163
+
1164
+ /**
1165
+ * A mutually exclusive group. One shared `name` is what makes the browser
1166
+ * keep exactly one option selected, so the three layouts can never be
1167
+ * combined — the CSS rules are all derived from the single stored value.
1168
+ */
1169
+ const choiceFor = (field) =>
1170
+ h(
1171
+ 'fieldset',
1172
+ {
1173
+ key: field.key,
1174
+ className: field.inline === true ? 'dhns-choice dhns-choiceInline' : 'dhns-choice',
1175
+ 'aria-label': field.title ?? field.key,
1176
+ },
1177
+ field.title === undefined
1178
+ ? null
1179
+ : h('legend', { className: 'dhns-legend' }, field.title),
1180
+ field.options.map((option) =>
1181
+ h(
1182
+ 'label',
1183
+ {
1184
+ key: option.value,
1185
+ className: 'dhns-row dhns-option',
1186
+ 'data-selected': values[field.key] === option.value ? 'true' : undefined,
1187
+ },
1188
+ h('input', {
1189
+ type: 'radio',
1190
+ name: 'dhns-' + field.key,
1191
+ checked: values[field.key] === option.value,
1192
+ onChange: () => setValue(field.key, option.value),
1193
+ }),
1194
+ h(
1195
+ 'span',
1196
+ { className: 'dhns-text' },
1197
+ h('span', { className: 'dhns-title' }, option.title),
1198
+ h('span', { className: 'dhns-hint' }, option.hint),
1199
+ ),
1200
+ ),
1201
+ ),
1202
+ )
1203
+
1204
+ const renderField = (field) => (field.type === 'choice' ? choiceFor(field) : rowFor(field))
1205
+
1206
+ const togglePanel = (id, on) => {
1207
+ const next = on
1208
+ ? values.hiddenPanels.includes(id) ? values.hiddenPanels : [...values.hiddenPanels, id]
1209
+ : values.hiddenPanels.filter((one) => one !== id)
1210
+ values = { ...values, hiddenPanels: next }
1211
+ writeStore()
1212
+ syncStyle()
1213
+ notify()
1214
+ }
1215
+
1216
+ /** Drop `fromId` where `toId` sits, then persist the whole arrangement. */
1217
+ const movePanel = (fromId, toId) => {
1218
+ const ids = orderedPanelEntries().map((one) => one.id)
1219
+ const from = ids.indexOf(fromId)
1220
+ const to = ids.indexOf(toId)
1221
+ if (from < 0 || to < 0 || from === to) return
1222
+ const [moved] = ids.splice(from, 1)
1223
+ ids.splice(to, 0, moved)
1224
+ values = { ...values, panelOrder: ids }
1225
+ writeStore()
1226
+ syncStyle()
1227
+ notify()
1228
+ }
1229
+
1230
+ /** Drop the arrangement and fall back to the registration order. */
1231
+ const resetPanelOrder = () => {
1232
+ if (values.panelOrder.length === 0) return
1233
+ values = { ...values, panelOrder: [] }
1234
+ writeStore()
1235
+ syncStyle()
1236
+ notify()
1237
+ }
1238
+
1239
+ const panelBlock = () => {
1240
+ if (panelEntries.length === 0) {
1241
+ return h('p', { className: 'dhns-hint' }, '当前没有检测到已注册的面板。')
1242
+ }
1243
+ return h(
1244
+ 'div',
1245
+ null,
1246
+ h(
1247
+ 'div',
1248
+ { className: 'dhns-subheadRow', key: 'subhead' },
1249
+ h('span', { className: 'dhns-subhead' }, '面板'),
1250
+ h(
1251
+ 'button',
1252
+ {
1253
+ type: 'button',
1254
+ className: 'dhns-subheadBtn',
1255
+ disabled: values.panelOrder.length === 0,
1256
+ onClick: resetPanelOrder,
1257
+ },
1258
+ '还原默认',
1259
+ ),
1260
+ ),
1261
+ h(
1262
+ 'div',
1263
+ { className: 'dhns-grid', key: 'grid' },
1264
+ orderedPanelEntries().map((entry) =>
1265
+ h(
1266
+ 'label',
1267
+ {
1268
+ key: entry.id,
1269
+ className: dragId === entry.id ? 'dhns-row dhns-rowDragging' : 'dhns-row',
1270
+ onDragOver: (event) => {
1271
+ if (dragId === null) return
1272
+ event.preventDefault()
1273
+ event.dataTransfer.dropEffect = 'move'
1274
+ },
1275
+ onDrop: (event) => {
1276
+ if (dragId === null) return
1277
+ event.preventDefault()
1278
+ movePanel(dragId, entry.id)
1279
+ setDragId(null)
1280
+ },
1281
+ },
1282
+ h(
1283
+ 'span',
1284
+ {
1285
+ className: 'dhns-grip',
1286
+ title: '拖动排序',
1287
+ draggable: true,
1288
+ onDragStart: (event) => {
1289
+ setDragId(entry.id)
1290
+ event.dataTransfer.effectAllowed = 'move'
1291
+ event.dataTransfer.setData('text/plain', entry.id)
1292
+ },
1293
+ onDragEnd: () => setDragId(null),
1294
+ },
1295
+ '⠿',
1296
+ ),
1297
+ h('input', {
1298
+ type: 'checkbox',
1299
+ checked: values.hiddenPanels.includes(entry.id),
1300
+ onChange: (event) => togglePanel(entry.id, event.target.checked === true),
1301
+ }),
1302
+ h(
1303
+ 'span',
1304
+ { className: 'dhns-text' },
1305
+ h('span', { className: 'dhns-title' }, entry.label),
1306
+ h('span', { className: 'dhns-hint' }, entry.id),
1307
+ ),
1308
+ ),
1309
+ ),
1310
+ ),
1311
+ )
1312
+ }
1313
+
1314
+ const togglePicked = (index, on) => {
1315
+ const next = values.picked.map((one, position) =>
1316
+ position === index ? { ...one, enabled: on } : one,
1317
+ )
1318
+ values = { ...values, picked: next }
1319
+ writeStore()
1320
+ syncStyle()
1321
+ notify()
1322
+ }
1323
+
1324
+ const pickedBlock = () => {
1325
+ const running = picker !== null
1326
+ return [
1327
+ h(
1328
+ 'div',
1329
+ { className: 'dhns-actions', key: 'actions' },
1330
+ h(
1331
+ 'button',
1332
+ {
1333
+ type: 'button',
1334
+ className: 'dhns-btn',
1335
+ 'data-tone': running ? undefined : 'primary',
1336
+ onClick: () => (running ? stopPicker() : startPicker()),
1337
+ },
1338
+ running ? '结束点选' : '开始点选',
1339
+ ),
1340
+ values.picked.length === 0
1341
+ ? null
1342
+ : h(
1343
+ 'button',
1344
+ {
1345
+ type: 'button',
1346
+ className: 'dhns-btn',
1347
+ onClick: () => {
1348
+ values = { ...values, picked: [] }
1349
+ writeStore()
1350
+ syncStyle()
1351
+ notify()
1352
+ },
1353
+ },
1354
+ '清空列表',
1355
+ ),
1356
+ ),
1357
+ values.picked.length === 0
1358
+ ? h('p', { className: 'dhns-hint', key: 'empty' }, '还没有点选任何元素。')
1359
+ : h(
1360
+ 'div',
1361
+ { className: 'dhns-rows', key: 'list' },
1362
+ values.picked.map((one, index) =>
1363
+ h(
1364
+ 'label',
1365
+ { className: 'dhns-row', key: one.selector + ':' + index },
1366
+ h('input', {
1367
+ type: 'checkbox',
1368
+ checked: one.enabled !== false,
1369
+ onChange: (event) => togglePicked(index, event.target.checked === true),
1370
+ }),
1371
+ h(
1372
+ 'span',
1373
+ { className: 'dhns-text' },
1374
+ h('span', { className: 'dhns-title' }, one.label),
1375
+ h(
1376
+ 'span',
1377
+ { className: 'dhns-hint dhns-sel', title: one.selector },
1378
+ one.selector,
1379
+ ),
1380
+ ),
1381
+ h(
1382
+ 'button',
1383
+ {
1384
+ type: 'button',
1385
+ className: 'dhns-btn',
1386
+ onClick: (event) => {
1387
+ event.preventDefault()
1388
+ removePickedAt(index)
1389
+ },
1390
+ },
1391
+ '移除',
1392
+ ),
1393
+ ),
1394
+ ),
1395
+ ),
1396
+ ]
1397
+ }
1398
+
1399
+ const menuBlock = () => {
1400
+ const presetKeys = MENU_PRESETS.map((one) => one.key)
1401
+ const other = menuState.seen
1402
+ .filter((one) => !presetKeys.includes(one.key) && !isPresetMenuKey(one.key))
1403
+ .sort((a, b) => a.key.localeCompare(b.key, 'zh'))
1404
+ const rowOf = (one) =>
1405
+ h(
1406
+ 'label',
1407
+ { key: one.key, className: 'dhns-row' },
1408
+ h('input', {
1409
+ type: 'checkbox',
1410
+ checked: menuState.hidden.includes(one.key),
1411
+ onChange: (event) => setMenuRowHidden(one.key, event.target.checked === true),
1412
+ }),
1413
+ h(
1414
+ 'span',
1415
+ { className: 'dhns-text' },
1416
+ h('span', { className: 'dhns-title' }, one.key),
1417
+ one.accel === '' ? null : h('span', { className: 'dhns-hint' }, one.accel),
1418
+ ),
1419
+ )
1420
+ return h(
1421
+ 'div',
1422
+ null,
1423
+ h('div', { className: 'dhns-subhead', key: 'official' }, '官方'),
1424
+ h('div', { className: 'dhns-grid', key: 'official-grid' },
1425
+ MENU_PRESETS.map((one) => rowOf({ key: one.key, accel: one.accel }))),
1426
+ other.length === 0
1427
+ ? null
1428
+ : h('div', { className: 'dhns-subhead', key: 'other' }, '社区'),
1429
+ other.length === 0
1430
+ ? null
1431
+ : h('div', { className: 'dhns-grid', key: 'other-grid' }, other.map(rowOf)),
1432
+ )
1433
+ }
1434
+
1435
+ return h(
1436
+ 'div',
1437
+ { className: 'dhns-form' },
1438
+ SECTIONS.map((section) =>
1439
+ h(
1440
+ 'section',
1441
+ { key: section.title, className: 'dhns-section' },
1442
+ h(
1443
+ 'div',
1444
+ { className: 'dhns-sectionHead' },
1445
+ h('h4', { className: 'dhns-sectionTitle' }, section.title),
1446
+ section.hint === undefined
1447
+ ? null
1448
+ : h('span', { className: 'dhns-sectionHint' }, section.hint),
1449
+ ),
1450
+ section.fields.length === 0
1451
+ ? null
1452
+ : h(
1453
+ 'div',
1454
+ {
1455
+ className: section.columns === 2
1456
+ ? 'dhns-grid dhns-grid-2'
1457
+ : section.columns === 3
1458
+ ? 'dhns-grid'
1459
+ : section.columns === 4
1460
+ ? 'dhns-grid dhns-grid-4'
1461
+ : 'dhns-rows',
1462
+ },
1463
+ section.fields.map(renderField),
1464
+ ),
1465
+ section.kind === 'picked' ? pickedBlock() : null,
1466
+ section.kind === 'menu' ? menuBlock() : null,
1467
+ section.panels === true ? panelBlock() : null,
1468
+ ),
1469
+ ),
1470
+ )
1471
+ }
1472
+
1473
+ return {
1474
+ inject: ['slots'],
1475
+ apply(ctx) {
1476
+ values = readStore()
1477
+ readMenuState()
1478
+
1479
+ // Mirror the panel registry the sidebar renders from: it is the only
1480
+ // place that knows every left-column button and its display name, so the
1481
+ // checkboxes follow newly installed plugins without any code change.
1482
+ const syncPanels = () => {
1483
+ try {
1484
+ panelEntries = ctx.slots
1485
+ .entriesOfSlot('sidebar.panellist')
1486
+ .map(({ options }) => ({
1487
+ id: options.id,
1488
+ order: options.order ?? 0,
1489
+ label:
1490
+ typeof options.label === 'function'
1491
+ ? options.label()
1492
+ : options.label ?? options.id,
1493
+ }))
1494
+ .sort((a, b) => a.order - b.order)
1495
+ } catch {
1496
+ panelEntries = []
1497
+ }
1498
+ stampPanelRows()
1499
+ syncStyle()
1500
+ notify()
1501
+ }
1502
+
1503
+ ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'dsh-sidebar-declutter: panel registry')
1504
+ const locale = (() => {
1505
+ try {
1506
+ return ctx.get('locale')
1507
+ } catch {
1508
+ return null
1509
+ }
1510
+ })()
1511
+ if (locale !== null && typeof locale?.subscribe === 'function') {
1512
+ ctx.effect(() => locale.subscribe(syncPanels), 'dsh-sidebar-declutter: panel labels')
1513
+ }
1514
+ syncPanels()
1515
+ ctx.effect(() => watchPanelRows(), 'dsh-sidebar-declutter: panel rows')
1516
+
1517
+ ctx.effect(() => {
1518
+ registerTeardown(() => {
1519
+ if (picker !== null) stopPicker()
1520
+ })
1521
+ return () => {
1522
+ if (picker !== null) stopPicker()
1523
+ }
1524
+ }, 'dsh-sidebar-declutter: pick teardown')
1525
+
1526
+ ctx.effect(() => {
1527
+ const tag = syncStyle()
1528
+
1529
+ // Diagnose selector drift after an upgrade instead of failing silently:
1530
+ // the sidebar is on screen but nothing matches the button.
1531
+ let timer = null
1532
+ if (typeof document !== 'undefined' && values.newSession) {
1533
+ timer = setTimeout(() => {
1534
+ timer = null
1535
+ if (document.querySelector('[class*="_panelList"]') === null) return
1536
+ if (document.querySelector('button[class*="_newSession"]') !== null) return
1537
+ console.warn(
1538
+ '[dsh-sidebar-declutter] 侧栏已渲染但没匹配到新会话按钮,选择器可能随版本变化。',
1539
+ )
1540
+ }, 2000)
1541
+ }
1542
+
1543
+ return () => {
1544
+ if (timer !== null) clearTimeout(timer)
1545
+ if (tag !== null) tag.remove()
1546
+ }
1547
+ }, 'dsh-sidebar-declutter: stylesheet')
1548
+
1549
+
1550
+ ctx.effect(() =>
1551
+ ctx.slots.inject('sidebar.footer.action', () =>
1552
+ ctx.slots.register(
1553
+ { name: 'sidebar.footer.action', id: 'sidebar-declutter-collapse', order: 10 },
1554
+ CollapseAction,
1555
+ ),
1556
+ ),
1557
+ )
1558
+
1559
+ // Same two-slot pattern the shipped panels use: the sidebar row only
1560
+ // carries the icon and label, and the main slot with the same key is the
1561
+ // page the shell routes #/panel/<id> to. Without the second registration
1562
+ // the click falls back to whatever panel was open before.
1563
+ if (ctx.slots && typeof ctx.slots.inject === 'function') {
1564
+ ctx.effect(() =>
1565
+ ctx.slots.inject('sidebar.panellist', () =>
1566
+ ctx.slots.register(
1567
+ {
1568
+ name: 'sidebar.panellist',
1569
+ id: PANEL_ENTRY_ID,
1570
+ order: 20,
1571
+ label: () => PANEL_ENTRY_LABEL,
1572
+ },
1573
+ PanelEntry,
1574
+ ),
1575
+ ),
1576
+ )
1577
+ ctx.effect(() =>
1578
+ ctx.slots.inject('main', () =>
1579
+ ctx.slots.register({ name: 'main', key: PANEL_ENTRY_ID }, SettingsPage),
1580
+ ),
1581
+ )
1582
+ }
1583
+
1584
+ if (ENABLE_ROW_CONFIG) {
1585
+ ctx.effect(() =>
1586
+ ctx.slots.inject('plugins.row.config', () =>
1587
+ ctx.slots.register({ name: 'plugins.row.config', key: ROW_CONFIG_KEY }, SettingsCard),
1588
+ ),
1589
+ )
1590
+ }
1591
+
1592
+ // Menu rows are plain DOM: sweep after every click that could have opened
1593
+ // one, never with a body-level observer (the chat stream mutates per token).
1594
+ ctx.effect(() => {
1595
+ const sweep = (event) => {
1596
+ const target = event && event.target
1597
+ const fromRow = !!(target && target.closest && target.closest('[class*="_sessionRow"]'))
1598
+ menuScheduleSweep(fromRow)
1599
+ }
1600
+ document.addEventListener('click', sweep, true)
1601
+ document.addEventListener('contextmenu', sweep, true)
1602
+ return () => {
1603
+ document.removeEventListener('click', sweep, true)
1604
+ document.removeEventListener('contextmenu', sweep, true)
1605
+ menuStopWatch()
1606
+ }
1607
+ }, 'dsh-sidebar-declutter: menu rows')
1608
+ },
1609
+ }
1610
+ },
1611
+ })