@dreanzy/dsh-skill-pack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +75 -0
- package/README.md +91 -0
- package/client/client.js +2024 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/lib/effective.d.ts +46 -0
- package/lib/effective.js +39 -0
- package/lib/hooks.d.ts +32 -0
- package/lib/hooks.js +403 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +109 -0
- package/lib/inventory.d.ts +54 -0
- package/lib/inventory.js +34 -0
- package/lib/policy.d.ts +85 -0
- package/lib/policy.js +386 -0
- package/lib/reconcile.d.ts +18 -0
- package/lib/reconcile.js +60 -0
- package/lib/routes.d.ts +102 -0
- package/lib/routes.js +511 -0
- package/lib/snapshot.d.ts +55 -0
- package/lib/snapshot.js +135 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.js +434 -0
- package/lib/types.d.ts +120 -0
- package/lib/types.js +13 -0
- package/locale/en.json +47 -0
- package/locale/zh.json +47 -0
- package/package.json +79 -0
package/client/client.js
ADDED
|
@@ -0,0 +1,2024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半边。手写单文件:宿主用 `require('react')` 提供 React,
|
|
3
|
+
* 因此不引入 JSX、CSS Modules 或打包器,避免给这个仓库加第二套构建链。
|
|
4
|
+
*/
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: '@dreanzy/dsh-skill-pack',
|
|
7
|
+
factory(require) {
|
|
8
|
+
const React = require('react')
|
|
9
|
+
const h = React.createElement
|
|
10
|
+
const { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = React
|
|
11
|
+
|
|
12
|
+
const PANEL_ID = 'skill-pack'
|
|
13
|
+
const CHIP_ID = 'skill-pack-chip'
|
|
14
|
+
const MODAL_ID = 'skill-pack-chip-modal'
|
|
15
|
+
const TITLE = 'Skill Pack'
|
|
16
|
+
/** 内置 pack 的 id。未绑定的工作区按它求值。 */
|
|
17
|
+
const DEFAULT_PACK_ID = 'default'
|
|
18
|
+
/** 工作区清单上限:会话一多,绑定表会变成一堵墙。 */
|
|
19
|
+
const MAX_WORKSPACES = 30
|
|
20
|
+
/**
|
|
21
|
+
* chip 文字隐藏的断点。
|
|
22
|
+
*
|
|
23
|
+
* 宿主输入框底栏 `.row` 声明了 `container-type: inline-size`,并用
|
|
24
|
+
* `@container (width <= 560px)` 控制这一带按钮的间距。跟随同一个断点,
|
|
25
|
+
* chip 就和官方那几个按钮同步折叠,而不是各自算一套阈值。
|
|
26
|
+
*
|
|
27
|
+
* 用 CSS 容器查询而不是 JS 量宽:插槽把 chip 包在一个与它等宽的容器里,
|
|
28
|
+
* 量父级永远偏小;往上找祖先又依赖具体层级,两种做法都会误判。
|
|
29
|
+
*/
|
|
30
|
+
const CHIP_CSS = '@container (max-width: 560px) { .dsh-skill-pack-chip-text { display: none } }'
|
|
31
|
+
|
|
32
|
+
// ---------- 传输 ----------
|
|
33
|
+
|
|
34
|
+
const PATHS = {
|
|
35
|
+
global: '/api/skill-pack/global',
|
|
36
|
+
workspace: '/api/skill-pack/workspace',
|
|
37
|
+
apply: '/api/skill-pack/apply',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function post(path, body) {
|
|
41
|
+
let response
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(path, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
cache: 'no-store',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(body || {}),
|
|
48
|
+
})
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error('网络请求失败:' + String(error))
|
|
51
|
+
}
|
|
52
|
+
let payload
|
|
53
|
+
try {
|
|
54
|
+
payload = await response.json()
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error('响应不是合法 JSON(HTTP ' + response.status + ')')
|
|
57
|
+
}
|
|
58
|
+
if (payload && payload.ok === true) return payload.value
|
|
59
|
+
const message = payload && payload.error && payload.error.message ? payload.error.message : '请求失败'
|
|
60
|
+
throw new Error(message)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const api = {
|
|
64
|
+
// 带上当前会话:服务端优先用它的 scope 读清单,否则会退回到「恰好排第一的那个会话」。
|
|
65
|
+
global: (sessionId) => post(PATHS.global, sessionId ? { sessionId: sessionId } : {}),
|
|
66
|
+
workspace: (sessionId) => post(PATHS.workspace, { sessionId: sessionId }),
|
|
67
|
+
apply: (commands, sessionId) =>
|
|
68
|
+
post(PATHS.apply, sessionId ? { sessionId: sessionId, commands: commands } : { commands: commands }),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- 样式 ----------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 颜色一律取宿主主题 token。
|
|
75
|
+
*
|
|
76
|
+
* 宿主的真实前缀是 `--dsw-`(不是 `--dsh-`),且全部 token 都要求浅色与深色两套取值,
|
|
77
|
+
* 底色用 `--dsw-alias-bg-layer-1`,不用 `--dsw-alias-bg-overlay`:后者在暗色主题下
|
|
78
|
+
* 绑定的是 neutral-bluish-700(中灰),弹窗会比页面亮一大截;后者是给「半透明浮层」
|
|
79
|
+
* 用的,语义也不是卡片底。layer-1 与页面同色,界线交给边框与阴影。
|
|
80
|
+
* 弹窗优先用它,弹层才与它自绘的浮层同色。
|
|
81
|
+
*/
|
|
82
|
+
const T = {
|
|
83
|
+
text: 'var(--dsw-alias-label-primary, CanvasText)',
|
|
84
|
+
dim: 'var(--dsw-alias-label-secondary, GrayText)',
|
|
85
|
+
faint: 'var(--dsw-alias-label-tertiary, GrayText)',
|
|
86
|
+
surface:
|
|
87
|
+
'var(--dsw-alias-bg-layer-1, Canvas)',
|
|
88
|
+
border: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.35))',
|
|
89
|
+
borderStrong: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.55))',
|
|
90
|
+
// 不用 --dsw-alias-state-idle-primary:暗色主题下它解析成接近白色,
|
|
91
|
+
// 与白色圆点叠成一条纯白胶囊,开/关完全看不出来。这里用固定的半透明灰,
|
|
92
|
+
// 再给轨道加一圈内描边,暗色与亮色下都有轮廓。
|
|
93
|
+
idle: 'rgba(127, 127, 127, 0.38)',
|
|
94
|
+
error: 'var(--dsw-alias-state-error-primary, #c0392b)',
|
|
95
|
+
onAccent: '#ffffff',
|
|
96
|
+
/*
|
|
97
|
+
* 开关配色照官方「反色」风格:开态轨道取前景色(暗色主题下是亮白),圆点取底色
|
|
98
|
+
* (近黑);关态轨道是中灰、圆点是浅圆点。反色在亮暗两套主题里都成立,
|
|
99
|
+
* 也不依赖品牌色——品牌色在主题里存在与否不确定,撞色时开关就废了。
|
|
100
|
+
*/
|
|
101
|
+
track: 'var(--dsw-alias-label-primary, #e6e6e6)',
|
|
102
|
+
trackOff: 'rgba(127, 127, 127, 0.30)',
|
|
103
|
+
knob: 'var(--dsw-alias-bg-layer-1, #111111)',
|
|
104
|
+
knobOff: 'rgba(255, 255, 255, 0.92)',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const S = {
|
|
108
|
+
title: { fontSize: '15px', fontWeight: 600, color: T.text },
|
|
109
|
+
hint: { color: T.dim, fontSize: '12px', lineHeight: 1.6 },
|
|
110
|
+
bar: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', margin: '14px 0 10px' },
|
|
111
|
+
input: {
|
|
112
|
+
flex: '1 1 200px',
|
|
113
|
+
minWidth: '160px',
|
|
114
|
+
padding: '6px 8px',
|
|
115
|
+
border: '1px solid ' + T.border,
|
|
116
|
+
borderRadius: '6px',
|
|
117
|
+
background: 'transparent',
|
|
118
|
+
color: T.text,
|
|
119
|
+
fontSize: '13px',
|
|
120
|
+
},
|
|
121
|
+
// 分段控件照官方:容器不画整条外框,每个选项是各自独立的小按钮;
|
|
122
|
+
// 选中项用浅底 + 更亮的文字,而不是整块品牌色——官方设置页就是这个观感,
|
|
123
|
+
// 品牌色实心块在暗色主题下会盖掉周围所有信息。
|
|
124
|
+
segment: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' },
|
|
125
|
+
segItem: (active) => ({
|
|
126
|
+
padding: '4px 10px',
|
|
127
|
+
cursor: 'pointer',
|
|
128
|
+
borderRadius: '6px',
|
|
129
|
+
border: '1px solid ' + (active ? T.borderStrong : T.border),
|
|
130
|
+
background: active ? 'rgba(127, 127, 127, 0.22)' : 'transparent',
|
|
131
|
+
color: active ? T.text : T.dim,
|
|
132
|
+
fontSize: '12px',
|
|
133
|
+
lineHeight: 1.4,
|
|
134
|
+
whiteSpace: 'nowrap',
|
|
135
|
+
transition: 'background 120ms ease, color 120ms ease, border-color 120ms ease',
|
|
136
|
+
}),
|
|
137
|
+
group: { borderRadius: '8px', border: '1px solid ' + T.border, marginBottom: '8px' },
|
|
138
|
+
groupHead: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', cursor: 'pointer' },
|
|
139
|
+
groupName: { fontWeight: 600, flex: '1 1 auto', color: T.text },
|
|
140
|
+
badge: {
|
|
141
|
+
fontSize: '11px',
|
|
142
|
+
padding: '1px 6px',
|
|
143
|
+
borderRadius: '999px',
|
|
144
|
+
background: 'transparent',
|
|
145
|
+
border: '1px solid ' + T.border,
|
|
146
|
+
color: T.dim,
|
|
147
|
+
whiteSpace: 'nowrap',
|
|
148
|
+
},
|
|
149
|
+
row: {
|
|
150
|
+
display: 'flex',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
gap: '10px',
|
|
153
|
+
padding: '6px 10px 6px 22px',
|
|
154
|
+
borderTop: '1px solid ' + T.border,
|
|
155
|
+
},
|
|
156
|
+
rowMain: { flex: '1 1 auto', minWidth: 0 },
|
|
157
|
+
rowName: { fontFamily: 'ui-monospace, monospace', fontSize: '12px', color: T.text },
|
|
158
|
+
origin: {
|
|
159
|
+
marginLeft: '6px',
|
|
160
|
+
padding: '0 4px',
|
|
161
|
+
fontSize: '10px',
|
|
162
|
+
fontFamily: 'inherit',
|
|
163
|
+
color: T.dim,
|
|
164
|
+
border: '1px solid ' + T.border,
|
|
165
|
+
borderRadius: '3px',
|
|
166
|
+
whiteSpace: 'nowrap',
|
|
167
|
+
},
|
|
168
|
+
groupInput: {
|
|
169
|
+
flex: '1 1 auto',
|
|
170
|
+
minWidth: '80px',
|
|
171
|
+
fontWeight: 600,
|
|
172
|
+
fontSize: '13px',
|
|
173
|
+
color: T.text,
|
|
174
|
+
background: 'transparent',
|
|
175
|
+
border: '1px solid ' + T.border,
|
|
176
|
+
borderRadius: '4px',
|
|
177
|
+
padding: '2px 6px',
|
|
178
|
+
},
|
|
179
|
+
rowDesc: { color: T.dim, fontSize: '11px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
180
|
+
sw: (on, disabled) => ({
|
|
181
|
+
width: '36px',
|
|
182
|
+
height: '20px',
|
|
183
|
+
borderRadius: '999px',
|
|
184
|
+
border: 0,
|
|
185
|
+
padding: 0,
|
|
186
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
187
|
+
opacity: disabled ? 0.45 : 1,
|
|
188
|
+
background: on ? T.track : T.trackOff,
|
|
189
|
+
position: 'relative',
|
|
190
|
+
flex: '0 0 auto',
|
|
191
|
+
transition: 'background 140ms ease',
|
|
192
|
+
}),
|
|
193
|
+
knob: (on) => ({
|
|
194
|
+
position: 'absolute',
|
|
195
|
+
top: '2px',
|
|
196
|
+
left: on ? '18px' : '2px',
|
|
197
|
+
width: '16px',
|
|
198
|
+
height: '16px',
|
|
199
|
+
borderRadius: '999px',
|
|
200
|
+
background: on ? T.knob : T.knobOff,
|
|
201
|
+
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.28)',
|
|
202
|
+
transition: 'left 140ms ease, background 140ms ease',
|
|
203
|
+
}),
|
|
204
|
+
reset: { border: 0, background: 'transparent', cursor: 'pointer', color: T.dim, fontSize: '12px', padding: '2px 4px' },
|
|
205
|
+
/*
|
|
206
|
+
* 自绘下拉。原生 <select> 的弹出层由系统绘制,不跟主题:暗色下一点开是一块
|
|
207
|
+
* 蓝底浮层,与官方设置页的语言选择器完全不是一个观感。这里改成按钮 + 浮层。
|
|
208
|
+
*/
|
|
209
|
+
pickerTrigger: {
|
|
210
|
+
display: 'inline-flex',
|
|
211
|
+
alignItems: 'center',
|
|
212
|
+
justifyContent: 'space-between',
|
|
213
|
+
gap: '8px',
|
|
214
|
+
width: '100%',
|
|
215
|
+
padding: '5px 8px',
|
|
216
|
+
border: '1px solid ' + T.border,
|
|
217
|
+
borderRadius: '8px',
|
|
218
|
+
background: 'transparent',
|
|
219
|
+
color: T.text,
|
|
220
|
+
fontSize: '12px',
|
|
221
|
+
lineHeight: 1.5,
|
|
222
|
+
cursor: 'pointer',
|
|
223
|
+
textAlign: 'left',
|
|
224
|
+
},
|
|
225
|
+
pickerLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
226
|
+
pickerMenu: {
|
|
227
|
+
position: 'absolute',
|
|
228
|
+
top: 'calc(100% + 4px)',
|
|
229
|
+
left: 0,
|
|
230
|
+
right: 0,
|
|
231
|
+
zIndex: 30,
|
|
232
|
+
padding: '4px',
|
|
233
|
+
borderRadius: '10px',
|
|
234
|
+
border: '1px solid ' + T.border,
|
|
235
|
+
background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1, Canvas))',
|
|
236
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)',
|
|
237
|
+
maxHeight: '260px',
|
|
238
|
+
overflowY: 'auto',
|
|
239
|
+
},
|
|
240
|
+
pickerItem: (active, hover) => ({
|
|
241
|
+
display: 'flex',
|
|
242
|
+
alignItems: 'center',
|
|
243
|
+
justifyContent: 'space-between',
|
|
244
|
+
gap: '10px',
|
|
245
|
+
padding: '6px 8px',
|
|
246
|
+
borderRadius: '6px',
|
|
247
|
+
fontSize: '12px',
|
|
248
|
+
color: T.text,
|
|
249
|
+
cursor: 'pointer',
|
|
250
|
+
background: active || hover ? 'rgba(127, 127, 127, 0.18)' : 'transparent',
|
|
251
|
+
}),
|
|
252
|
+
error: { color: T.error, fontSize: '12px', marginBottom: '8px' },
|
|
253
|
+
empty: { color: T.faint, padding: '12px 4px', fontSize: '12px' },
|
|
254
|
+
modalBack: {
|
|
255
|
+
position: 'fixed',
|
|
256
|
+
inset: 0,
|
|
257
|
+
background: 'rgba(0,0,0,0.42)',
|
|
258
|
+
display: 'flex',
|
|
259
|
+
alignItems: 'center',
|
|
260
|
+
justifyContent: 'center',
|
|
261
|
+
zIndex: 9999,
|
|
262
|
+
},
|
|
263
|
+
modal: {
|
|
264
|
+
width: 'min(560px, 92vw)',
|
|
265
|
+
maxHeight: '78vh',
|
|
266
|
+
// 下拉浮层要溢出弹层边缘,裁切会把它切掉一半;内容本来就短,不需要滚动。
|
|
267
|
+
overflow: 'visible',
|
|
268
|
+
background: T.surface,
|
|
269
|
+
color: T.text,
|
|
270
|
+
border: '1px solid ' + T.borderStrong,
|
|
271
|
+
borderRadius: '12px',
|
|
272
|
+
padding: '16px 18px',
|
|
273
|
+
boxShadow: '0 18px 48px rgba(0,0,0,0.28)',
|
|
274
|
+
fontSize: '13px',
|
|
275
|
+
},
|
|
276
|
+
block: { marginTop: '16px', paddingTop: '12px', borderTop: '1px solid ' + T.border },
|
|
277
|
+
blockTitle: { fontSize: '12px', fontWeight: 600, color: T.text, marginBottom: '6px' },
|
|
278
|
+
packRow: {
|
|
279
|
+
display: 'flex',
|
|
280
|
+
alignItems: 'center',
|
|
281
|
+
gap: '8px',
|
|
282
|
+
padding: '6px 8px',
|
|
283
|
+
borderRadius: '6px',
|
|
284
|
+
cursor: 'pointer',
|
|
285
|
+
border: '1px solid ' + T.border,
|
|
286
|
+
marginBottom: '6px',
|
|
287
|
+
},
|
|
288
|
+
path: { fontFamily: 'ui-monospace, monospace', fontSize: '11px', color: T.dim, wordBreak: 'break-all' },
|
|
289
|
+
chip: {
|
|
290
|
+
display: 'inline-flex',
|
|
291
|
+
alignItems: 'center',
|
|
292
|
+
gap: '4px',
|
|
293
|
+
padding: '3px 8px',
|
|
294
|
+
borderRadius: '999px',
|
|
295
|
+
border: '1px solid ' + T.border,
|
|
296
|
+
background: 'transparent',
|
|
297
|
+
color: T.text,
|
|
298
|
+
fontSize: '11px',
|
|
299
|
+
cursor: 'pointer',
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 极小的可观测量。
|
|
305
|
+
*
|
|
306
|
+
* 设置页改的是持久化配置,槽位注册却发生在 `apply` 里,两者在 React 上没有
|
|
307
|
+
* 父子关系,改完不会自动重挂;chip 在会话页、设置页在设置弹层里,同理。
|
|
308
|
+
* 一个 set/subscribe 就够了,不引任何状态库。
|
|
309
|
+
*/
|
|
310
|
+
function createSignal(initial) {
|
|
311
|
+
let value = initial
|
|
312
|
+
const listeners = new Set()
|
|
313
|
+
return {
|
|
314
|
+
read: () => value,
|
|
315
|
+
set: (next) => {
|
|
316
|
+
if (value === next) return
|
|
317
|
+
value = next
|
|
318
|
+
for (const listener of listeners) listener()
|
|
319
|
+
},
|
|
320
|
+
subscribe: (listener) => {
|
|
321
|
+
listeners.add(listener)
|
|
322
|
+
return () => listeners.delete(listener)
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 跨组件共享的界面状态。
|
|
329
|
+
*
|
|
330
|
+
* `showChip` 由 `apply` 订阅:设置页拨动后立刻挂上或摘下槽位。
|
|
331
|
+
* `chipName` 是 chip 上显示的 pack 名。
|
|
332
|
+
*/
|
|
333
|
+
const env = {
|
|
334
|
+
onChipChange: null,
|
|
335
|
+
sessionId: undefined,
|
|
336
|
+
}
|
|
337
|
+
const chipName = createSignal(null)
|
|
338
|
+
/**
|
|
339
|
+
* 未绑定 pack 时 chip 上的字。
|
|
340
|
+
*
|
|
341
|
+
* 跟宿主的语言走:中文下「默认」比 `Default` 自然。取不到语言服务就退回英文,
|
|
342
|
+
* 不让 chip 因为一个文案取不到而消失。
|
|
343
|
+
*/
|
|
344
|
+
const chipFallback = createSignal('Default')
|
|
345
|
+
|
|
346
|
+
// ---------- 基础组件 ----------
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 策略文件的告警。
|
|
350
|
+
*
|
|
351
|
+
* 写入失败不再是错误信封:它和既有的读告警一样,跟着视图的 `warnings` 回来,
|
|
352
|
+
* 所以面板得有一处能看见。默认折起只显示条数——正常情况一条都没有,出现时才
|
|
353
|
+
* 占位置;展开才列出全文。
|
|
354
|
+
*/
|
|
355
|
+
function Warnings(props) {
|
|
356
|
+
const [open, setOpen] = useState(false)
|
|
357
|
+
const list = Array.isArray(props.warnings) ? props.warnings : []
|
|
358
|
+
if (list.length === 0) return null
|
|
359
|
+
return h(
|
|
360
|
+
'div',
|
|
361
|
+
{ style: { marginTop: '8px' } },
|
|
362
|
+
h(
|
|
363
|
+
'button',
|
|
364
|
+
{
|
|
365
|
+
type: 'button',
|
|
366
|
+
'aria-expanded': open,
|
|
367
|
+
style: { ...S.badge, cursor: 'pointer' },
|
|
368
|
+
onClick: () => setOpen(!open),
|
|
369
|
+
},
|
|
370
|
+
(open ? '▾ ' : '▸ ') + list.length + ' 条告警',
|
|
371
|
+
),
|
|
372
|
+
open
|
|
373
|
+
? h(
|
|
374
|
+
'div',
|
|
375
|
+
{ style: { ...S.hint, marginTop: '4px' } },
|
|
376
|
+
list.map((warning, index) => h('div', { key: index }, warning)),
|
|
377
|
+
)
|
|
378
|
+
: null,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function Switch(props) {
|
|
383
|
+
return h(
|
|
384
|
+
'button',
|
|
385
|
+
{
|
|
386
|
+
type: 'button',
|
|
387
|
+
'aria-pressed': props.on,
|
|
388
|
+
title: props.title || (props.on ? '已开启' : '已关闭'),
|
|
389
|
+
disabled: props.disabled,
|
|
390
|
+
style: S.sw(props.on, props.disabled),
|
|
391
|
+
onClick: props.disabled ? undefined : () => props.onChange(!props.on),
|
|
392
|
+
},
|
|
393
|
+
h('span', { style: S.knob(props.on) }),
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function Segmented(props) {
|
|
398
|
+
return h(
|
|
399
|
+
'div',
|
|
400
|
+
{ style: S.segment },
|
|
401
|
+
props.options.map((option) =>
|
|
402
|
+
h(
|
|
403
|
+
'button',
|
|
404
|
+
{
|
|
405
|
+
key: option.value,
|
|
406
|
+
type: 'button',
|
|
407
|
+
style: S.segItem(props.value === option.value),
|
|
408
|
+
onClick: () => props.onChange(option.value),
|
|
409
|
+
},
|
|
410
|
+
option.label,
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 三态开关:开 / 关 / 跟随。
|
|
418
|
+
*
|
|
419
|
+
* 「跟随」必须是独立可点的第三个选项:显式关掉与「不表态」是两件事,
|
|
420
|
+
* 用「点两次循环」或和「开」共用一个按钮都表达不出来。选中态沿用分段控件的
|
|
421
|
+
* 浅底样式,不引新样式。
|
|
422
|
+
*/
|
|
423
|
+
const TRI_STATE = [
|
|
424
|
+
{ value: 'on', label: '开', title: '显式打开(压过组)' },
|
|
425
|
+
{ value: 'off', label: '关', title: '显式关闭(压过组)' },
|
|
426
|
+
{ value: 'follow', label: '跟随', title: '不记录,按组与默认求值' },
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
function triStateOf(value, allowInherit) {
|
|
430
|
+
if (value === true) return 'on'
|
|
431
|
+
if (value === false) return 'off'
|
|
432
|
+
// default pack 没有上一层:未记录就是取内置缺省(开),显示成「开」而不是「跟随」。
|
|
433
|
+
return allowInherit === false ? 'on' : 'follow'
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function TriState(props) {
|
|
437
|
+
// `default` 是求值链的终点,没有「跟随」可言;两态就够,多一个按钮反而让人
|
|
438
|
+
// 以为还能往上找。
|
|
439
|
+
const allowInherit = props.allowInherit !== false
|
|
440
|
+
const active = triStateOf(props.value, allowInherit)
|
|
441
|
+
const options = allowInherit ? TRI_STATE : TRI_STATE.filter((option) => option.value !== 'follow')
|
|
442
|
+
return h(
|
|
443
|
+
'div',
|
|
444
|
+
{ style: { display: 'inline-flex', gap: '4px', alignItems: 'center', flex: '0 0 auto' } },
|
|
445
|
+
options.map((option) =>
|
|
446
|
+
h(
|
|
447
|
+
'button',
|
|
448
|
+
{
|
|
449
|
+
key: option.value,
|
|
450
|
+
type: 'button',
|
|
451
|
+
title: option.title,
|
|
452
|
+
'aria-pressed': active === option.value,
|
|
453
|
+
disabled: props.disabled,
|
|
454
|
+
style: {
|
|
455
|
+
...S.segItem(active === option.value),
|
|
456
|
+
fontSize: '11px',
|
|
457
|
+
padding: '2px 7px',
|
|
458
|
+
opacity: props.disabled ? 0.45 : 1,
|
|
459
|
+
cursor: props.disabled ? 'not-allowed' : 'pointer',
|
|
460
|
+
},
|
|
461
|
+
onClick: () => {
|
|
462
|
+
if (props.disabled) return
|
|
463
|
+
props.onChange(option.value === 'on' ? true : option.value === 'off' ? false : null)
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
option.label,
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 删除的二次确认:第一次点变成「删除」(警告色),再点一次才执行。
|
|
474
|
+
*
|
|
475
|
+
* 不用弹窗:这类删除都发生在列表行里,弹窗要额外一次「关闭」动作;
|
|
476
|
+
* 行内二次确认把成本压到一次点击,误点的代价只是多看一眼。
|
|
477
|
+
* 进入确认态后 4 秒自动撤销,避免一直停在危险状态。
|
|
478
|
+
*/
|
|
479
|
+
function ConfirmButton(props) {
|
|
480
|
+
const [armed, setArmed] = useState(false)
|
|
481
|
+
const disabled = props.disabled === true
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
if (!armed) return undefined
|
|
484
|
+
const timer = setTimeout(() => setArmed(false), 4000)
|
|
485
|
+
return () => clearTimeout(timer)
|
|
486
|
+
}, [armed])
|
|
487
|
+
return h(
|
|
488
|
+
'button',
|
|
489
|
+
{
|
|
490
|
+
type: 'button',
|
|
491
|
+
disabled: disabled,
|
|
492
|
+
style: armed
|
|
493
|
+
? { ...S.reset, color: T.error, fontWeight: 600, whiteSpace: 'nowrap' }
|
|
494
|
+
: props.style || S.reset,
|
|
495
|
+
title: armed ? '再点一次确认删除' : props.title || '',
|
|
496
|
+
onClick: (event) => {
|
|
497
|
+
event.stopPropagation()
|
|
498
|
+
if (disabled) return
|
|
499
|
+
if (!armed) {
|
|
500
|
+
setArmed(true)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
setArmed(false)
|
|
504
|
+
props.onConfirm()
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
armed ? props.confirmLabel || '删除' : props.label || '✕',
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function Chevron() {
|
|
512
|
+
return h(
|
|
513
|
+
'svg',
|
|
514
|
+
{
|
|
515
|
+
width: 10,
|
|
516
|
+
height: 10,
|
|
517
|
+
viewBox: '0 0 10 10',
|
|
518
|
+
'aria-hidden': true,
|
|
519
|
+
style: { flex: '0 0 auto', opacity: 0.65 },
|
|
520
|
+
},
|
|
521
|
+
h('path', {
|
|
522
|
+
d: 'M2.5 4L5 6.5L7.5 4',
|
|
523
|
+
stroke: 'currentColor',
|
|
524
|
+
strokeWidth: 1.3,
|
|
525
|
+
fill: 'none',
|
|
526
|
+
strokeLinecap: 'round',
|
|
527
|
+
strokeLinejoin: 'round',
|
|
528
|
+
}),
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 下拉选择器。
|
|
534
|
+
*
|
|
535
|
+
* `options` 是 `[{ value, label }]`。点外部或按 Esc 关闭:监听挂在文档上,
|
|
536
|
+
* 不依赖父级的点击处理,所以嵌在可点击的列表行里也不会误触。
|
|
537
|
+
*/
|
|
538
|
+
function Picker(props) {
|
|
539
|
+
const [open, setOpen] = useState(false)
|
|
540
|
+
const [hover, setHover] = useState(null)
|
|
541
|
+
const boxRef = useRef(null)
|
|
542
|
+
const options = Array.isArray(props.options) ? props.options : []
|
|
543
|
+
let current = null
|
|
544
|
+
for (const option of options) {
|
|
545
|
+
if (option.value === props.value) current = option
|
|
546
|
+
}
|
|
547
|
+
const label = current === null ? props.placeholder || '' : current.label
|
|
548
|
+
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
if (!open) return undefined
|
|
551
|
+
const onDown = (event) => {
|
|
552
|
+
const node = boxRef.current
|
|
553
|
+
if (node && node.contains(event.target)) return
|
|
554
|
+
setOpen(false)
|
|
555
|
+
}
|
|
556
|
+
const onKey = (event) => {
|
|
557
|
+
if (event.key === 'Escape') setOpen(false)
|
|
558
|
+
}
|
|
559
|
+
document.addEventListener('mousedown', onDown, true)
|
|
560
|
+
document.addEventListener('keydown', onKey, true)
|
|
561
|
+
return () => {
|
|
562
|
+
document.removeEventListener('mousedown', onDown, true)
|
|
563
|
+
document.removeEventListener('keydown', onKey, true)
|
|
564
|
+
}
|
|
565
|
+
}, [open])
|
|
566
|
+
|
|
567
|
+
return h(
|
|
568
|
+
'div',
|
|
569
|
+
{ ref: boxRef, style: { position: 'relative', minWidth: props.minWidth || '120px' } },
|
|
570
|
+
h(
|
|
571
|
+
'button',
|
|
572
|
+
{
|
|
573
|
+
type: 'button',
|
|
574
|
+
style: { ...S.pickerTrigger, ...(props.triggerStyle || {}) },
|
|
575
|
+
title: props.title || label,
|
|
576
|
+
'aria-haspopup': 'listbox',
|
|
577
|
+
'aria-expanded': open,
|
|
578
|
+
onClick: (event) => {
|
|
579
|
+
event.stopPropagation()
|
|
580
|
+
setOpen(!open)
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
h('span', { style: S.pickerLabel }, label),
|
|
584
|
+
h(Chevron),
|
|
585
|
+
),
|
|
586
|
+
open
|
|
587
|
+
? h(
|
|
588
|
+
'div',
|
|
589
|
+
{ style: S.pickerMenu, role: 'listbox' },
|
|
590
|
+
options.map((option) =>
|
|
591
|
+
h(
|
|
592
|
+
'div',
|
|
593
|
+
{
|
|
594
|
+
key: option.value,
|
|
595
|
+
role: 'option',
|
|
596
|
+
'aria-selected': option.value === props.value,
|
|
597
|
+
style: S.pickerItem(option.value === props.value, hover === option.value),
|
|
598
|
+
onMouseEnter: () => setHover(option.value),
|
|
599
|
+
onMouseLeave: () => setHover(null),
|
|
600
|
+
onClick: (event) => {
|
|
601
|
+
event.stopPropagation()
|
|
602
|
+
setOpen(false)
|
|
603
|
+
props.onChange(option.value)
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
h('span', { style: S.pickerLabel }, option.label),
|
|
607
|
+
option.value === props.value ? h('span', { style: { opacity: 0.9 } }, '✓') : null,
|
|
608
|
+
),
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
: null,
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------- 宿主与求值 ----------
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 取 `ctx.remote`。
|
|
619
|
+
*
|
|
620
|
+
* 服务是否在 `ctx` 上由 `inject` 决定:声明了才可能有值。但 `ctx.get` 对取不到
|
|
621
|
+
* 的已声明服务是**抛错**而不是返回 undefined,所以探测必须包 try/catch——
|
|
622
|
+
* 这一行漏了会让整个 client 模块加载失败,桌面端直接起不来。
|
|
623
|
+
* 取不到就退化成 `remote: null`,工作区清单只剩 extra 里的路径,其它功能照常。
|
|
624
|
+
*/
|
|
625
|
+
function createHost(ctx) {
|
|
626
|
+
let remote = null
|
|
627
|
+
try {
|
|
628
|
+
remote = (ctx && ctx.remote) || null
|
|
629
|
+
} catch {
|
|
630
|
+
remote = null
|
|
631
|
+
}
|
|
632
|
+
if (remote === null || !remote.session) {
|
|
633
|
+
try {
|
|
634
|
+
const session = typeof ctx.get === 'function' ? ctx.get('remote.session') : undefined
|
|
635
|
+
if (session) remote = { session: session }
|
|
636
|
+
} catch {
|
|
637
|
+
// 取不到就保持 null。
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return { ctx: ctx, remote: remote }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* 工作区键归一,必须与服务端 `normalizeCwd` 逐字节一致:
|
|
645
|
+
* 反斜杠转正斜杠、去尾斜杠、Windows 下转小写、盘符根保留尾斜杠(`c:/`)。
|
|
646
|
+
* 这里只用它回读 `bindings`;发出去的 cwd 保持原样,由服务端自己归一。
|
|
647
|
+
*/
|
|
648
|
+
function normalizeCwd(cwd) {
|
|
649
|
+
if (typeof cwd !== 'string') return null
|
|
650
|
+
const trimmed = cwd.trim()
|
|
651
|
+
if (trimmed === '') return null
|
|
652
|
+
let unified = trimmed.replace(/\\/gu, '/')
|
|
653
|
+
if (isWindows()) unified = unified.toLowerCase()
|
|
654
|
+
if (unified.length > 1) {
|
|
655
|
+
const stripped = unified.replace(/\/+$/u, '')
|
|
656
|
+
if (stripped === '') return '/'
|
|
657
|
+
return /^[a-z]:$/iu.test(stripped) ? stripped + '/' : stripped
|
|
658
|
+
}
|
|
659
|
+
return unified
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isWindows() {
|
|
663
|
+
try {
|
|
664
|
+
if (typeof process !== 'undefined' && process && typeof process.platform === 'string') {
|
|
665
|
+
return process.platform === 'win32'
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
// 浏览器里没有 process。
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
return typeof navigator !== 'undefined' && /windows/i.test(String(navigator.userAgent || ''))
|
|
672
|
+
} catch {
|
|
673
|
+
return false
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** 读绑定:先精确匹配;历史键大小写不同时再比一次小写。 */
|
|
678
|
+
function bindingOf(bindings, cwd) {
|
|
679
|
+
if (!bindings || !bindings.bindings) return null
|
|
680
|
+
const key = normalizeCwd(cwd)
|
|
681
|
+
if (key === null) return null
|
|
682
|
+
const exact = bindings.bindings[key]
|
|
683
|
+
if (typeof exact === 'string') return exact
|
|
684
|
+
const lower = key.toLowerCase()
|
|
685
|
+
for (const candidate of Object.keys(bindings.bindings)) {
|
|
686
|
+
if (candidate.toLowerCase() === lower) return bindings.bindings[candidate]
|
|
687
|
+
}
|
|
688
|
+
return null
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function hasPack(packs, id) {
|
|
692
|
+
if (typeof id !== 'string') return false
|
|
693
|
+
for (const pack of packs || []) {
|
|
694
|
+
if (pack.id === id) return true
|
|
695
|
+
}
|
|
696
|
+
return false
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function packById(packs, id) {
|
|
700
|
+
if (!Array.isArray(packs)) return null
|
|
701
|
+
for (const pack of packs) {
|
|
702
|
+
if (pack.id === id) return pack
|
|
703
|
+
}
|
|
704
|
+
return packs.length > 0 ? packs[0] : null
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function defaultPackOf(packs) {
|
|
708
|
+
for (const pack of packs || []) {
|
|
709
|
+
if (pack.id === DEFAULT_PACK_ID) return pack
|
|
710
|
+
}
|
|
711
|
+
return null
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 内置 pack 的显示名。数据里仍是 `default`,不写盘,也不参与重名校验。 */
|
|
715
|
+
function packLabel(pack) {
|
|
716
|
+
if (pack === null || pack === undefined) return ''
|
|
717
|
+
return pack.builtin === true || pack.id === DEFAULT_PACK_ID ? '默认' : pack.name
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** pack 里的项级显式值;没有键返回 null(跟随)。 */
|
|
721
|
+
function itemValue(pack, name) {
|
|
722
|
+
const value = pack && pack.items ? pack.items[name] : undefined
|
|
723
|
+
return typeof value === 'boolean' ? value : null
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** pack 里的组级显式值;没有键返回 null(跟随)。 */
|
|
727
|
+
function groupValue(pack, groupId) {
|
|
728
|
+
const value = pack && pack.groups ? pack.groups[groupId] : undefined
|
|
729
|
+
return typeof value === 'boolean' ? value : null
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function groupIdOf(row) {
|
|
733
|
+
return row.groupId || null
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 排序档位:显式开 0、显式关 1、跟随且落到开 2、跟随且落到关 3。
|
|
738
|
+
*
|
|
739
|
+
* 两种跟随在界面上长得一样,但一个等于开、一个等于关,混在同一档里
|
|
740
|
+
* 用户没法按状态扫读。
|
|
741
|
+
*
|
|
742
|
+
* `default` 没有上一层,它的「未记录」就等于开,所以直接归 0——否则同一屏会
|
|
743
|
+
* 出现「排在跟随档、按钮却亮在开上」的错位。
|
|
744
|
+
*/
|
|
745
|
+
function rankOf(value, isDefault, inherit) {
|
|
746
|
+
if (value === true) return 0
|
|
747
|
+
if (value === false) return 1
|
|
748
|
+
if (isDefault) return 0
|
|
749
|
+
return inherit ? 2 : 3
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function groupNameOf(groups, groupId) {
|
|
753
|
+
for (const group of groups || []) {
|
|
754
|
+
if (group.id === groupId) return group.name
|
|
755
|
+
}
|
|
756
|
+
return '未分组'
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* `ui` 段。
|
|
761
|
+
*
|
|
762
|
+
* 服务端一定有 `ui`;旧面板靠 `policy` 投影兜底的那条分支随兼容层一起删掉了。
|
|
763
|
+
*/
|
|
764
|
+
function uiOf(view) {
|
|
765
|
+
if (!view) return null
|
|
766
|
+
if (view.ui) return view.ui
|
|
767
|
+
return { showOfficialSkills: false, showChip: true, defaultTab: 'groups' }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 已知工作区:从会话列表里取 cwd,排除子代理会话,按最近更新倒序去重,
|
|
772
|
+
* 当前会话所在目录置顶。取不到会话时返回空列表——面板仍能编辑 pack,
|
|
773
|
+
* 只是绑定块里只剩手动添加的路径。
|
|
774
|
+
*/
|
|
775
|
+
async function listWorkspaceCwds(host, sessionId) {
|
|
776
|
+
const session = host && host.remote ? host.remote.session : null
|
|
777
|
+
if (!session || typeof session.list !== 'function') return []
|
|
778
|
+
let result
|
|
779
|
+
try {
|
|
780
|
+
result = await session.list({})
|
|
781
|
+
} catch {
|
|
782
|
+
return []
|
|
783
|
+
}
|
|
784
|
+
if (!result || result.ok !== true) return []
|
|
785
|
+
const items = result.value && Array.isArray(result.value.items) ? result.value.items : []
|
|
786
|
+
const roots = items.filter(
|
|
787
|
+
(item) => item && typeof item.cwd === 'string' && item.cwd !== '' && item.origin !== 'subagent',
|
|
788
|
+
)
|
|
789
|
+
let pinned = null
|
|
790
|
+
if (typeof sessionId === 'string' && sessionId !== '') {
|
|
791
|
+
for (const item of roots) {
|
|
792
|
+
const id = item.sessionId ?? item.id ?? (item.session && item.session.id)
|
|
793
|
+
if (id === sessionId) {
|
|
794
|
+
pinned = item.cwd
|
|
795
|
+
break
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
roots.sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0))
|
|
800
|
+
const cwds = []
|
|
801
|
+
if (pinned !== null) cwds.push(pinned)
|
|
802
|
+
for (const item of roots) {
|
|
803
|
+
if (cwds.indexOf(item.cwd) < 0) cwds.push(item.cwd)
|
|
804
|
+
if (cwds.length >= MAX_WORKSPACES) break
|
|
805
|
+
}
|
|
806
|
+
return cwds
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ---------- 面板 ----------
|
|
810
|
+
|
|
811
|
+
function useGlobalView() {
|
|
812
|
+
const [state, setState] = useState({ loading: true, error: null, data: null })
|
|
813
|
+
// 单调序号:连点时多个请求同时在途,旧响应可能后到;只认最后一次。
|
|
814
|
+
const seq = useRef(0)
|
|
815
|
+
|
|
816
|
+
const load = useCallback(async () => {
|
|
817
|
+
const mine = (seq.current += 1)
|
|
818
|
+
try {
|
|
819
|
+
// 带当前会话:服务端用它的 scope 读清单,否则会退回到 list() 的第一个。
|
|
820
|
+
const data = await api.global(env.sessionId)
|
|
821
|
+
if (mine === seq.current) setState({ loading: false, error: null, data: data })
|
|
822
|
+
return data
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (mine === seq.current) {
|
|
825
|
+
setState({ loading: false, error: String(error.message || error), data: null })
|
|
826
|
+
}
|
|
827
|
+
return null
|
|
828
|
+
}
|
|
829
|
+
}, [])
|
|
830
|
+
|
|
831
|
+
useEffect(() => {
|
|
832
|
+
void load()
|
|
833
|
+
}, [load])
|
|
834
|
+
|
|
835
|
+
const run = useCallback(
|
|
836
|
+
async (commands) => {
|
|
837
|
+
const mine = (seq.current += 1)
|
|
838
|
+
try {
|
|
839
|
+
const data = await api.apply(commands)
|
|
840
|
+
if (mine === seq.current) {
|
|
841
|
+
setState({ loading: false, error: null, data: data })
|
|
842
|
+
// 绑定改了要把 chip 上的 pack 名一起更新:chip 直接读当前会话的工作区视图,
|
|
843
|
+
// 与设置页之间没有通道,只能在这里主动重读一次。
|
|
844
|
+
if (commands.some((command) => command.op === 'bind')) refreshChipName(env.sessionId)
|
|
845
|
+
}
|
|
846
|
+
return true
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (mine === seq.current) {
|
|
849
|
+
setState((prev) => ({ ...prev, error: String(error.message || error) }))
|
|
850
|
+
}
|
|
851
|
+
await load()
|
|
852
|
+
return false
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
[load],
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
return { state, run, reload: load }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** 新建分组。组定义跨工作区共享,建一次处处可用。 */
|
|
862
|
+
function GroupCreator(props) {
|
|
863
|
+
const [name, setName] = useState('')
|
|
864
|
+
const submit = () => {
|
|
865
|
+
const trimmed = name.trim()
|
|
866
|
+
if (trimmed === '') return
|
|
867
|
+
props.onCreate(trimmed)
|
|
868
|
+
setName('')
|
|
869
|
+
}
|
|
870
|
+
return h(
|
|
871
|
+
'div',
|
|
872
|
+
{ style: { marginTop: '0', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
873
|
+
h('input', {
|
|
874
|
+
style: { ...S.input, flex: '0 0 200px' },
|
|
875
|
+
placeholder: '新分组名',
|
|
876
|
+
value: name,
|
|
877
|
+
onChange: (event) => setName(event.target.value),
|
|
878
|
+
onKeyDown: (event) => {
|
|
879
|
+
if (event.key === 'Enter') submit()
|
|
880
|
+
},
|
|
881
|
+
}),
|
|
882
|
+
h(
|
|
883
|
+
'button',
|
|
884
|
+
{
|
|
885
|
+
type: 'button',
|
|
886
|
+
style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
|
|
887
|
+
onClick: submit,
|
|
888
|
+
},
|
|
889
|
+
'新建分组',
|
|
890
|
+
),
|
|
891
|
+
h('span', { style: S.hint }, '分组对全部 pack 共享;这里改的是成员归属,不是开关。'),
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---------- 弹层 ----------
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* 当前 pack 相对 default 的差异:分组在上,技能在下。
|
|
899
|
+
*
|
|
900
|
+
* 只列「记录过、且与 default 取值不同」的键。没有键是「不表态」,与 default
|
|
901
|
+
* 同值的记录不改变任何结果——两者都不算「差在哪」。
|
|
902
|
+
* 没绑 pack 时的生效值是 `default.items ?? default.groups[该技能的组] ?? true`。
|
|
903
|
+
*/
|
|
904
|
+
function diffOf(data) {
|
|
905
|
+
const packs = Array.isArray(data.packs) ? data.packs : []
|
|
906
|
+
const pack = packById(packs, data.packId)
|
|
907
|
+
const fallback = defaultPackOf(packs)
|
|
908
|
+
const assignments = data.groups && data.groups.assignments ? data.groups.assignments : {}
|
|
909
|
+
const rows = []
|
|
910
|
+
// 组已从注册表删掉时没有名字可显示:那是悬空引用,不是差异。
|
|
911
|
+
const registry = (data.groups && data.groups.groups) || []
|
|
912
|
+
for (const group of registry) {
|
|
913
|
+
const own = groupValue(pack, group.id)
|
|
914
|
+
if (own === null) continue
|
|
915
|
+
if (own === (groupValue(fallback, group.id) ?? true)) continue
|
|
916
|
+
rows.push({ key: 'group:' + group.id, isGroup: true, name: group.name, on: own })
|
|
917
|
+
}
|
|
918
|
+
for (const skill of data.skills || []) {
|
|
919
|
+
const own = itemValue(pack, skill.name)
|
|
920
|
+
if (own === null) continue
|
|
921
|
+
const gid = assignments[skill.name] || null
|
|
922
|
+
const base = itemValue(fallback, skill.name) ?? (gid === null ? null : groupValue(fallback, gid))
|
|
923
|
+
if (own === (base ?? true)) continue
|
|
924
|
+
rows.push({ key: 'skill:' + skill.name, isGroup: false, name: skill.name, on: own })
|
|
925
|
+
}
|
|
926
|
+
return rows
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* chip 的弹层:这张 pack 与默认差在哪,就地切换。
|
|
931
|
+
*
|
|
932
|
+
* 选「默认」发 `packId: null`(解除绑定),不是写一个 default 的 id——
|
|
933
|
+
* 未绑定与悬空引用在服务端是同一种状态。
|
|
934
|
+
*/
|
|
935
|
+
function ChipModal(props) {
|
|
936
|
+
const open = props.open === true
|
|
937
|
+
const sessionId = props.sessionId
|
|
938
|
+
const [state, setState] = useState({ loading: false, error: null, data: null })
|
|
939
|
+
|
|
940
|
+
useEffect(() => {
|
|
941
|
+
if (!open) return undefined
|
|
942
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined
|
|
943
|
+
let alive = true
|
|
944
|
+
setState((prev) => ({ loading: prev.data === null, error: null, data: prev.data }))
|
|
945
|
+
api.workspace(sessionId).then(
|
|
946
|
+
(data) => {
|
|
947
|
+
if (alive) setState({ loading: false, error: null, data: data })
|
|
948
|
+
},
|
|
949
|
+
(error) => {
|
|
950
|
+
if (alive) setState({ loading: false, error: String(error.message || error), data: null })
|
|
951
|
+
},
|
|
952
|
+
)
|
|
953
|
+
return () => {
|
|
954
|
+
alive = false
|
|
955
|
+
}
|
|
956
|
+
}, [open, sessionId])
|
|
957
|
+
|
|
958
|
+
if (!open) return null
|
|
959
|
+
|
|
960
|
+
const data = state.data
|
|
961
|
+
const bindTo = (value) => {
|
|
962
|
+
const cwd = data ? data.cwd : null
|
|
963
|
+
if (typeof cwd !== 'string' || cwd === '') return
|
|
964
|
+
// 带 sessionId 才拿得到 WorkspaceView(`binding` / `cwd`);不带只回 GlobalView。
|
|
965
|
+
api.apply([{ op: 'bind', cwd: cwd, packId: value === DEFAULT_PACK_ID ? null : value }], sessionId).then(
|
|
966
|
+
(view) => {
|
|
967
|
+
setState({ loading: false, error: null, data: view })
|
|
968
|
+
chipName.set(view && view.packId !== DEFAULT_PACK_ID ? view.packName : null)
|
|
969
|
+
},
|
|
970
|
+
(error) => setState((prev) => ({ ...prev, error: String(error.message || error) })),
|
|
971
|
+
)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// 生效的 pack 就是 default 时没有差异可言:整块只留一句说明。
|
|
975
|
+
const usingDefault = data !== null && (data.binding === null || data.packId === DEFAULT_PACK_ID)
|
|
976
|
+
const rows = data !== null && !usingDefault ? diffOf(data) : []
|
|
977
|
+
const bound =
|
|
978
|
+
data !== null && data.binding !== null && hasPack(data.packs, data.binding) ? data.binding : DEFAULT_PACK_ID
|
|
979
|
+
|
|
980
|
+
return h(
|
|
981
|
+
'div',
|
|
982
|
+
{ style: S.modalBack, onClick: props.onClose },
|
|
983
|
+
h(
|
|
984
|
+
'div',
|
|
985
|
+
{ style: S.modal, onClick: (event) => event.stopPropagation() },
|
|
986
|
+
// 插件名常驻在标题上方:这一排 chip 只靠图标区分,弹层里不写明是哪个插件,
|
|
987
|
+
// 从图标点进来的人会先愣一下。
|
|
988
|
+
h('div', { style: { ...S.hint, fontSize: '11px' } }, TITLE),
|
|
989
|
+
h(
|
|
990
|
+
'div',
|
|
991
|
+
{ style: { display: 'flex', alignItems: 'baseline', gap: '10px', marginTop: '2px' } },
|
|
992
|
+
h('div', { style: S.title }, data === null ? '读取中' : usingDefault ? '默认' : data.packName),
|
|
993
|
+
h('span', { style: { ...S.hint, marginLeft: 'auto' } }, '当前工作区'),
|
|
994
|
+
),
|
|
995
|
+
state.error ? h('div', { style: { ...S.error, marginTop: '8px' } }, state.error) : null,
|
|
996
|
+
h(Warnings, { warnings: data === null ? null : data.warnings }),
|
|
997
|
+
data === null
|
|
998
|
+
? h('div', { style: S.empty }, state.loading ? '加载中…' : '读不到当前工作区')
|
|
999
|
+
: h(
|
|
1000
|
+
'div',
|
|
1001
|
+
{ style: { marginTop: '10px' } },
|
|
1002
|
+
usingDefault
|
|
1003
|
+
? h('div', { style: S.hint }, '这个目录没有指定 pack,按默认运行。')
|
|
1004
|
+
: h(
|
|
1005
|
+
'div',
|
|
1006
|
+
null,
|
|
1007
|
+
h('div', { style: { ...S.hint, marginBottom: '4px' } }, '与默认相比'),
|
|
1008
|
+
rows.length === 0
|
|
1009
|
+
? h('div', { style: S.hint }, '没有任何改动。')
|
|
1010
|
+
: rows.map((row) =>
|
|
1011
|
+
h(
|
|
1012
|
+
'div',
|
|
1013
|
+
{
|
|
1014
|
+
key: row.key,
|
|
1015
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 0' },
|
|
1016
|
+
},
|
|
1017
|
+
h(
|
|
1018
|
+
'span',
|
|
1019
|
+
{
|
|
1020
|
+
style: row.isGroup
|
|
1021
|
+
? { ...S.rowMain, fontWeight: 600, color: T.text }
|
|
1022
|
+
: { ...S.rowMain, ...S.rowName },
|
|
1023
|
+
},
|
|
1024
|
+
row.name,
|
|
1025
|
+
),
|
|
1026
|
+
h(
|
|
1027
|
+
'span',
|
|
1028
|
+
{ style: row.on ? S.badge : { ...S.badge, color: T.text } },
|
|
1029
|
+
row.on ? '开' : '关',
|
|
1030
|
+
),
|
|
1031
|
+
),
|
|
1032
|
+
),
|
|
1033
|
+
),
|
|
1034
|
+
h(
|
|
1035
|
+
'div',
|
|
1036
|
+
{ style: { ...S.hint, marginTop: '14px', marginBottom: '6px' } },
|
|
1037
|
+
'给这个目录换一张 pack',
|
|
1038
|
+
),
|
|
1039
|
+
h(Picker, {
|
|
1040
|
+
value: bound,
|
|
1041
|
+
options: [
|
|
1042
|
+
{ value: DEFAULT_PACK_ID, label: '默认' },
|
|
1043
|
+
...data.packs
|
|
1044
|
+
.filter((pack) => pack.id !== DEFAULT_PACK_ID)
|
|
1045
|
+
.map((pack) => ({ value: pack.id, label: pack.name })),
|
|
1046
|
+
],
|
|
1047
|
+
onChange: (next) => bindTo(next),
|
|
1048
|
+
}),
|
|
1049
|
+
h(
|
|
1050
|
+
'div',
|
|
1051
|
+
{ style: { ...S.hint, marginTop: '6px' } },
|
|
1052
|
+
'保存后,这个目录里已开着的会话也会在下一个回合边界重新求值。',
|
|
1053
|
+
),
|
|
1054
|
+
),
|
|
1055
|
+
h(
|
|
1056
|
+
'div',
|
|
1057
|
+
{ style: { display: 'flex', justifyContent: 'flex-end', marginTop: '14px' } },
|
|
1058
|
+
h(
|
|
1059
|
+
'button',
|
|
1060
|
+
{
|
|
1061
|
+
type: 'button',
|
|
1062
|
+
style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
|
|
1063
|
+
onClick: props.onClose,
|
|
1064
|
+
},
|
|
1065
|
+
'关闭',
|
|
1066
|
+
),
|
|
1067
|
+
),
|
|
1068
|
+
),
|
|
1069
|
+
)
|
|
1070
|
+
}
|
|
1071
|
+
/** chip 与设置页共用的图标;`size` 缺省 12,设置页会传 16。 */
|
|
1072
|
+
function ChipIcon(props) {
|
|
1073
|
+
const size = props && props.size ? props.size : 12
|
|
1074
|
+
return h(
|
|
1075
|
+
'svg',
|
|
1076
|
+
{
|
|
1077
|
+
width: size,
|
|
1078
|
+
height: size,
|
|
1079
|
+
viewBox: '0 0 16 16',
|
|
1080
|
+
'aria-hidden': true,
|
|
1081
|
+
style: { display: 'block' },
|
|
1082
|
+
},
|
|
1083
|
+
h('path', {
|
|
1084
|
+
d: 'M3 4h10M3 8h6M3 12h8',
|
|
1085
|
+
stroke: 'currentColor',
|
|
1086
|
+
strokeWidth: 1.6,
|
|
1087
|
+
strokeLinecap: 'round',
|
|
1088
|
+
fill: 'none',
|
|
1089
|
+
}),
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------- 设置页 ----------
|
|
1094
|
+
|
|
1095
|
+
function SettingRow(props) {
|
|
1096
|
+
return h(
|
|
1097
|
+
'div',
|
|
1098
|
+
{
|
|
1099
|
+
style: {
|
|
1100
|
+
display: 'flex',
|
|
1101
|
+
alignItems: 'center',
|
|
1102
|
+
gap: '12px',
|
|
1103
|
+
padding: '10px 0',
|
|
1104
|
+
borderTop: '1px solid ' + T.border,
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
h(
|
|
1108
|
+
'div',
|
|
1109
|
+
{ style: { flex: '1 1 auto', minWidth: 0 } },
|
|
1110
|
+
h('div', { style: { color: T.text, fontSize: '13px' } }, props.label),
|
|
1111
|
+
props.hint ? h('div', { style: { color: T.dim, fontSize: '11px', marginTop: '2px' } }, props.hint) : null,
|
|
1112
|
+
),
|
|
1113
|
+
h(Switch, { on: props.on, onChange: props.onChange }),
|
|
1114
|
+
)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** 设置页的三页,顺序固定:分组 → 分包 → 工作区。 */
|
|
1118
|
+
const TABS = [
|
|
1119
|
+
{ value: 'groups', label: '分组' },
|
|
1120
|
+
{ value: 'packs', label: '分包' },
|
|
1121
|
+
{ value: 'workspace', label: '工作区' },
|
|
1122
|
+
]
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 设置 → Skill Pack。
|
|
1126
|
+
*
|
|
1127
|
+
* 三页:分组管结构(项与分组),分包管 pack 名册与包内开关,工作区管目录到
|
|
1128
|
+
* pack 的绑定。「当前页」与「默认页」是两件事:切页只改当前页,打开设置时落在
|
|
1129
|
+
* 哪一页由顶部那组单选写盘(`ui.defaultTab`)——两者混成一个状态时,
|
|
1130
|
+
* 「临时看一眼分包」会顺手改掉用户的默认页。
|
|
1131
|
+
*/
|
|
1132
|
+
function SettingsPanel(props) {
|
|
1133
|
+
const host = props.host
|
|
1134
|
+
const { state, run } = useGlobalView()
|
|
1135
|
+
// 当前页只由分页按钮切;初值取 `ui.defaultTab`,之后不再跟随任何外部请求。
|
|
1136
|
+
const [tab, setTab] = useState(null)
|
|
1137
|
+
|
|
1138
|
+
const data = state.data
|
|
1139
|
+
const ui = uiOf(data)
|
|
1140
|
+
const defaultTab = ui ? ui.defaultTab : 'groups'
|
|
1141
|
+
const current = tab !== null ? tab : defaultTab
|
|
1142
|
+
|
|
1143
|
+
if (state.loading) return h('div', { style: S.hint }, '加载中…')
|
|
1144
|
+
if (!data) return h('div', { style: S.error }, state.error || '读取失败')
|
|
1145
|
+
|
|
1146
|
+
return h(
|
|
1147
|
+
'div',
|
|
1148
|
+
{ style: { fontSize: '13px', color: T.text } },
|
|
1149
|
+
h('div', { style: S.title }, TITLE),
|
|
1150
|
+
h(
|
|
1151
|
+
'div',
|
|
1152
|
+
{ style: { ...S.hint, marginTop: '4px' } },
|
|
1153
|
+
'一张 pack 是一套具名策略:工作区绑到哪个 pack,就按哪套开关生效;未绑定按默认。',
|
|
1154
|
+
),
|
|
1155
|
+
h(
|
|
1156
|
+
'div',
|
|
1157
|
+
{ style: { ...S.bar, marginTop: '10px', marginBottom: '0' } },
|
|
1158
|
+
h('span', { style: S.hint }, '打开设置时默认显示'),
|
|
1159
|
+
h(
|
|
1160
|
+
'div',
|
|
1161
|
+
{ style: { flex: '0 0 auto', minWidth: '120px' } },
|
|
1162
|
+
h(Picker, {
|
|
1163
|
+
value: defaultTab,
|
|
1164
|
+
options: TABS.map((entry) => ({ value: entry.value, label: entry.label })),
|
|
1165
|
+
onChange: (next) => run([{ op: 'defaultTab', value: next }]),
|
|
1166
|
+
}),
|
|
1167
|
+
),
|
|
1168
|
+
),
|
|
1169
|
+
h(
|
|
1170
|
+
'div',
|
|
1171
|
+
{ style: { marginTop: '10px' } },
|
|
1172
|
+
h(Segmented, { value: current, onChange: setTab, options: TABS }),
|
|
1173
|
+
),
|
|
1174
|
+
state.error ? h('div', { style: { ...S.error, marginTop: '8px' } }, state.error) : null,
|
|
1175
|
+
h(Warnings, { warnings: data.warnings }),
|
|
1176
|
+
// 「显示官方技能」只影响列表:分组页要它才看得见官方技能,分包页要靠它
|
|
1177
|
+
// 才能给官方技能设开关。工作区页是绑定表,与列不列官方项无关,因此不显示。
|
|
1178
|
+
current === 'workspace'
|
|
1179
|
+
? null
|
|
1180
|
+
: h(SettingRow, {
|
|
1181
|
+
label: '显示官方技能',
|
|
1182
|
+
hint: '官方 @deepseek-ai 技能默认不上面板,但模型仍可调用;打开后可逐项关闭。',
|
|
1183
|
+
on: ui.showOfficialSkills === true,
|
|
1184
|
+
onChange: (value) => run([{ op: 'showOfficialSkills', value: value }]),
|
|
1185
|
+
}),
|
|
1186
|
+
current === 'groups'
|
|
1187
|
+
? h(
|
|
1188
|
+
'div',
|
|
1189
|
+
null,
|
|
1190
|
+
h(
|
|
1191
|
+
'div',
|
|
1192
|
+
{ style: { marginTop: '12px' } },
|
|
1193
|
+
h(SettingRow, {
|
|
1194
|
+
label: '在输入框右侧显示入口',
|
|
1195
|
+
hint: '关掉后会话输入框右侧不再出现 Skills chip;pack 绑定仍可在「工作区」页操作。立刻生效。',
|
|
1196
|
+
on: ui.showChip !== false,
|
|
1197
|
+
onChange: (value) =>
|
|
1198
|
+
run([{ op: 'showChip', value: value }]).then((ok) => {
|
|
1199
|
+
if (!ok) return
|
|
1200
|
+
if (env.onChipChange) env.onChipChange(value)
|
|
1201
|
+
}),
|
|
1202
|
+
}),
|
|
1203
|
+
),
|
|
1204
|
+
h(GroupsPage, { data: data, run: run }),
|
|
1205
|
+
)
|
|
1206
|
+
: null,
|
|
1207
|
+
current === 'packs' ? h(PackSettings, { data: data, run: run }) : null,
|
|
1208
|
+
current === 'workspace' ? h(WorkspacePage, { data: data, run: run, host: host }) : null,
|
|
1209
|
+
)
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* 「分组」页:只做结构,不做策略。
|
|
1214
|
+
*
|
|
1215
|
+
* 按分组折叠展示成员,展开后在行内直接改归属;开关不在这里——它属于 pack,
|
|
1216
|
+
* 由「分包」页按 pack 设置。一页只做一件事,否则同一个开关会有两个入口。
|
|
1217
|
+
*
|
|
1218
|
+
* 顺序是「新建 → 搜索 → 列表」:新建入口在列表底下时,用户要滚到最底才看得见。
|
|
1219
|
+
* 搜索同时匹配分组名——搜「三维」多半是想找那一组,而不是名字里恰好含这两个
|
|
1220
|
+
* 字的项,所以组名命中时整组保留。
|
|
1221
|
+
*/
|
|
1222
|
+
function GroupsPage(props) {
|
|
1223
|
+
const data = props.data
|
|
1224
|
+
const run = props.run
|
|
1225
|
+
const [query, setQuery] = useState('')
|
|
1226
|
+
const [collapsed, setCollapsed] = useState({})
|
|
1227
|
+
const [editing, setEditing] = useState(null)
|
|
1228
|
+
/** 批量归组的目标分组;`''` 表示未分组。 */
|
|
1229
|
+
const [bulkGroup, setBulkGroup] = useState('')
|
|
1230
|
+
|
|
1231
|
+
const groups = data.groups.groups
|
|
1232
|
+
const needle = query.trim().toLowerCase()
|
|
1233
|
+
|
|
1234
|
+
const buckets = useMemo(() => {
|
|
1235
|
+
const matchRow = (row) =>
|
|
1236
|
+
needle === '' ? true : (row.name + ' ' + row.description).toLowerCase().indexOf(needle) >= 0
|
|
1237
|
+
const byId = new Map()
|
|
1238
|
+
const loose = []
|
|
1239
|
+
for (const row of data.skills) {
|
|
1240
|
+
const gid = row.groupId
|
|
1241
|
+
if (gid === null || gid === undefined || gid === '' || !groups.some((group) => group.id === gid)) {
|
|
1242
|
+
if (matchRow(row)) loose.push(row)
|
|
1243
|
+
continue
|
|
1244
|
+
}
|
|
1245
|
+
if (!byId.has(gid)) byId.set(gid, [])
|
|
1246
|
+
byId.get(gid).push(row)
|
|
1247
|
+
}
|
|
1248
|
+
const byName = (a, b) => a.name.localeCompare(b.name, 'zh')
|
|
1249
|
+
// 未分组恒在最上:新装的项先落这里,用户第一眼要看到的就是它。
|
|
1250
|
+
const out = [{ id: null, name: '未分组', rows: loose.sort(byName) }]
|
|
1251
|
+
for (const group of [...groups].sort((a, b) => a.name.localeCompare(b.name, 'zh'))) {
|
|
1252
|
+
const members = byId.get(group.id) || []
|
|
1253
|
+
const groupHit = needle !== '' && group.name.toLowerCase().indexOf(needle) >= 0
|
|
1254
|
+
out.push({
|
|
1255
|
+
id: group.id,
|
|
1256
|
+
name: group.name,
|
|
1257
|
+
rows: (groupHit ? members : members.filter(matchRow)).sort(byName),
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
return out
|
|
1261
|
+
}, [data.skills, groups, needle])
|
|
1262
|
+
|
|
1263
|
+
const searching = needle !== ''
|
|
1264
|
+
const total = buckets.reduce((sum, bucket) => sum + bucket.rows.length, 0)
|
|
1265
|
+
/** 当前筛选命中的项(跨分组拍平),批量归组的对象。 */
|
|
1266
|
+
const matched = buckets.reduce((list, bucket) => list.concat(bucket.rows), [])
|
|
1267
|
+
const assignTo = (row, groupId) =>
|
|
1268
|
+
run([{ op: 'assign', name: row.name, groupId: groupId === '' ? null : groupId }])
|
|
1269
|
+
// 一次 apply 带上 N 条 assign,只发一条请求:逐个发在几十项时会被乱序返回的
|
|
1270
|
+
// 响应来回覆盖。
|
|
1271
|
+
const assignAll = () => {
|
|
1272
|
+
if (matched.length === 0) return
|
|
1273
|
+
run(
|
|
1274
|
+
matched.map((row) => ({
|
|
1275
|
+
op: 'assign',
|
|
1276
|
+
name: row.name,
|
|
1277
|
+
groupId: bulkGroup === '' ? null : bulkGroup,
|
|
1278
|
+
})),
|
|
1279
|
+
)
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return h(
|
|
1283
|
+
'div',
|
|
1284
|
+
{ style: { marginTop: '12px' } },
|
|
1285
|
+
h(
|
|
1286
|
+
'div',
|
|
1287
|
+
{ style: { ...S.hint, marginBottom: '6px' } },
|
|
1288
|
+
'点分组展开成员,在行内直接改归属。开关不在这里——去「分包」页按 pack 设置。',
|
|
1289
|
+
),
|
|
1290
|
+
h(GroupCreator, { onCreate: (name) => run([{ op: 'createGroup', name: name }]) }),
|
|
1291
|
+
h(
|
|
1292
|
+
'div',
|
|
1293
|
+
{ style: S.bar },
|
|
1294
|
+
h('input', {
|
|
1295
|
+
style: S.input,
|
|
1296
|
+
placeholder: '搜索名字、描述或分组名',
|
|
1297
|
+
value: query,
|
|
1298
|
+
onChange: (event) => setQuery(event.target.value),
|
|
1299
|
+
}),
|
|
1300
|
+
h('span', { style: S.hint }, total + ' 项'),
|
|
1301
|
+
),
|
|
1302
|
+
h(
|
|
1303
|
+
'div',
|
|
1304
|
+
{ style: { ...S.bar, marginTop: '0' } },
|
|
1305
|
+
h('span', { style: S.hint }, '把当前 ' + matched.length + ' 项归到'),
|
|
1306
|
+
h(
|
|
1307
|
+
'div',
|
|
1308
|
+
{ style: { flex: '0 0 auto', minWidth: '130px' } },
|
|
1309
|
+
h(Picker, {
|
|
1310
|
+
value: bulkGroup,
|
|
1311
|
+
options: [
|
|
1312
|
+
{ value: '', label: '未分组' },
|
|
1313
|
+
...groups.map((group) => ({ value: group.id, label: group.name })),
|
|
1314
|
+
],
|
|
1315
|
+
onChange: (next) => setBulkGroup(next),
|
|
1316
|
+
}),
|
|
1317
|
+
),
|
|
1318
|
+
h(
|
|
1319
|
+
'button',
|
|
1320
|
+
{
|
|
1321
|
+
type: 'button',
|
|
1322
|
+
disabled: matched.length === 0,
|
|
1323
|
+
style: {
|
|
1324
|
+
...S.segItem(false),
|
|
1325
|
+
border: '1px solid ' + T.border,
|
|
1326
|
+
borderRadius: '6px',
|
|
1327
|
+
opacity: matched.length === 0 ? 0.45 : 1,
|
|
1328
|
+
cursor: matched.length === 0 ? 'not-allowed' : 'pointer',
|
|
1329
|
+
},
|
|
1330
|
+
onClick: assignAll,
|
|
1331
|
+
},
|
|
1332
|
+
'应用',
|
|
1333
|
+
),
|
|
1334
|
+
),
|
|
1335
|
+
buckets.map((bucket) => {
|
|
1336
|
+
const key = bucket.id === null ? '__loose' : bucket.id
|
|
1337
|
+
// 组一多,全展开会把列表拉成长条:未分组默认展开,其余默认折叠。
|
|
1338
|
+
// 搜索时按「这一组有没有命中」决定展开,否则要么摊出一片「(空)」,
|
|
1339
|
+
// 要么把命中藏起来。
|
|
1340
|
+
const isCollapsed = searching
|
|
1341
|
+
? bucket.rows.length === 0
|
|
1342
|
+
: collapsed[key] === undefined
|
|
1343
|
+
? bucket.id !== null
|
|
1344
|
+
: collapsed[key] === true
|
|
1345
|
+
return h(
|
|
1346
|
+
'div',
|
|
1347
|
+
{ key: key, style: S.group },
|
|
1348
|
+
h(
|
|
1349
|
+
'div',
|
|
1350
|
+
{ style: S.groupHead, onClick: () => setCollapsed({ ...collapsed, [key]: !isCollapsed }) },
|
|
1351
|
+
h('span', { style: { opacity: 0.5, width: '12px' } }, isCollapsed ? '▸' : '▾'),
|
|
1352
|
+
// 未分组的 id 就是 null,与「没有在改名」共享同一个值,所以这里必须
|
|
1353
|
+
// 先判 bucket.id !== null,否则未分组一进来就被渲染成输入框。
|
|
1354
|
+
bucket.id !== null && editing === bucket.id
|
|
1355
|
+
? h('input', {
|
|
1356
|
+
autoFocus: true,
|
|
1357
|
+
defaultValue: bucket.name,
|
|
1358
|
+
style: S.groupInput,
|
|
1359
|
+
onClick: (event) => event.stopPropagation(),
|
|
1360
|
+
onBlur: (event) => {
|
|
1361
|
+
const trimmed = String(event.target.value || '').trim()
|
|
1362
|
+
setEditing(null)
|
|
1363
|
+
if (trimmed === '' || trimmed === bucket.name) return
|
|
1364
|
+
run([{ op: 'renameGroup', groupId: bucket.id, name: trimmed }])
|
|
1365
|
+
},
|
|
1366
|
+
onKeyDown: (event) => {
|
|
1367
|
+
if (event.key === 'Enter') event.target.blur()
|
|
1368
|
+
if (event.key === 'Escape') {
|
|
1369
|
+
event.target.value = bucket.name
|
|
1370
|
+
event.target.blur()
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
})
|
|
1374
|
+
: h('span', { style: S.groupName }, bucket.name),
|
|
1375
|
+
h('span', { style: S.badge }, bucket.rows.length + ' 项'),
|
|
1376
|
+
bucket.id !== null && editing !== bucket.id
|
|
1377
|
+
? h(
|
|
1378
|
+
'button',
|
|
1379
|
+
{
|
|
1380
|
+
type: 'button',
|
|
1381
|
+
style: S.reset,
|
|
1382
|
+
title: '重命名分组',
|
|
1383
|
+
onClick: (event) => {
|
|
1384
|
+
event.stopPropagation()
|
|
1385
|
+
setEditing(bucket.id)
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
'✎',
|
|
1389
|
+
)
|
|
1390
|
+
: null,
|
|
1391
|
+
bucket.id !== null
|
|
1392
|
+
? h(ConfirmButton, {
|
|
1393
|
+
title: '删除分组(成员回到未分组)',
|
|
1394
|
+
onConfirm: () => run([{ op: 'deleteGroup', groupId: bucket.id }]),
|
|
1395
|
+
})
|
|
1396
|
+
: null,
|
|
1397
|
+
),
|
|
1398
|
+
isCollapsed
|
|
1399
|
+
? null
|
|
1400
|
+
: bucket.rows.length === 0
|
|
1401
|
+
? h('div', { style: { ...S.empty, paddingLeft: '22px' } }, '(空)')
|
|
1402
|
+
: bucket.rows.map((row) =>
|
|
1403
|
+
h(
|
|
1404
|
+
'div',
|
|
1405
|
+
{ key: row.name, style: S.row },
|
|
1406
|
+
h(
|
|
1407
|
+
'div',
|
|
1408
|
+
{ style: S.rowMain },
|
|
1409
|
+
h(
|
|
1410
|
+
'div',
|
|
1411
|
+
{ style: S.rowName },
|
|
1412
|
+
row.name,
|
|
1413
|
+
row.origin ? h('span', { style: S.origin }, row.origin) : null,
|
|
1414
|
+
),
|
|
1415
|
+
h('div', { style: S.rowDesc, title: row.description }, row.description),
|
|
1416
|
+
),
|
|
1417
|
+
row.official ? h('span', { style: S.badge }, '官方') : null,
|
|
1418
|
+
h(
|
|
1419
|
+
'div',
|
|
1420
|
+
{ style: { flex: '0 0 auto', minWidth: '128px' } },
|
|
1421
|
+
h(Picker, {
|
|
1422
|
+
value: bucket.id === null ? '' : bucket.id,
|
|
1423
|
+
options: [
|
|
1424
|
+
{ value: '', label: '未分组' },
|
|
1425
|
+
...groups.map((group) => ({ value: group.id, label: group.name })),
|
|
1426
|
+
],
|
|
1427
|
+
onChange: (next) => assignTo(row, next),
|
|
1428
|
+
}),
|
|
1429
|
+
),
|
|
1430
|
+
),
|
|
1431
|
+
),
|
|
1432
|
+
)
|
|
1433
|
+
}),
|
|
1434
|
+
)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** 「分包」页:包名册 → 包内开关,自上而下两块。工作区绑定已独立成一页。 */
|
|
1438
|
+
function PackSettings(props) {
|
|
1439
|
+
const data = props.data
|
|
1440
|
+
const run = props.run
|
|
1441
|
+
const packs = data.packs
|
|
1442
|
+
const defaultPack = defaultPackOf(packs)
|
|
1443
|
+
const [selected, setSelected] = useState(DEFAULT_PACK_ID)
|
|
1444
|
+
const [query, setQuery] = useState('')
|
|
1445
|
+
const [newName, setNewName] = useState('')
|
|
1446
|
+
const [editing, setEditing] = useState(null)
|
|
1447
|
+
const pack = packById(packs, selected)
|
|
1448
|
+
/** `default` 没有上一层,未记录直接显示成「开」,排序也按开算。 */
|
|
1449
|
+
const isDefault = pack === null || pack.id === DEFAULT_PACK_ID
|
|
1450
|
+
/** 已注册遮蔽但复验仍可见:同层被别的插件先占了,这条关不掉。 */
|
|
1451
|
+
const stuck = new Set(Array.isArray(data.stuck) ? data.stuck : [])
|
|
1452
|
+
|
|
1453
|
+
const items = useMemo(() => {
|
|
1454
|
+
const needle = query.trim().toLowerCase()
|
|
1455
|
+
// 命中组名等于命中整组:在分包页搜「前端」应该把该组的所有项都筛出来,
|
|
1456
|
+
// 而不是因为项名里没有「前端」两个字就报没结果。
|
|
1457
|
+
const hitGroups = new Set()
|
|
1458
|
+
if (needle !== '') {
|
|
1459
|
+
for (const group of data.groups.groups) {
|
|
1460
|
+
if (group.name.toLowerCase().indexOf(needle) >= 0) hitGroups.add(group.id)
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const visible =
|
|
1464
|
+
needle === ''
|
|
1465
|
+
? [...data.skills]
|
|
1466
|
+
: data.skills.filter((row) => {
|
|
1467
|
+
if ((row.name + ' ' + row.description).toLowerCase().indexOf(needle) >= 0) return true
|
|
1468
|
+
const gid = groupIdOf(row)
|
|
1469
|
+
return gid !== null && hitGroups.has(gid)
|
|
1470
|
+
})
|
|
1471
|
+
// 顺序是「开 > 关 > 跟随(开) > 跟随(关)」:显式决定过的排在前面,
|
|
1472
|
+
// 跟随项再按它实际会落到开还是关分档。同档按名字排。
|
|
1473
|
+
// 「跟随」会落到哪:选中 pack 的组记录优先,其余由宿主给的 default 链答案决定。
|
|
1474
|
+
const landing = (row) => groupValue(pack, groupIdOf(row)) ?? row.openGlobal
|
|
1475
|
+
return visible.sort((a, b) => {
|
|
1476
|
+
const left = rankOf(itemValue(pack, a.name), isDefault, landing(a))
|
|
1477
|
+
const right = rankOf(itemValue(pack, b.name), isDefault, landing(b))
|
|
1478
|
+
if (left !== right) return left - right
|
|
1479
|
+
return a.name.localeCompare(b.name, 'zh')
|
|
1480
|
+
})
|
|
1481
|
+
}, [data.skills, data.groups.groups, data.groups.assignments, query, pack, defaultPack, isDefault])
|
|
1482
|
+
|
|
1483
|
+
/** 分组同样「开 > 关 > 跟随(开) > 跟随(关)」。 */
|
|
1484
|
+
const orderedGroups = useMemo(() => {
|
|
1485
|
+
return [...data.groups.groups].sort((a, b) => {
|
|
1486
|
+
// 选中 pack 没有组记录时,落在 default 的组记录上;再没有就是开。
|
|
1487
|
+
const left = rankOf(groupValue(pack, a.id), isDefault, groupValue(defaultPack, a.id) ?? true)
|
|
1488
|
+
const right = rankOf(groupValue(pack, b.id), isDefault, groupValue(defaultPack, b.id) ?? true)
|
|
1489
|
+
if (left !== right) return left - right
|
|
1490
|
+
return a.name.localeCompare(b.name, 'zh')
|
|
1491
|
+
})
|
|
1492
|
+
}, [data.groups.groups, pack, isDefault])
|
|
1493
|
+
|
|
1494
|
+
const groupCounts = useMemo(() => {
|
|
1495
|
+
const counts = {}
|
|
1496
|
+
for (const row of data.skills) {
|
|
1497
|
+
const gid = groupIdOf(row)
|
|
1498
|
+
if (gid === null) continue
|
|
1499
|
+
counts[gid] = (counts[gid] || 0) + 1
|
|
1500
|
+
}
|
|
1501
|
+
return counts
|
|
1502
|
+
}, [data.skills])
|
|
1503
|
+
|
|
1504
|
+
if (pack === null) return h('div', { style: S.hint }, '没有可用的 pack')
|
|
1505
|
+
|
|
1506
|
+
const createPack = () => {
|
|
1507
|
+
const trimmed = newName.trim()
|
|
1508
|
+
if (trimmed === '') return
|
|
1509
|
+
run([{ op: 'createPack', name: trimmed }])
|
|
1510
|
+
setNewName('')
|
|
1511
|
+
}
|
|
1512
|
+
const overrides = (entry) => Object.keys(entry.items).length + Object.keys(entry.groups).length
|
|
1513
|
+
|
|
1514
|
+
return h(
|
|
1515
|
+
'div',
|
|
1516
|
+
{ style: { marginTop: '12px' } },
|
|
1517
|
+
|
|
1518
|
+
// 1. pack 列表
|
|
1519
|
+
h(
|
|
1520
|
+
'div',
|
|
1521
|
+
{ style: S.block },
|
|
1522
|
+
h('div', { style: S.blockTitle }, 'pack'),
|
|
1523
|
+
packs.map((entry) =>
|
|
1524
|
+
h(
|
|
1525
|
+
'div',
|
|
1526
|
+
{
|
|
1527
|
+
key: entry.id,
|
|
1528
|
+
style: {
|
|
1529
|
+
...S.packRow,
|
|
1530
|
+
borderColor: entry.id === pack.id ? T.borderStrong : T.border,
|
|
1531
|
+
background: entry.id === pack.id ? 'rgba(127, 127, 127, 0.16)' : 'transparent',
|
|
1532
|
+
},
|
|
1533
|
+
onClick: () => setSelected(entry.id),
|
|
1534
|
+
},
|
|
1535
|
+
editing === entry.id
|
|
1536
|
+
? h('input', {
|
|
1537
|
+
autoFocus: true,
|
|
1538
|
+
defaultValue: entry.name,
|
|
1539
|
+
style: S.groupInput,
|
|
1540
|
+
onClick: (event) => event.stopPropagation(),
|
|
1541
|
+
onBlur: (event) => {
|
|
1542
|
+
const trimmed = String(event.target.value || '').trim()
|
|
1543
|
+
setEditing(null)
|
|
1544
|
+
if (trimmed === '' || trimmed === entry.name) return
|
|
1545
|
+
run([{ op: 'renamePack', packId: entry.id, name: trimmed }])
|
|
1546
|
+
},
|
|
1547
|
+
onKeyDown: (event) => {
|
|
1548
|
+
if (event.key === 'Enter') event.target.blur()
|
|
1549
|
+
if (event.key === 'Escape') {
|
|
1550
|
+
event.target.value = entry.name
|
|
1551
|
+
event.target.blur()
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
})
|
|
1555
|
+
: h('span', { style: S.groupName }, packLabel(entry)),
|
|
1556
|
+
h('span', { style: S.badge }, overrides(entry) + ' 条覆盖'),
|
|
1557
|
+
// `default` 没有改名与删除入口:它是求值链终点,删掉或改个名都会让
|
|
1558
|
+
// 「未绑定即默认」失去指代。
|
|
1559
|
+
entry.builtin
|
|
1560
|
+
? null
|
|
1561
|
+
: h(
|
|
1562
|
+
'button',
|
|
1563
|
+
{
|
|
1564
|
+
type: 'button',
|
|
1565
|
+
style: S.reset,
|
|
1566
|
+
title: '重命名 pack',
|
|
1567
|
+
onClick: (event) => {
|
|
1568
|
+
event.stopPropagation()
|
|
1569
|
+
setEditing(entry.id)
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
'✎',
|
|
1573
|
+
),
|
|
1574
|
+
entry.builtin
|
|
1575
|
+
? null
|
|
1576
|
+
: h(ConfirmButton, {
|
|
1577
|
+
title: '删除 pack(指向它的绑定会变成悬空引用,按默认求值)',
|
|
1578
|
+
onConfirm: () => {
|
|
1579
|
+
run([{ op: 'deletePack', packId: entry.id }])
|
|
1580
|
+
if (entry.id === selected) setSelected(DEFAULT_PACK_ID)
|
|
1581
|
+
},
|
|
1582
|
+
}),
|
|
1583
|
+
),
|
|
1584
|
+
),
|
|
1585
|
+
h(
|
|
1586
|
+
'div',
|
|
1587
|
+
{ style: { display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' } },
|
|
1588
|
+
h('input', {
|
|
1589
|
+
style: { ...S.input, flex: '0 0 200px' },
|
|
1590
|
+
placeholder: '新 pack 名',
|
|
1591
|
+
value: newName,
|
|
1592
|
+
onChange: (event) => setNewName(event.target.value),
|
|
1593
|
+
onKeyDown: (event) => {
|
|
1594
|
+
if (event.key === 'Enter') createPack()
|
|
1595
|
+
},
|
|
1596
|
+
}),
|
|
1597
|
+
h(
|
|
1598
|
+
'button',
|
|
1599
|
+
{
|
|
1600
|
+
type: 'button',
|
|
1601
|
+
style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
|
|
1602
|
+
onClick: createPack,
|
|
1603
|
+
},
|
|
1604
|
+
'新建 pack',
|
|
1605
|
+
),
|
|
1606
|
+
),
|
|
1607
|
+
),
|
|
1608
|
+
|
|
1609
|
+
// 2. 包内开关
|
|
1610
|
+
h(
|
|
1611
|
+
'div',
|
|
1612
|
+
{ style: S.block },
|
|
1613
|
+
h('div', { style: S.blockTitle }, '包内开关:' + packLabel(pack)),
|
|
1614
|
+
h(
|
|
1615
|
+
'div',
|
|
1616
|
+
{ style: { ...S.hint, marginBottom: '8px' } },
|
|
1617
|
+
'每个开关三态:开 / 关 / 跟随。「跟随」是独立的第三个选项——显式关掉与「这张 pack 不表态」是两件事。',
|
|
1618
|
+
),
|
|
1619
|
+
data.groups.groups.length === 0
|
|
1620
|
+
? h('div', { style: S.hint }, '还没有分组。到「分组」页新建,或直接在下面逐项设置。')
|
|
1621
|
+
: orderedGroups.map((group) => {
|
|
1622
|
+
const value = groupValue(pack, group.id)
|
|
1623
|
+
const inherited = groupValue(defaultPack, group.id) ?? true
|
|
1624
|
+
return h(
|
|
1625
|
+
'div',
|
|
1626
|
+
{
|
|
1627
|
+
key: group.id,
|
|
1628
|
+
style: {
|
|
1629
|
+
display: 'flex',
|
|
1630
|
+
alignItems: 'center',
|
|
1631
|
+
gap: '10px',
|
|
1632
|
+
padding: '4px 0',
|
|
1633
|
+
borderTop: '1px solid ' + T.border,
|
|
1634
|
+
},
|
|
1635
|
+
},
|
|
1636
|
+
h('span', { style: { ...S.groupName, flex: '1 1 auto' } }, group.name),
|
|
1637
|
+
h('span', { style: S.badge }, (groupCounts[group.id] || 0) + ' 项'),
|
|
1638
|
+
value === null && !isDefault
|
|
1639
|
+
? h('span', { style: { ...S.hint, whiteSpace: 'nowrap' } }, inherited ? '跟随(开)' : '跟随(关)')
|
|
1640
|
+
: null,
|
|
1641
|
+
h(TriState, {
|
|
1642
|
+
value: value,
|
|
1643
|
+
allowInherit: !isDefault,
|
|
1644
|
+
onChange: (next) => run([{ op: 'packGroup', packId: pack.id, groupId: group.id, value: next }]),
|
|
1645
|
+
}),
|
|
1646
|
+
)
|
|
1647
|
+
}),
|
|
1648
|
+
h('div', { style: { ...S.bar, marginTop: '10px' } },
|
|
1649
|
+
h('input', {
|
|
1650
|
+
style: S.input,
|
|
1651
|
+
placeholder: '搜索名字或描述',
|
|
1652
|
+
value: query,
|
|
1653
|
+
onChange: (event) => setQuery(event.target.value),
|
|
1654
|
+
}),
|
|
1655
|
+
h('span', { style: S.hint }, items.length + ' 项'),
|
|
1656
|
+
),
|
|
1657
|
+
items.length === 0
|
|
1658
|
+
? h('div', { style: S.empty }, '没有匹配的项')
|
|
1659
|
+
: items.map((row) => {
|
|
1660
|
+
const value = itemValue(pack, row.name)
|
|
1661
|
+
const gid = groupIdOf(row)
|
|
1662
|
+
const inherited = groupValue(pack, gid) ?? row.openGlobal
|
|
1663
|
+
return h(
|
|
1664
|
+
'div',
|
|
1665
|
+
{
|
|
1666
|
+
key: row.name,
|
|
1667
|
+
style: {
|
|
1668
|
+
display: 'flex',
|
|
1669
|
+
alignItems: 'center',
|
|
1670
|
+
gap: '10px',
|
|
1671
|
+
padding: '4px 0',
|
|
1672
|
+
borderTop: '1px solid ' + T.border,
|
|
1673
|
+
},
|
|
1674
|
+
},
|
|
1675
|
+
h(
|
|
1676
|
+
'div',
|
|
1677
|
+
{ style: S.rowMain },
|
|
1678
|
+
h('div', { style: S.rowName }, row.name, row.origin
|
|
1679
|
+
? h('span', { style: S.origin }, row.origin)
|
|
1680
|
+
: null),
|
|
1681
|
+
h('div', { style: S.rowDesc, title: row.description }, row.description),
|
|
1682
|
+
),
|
|
1683
|
+
h('span', { style: S.badge }, gid === null ? '未分组' : groupNameOf(data.groups.groups, gid)),
|
|
1684
|
+
stuck.has(row.name)
|
|
1685
|
+
? h(
|
|
1686
|
+
'span',
|
|
1687
|
+
{
|
|
1688
|
+
style: { ...S.badge, color: T.error, borderColor: T.error },
|
|
1689
|
+
title: '同层已被别的插件先注册,关闭不生效',
|
|
1690
|
+
},
|
|
1691
|
+
'关不掉',
|
|
1692
|
+
)
|
|
1693
|
+
: null,
|
|
1694
|
+
row.open === null
|
|
1695
|
+
? h(
|
|
1696
|
+
'span',
|
|
1697
|
+
{
|
|
1698
|
+
style: { ...S.badge, color: T.dim },
|
|
1699
|
+
title: '清单不完整:这一行此刻没有生效状态,改动会在清单补齐后生效',
|
|
1700
|
+
},
|
|
1701
|
+
'未生效',
|
|
1702
|
+
)
|
|
1703
|
+
: null,
|
|
1704
|
+
value === null && !isDefault
|
|
1705
|
+
? h('span', { style: { ...S.hint, whiteSpace: 'nowrap' } }, inherited ? '跟随(开)' : '跟随(关)')
|
|
1706
|
+
: null,
|
|
1707
|
+
h(TriState, {
|
|
1708
|
+
value: value,
|
|
1709
|
+
allowInherit: !isDefault,
|
|
1710
|
+
onChange: (next) => run([{ op: 'packItem', packId: pack.id, name: row.name, value: next }]),
|
|
1711
|
+
}),
|
|
1712
|
+
)
|
|
1713
|
+
}),
|
|
1714
|
+
),
|
|
1715
|
+
)
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* 「工作区」页:会话 cwd 去重 + 手动添加的路径,每行一个 pack 下拉。
|
|
1720
|
+
*
|
|
1721
|
+
* 顺序是「说明 → 手动添加 → 列表」:手动添加是入口,放到列表底下等于没有。
|
|
1722
|
+
* 绑定保存时就会对所有 live 会话重新求值(`reapply`),不是只影响新会话:
|
|
1723
|
+
* 每个 Agent 按自己的 cwd 重新读绑定,改动落在下一个回合边界。
|
|
1724
|
+
*/
|
|
1725
|
+
function WorkspacePage(props) {
|
|
1726
|
+
const data = props.data
|
|
1727
|
+
const run = props.run
|
|
1728
|
+
const host = props.host
|
|
1729
|
+
const [cwds, setCwds] = useState([])
|
|
1730
|
+
const [settled, setSettled] = useState(false)
|
|
1731
|
+
const [manual, setManual] = useState('')
|
|
1732
|
+
|
|
1733
|
+
useEffect(() => {
|
|
1734
|
+
let alive = true
|
|
1735
|
+
setSettled(false)
|
|
1736
|
+
listWorkspaceCwds(host, env.sessionId).then(
|
|
1737
|
+
(list) => {
|
|
1738
|
+
if (!alive) return
|
|
1739
|
+
setCwds(list)
|
|
1740
|
+
setSettled(true)
|
|
1741
|
+
},
|
|
1742
|
+
() => {
|
|
1743
|
+
if (!alive) return
|
|
1744
|
+
setCwds([])
|
|
1745
|
+
setSettled(true)
|
|
1746
|
+
},
|
|
1747
|
+
)
|
|
1748
|
+
return () => {
|
|
1749
|
+
alive = false
|
|
1750
|
+
}
|
|
1751
|
+
}, [host])
|
|
1752
|
+
|
|
1753
|
+
const extras = data.bindings && Array.isArray(data.bindings.extra) ? data.bindings.extra : []
|
|
1754
|
+
const paths = []
|
|
1755
|
+
const seen = {}
|
|
1756
|
+
for (const cwd of [...cwds, ...extras]) {
|
|
1757
|
+
if (typeof cwd !== 'string' || cwd === '') continue
|
|
1758
|
+
const key = normalizeCwd(cwd)
|
|
1759
|
+
if (key === null || seen[key] === true) continue
|
|
1760
|
+
seen[key] = true
|
|
1761
|
+
paths.push(cwd)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const addManual = () => {
|
|
1765
|
+
const trimmed = manual.trim()
|
|
1766
|
+
if (trimmed === '') return
|
|
1767
|
+
run([{ op: 'addExtra', cwd: trimmed }])
|
|
1768
|
+
setManual('')
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
return h(
|
|
1772
|
+
'div',
|
|
1773
|
+
{ style: { marginTop: '12px' } },
|
|
1774
|
+
h(
|
|
1775
|
+
'div',
|
|
1776
|
+
{ style: { ...S.hint, marginBottom: '8px' } },
|
|
1777
|
+
'保存后立即生效:该目录下已开着的会话也在下一个回合边界重新求值。未绑定即默认。',
|
|
1778
|
+
),
|
|
1779
|
+
h(
|
|
1780
|
+
'div',
|
|
1781
|
+
{ style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },
|
|
1782
|
+
h('input', {
|
|
1783
|
+
style: S.input,
|
|
1784
|
+
placeholder: '手动添加路径,例如 D:/work/repo',
|
|
1785
|
+
value: manual,
|
|
1786
|
+
onChange: (event) => setManual(event.target.value),
|
|
1787
|
+
onKeyDown: (event) => {
|
|
1788
|
+
if (event.key === 'Enter') addManual()
|
|
1789
|
+
},
|
|
1790
|
+
}),
|
|
1791
|
+
h(
|
|
1792
|
+
'button',
|
|
1793
|
+
{
|
|
1794
|
+
type: 'button',
|
|
1795
|
+
style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
|
|
1796
|
+
onClick: addManual,
|
|
1797
|
+
},
|
|
1798
|
+
'添加',
|
|
1799
|
+
),
|
|
1800
|
+
),
|
|
1801
|
+
!settled
|
|
1802
|
+
? h('div', { style: S.hint }, '读取会话列表…')
|
|
1803
|
+
: paths.length === 0
|
|
1804
|
+
? h('div', { style: S.empty }, '没有可绑定的工作区。用上面的输入框手动添加。')
|
|
1805
|
+
: paths.map((cwd) => {
|
|
1806
|
+
const isExtra = extras.indexOf(cwd) >= 0
|
|
1807
|
+
const bound = bindingOf(data.bindings, cwd)
|
|
1808
|
+
// 内置 default 与「未绑定」等价(服务端把 bind default 当解绑):两者都显示成
|
|
1809
|
+
// 「默认」,否则下拉里会出现两个同名的默认项。悬空引用同样落到「默认」。
|
|
1810
|
+
const value =
|
|
1811
|
+
bound === null || bound === DEFAULT_PACK_ID || !hasPack(data.packs, bound) ? '' : bound
|
|
1812
|
+
return h(
|
|
1813
|
+
'div',
|
|
1814
|
+
{
|
|
1815
|
+
key: cwd,
|
|
1816
|
+
style: {
|
|
1817
|
+
display: 'flex',
|
|
1818
|
+
alignItems: 'center',
|
|
1819
|
+
gap: '10px',
|
|
1820
|
+
padding: '6px 0',
|
|
1821
|
+
borderTop: '1px solid ' + T.border,
|
|
1822
|
+
},
|
|
1823
|
+
},
|
|
1824
|
+
h(
|
|
1825
|
+
'div',
|
|
1826
|
+
{ style: { flex: '1 1 auto', minWidth: 0 } },
|
|
1827
|
+
h('div', { style: S.path, title: cwd }, cwd),
|
|
1828
|
+
h('div', { style: S.rowDesc }, isExtra ? '手动添加' : '来自会话'),
|
|
1829
|
+
),
|
|
1830
|
+
h(
|
|
1831
|
+
'div',
|
|
1832
|
+
{ style: { flex: '0 0 auto', minWidth: '150px' } },
|
|
1833
|
+
h(Picker, {
|
|
1834
|
+
value: value === '' ? '' : value,
|
|
1835
|
+
options: [
|
|
1836
|
+
{ value: '', label: '默认' },
|
|
1837
|
+
// 内置 default 与「默认」等价(服务端把 bind default 当解绑),
|
|
1838
|
+
// 列出来只会让人以为有两个不同的选择。
|
|
1839
|
+
...data.packs
|
|
1840
|
+
.filter((pack) => pack.id !== DEFAULT_PACK_ID)
|
|
1841
|
+
.map((pack) => ({ value: pack.id, label: pack.name })),
|
|
1842
|
+
],
|
|
1843
|
+
onChange: (next) => run([{ op: 'bind', cwd: cwd, packId: next === '' ? null : next }]),
|
|
1844
|
+
}),
|
|
1845
|
+
),
|
|
1846
|
+
isExtra
|
|
1847
|
+
? h(ConfirmButton, {
|
|
1848
|
+
title: '从手动清单移除(不影响已有绑定)',
|
|
1849
|
+
onConfirm: () => run([{ op: 'removeExtra', cwd: cwd }]),
|
|
1850
|
+
})
|
|
1851
|
+
: null,
|
|
1852
|
+
)
|
|
1853
|
+
}),
|
|
1854
|
+
)
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ---------- 注册 ----------
|
|
1858
|
+
|
|
1859
|
+
/** chip 上的 pack 名:绑到哪个 pack 就显示哪个,未绑定显示「默认」/`Default`。 */
|
|
1860
|
+
function refreshChipName(sessionId) {
|
|
1861
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
1862
|
+
chipName.set(null)
|
|
1863
|
+
return
|
|
1864
|
+
}
|
|
1865
|
+
api.workspace(sessionId).then(
|
|
1866
|
+
(view) => {
|
|
1867
|
+
chipName.set(view && view.packId !== DEFAULT_PACK_ID ? view.packName : null)
|
|
1868
|
+
},
|
|
1869
|
+
() => {
|
|
1870
|
+
// 读不到就退回 Default:chip 本身不能因此消失。
|
|
1871
|
+
chipName.set(null)
|
|
1872
|
+
},
|
|
1873
|
+
)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
return {
|
|
1877
|
+
// `remote` 与 `remote.session` 都是独立服务名:只声明 `remote` 拿不到 session。
|
|
1878
|
+
// 声明是「服务在不在 ctx 上」的唯一开关;取用仍要 try/catch(ctx.get 对取不到的
|
|
1879
|
+
// 已声明服务会抛错)。缺了这两个,工作区清单只剩手动添加的路径。
|
|
1880
|
+
// `locale` 只用来判断 chip 的缺省文案该写「默认」还是 Default。
|
|
1881
|
+
inject: ['slots', 'remote', 'remote.session', 'locale'],
|
|
1882
|
+
apply(ctx) {
|
|
1883
|
+
// 兜底:client 模块的 apply 抛错会把 web boot 判成 entry failed,桌面端整个起不来。
|
|
1884
|
+
// 任何意外都只降级成「面板不出来」,绝不连坐宿主。
|
|
1885
|
+
try {
|
|
1886
|
+
const host = createHost(ctx)
|
|
1887
|
+
|
|
1888
|
+
// chip 的缺省文案随宿主语言走。只读一次:语言切换后刷新页面生效——
|
|
1889
|
+
// 为一个词多挂一条 locale/change 生命周期不值得。
|
|
1890
|
+
try {
|
|
1891
|
+
const locale = typeof ctx.get === 'function' ? ctx.get('locale') : null
|
|
1892
|
+
const snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : null
|
|
1893
|
+
const active = snapshot && typeof snapshot.active === 'string' ? snapshot.active : ''
|
|
1894
|
+
chipFallback.set(/^zh/i.test(active) ? '默认' : 'Default')
|
|
1895
|
+
} catch {
|
|
1896
|
+
// 取不到就保持 Default,chip 照常显示。
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// 组件必须是稳定标识符:内联箭头每次渲染都是新类型,React 会整棵卸载重挂
|
|
1900
|
+
// (AGENTS.md 客户端红线 4)。apply 只跑一次,闭包里拿 host 是安全的。
|
|
1901
|
+
const SettingsSection = () => h(SettingsPanel, { host: host })
|
|
1902
|
+
// 设置页侧栏的图标:宿主给 `settings.section.icon` 留了槽位,按 section 的 id
|
|
1903
|
+
// 匹配。不注册就落到 `navIcon(id)` 的通用齿轮上,那一排分不清谁是谁。
|
|
1904
|
+
const SettingsIcon = (props) => h(ChipIcon, { size: props && props.size ? props.size : 16 })
|
|
1905
|
+
ctx.slots.inject('settings.section', () =>
|
|
1906
|
+
ctx.slots.register(
|
|
1907
|
+
{ name: 'settings.section', id: PANEL_ID, order: 60, label: () => TITLE },
|
|
1908
|
+
SettingsSection,
|
|
1909
|
+
),
|
|
1910
|
+
)
|
|
1911
|
+
ctx.slots.inject('settings.section.icon', () =>
|
|
1912
|
+
ctx.slots.register({ name: 'settings.section.icon', id: PANEL_ID }, SettingsIcon),
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
// ---------- chip ----------
|
|
1916
|
+
|
|
1917
|
+
let chipOpen = false
|
|
1918
|
+
const chipListeners = new Set()
|
|
1919
|
+
const notifyChip = () => {
|
|
1920
|
+
for (const listener of chipListeners) listener()
|
|
1921
|
+
}
|
|
1922
|
+
const subscribeChip = (listener) => {
|
|
1923
|
+
chipListeners.add(listener)
|
|
1924
|
+
return () => chipListeners.delete(listener)
|
|
1925
|
+
}
|
|
1926
|
+
const readChipOpen = () => chipOpen
|
|
1927
|
+
|
|
1928
|
+
const SkillChip = (props) => {
|
|
1929
|
+
const sessionId = props && props.sessionId ? props.sessionId : undefined
|
|
1930
|
+
env.sessionId = sessionId
|
|
1931
|
+
const name = useSyncExternalStore(chipName.subscribe, chipName.read, chipName.read)
|
|
1932
|
+
const isOpen = useSyncExternalStore(subscribeChip, readChipOpen, readChipOpen)
|
|
1933
|
+
const fallback = useSyncExternalStore(chipFallback.subscribe, chipFallback.read, chipFallback.read)
|
|
1934
|
+
useEffect(() => {
|
|
1935
|
+
refreshChipName(sessionId)
|
|
1936
|
+
}, [sessionId])
|
|
1937
|
+
const label = name || fallback
|
|
1938
|
+
return h(
|
|
1939
|
+
React.Fragment,
|
|
1940
|
+
null,
|
|
1941
|
+
// 容器查询的规则随组件一起挂载:没有 `container-type` 的祖先时它不生效,
|
|
1942
|
+
// 结果就是一直显示文字——降级方向是「多显示」,不是「永远只有图标」。
|
|
1943
|
+
h('style', { key: 'chip-css' }, CHIP_CSS),
|
|
1944
|
+
h(
|
|
1945
|
+
'button',
|
|
1946
|
+
{
|
|
1947
|
+
type: 'button',
|
|
1948
|
+
style: S.chip,
|
|
1949
|
+
title: '当前工作区绑定的 skill pack:' + label,
|
|
1950
|
+
onClick: () => {
|
|
1951
|
+
chipOpen = !isOpen
|
|
1952
|
+
notifyChip()
|
|
1953
|
+
},
|
|
1954
|
+
},
|
|
1955
|
+
h(ChipIcon),
|
|
1956
|
+
h('span', { key: 'chip-text', className: 'dsh-skill-pack-chip-text' }, label),
|
|
1957
|
+
),
|
|
1958
|
+
)
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const ChipOverlay = () => {
|
|
1962
|
+
const isOpen = useSyncExternalStore(subscribeChip, readChipOpen, readChipOpen)
|
|
1963
|
+
return h(ChipModal, {
|
|
1964
|
+
open: isOpen,
|
|
1965
|
+
host: host,
|
|
1966
|
+
sessionId: env.sessionId,
|
|
1967
|
+
onClose: () => {
|
|
1968
|
+
chipOpen = false
|
|
1969
|
+
notifyChip()
|
|
1970
|
+
},
|
|
1971
|
+
})
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* `ui.showChip === false` 时不注册这个槽位。
|
|
1976
|
+
* 记住 disposer:拨回去要真的重挂,不能只读一次配置。
|
|
1977
|
+
*/
|
|
1978
|
+
let chipDisposers = []
|
|
1979
|
+
const unmountChip = () => {
|
|
1980
|
+
for (const dispose of chipDisposers) {
|
|
1981
|
+
try {
|
|
1982
|
+
dispose()
|
|
1983
|
+
} catch {
|
|
1984
|
+
// 槽位可能已随视图卸载,忽略。
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
chipDisposers = []
|
|
1988
|
+
}
|
|
1989
|
+
const registerChip = () => {
|
|
1990
|
+
if (chipDisposers.length > 0) return
|
|
1991
|
+
chipDisposers.push(
|
|
1992
|
+
ctx.slots.inject('conversation.input.right', () =>
|
|
1993
|
+
ctx.slots.register({ name: 'conversation.input.right', id: CHIP_ID, order: 42 }, SkillChip),
|
|
1994
|
+
),
|
|
1995
|
+
)
|
|
1996
|
+
chipDisposers.push(
|
|
1997
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1998
|
+
ctx.slots.register({ name: 'shell.overlay', id: MODAL_ID, order: 60 }, ChipOverlay),
|
|
1999
|
+
),
|
|
2000
|
+
)
|
|
2001
|
+
}
|
|
2002
|
+
env.onChipChange = (visible) => {
|
|
2003
|
+
if (visible) registerChip()
|
|
2004
|
+
else unmountChip()
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// 读一次 UI 开关决定挂不挂 chip。失败按缺省(显示)处理,不让入口整体缺失。
|
|
2008
|
+
api.global().then(
|
|
2009
|
+
(view) => {
|
|
2010
|
+
const ui = uiOf(view)
|
|
2011
|
+
const chip = !ui || ui.showChip !== false
|
|
2012
|
+
env.onChipChange(chip)
|
|
2013
|
+
},
|
|
2014
|
+
() => {
|
|
2015
|
+
env.onChipChange(true)
|
|
2016
|
+
},
|
|
2017
|
+
)
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
console.error('dsh-skill-pack: 客户端初始化失败', error)
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
}
|
|
2023
|
+
},
|
|
2024
|
+
})
|