@rezti/dsh-rez-suite 0.1.4 → 0.1.5

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.
@@ -1,117 +1,89 @@
1
1
  /**
2
- * dsh-rez-suite panel styles. Scoped by plugin data attributes, colors ride
3
- * dsh --dsw-* tokens. The center-column takeover rules are attribute-scoped
4
- * and must stay in this stylesheet (imported by mount.tsx).
2
+ * Settings-native styles for the ReZ card/tab. Colors ride dsh --dsw-* tokens
3
+ * so the form matches 设置 → 插件. The stylesheet is inlined into client.js
4
+ * at wrap time dsh only loads the JS module, not lib/style.css.
5
5
  */
6
6
 
7
- [data-pane='conversation'] {
8
- position: relative;
7
+ .shell {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 12px;
11
+ min-width: 0;
9
12
  }
10
13
 
11
- [data-dsh-rez-view] {
12
- position: absolute;
13
- inset: 0;
14
- display: none;
15
- z-index: 60;
16
- background: var(--dsw-alias-bg-base);
14
+ .card {
15
+ list-style: none;
16
+ border: 1px solid var(--dsw-alias-border-l2);
17
+ border-radius: 12px;
18
+ background: var(--dsw-alias-bg-layer-3);
19
+ transition: border-color 0.16s, background 0.16s;
17
20
  }
18
21
 
19
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-dsh-rez-view] {
20
- display: block;
22
+ .card:hover {
23
+ border-color: var(--dsw-alias-label-dimmed);
21
24
  }
22
25
 
23
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-rez-view]),
24
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*='centerCol'] > :not([data-dsh-rez-view]) {
25
- display: none !important;
26
+ .cardOpen {
27
+ background: var(--dsw-alias-bg-layer-2);
28
+ border-color: var(--dsw-alias-label-dimmed);
26
29
  }
27
30
 
28
- .entry {
29
- display: flex;
30
- align-items: center;
31
- gap: 8px;
31
+ .header {
32
32
  width: 100%;
33
- height: 32px;
34
- padding: 0 12px;
35
- background: transparent;
36
- border: none;
37
- border-radius: 8px;
38
- color: var(--dsw-alias-label-secondary);
33
+ appearance: none;
34
+ border: 0;
35
+ background: none;
36
+ font: inherit;
37
+ color: inherit;
38
+ text-align: left;
39
39
  cursor: pointer;
40
- font-size: 13px;
41
- white-space: nowrap;
42
- }
43
-
44
- .entry:hover {
45
- background: var(--dsw-specific-sidebar-nav-item-hover);
46
- color: var(--dsw-alias-label-primary);
47
- }
48
-
49
- .entryIcon {
50
- display: inline-flex;
40
+ display: flex;
51
41
  align-items: center;
52
- justify-content: center;
53
- flex: none;
54
- }
55
-
56
- .entryLabel {
57
- overflow: hidden;
58
- text-overflow: ellipsis;
59
- }
60
-
61
- [data-dsh-frame][data-sidebar-collapsed] .entry {
62
- justify-content: center;
63
- padding: 0;
64
- width: 100%;
65
- }
66
-
67
- [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
68
- display: none;
42
+ gap: 12px;
43
+ padding: 14px 16px;
44
+ border-radius: 12px;
69
45
  }
70
46
 
71
- .view {
72
- overflow: hidden;
47
+ .header:focus-visible {
48
+ outline: 2px solid var(--dsw-alias-brand-primary);
49
+ outline-offset: -2px;
73
50
  }
74
51
 
75
- .panel {
52
+ .headText {
53
+ flex: 1;
54
+ min-width: 0;
76
55
  display: flex;
77
56
  flex-direction: column;
78
- height: 100%;
79
- min-width: 0;
80
- min-height: 0;
81
- padding: 14px 16px 16px;
82
- gap: 10px;
83
- background: var(--dsw-alias-bg-base);
57
+ gap: 4px;
58
+ }
59
+
60
+ .name {
61
+ font-size: 15px;
62
+ font-weight: 600;
63
+ line-height: 1.4;
84
64
  color: var(--dsw-alias-label-primary);
85
- font-family: var(--dsw-font-family);
86
65
  }
87
66
 
88
- .panelHeader {
89
- display: flex;
90
- align-items: center;
91
- gap: 10px;
92
- flex: none;
67
+ .description {
68
+ font-size: 13px;
69
+ line-height: 1.5;
70
+ color: var(--dsw-alias-label-tertiary);
93
71
  }
94
72
 
95
- .panelTitle {
96
- margin: 0;
97
- flex: 1;
98
- font-size: 16px;
99
- font-weight: 700;
73
+ .chevron {
74
+ flex: none;
75
+ color: var(--dsw-alias-label-tertiary);
76
+ transition: transform 0.16s;
100
77
  }
101
78
 
102
- .iconButton {
103
- border: none;
104
- background: transparent;
105
- color: var(--dsw-alias-label-secondary);
106
- cursor: pointer;
107
- font-size: 14px;
108
- padding: 4px 8px;
109
- border-radius: 6px;
79
+ .chevronOpen {
80
+ transform: rotate(180deg);
110
81
  }
111
82
 
112
- .iconButton:hover {
113
- background: var(--dsw-alias-interactive-bg-hover);
114
- color: var(--dsw-alias-label-primary);
83
+ .body {
84
+ border-top: 1px solid var(--dsw-alias-border-l2);
85
+ margin: 0 16px 12px;
86
+ padding-top: 8px;
115
87
  }
116
88
 
117
89
  .tabBar {
@@ -143,21 +115,12 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
143
115
  border-bottom-color: var(--dsw-alias-state-business-primary);
144
116
  }
145
117
 
146
- .panelContent {
147
- flex: 1;
148
- min-height: 0;
149
- display: flex;
150
- flex-direction: column;
151
- overflow: hidden;
152
- }
153
-
154
118
  .tabBody {
155
119
  display: flex;
156
120
  flex-direction: column;
157
121
  gap: 12px;
158
- flex: 1;
159
- min-height: 0;
160
- overflow-y: auto;
122
+ padding: 12px 0 4px;
123
+ min-width: 0;
161
124
  }
162
125
 
163
126
  .section {
@@ -174,13 +137,14 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
174
137
  margin: 0;
175
138
  font-size: 13px;
176
139
  font-weight: 600;
140
+ color: var(--dsw-alias-label-primary);
177
141
  }
178
142
 
179
143
  .field {
180
- display: grid;
181
- grid-template-columns: 140px 1fr;
182
- gap: 8px;
183
- align-items: center;
144
+ display: flex;
145
+ flex-direction: column;
146
+ align-items: stretch;
147
+ gap: 4px;
184
148
  }
185
149
 
186
150
  .label {
@@ -188,11 +152,12 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
188
152
  color: var(--dsw-alias-label-secondary);
189
153
  }
190
154
 
191
- .input, .select {
155
+ .input,
156
+ .select {
192
157
  padding: 6px 8px;
193
158
  font-size: 13px;
194
159
  color: var(--dsw-alias-label-primary);
195
- background: var(--dsw-specific-input-major);
160
+ background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-3));
196
161
  border: 1px solid var(--dsw-alias-border-l2);
197
162
  border-radius: 8px;
198
163
  outline: none;
@@ -217,28 +182,24 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
217
182
  font-size: 13px;
218
183
  border-radius: 8px;
219
184
  border: 1px solid var(--dsw-alias-border-l2);
220
- background: var(--dsw-specific-input-major);
185
+ background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-3));
221
186
  color: var(--dsw-alias-label-primary);
222
187
  cursor: pointer;
223
188
  }
224
189
 
225
- .button:hover {
190
+ .button:hover:not(:disabled) {
226
191
  background: var(--dsw-alias-interactive-bg-hover);
227
192
  }
228
193
 
229
- .qr {
230
- display: block;
231
- width: 240px;
232
- height: 240px;
233
- margin: 8px 0;
234
- background: #fff;
235
- border-radius: 8px;
194
+ .button:disabled {
195
+ opacity: 0.4;
196
+ cursor: default;
236
197
  }
237
198
 
238
199
  .primaryButton {
239
- background: var(--dsw-alias-state-business-primary);
240
- border-color: var(--dsw-alias-state-business-primary);
241
- color: #fff;
200
+ background: var(--dsw-alias-label-primary);
201
+ border-color: var(--dsw-alias-label-primary);
202
+ color: var(--dsw-alias-bg-layer-3);
242
203
  }
243
204
 
244
205
  .dangerButton {
@@ -247,8 +208,19 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
247
208
  color: #fff;
248
209
  }
249
210
 
211
+ .qr {
212
+ display: block;
213
+ width: 200px;
214
+ height: 200px;
215
+ margin: 8px 0;
216
+ background: #fff;
217
+ border-radius: 8px;
218
+ }
219
+
250
220
  .message {
221
+ margin: 0;
251
222
  font-size: 12.5px;
223
+ line-height: 1.5;
252
224
  color: var(--dsw-alias-label-secondary);
253
225
  }
254
226
 
@@ -257,8 +229,6 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
257
229
  }
258
230
 
259
231
  .tableWrap {
260
- flex: 1;
261
- min-height: 0;
262
232
  overflow: auto;
263
233
  border: 1px solid var(--dsw-alias-border-l1);
264
234
  border-radius: 10px;
@@ -304,11 +274,11 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
304
274
 
305
275
  .cards {
306
276
  display: grid;
307
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
277
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
308
278
  gap: 8px;
309
279
  }
310
280
 
311
- .card {
281
+ .stat {
312
282
  padding: 10px;
313
283
  border: 1px solid var(--dsw-alias-border-l1);
314
284
  border-radius: 10px;
@@ -322,4 +292,5 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
322
292
  .cardValue {
323
293
  font-size: 15px;
324
294
  font-weight: 600;
295
+ color: var(--dsw-alias-label-primary);
325
296
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ReZ settings pages: one accordion card in 设置 → 插件 → 插件配置,
3
+ * plus a dedicated Plugins tab for the same content.
4
+ */
5
+
6
+ import { useMemo, useState } from 'react'
7
+ import { RezApi } from './api.ts'
8
+ import { tt } from './panel/helpers.ts'
9
+ import { AuditTab } from './panel/AuditTab.tsx'
10
+ import { ConfigTab } from './panel/ConfigTab.tsx'
11
+ import { StatusTab } from './panel/StatusTab.tsx'
12
+ import { WeixinTab } from './panel/WeixinTab.tsx'
13
+ import css from './panel/panel.module.css'
14
+
15
+ export type RezTab = 'config' | 'weixin' | 'status' | 'audit'
16
+
17
+ const TABS: ReadonlyArray<{ id: RezTab; label: () => string }> = [
18
+ { id: 'config', label: () => tt('tab.config') },
19
+ { id: 'weixin', label: () => tt('tab.weixin') },
20
+ { id: 'status', label: () => tt('tab.status') },
21
+ { id: 'audit', label: () => tt('tab.audit') },
22
+ ]
23
+
24
+ export function RezSettingsPage() {
25
+ const api = useMemo(() => new RezApi(), [])
26
+ const [activeTab, setActiveTab] = useState<RezTab>('config')
27
+ return (
28
+ <div className={css.shell}>
29
+ <div className={css.tabBar} role="tablist">
30
+ {TABS.map(tab => (
31
+ <button
32
+ key={tab.id}
33
+ type="button"
34
+ role="tab"
35
+ aria-selected={activeTab === tab.id}
36
+ data-active={activeTab === tab.id ? '' : undefined}
37
+ className={css.tab}
38
+ onClick={() => { setActiveTab(tab.id) }}
39
+ >
40
+ {tab.label()}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ {activeTab === 'config' && <ConfigTab api={api} />}
45
+ {activeTab === 'weixin' && <WeixinTab api={api} />}
46
+ {activeTab === 'status' && <StatusTab api={api} />}
47
+ {activeTab === 'audit' && <AuditTab api={api} />}
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export function RezPluginCard() {
53
+ const [open, setOpen] = useState(false)
54
+ return (
55
+ <li className={css.card + (open ? ' ' + css.cardOpen : '')}>
56
+ <button type="button" className={css.header} onClick={() => { setOpen(!open) }}>
57
+ <div className={css.headText}>
58
+ <div className={css.name}>{tt('settings.title')}</div>
59
+ <div className={css.description}>{tt('settings.description')}</div>
60
+ </div>
61
+ <svg className={css.chevron + (open ? ' ' + css.chevronOpen : '')} viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
62
+ <path d="M3 5l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
63
+ </svg>
64
+ </button>
65
+ {open ? <div className={css.body}><RezSettingsPage /></div> : null}
66
+ </li>
67
+ )
68
+ }
package/src/index.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * dsh-rez-suite — employee-facing host half.
3
3
  *
4
4
  * MCP connections are owned by official @deepseek-ai/dsh-mcp-client rows in
5
- * this package's cordis.patch.yml. This plugin only mounts the Rez panel,
6
- * loopback settings API, and a short system-prompt announcement. Do not
7
- * start a second MCP host here.
5
+ * this package's cordis.patch.yml. This plugin only mounts the loopback
6
+ * settings API, a Settings → Plugins card, and a short system-prompt
7
+ * announcement. Do not start a second MCP host here.
8
8
  */
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
@@ -60,7 +60,7 @@ export const REZ_GUIDANCE = [
60
60
  '本机已安装 ReZ-TI 套件(一次安装):工作身份由 dsh-rez-sso 提供,MCP 由官方 dsh-mcp-client 接入 Odoo / Nextcloud / 企业微信 / Home Assistant。',
61
61
  '工具名 mcp__odoo__*、mcp__nextcloud__*、mcp__wechat__*、mcp__homeassistant__*。',
62
62
  '密钥:REZ_ODOO_TOKEN、REZ_NEXTCLOUD_PASSWORD、REZ_WECHAT_WEBHOOK、REZ_HA_TOKEN。',
63
- '个人微信(QClaw/ClawBot):打开 Rez 面板「微信」页扫码,然后直接在微信里说话,不必再跑命令、也不要调用微信 MCP 工具。',
63
+ '个人微信(QClaw/ClawBot):打开 设置 插件 → ReZ-TI → 微信 扫码,然后直接在微信里说话,不必再跑命令、也不要调用微信 MCP 工具。',
64
64
  '意图用 rez_set_intent(erp/files/chat/home/auto)。审计用 rez_audit。',
65
65
  '涉及资金、对外发送、设备控制、生产写入时先征得用户批准。',
66
66
  ].join('')
@@ -1,97 +0,0 @@
1
- /**
2
- * Rez panel view mounting. Single-occupant center-column takeover, same
3
- * mechanism as the task board and ssh panels. The conversation subtree stays
4
- * mounted under the panel.
5
- */
6
-
7
- import { createRoot, type Root } from 'react-dom/client'
8
- import type { RezApi } from './api.ts'
9
- import type { PanelController } from './panel/controller.ts'
10
- import { RezPanel } from './panel/RezPanel.tsx'
11
- import css from './panel/panel.module.css'
12
-
13
- export const PANEL_VIEW_SELECTOR = '[data-dsh-rez-view]'
14
-
15
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
16
- const ACTIVE_ATTR = 'data-dsh-rez-active'
17
- const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
18
- const ACTIVATE_EVENT = 'dsh-panel-activate'
19
- const PANEL_NAME = 'rez'
20
-
21
- function conversationColumn(): HTMLElement | undefined {
22
- return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
23
- }
24
-
25
- export function mountPanel(controller: PanelController, api: RezApi): () => void {
26
- let root: Root | undefined
27
- let container: HTMLDivElement | undefined
28
-
29
- const ensure = (): void => {
30
- if (container !== undefined) {
31
- if (container.isConnected) return
32
- root?.unmount()
33
- root = undefined
34
- container.remove()
35
- container = undefined
36
- }
37
- const column = conversationColumn()
38
- if (column === undefined) return
39
- container = document.createElement('div')
40
- container.dataset.dshRezView = ''
41
- container.className = css.view
42
- column.appendChild(container)
43
- root = createRoot(container)
44
- root.render(<RezPanel controller={controller} api={api} />)
45
- }
46
-
47
- const waitObserver = new MutationObserver(() => { ensure() })
48
- waitObserver.observe(document.body, { childList: true, subtree: true })
49
-
50
- // The shell may mount the center column after this plugin applies. Retry
51
- // until the container is attached; ensure() is idempotent once connected.
52
- const retryTimer = window.setInterval(() => {
53
- ensure()
54
- if (container?.isConnected === true) window.clearInterval(retryTimer)
55
- }, 250)
56
-
57
- const applyActive = (): void => {
58
- if (controller.getSnapshot().panelOpen) {
59
- for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
60
- document.documentElement.setAttribute(ACTIVE_ATTR, '')
61
- document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
62
- } else {
63
- document.documentElement.removeAttribute(ACTIVE_ATTR)
64
- }
65
- }
66
- const onOtherActivate = (event: Event): void => {
67
- const detail = (event as CustomEvent).detail
68
- if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
69
- controller.close()
70
- }
71
- }
72
- const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
73
- const onClickSidebarRow = (event: MouseEvent): void => {
74
- if (!controller.getSnapshot().panelOpen) return
75
- const target = event.target as HTMLElement | null
76
- if (target === null) return
77
- if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
78
- }
79
- document.addEventListener('click', onClickSidebarRow, true)
80
- document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
81
- const unsubscribe = controller.subscribe(applyActive)
82
- applyActive()
83
- ensure()
84
-
85
- return () => {
86
- window.clearInterval(retryTimer)
87
- document.removeEventListener('click', onClickSidebarRow, true)
88
- document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
89
- waitObserver.disconnect()
90
- unsubscribe()
91
- document.documentElement.removeAttribute(ACTIVE_ATTR)
92
- root?.unmount()
93
- root = undefined
94
- container?.remove()
95
- container = undefined
96
- }
97
- }
@@ -1,53 +0,0 @@
1
- /**
2
- * Rez Suite panel shell: header + three tabs (config/status/audit).
3
- */
4
-
5
- import { useState } from 'react'
6
- import type { RezApi } from '../api.ts'
7
- import type { PanelController } from './controller.ts'
8
- import { tt } from './helpers.ts'
9
- import { AuditTab } from './AuditTab.tsx'
10
- import { ConfigTab } from './ConfigTab.tsx'
11
- import { StatusTab } from './StatusTab.tsx'
12
- import { WeixinTab } from './WeixinTab.tsx'
13
- import css from './panel.module.css'
14
-
15
- export type RezTab = 'config' | 'weixin' | 'status' | 'audit'
16
-
17
- export interface RezPanelProps {
18
- controller: PanelController
19
- api: RezApi
20
- }
21
-
22
- const TABS: ReadonlyArray<{ id: RezTab; label: () => string }> = [
23
- { id: 'config', label: () => tt('tab.config') },
24
- { id: 'weixin', label: () => tt('tab.weixin') },
25
- { id: 'status', label: () => tt('tab.status') },
26
- { id: 'audit', label: () => tt('tab.audit') },
27
- ]
28
-
29
- export function RezPanel({ controller, api }: RezPanelProps) {
30
- const [activeTab, setActiveTab] = useState<RezTab>('config')
31
-
32
- return (
33
- <div className={css.panel}>
34
- <div className={css.panelHeader}>
35
- <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
36
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
37
- </div>
38
- <div className={css.tabBar} role="tablist">
39
- {TABS.map(tab => (
40
- <button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
41
- {tab.label()}
42
- </button>
43
- ))}
44
- </div>
45
- <div className={css.panelContent}>
46
- {activeTab === 'config' && <ConfigTab api={api} />}
47
- {activeTab === 'weixin' && <WeixinTab api={api} />}
48
- {activeTab === 'status' && <StatusTab api={api} />}
49
- {activeTab === 'audit' && <AuditTab api={api} />}
50
- </div>
51
- </div>
52
- )
53
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * Rez panel controller: single owner of open/closed state (browser session).
3
- */
4
-
5
- export interface PanelControllerSnapshot {
6
- panelOpen: boolean
7
- }
8
-
9
- export class PanelController {
10
- private panelOpen = false
11
- private listeners = new Set<() => void>()
12
-
13
- getSnapshot(): PanelControllerSnapshot {
14
- return { panelOpen: this.panelOpen }
15
- }
16
-
17
- subscribe(fn: () => void): () => void {
18
- this.listeners.add(fn)
19
- return () => { this.listeners.delete(fn) }
20
- }
21
-
22
- open(): void {
23
- if (this.panelOpen) return
24
- this.panelOpen = true
25
- this.notify()
26
- }
27
-
28
- close(): void {
29
- if (!this.panelOpen) return
30
- this.panelOpen = false
31
- this.notify()
32
- }
33
-
34
- toggle(): void {
35
- if (this.panelOpen) this.close()
36
- else this.open()
37
- }
38
-
39
- private notify(): void {
40
- for (const fn of [...this.listeners]) fn()
41
- }
42
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * Sidebar entry injection (DOM-level extension, task-board/ssh precedent).
3
- */
4
-
5
- import type { PanelController } from './panel/controller.ts'
6
- import { tt } from './panel/helpers.ts'
7
- import css from './panel/panel.module.css'
8
-
9
- export const ENTRY_SELECTOR = '[data-dsh-rez-entry]'
10
-
11
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="2"/><path d="M5 8l2 2 4-4"/></svg>'
12
-
13
- function sidebarRoot(): HTMLElement | undefined {
14
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
15
- if (column === null) return undefined
16
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
17
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
18
- }
19
-
20
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
21
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
22
- if (nested !== null) return nested
23
- for (const child of root.children) {
24
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
25
- }
26
- return undefined
27
- }
28
-
29
- function createEntry(controller: PanelController): HTMLButtonElement {
30
- const entry = document.createElement('button')
31
- entry.type = 'button'
32
- entry.dataset.dshRezEntry = ''
33
- entry.className = css.entry
34
- entry.setAttribute('aria-label', tt('entry.label'))
35
- entry.setAttribute('title', tt('entry.tooltip'))
36
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
37
- entry.addEventListener('click', () => { controller.toggle() })
38
- return entry
39
- }
40
-
41
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
42
- const button = newSessionButton(root)
43
- if (button === undefined) return false
44
- if (entry.parentElement !== root) {
45
- const row = button.closest('[class*="logoRow"]')
46
- const base = (row !== null && row.parentElement === root) ? row : button
47
- const family = Array.from(root.children).filter(
48
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-rez-entry]'),
49
- )
50
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
51
- root.insertBefore(entry, anchor)
52
- }
53
- return true
54
- }
55
-
56
- export function mountSidebarEntry(controller: PanelController): () => void {
57
- const entry = createEntry(controller)
58
- let root: HTMLElement | undefined
59
- let placed = false
60
-
61
- const place = (): void => {
62
- const next = sidebarRoot()
63
- if (next === undefined) return
64
- if (root !== undefined && root !== next) {
65
- if (entry.parentElement === root) entry.remove()
66
- placed = false
67
- }
68
- root = next
69
- if (!placed) placed = placeEntry(root, entry)
70
- }
71
-
72
- const observer = new MutationObserver(place)
73
- observer.observe(document.body, { childList: true, subtree: true })
74
- place()
75
-
76
- return () => {
77
- observer.disconnect()
78
- entry.remove()
79
- }
80
- }