dsh-vscode-mode 0.22.0 → 0.23.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.
@@ -0,0 +1,835 @@
1
+ /**
2
+ * dsh-vscode-mode client — 快捷菜单的配置弹窗(新增/编辑项 + 全局设置)。
3
+ *
4
+ * 设计要点:配置不再有独立设置页,全部从菜单就地入口打开;
5
+ * 表单按「类型」动态取舍字段(组只要名称/归属/优先级,按钮额外要提示词与绑定);
6
+ * 工作区限定沿祖先链继承——父组已限定时本项只读展示继承值,避免出现互相矛盾的配置。
7
+ * 技能与规则都走多选下拉(候选来自目录扫描,不再手输名字);分段与开关统一由 QuickMenuPickers 提供。
8
+ * 作者 ddj 2026年10月30号
9
+ */
10
+ import React from 'react'
11
+ import { ModalShell } from './ModalShell.js'
12
+ import { PickMulti, PickSeg, PickSwitch, PickWorkspace } from './QuickMenuPickers.js'
13
+ import type { PickGroup, PickOption, PickSegOption } from './QuickMenuPickers.js'
14
+ import type { QuickMenuHost } from '../quickMenuHost.js'
15
+ import {
16
+ QUICK_MENU_DEFAULT_DEPTH,
17
+ QUICK_MENU_MAX_DEPTH,
18
+ QUICK_MENU_MIN_DEPTH,
19
+ QUICK_MENU_NAME_CAP,
20
+ QUICK_MENU_ORDER_CAP,
21
+ QUICK_MENU_PROMPT_CAP,
22
+ bindingKey,
23
+ depthOf,
24
+ descendantIds,
25
+ inheritWorkspaces,
26
+ itemStatus,
27
+ normalizePath,
28
+ parseDoc,
29
+ serializeDoc,
30
+ validateItem,
31
+ validateRule,
32
+ validateSkill,
33
+ } from '../../shared/quickMenu.js'
34
+ import type {
35
+ ClickAction,
36
+ QuickButton,
37
+ QuickGroup,
38
+ QuickItemType,
39
+ QuickMenuItem,
40
+ QuickMenuDoc,
41
+ RuleBinding,
42
+ RuleInjectMode,
43
+ RuleListSnapshot,
44
+ ValidateContext,
45
+ } from '../../shared/quickMenu.js'
46
+ import { groupSkills } from '../../shared/skillCatalog.js'
47
+ import type { SkillCatalog, SkillInfo } from '../../shared/skillCatalog.js'
48
+
49
+ /** 表单项候选(规则下拉的展示与状态)。 */
50
+ interface RuleChoice {
51
+ binding: RuleBinding
52
+ label: string
53
+ hint: string
54
+ }
55
+
56
+ /** 类型分段的可选值。 */
57
+ const TYPE_OPTIONS: PickSegOption[] = [
58
+ { value: 'button', label: '按钮' },
59
+ { value: 'group', label: '组' },
60
+ ]
61
+
62
+ /** 规则注入方式分段的可选值。 */
63
+ const MODE_OPTIONS: PickSegOption[] = [
64
+ { value: 'reference', label: '引用' },
65
+ { value: 'inline', label: '内联正文' },
66
+ ]
67
+
68
+ /** 最大嵌套层数分段的可选值(1–5)。 */
69
+ const DEPTH_OPTIONS: PickSegOption[] = Array.from({ length: QUICK_MENU_MAX_DEPTH }, (_, index) => {
70
+ const depth = QUICK_MENU_MIN_DEPTH + index
71
+ return { value: String(depth), label: String(depth) }
72
+ })
73
+
74
+ /** 技能描述在候选行里的最大长度。 */
75
+ const SKILL_HINT_CAP = 28
76
+
77
+ /**
78
+ * 截断技能描述,作为候选行的次要说明。
79
+ * @author ddj 2026年10月30号
80
+ * @param description 技能描述
81
+ * @returns 单行摘要
82
+ */
83
+ function brief(description: string): string | undefined {
84
+ const text = description.trim().replace(/\s+/g, ' ')
85
+ if (text === '') return undefined
86
+ return text.length > SKILL_HINT_CAP ? text.slice(0, SKILL_HINT_CAP) + '…' : text
87
+ }
88
+
89
+ /**
90
+ * 单个技能候选(状态点来自会话可见性校验)。
91
+ * @author ddj 2026年10月30号
92
+ * @param name 技能名
93
+ * @param ctx 校验上下文
94
+ * @param hint 次要说明
95
+ * @returns 候选项
96
+ */
97
+ function skillOption(name: string, ctx: ValidateContext, hint?: string): PickOption {
98
+ const status = validateSkill(name, ctx)
99
+ return {
100
+ key: name,
101
+ label: name,
102
+ ...(hint ? { hint } : {}),
103
+ ...(status.state === 'ok' ? {} : { state: status.state === 'warn' ? 'warn' : undefined }),
104
+ ...(status.reason ? { reason: status.reason } : {}),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 技能候选分组:全局技能 / 当前工作区技能 / 已绑定但不在候选内。
110
+ * @author ddj 2026年10月30号
111
+ * @param catalog 目录扫描结果(读取失败为 null)
112
+ * @param names 当前会话可见技能名(候选为空时的兜底来源)
113
+ * @param selected 已绑定技能名
114
+ * @param ctx 校验上下文
115
+ * @returns 分组候选
116
+ */
117
+ function skillGroups(
118
+ catalog: SkillCatalog | null,
119
+ names: string[] | null,
120
+ selected: string[],
121
+ ctx: ValidateContext,
122
+ ): PickGroup[] {
123
+ const groups: PickGroup[] = []
124
+ const known = new Set<string>()
125
+
126
+ for (const group of groupSkills(catalog)) {
127
+ const options = group.skills.map((info: SkillInfo) => skillOption(info.name, ctx, brief(info.description)))
128
+ for (const option of options) known.add(option.key)
129
+ if (options.length > 0) groups.push({ key: group.key, label: group.label, options })
130
+ }
131
+
132
+ // 目录读不到(旧 host 或权限问题)时退回会话可见技能名,避免下拉变空
133
+ if (groups.length === 0 && names && names.length > 0) {
134
+ const options = names.map((name) => skillOption(name, ctx))
135
+ for (const option of options) known.add(option.key)
136
+ groups.push({ key: 'session', label: '当前会话技能', options })
137
+ }
138
+
139
+ const extra = selected.filter((name) => !known.has(name))
140
+ if (extra.length > 0) {
141
+ groups.push({
142
+ key: 'bound',
143
+ label: '已绑定(不在候选内)',
144
+ options: extra.map((name) => skillOption(name, ctx, '候选目录里没有该技能,可取消绑定')),
145
+ })
146
+ }
147
+ return groups
148
+ }
149
+
150
+ /**
151
+ * 单个规则候选(状态点来自 validateRule)。
152
+ * @author ddj 2026年10月30号
153
+ * @param choice 规则候选
154
+ * @param ctx 校验上下文
155
+ * @returns 候选项
156
+ */
157
+ function ruleOption(choice: RuleChoice, ctx: ValidateContext): PickOption {
158
+ const status = validateRule(choice.binding, ctx)
159
+ const state = status.state === 'warn' || status.state === 'error' ? status.state : undefined
160
+ return {
161
+ key: bindingKey(choice.binding),
162
+ label: choice.label,
163
+ hint: choice.hint,
164
+ ...(state ? { state } : {}),
165
+ ...(status.reason ? { reason: status.reason } : {}),
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 规则候选分组:用户规则 / 当前工作区规则 / 已绑定但已失效。
171
+ * @author ddj 2026年10月30号
172
+ * @param choices 规则候选
173
+ * @param selected 已绑定规则
174
+ * @param ctx 校验上下文
175
+ * @returns 分组候选
176
+ */
177
+ function ruleGroups(choices: RuleChoice[], selected: RuleBinding[], ctx: ValidateContext): PickGroup[] {
178
+ const known = new Set(choices.map((choice) => bindingKey(choice.binding)))
179
+ const user = choices.filter((choice) => choice.binding.scope === 'user')
180
+ const project = choices.filter((choice) => choice.binding.scope === 'project')
181
+ const groups: PickGroup[] = []
182
+
183
+ if (user.length > 0) {
184
+ groups.push({ key: 'user', label: '用户规则', options: user.map((choice) => ruleOption(choice, ctx)) })
185
+ }
186
+ if (project.length > 0) {
187
+ groups.push({ key: 'project', label: '当前工作区规则', options: project.map((choice) => ruleOption(choice, ctx)) })
188
+ }
189
+
190
+ const extra = selected.filter((binding) => !known.has(bindingKey(binding)))
191
+ if (extra.length > 0) {
192
+ groups.push({
193
+ key: 'bound',
194
+ label: '已绑定(规则已不存在)',
195
+ options: extra.map((binding) => {
196
+ const status = validateRule(binding, ctx)
197
+ return {
198
+ key: bindingKey(binding),
199
+ label: binding.file,
200
+ hint: binding.scope === 'user' ? '用户规则' : '项目规则',
201
+ state: status.state === 'warn' ? 'warn' : 'error',
202
+ reason: status.reason ?? '候选里没有该规则,可取消绑定',
203
+ }
204
+ }),
205
+ })
206
+ }
207
+ return groups
208
+ }
209
+
210
+ /**
211
+ * 构造待保存草稿(编辑时沿用原 id,校验需要它判断自身子树)。
212
+ * @author ddj 2026年10月30号
213
+ * @param id 项 id
214
+ * @param fields 表单字段
215
+ * @returns 菜单项
216
+ */
217
+ function buildDraft(
218
+ id: string,
219
+ fields: {
220
+ type: QuickItemType
221
+ name: string
222
+ order: number
223
+ parentId: string | null
224
+ workspaces: string[] | null
225
+ prompt: string
226
+ skills: string[]
227
+ rules: RuleBinding[]
228
+ ruleMode: RuleInjectMode
229
+ clickAction: ClickAction | 'inherit'
230
+ },
231
+ ): QuickMenuItem {
232
+ const base = {
233
+ id,
234
+ name: fields.name,
235
+ order: fields.order,
236
+ parentId: fields.parentId,
237
+ workspaces: fields.workspaces,
238
+ }
239
+ if (fields.type === 'group') return { ...base, type: 'group' } as QuickGroup
240
+ const button: QuickButton = {
241
+ ...base,
242
+ type: 'button',
243
+ prompt: fields.prompt,
244
+ skills: fields.skills,
245
+ rules: fields.rules,
246
+ ruleMode: fields.ruleMode,
247
+ }
248
+ if (fields.clickAction !== 'inherit') button.clickAction = fields.clickAction
249
+ return button
250
+ }
251
+
252
+ /**
253
+ * 归属组候选(组自身与自身子树不能当父级,否则会成环)。
254
+ * @author ddj 2026年10月30号
255
+ * @param doc 文档
256
+ * @param selfId 正在编辑的项 id
257
+ * @returns 候选(顶层 + 可选组)
258
+ */
259
+ function groupChoices(doc: QuickMenuDoc, selfId: string): Array<{ id: string; label: string }> {
260
+ const banned = selfId === '' ? [] : descendantIds(doc, selfId)
261
+ const out: Array<{ id: string; label: string }> = [{ id: '', label: '顶层(root 组)' }]
262
+ for (const item of doc.items) {
263
+ if (item.type !== 'group' || banned.includes(item.id)) continue
264
+ out.push({ id: item.id, label: item.name + '(第 ' + depthOf(doc, item.id) + ' 层)' })
265
+ }
266
+ return out
267
+ }
268
+
269
+ /**
270
+ * 规则候选(用户规则 + 当前工作区的项目规则)。
271
+ * @author ddj 2026年10月30号
272
+ * @param snapshot 规则快照
273
+ * @param cwd 当前工作区
274
+ * @returns 候选列表
275
+ */
276
+ function ruleChoices(snapshot: RuleListSnapshot | null, cwd?: string): RuleChoice[] {
277
+ if (!snapshot) return []
278
+ const out: RuleChoice[] = []
279
+ for (const info of snapshot.user) {
280
+ const binding: RuleBinding = { scope: 'user', file: info.file }
281
+ if (info.origin) binding.origin = info.origin
282
+ out.push({ binding, label: info.file, hint: '用户规则 · ' + (info.type === 'always' ? '总是生效' : info.type === 'auto' ? '按 globs' : '手动') })
283
+ }
284
+ const wanted = normalizePath(cwd ?? '')
285
+ for (const project of snapshot.projects) {
286
+ if (wanted !== '' && normalizePath(project.workspacePath) !== wanted) continue
287
+ for (const info of project.rules) {
288
+ out.push({
289
+ binding: { scope: 'project', workspacePath: project.workspacePath, file: info.file },
290
+ label: info.file,
291
+ hint: '项目规则 · ' + project.title,
292
+ })
293
+ }
294
+ }
295
+ return out
296
+ }
297
+
298
+ /** 分组区块的属性。 */
299
+ interface SectionProps {
300
+ title: string
301
+ hint?: string
302
+ children?: React.ReactNode
303
+ }
304
+
305
+ /**
306
+ * 表单里的分组区块(标题 + 说明 + 内容),把长表单切成可扫读的几段。
307
+ * @author ddj 2026年10月30号
308
+ * @param props 区块属性
309
+ * @returns 区块元素
310
+ */
311
+ function Section(props: SectionProps) {
312
+ return React.createElement(
313
+ 'div',
314
+ { className: 'edrv-qm-section' },
315
+ React.createElement(
316
+ 'div',
317
+ { className: 'edrv-qm-section-head' },
318
+ React.createElement('span', { className: 'edrv-qm-section-title' }, props.title),
319
+ props.hint ? React.createElement('span', { className: 'edrv-qm-section-hint' }, props.hint) : null,
320
+ ),
321
+ React.createElement('div', { className: 'edrv-qm-section-body' }, props.children),
322
+ )
323
+ }
324
+
325
+ /** 新建项弹窗的 props。 */
326
+ export interface QuickItemDialogProps {
327
+ host: QuickMenuHost
328
+ /** 编辑已有项时传入(新建省略)。 */
329
+ item?: QuickMenuItem
330
+ /** 新建时的归属组(null = 顶层)。 */
331
+ parentId: string | null
332
+ onClose(): void
333
+ }
334
+
335
+ /**
336
+ * 新增/编辑菜单项弹窗。
337
+ * @author ddj 2026年10月30号
338
+ * @param props host、待编辑项、父组、关闭回调
339
+ * @returns 弹窗元素
340
+ */
341
+ export function QuickItemDialog(props: QuickItemDialogProps) {
342
+ const { host, item, parentId } = props
343
+ const doc = host.store.getState().doc
344
+ const editing = item
345
+ const [type, setType] = React.useState<QuickItemType>(editing?.type ?? 'button')
346
+ const [name, setName] = React.useState(editing?.name ?? '')
347
+ const [order, setOrder] = React.useState(editing?.order ?? 100)
348
+ const [parent, setParent] = React.useState<string | null>(editing?.parentId ?? parentId)
349
+ const [workspaces, setWorkspaces] = React.useState<string[] | null>(editing?.workspaces ?? null)
350
+ const [prompt, setPrompt] = React.useState(editing?.type === 'button' ? editing.prompt : '')
351
+ const [skills, setSkills] = React.useState<string[]>(editing?.type === 'button' ? editing.skills.slice() : [])
352
+ const [rules, setRules] = React.useState<RuleBinding[]>(editing?.type === 'button' ? editing.rules.slice() : [])
353
+ const [ruleMode, setRuleMode] = React.useState<RuleInjectMode>(editing?.type === 'button' ? editing.ruleMode : 'reference')
354
+ const [clickAction, setClickAction] = React.useState<ClickAction | 'inherit'>(editing?.type === 'button' ? editing.clickAction ?? 'inherit' : 'inherit')
355
+ const [catalog, setCatalog] = React.useState<SkillCatalog | null>(null)
356
+ const [snapshot, setSnapshot] = React.useState<RuleListSnapshot | null>(null)
357
+ const [skillNames, setSkillNames] = React.useState<string[] | null>(null)
358
+ const [busy, setBusy] = React.useState(false)
359
+ const [error, setError] = React.useState('')
360
+
361
+ React.useEffect(() => {
362
+ let alive = true
363
+ const cwd = host.store.getContext().cwd
364
+ void (async () => {
365
+ const [loadedRules, loadedSkills, loadedCatalog] = await Promise.all([
366
+ host.rules().catch(() => null),
367
+ host.skills().catch(() => null),
368
+ host.skillCatalog(cwd).catch(() => null),
369
+ ])
370
+ if (!alive) return
371
+ setSnapshot(loadedRules)
372
+ setSkillNames(loadedSkills)
373
+ setCatalog(loadedCatalog)
374
+ })()
375
+ return () => { alive = false }
376
+ }, [host])
377
+
378
+ const before = host.store.getContext()
379
+ const ctx: ValidateContext = { cwd: before.cwd, rules: snapshot ?? undefined, skillNames }
380
+ const draftId = editing?.id ?? ''
381
+ const draft = buildDraft(draftId === '' ? 'draft' : draftId, {
382
+ type, name, order, parentId: parent, workspaces, prompt, skills, rules, ruleMode, clickAction,
383
+ })
384
+ const status = validateItem(draft, doc)
385
+ const choices = ruleChoices(snapshot, before.cwd)
386
+ const parentItem = parent === null ? null : doc.items.find((entry) => entry.id === parent)
387
+ const inherited = parentItem ? inheritWorkspaces(doc, parentItem) : null
388
+ const workspaceList = host.workspaces()
389
+ const skillPick = skillGroups(catalog, skillNames, skills, ctx)
390
+ const rulePick = ruleGroups(choices, rules, ctx)
391
+ const ruleByKey = new Map(choices.map((choice) => [bindingKey(choice.binding), choice.binding]))
392
+
393
+ /**
394
+ * 切换技能绑定。
395
+ * @author ddj 2026年10月30号
396
+ * @param target 技能名
397
+ */
398
+ function toggleSkill(target: string): void {
399
+ setSkills((current) => current.includes(target) ? current.filter((entry) => entry !== target) : current.concat(target))
400
+ }
401
+
402
+ /**
403
+ * 切换规则绑定(键来自下拉,失效规则也能取消绑定)。
404
+ * @author ddj 2026年10月30号
405
+ * @param key bindingKey 结果
406
+ */
407
+ function toggleRuleKey(key: string): void {
408
+ const binding = ruleByKey.get(key)
409
+ setRules((current) => {
410
+ if (current.some((entry) => bindingKey(entry) === key)) {
411
+ return current.filter((entry) => bindingKey(entry) !== key)
412
+ }
413
+ return binding ? current.concat(binding) : current
414
+ })
415
+ }
416
+
417
+ /**
418
+ * 保存(新建或更新);冲突/校验失败时留在弹窗内提示。
419
+ * @author ddj 2026年10月30号
420
+ */
421
+ async function onSave(): Promise<void> {
422
+ if (status.errors.length > 0) return
423
+ setBusy(true)
424
+ const finalItem = buildDraft(editing?.id ?? host.store.nextId(type), {
425
+ type, name, order, parentId: parent, workspaces, prompt, skills, rules, ruleMode, clickAction,
426
+ })
427
+ const result = editing ? await host.store.updateItem(finalItem) : await host.store.createItem(finalItem)
428
+ setBusy(false)
429
+ if (!result.ok) {
430
+ setError(result.error ?? '保存失败')
431
+ return
432
+ }
433
+ props.onClose()
434
+ }
435
+
436
+ /**
437
+ * 删除该项及其子项。
438
+ * @author ddj 2026年10月30号
439
+ */
440
+ async function onDelete(): Promise<void> {
441
+ if (!editing) return
442
+ const suffix = editing.type === 'group' ? '及其全部子项' : ''
443
+ if (!window.confirm('确认删除「' + editing.name + '」' + suffix + '?')) return
444
+ setBusy(true)
445
+ const result = await host.store.removeItem(editing.id)
446
+ setBusy(false)
447
+ if (!result.ok) {
448
+ setError(result.error ?? '删除失败')
449
+ return
450
+ }
451
+ props.onClose()
452
+ }
453
+
454
+ /**
455
+ * 类型与名称。
456
+ * @author ddj 2026年10月30号
457
+ * @returns 表单行
458
+ */
459
+ function basicRow(): React.ReactNode {
460
+ return React.createElement('div', { className: 'edrv-qm-row2' },
461
+ React.createElement('div', { className: 'edrv-qm-field edrv-qm-field--narrow' },
462
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '类型'),
463
+ React.createElement(PickSeg, {
464
+ options: TYPE_OPTIONS,
465
+ value: type,
466
+ label: '菜单项类型',
467
+ onChange: (next) => setType(next as QuickItemType),
468
+ })),
469
+ React.createElement('label', { className: 'edrv-qm-field' },
470
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '名称'),
471
+ React.createElement('input', {
472
+ className: 'edrv-qm-input', value: name, maxLength: QUICK_MENU_NAME_CAP,
473
+ placeholder: type === 'group' ? '例如:代码审查' : '例如:审查当前改动',
474
+ onChange: (event) => setName(event.target.value),
475
+ })))
476
+ }
477
+
478
+ /**
479
+ * 归属组与同级优先级。
480
+ * @author ddj 2026年10月30号
481
+ * @returns 表单行
482
+ */
483
+ function placeRow(): React.ReactNode {
484
+ return React.createElement('div', { className: 'edrv-qm-row2' },
485
+ React.createElement('label', { className: 'edrv-qm-field' },
486
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '归属组'),
487
+ React.createElement('select', {
488
+ className: 'edrv-qm-select', value: parent ?? '',
489
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setParent(event.target.value === '' ? null : event.target.value),
490
+ }, groupChoices(doc, draftId).map((choice) => React.createElement('option', { key: choice.id, value: choice.id }, choice.label)))),
491
+ React.createElement('div', { className: 'edrv-qm-field' },
492
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '同级优先级(越小越靠前)'),
493
+ React.createElement('div', { className: 'edrv-qm-inline' },
494
+ React.createElement('input', {
495
+ className: 'edrv-qm-input edrv-qm-input--order', type: 'number', min: 0, max: QUICK_MENU_ORDER_CAP, value: order,
496
+ onChange: (event) => setOrder(Number(event.target.value) || 0),
497
+ }),
498
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--sm', onClick: () => setOrder((value) => Math.max(0, value - 10)) }, '上移'),
499
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--sm', onClick: () => setOrder((value) => Math.min(QUICK_MENU_ORDER_CAP, value + 10)) }, '下移'))))
500
+ }
501
+
502
+ /**
503
+ * 限定工作区(继承上级组时只读)。
504
+ * @author ddj 2026年10月30号
505
+ * @returns 表单行
506
+ */
507
+ function workspaceField(): React.ReactNode {
508
+ return React.createElement('div', { className: 'edrv-qm-field' },
509
+ React.createElement('div', { className: 'edrv-qm-field-head' },
510
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '限定工作区'),
511
+ React.createElement('span', { className: 'edrv-qm-field-hint' }, inherited
512
+ ? '遵循上级组:' + inherited.join('、') + '(此项不可编辑)'
513
+ : '默认全部工作区可见,可切到当前工作区或展开更多')),
514
+ React.createElement(PickWorkspace, {
515
+ buttonId: 'edrv-qm-pick-workspaces',
516
+ workspaces: workspaceList,
517
+ selected: inherited ?? workspaces,
518
+ cwd: before.cwd,
519
+ inherited: inherited !== null,
520
+ inheritedHint: '遵循上级组,此项不可编辑',
521
+ onChange: (next) => setWorkspaces(next),
522
+ }))
523
+ }
524
+
525
+ /**
526
+ * 提示词输入。
527
+ * @author ddj 2026年10月30号
528
+ * @returns 表单行
529
+ */
530
+ function promptField(): React.ReactNode {
531
+ return React.createElement('div', { className: 'edrv-qm-field' },
532
+ React.createElement('div', { className: 'edrv-qm-field-head' },
533
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '预填提示词'),
534
+ React.createElement('span', { className: 'edrv-qm-field-hint' }, '占位符:{{workspace}} {{selection}} {{date}} {{rules}} {{skills}}')),
535
+ React.createElement('textarea', {
536
+ className: 'edrv-qm-textarea', value: prompt, maxLength: QUICK_MENU_PROMPT_CAP,
537
+ placeholder: '点击后发送给 AI 的内容…',
538
+ onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt(event.target.value),
539
+ }))
540
+ }
541
+
542
+ /**
543
+ * 绑定技能(多选下拉,候选来自全局与工作区技能目录)。
544
+ * @author ddj 2026年10月30号
545
+ * @returns 表单行
546
+ */
547
+ function skillField(): React.ReactNode {
548
+ return React.createElement('div', { className: 'edrv-qm-field' },
549
+ React.createElement('div', { className: 'edrv-qm-field-head' },
550
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '绑定技能'),
551
+ React.createElement('span', { className: 'edrv-qm-field-hint' }, '发送时写成 /技能名;黄点=当前会话不可见')),
552
+ React.createElement(PickMulti, {
553
+ buttonId: 'edrv-qm-pick-skills',
554
+ groups: skillPick,
555
+ selected: skills,
556
+ unit: '个技能',
557
+ emptyText: '没有可选技能(全局技能目录与当前工作区 .dsh/skills 均为空)',
558
+ onToggle: toggleSkill,
559
+ }))
560
+ }
561
+
562
+ /**
563
+ * 绑定规则(多选下拉 + 引用/内联切换)。
564
+ * @author ddj 2026年10月30号
565
+ * @returns 表单行
566
+ */
567
+ function ruleField(): React.ReactNode {
568
+ return React.createElement('div', { className: 'edrv-qm-field' },
569
+ React.createElement('div', { className: 'edrv-qm-field-head' },
570
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '绑定规则'),
571
+ React.createElement(PickSeg, {
572
+ options: MODE_OPTIONS,
573
+ value: ruleMode,
574
+ label: '规则注入方式',
575
+ onChange: (next) => setRuleMode(next as RuleInjectMode),
576
+ })),
577
+ React.createElement(PickMulti, {
578
+ buttonId: 'edrv-qm-pick-rules',
579
+ groups: rulePick,
580
+ selected: rules.map((binding) => bindingKey(binding)),
581
+ unit: '条规则',
582
+ emptyText: '没有可选规则(可在规则面板新建 .mdc 规则)',
583
+ onToggle: toggleRuleKey,
584
+ }))
585
+ }
586
+
587
+ /**
588
+ * 点击动作(跟随全局 / 直接发送 / 只填入)。
589
+ * @author ddj 2026年10月30号
590
+ * @returns 表单行
591
+ */
592
+ function clickField(): React.ReactNode {
593
+ return React.createElement('label', { className: 'edrv-qm-field' },
594
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '点击动作'),
595
+ React.createElement('select', {
596
+ className: 'edrv-qm-select', value: clickAction,
597
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setClickAction(event.target.value as ClickAction | 'inherit'),
598
+ },
599
+ React.createElement('option', { value: 'inherit' }, '跟随全局'),
600
+ React.createElement('option', { value: 'send' }, '直接发送'),
601
+ React.createElement('option', { value: 'fill' }, '只填入输入框')))
602
+ }
603
+
604
+ // 标题从滚动区里提出来做成固定头:长表单往下滚时仍知道自己在编辑什么
605
+ const parentName = parent === null ? null : (doc.items.find((entry) => entry.id === parent)?.name ?? null)
606
+ const typeLabel = type === 'group' ? '组' : '按钮'
607
+ const headTitle = editing ? '编辑「' + editing.name + '」' : parent === null ? '新建顶层项' : '新建子项'
608
+ const headSub = (editing ? (editing.type === 'group' ? '组' : '按钮') : typeLabel)
609
+ + (parentName ? ' · 位于「' + parentName + '」' : ' · 位于顶层')
610
+
611
+ const header = React.createElement('div', { className: 'edrv-qm-head' },
612
+ React.createElement('div', { className: 'edrv-qm-head-title' }, headTitle),
613
+ React.createElement('div', { className: 'edrv-qm-head-sub' }, headSub))
614
+
615
+ const body = React.createElement('div', { className: 'edrv-qm-dialog-body' },
616
+ React.createElement(Section, { title: '基本信息' }, basicRow(), placeRow(), workspaceField()),
617
+ type === 'button' ? React.createElement(Section, { title: '提示词', hint: '点击后发给 AI 的内容' }, promptField()) : null,
618
+ type === 'button' ? React.createElement(Section, { title: '绑定' }, skillField(), ruleField()) : null,
619
+ type === 'button' ? React.createElement(Section, { title: '行为' }, clickField()) : null,
620
+ error !== '' ? React.createElement('div', { className: 'edrv-qm-error-list' }, error) : null,
621
+ status.errors.length > 0 ? React.createElement('ul', { className: 'edrv-qm-error-list' }, status.errors.map((text) => React.createElement('li', { key: text }, text))) : null,
622
+ status.warnings.length > 0 ? React.createElement('ul', { className: 'edrv-qm-warn-list' }, status.warnings.map((text) => React.createElement('li', { key: text }, text))) : null,
623
+ )
624
+
625
+ const actions = React.createElement('div', { className: 'edrv-qm-actions' },
626
+ editing ? React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--danger', disabled: busy, onClick: () => { void onDelete() } }, '删除') : null,
627
+ React.createElement('span', { className: 'edrv-qm-actions-grow' }),
628
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: props.onClose }, '取消'),
629
+ React.createElement('button', {
630
+ type: 'button', className: 'edrv-qm-btn edrv-qm-btn--primary', disabled: busy || status.errors.length > 0,
631
+ onClick: () => { void onSave() },
632
+ }, busy ? '保存中…' : editing ? '保存' : '创建'))
633
+
634
+ return React.createElement(ModalShell, {
635
+ dialogClass: 'edrv-qm-dialog', zIndex: 220,
636
+ onClose: props.onClose, closeOnMask: false,
637
+ }, header, body, actions)
638
+ }
639
+
640
+ /** 全局设置弹窗 props。 */
641
+ export interface MenuGlobalDialogProps {
642
+ host: QuickMenuHost
643
+ onClose(): void
644
+ }
645
+
646
+ /**
647
+ * 菜单全局设置(显隐、点击动作、最大嵌套层数、位置重置、导入导出、失效绑定清理)。
648
+ * @author ddj 2026年10月30号
649
+ * @param props host 与关闭回调
650
+ * @returns 弹窗元素
651
+ */
652
+ export function MenuGlobalDialog(props: MenuGlobalDialogProps) {
653
+ const { host } = props
654
+ const doc = host.store.getState().doc
655
+ const [enabled, setEnabled] = React.useState(doc.enabled)
656
+ const [clickAction, setClickAction] = React.useState<ClickAction>(doc.clickAction)
657
+ const [maxDepth, setMaxDepth] = React.useState(doc.maxDepth || QUICK_MENU_DEFAULT_DEPTH)
658
+ const [busy, setBusy] = React.useState(false)
659
+ const [error, setError] = React.useState('')
660
+ const ctx = host.store.getContext()
661
+ const broken = collectBroken(doc, ctx)
662
+ const fileRef = React.useRef<HTMLInputElement>(null)
663
+
664
+ /**
665
+ * 保存全局设置。
666
+ * @author ddj 2026年10月30号
667
+ */
668
+ async function onSave(): Promise<void> {
669
+ setBusy(true)
670
+ const result = await host.store.save({ ...doc, enabled, clickAction, maxDepth })
671
+ setBusy(false)
672
+ if (!result.ok) {
673
+ setError(result.error ?? '保存失败')
674
+ return
675
+ }
676
+ props.onClose()
677
+ }
678
+
679
+ /**
680
+ * 清理全部失效绑定(移除失效规则/技能,保留提示词)。
681
+ * @author ddj 2026年10月30号
682
+ */
683
+ async function onClean(): Promise<void> {
684
+ setBusy(true)
685
+ const items = doc.items.map((item) => {
686
+ if (item.type !== 'button') return item
687
+ const rules = item.rules.filter((binding) => validateRule(binding, ctx).state === 'ok')
688
+ const skills = item.skills.filter((name) => validateSkill(name, ctx).state === 'ok')
689
+ return { ...item, rules, skills }
690
+ })
691
+ const result = await host.store.save({ ...doc, enabled, clickAction, maxDepth, items })
692
+ setBusy(false)
693
+ if (!result.ok) setError(result.error ?? '清理失败')
694
+ else host.notify('已清理失效绑定')
695
+ }
696
+
697
+ /**
698
+ * 配置备份与迁移(导出 / 导入 / 重置按钮位置)。
699
+ * @author ddj 2026年10月30号
700
+ * @returns 按钮行
701
+ */
702
+ function backupRow(): React.ReactNode {
703
+ // 导入用真按钮触发隐藏的 file input:label 会被弹窗样式(display:flex + margin)改盒模型,导致与相邻按钮错位
704
+ return React.createElement('div', { className: 'edrv-qm-btn-row' },
705
+ React.createElement('input', {
706
+ ref: fileRef, type: 'file', accept: 'application/json', style: { display: 'none' },
707
+ onChange: (event) => { void onImport(event, host, setError) },
708
+ }),
709
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: () => downloadDoc(doc) }, '导出 JSON'),
710
+ React.createElement('button', {
711
+ type: 'button', className: 'edrv-qm-btn',
712
+ onClick: () => fileRef.current?.click(),
713
+ }, '导入 JSON'),
714
+ React.createElement('button', {
715
+ type: 'button', className: 'edrv-qm-btn',
716
+ onClick: () => { host.store.patchLauncher(null, null); void host.store.flushLauncher(); host.notify('已重置悬浮按钮位置') },
717
+ }, '重置按钮位置'))
718
+ }
719
+
720
+ const header = React.createElement('div', { className: 'edrv-qm-head' },
721
+ React.createElement('div', { className: 'edrv-qm-head-title' }, '菜单设置'),
722
+ React.createElement('div', { className: 'edrv-qm-head-sub' }, '悬浮按钮、交互行为与配置迁移'))
723
+
724
+ const body = React.createElement('div', { className: 'edrv-qm-dialog-body' },
725
+ React.createElement(Section, { title: '外观' },
726
+ React.createElement(PickSwitch, {
727
+ buttonId: 'edrv-qm-switch-enabled',
728
+ label: '显示悬浮按钮',
729
+ hint: '关闭后左侧不再显示快捷菜单按钮,配置会保留',
730
+ checked: enabled,
731
+ onChange: setEnabled,
732
+ })),
733
+ React.createElement(Section, { title: '交互' },
734
+ React.createElement('label', { className: 'edrv-qm-field' },
735
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '点击默认动作'),
736
+ React.createElement('select', {
737
+ className: 'edrv-qm-select', value: clickAction,
738
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setClickAction(event.target.value as ClickAction),
739
+ },
740
+ React.createElement('option', { value: 'send' }, '直接发送给 AI'),
741
+ React.createElement('option', { value: 'fill' }, '只填入输入框'))),
742
+ React.createElement('div', { className: 'edrv-qm-field' },
743
+ React.createElement('div', { className: 'edrv-qm-field-head' },
744
+ React.createElement('span', { className: 'edrv-qm-field-label' }, '最大嵌套层数'),
745
+ React.createElement('span', { className: 'edrv-qm-field-hint' }, 'root 列里的项是第 1 层;调小不删除已有深层项,只禁止继续新增')),
746
+ React.createElement(PickSeg, {
747
+ options: DEPTH_OPTIONS,
748
+ value: String(maxDepth),
749
+ label: '最大嵌套层数',
750
+ onChange: (next) => setMaxDepth(Number(next) || QUICK_MENU_DEFAULT_DEPTH),
751
+ }))),
752
+ React.createElement(Section, { title: '配置备份与迁移' }, backupRow()),
753
+ React.createElement(Section, {
754
+ title: '绑定状态',
755
+ hint: broken.length === 0 ? '全部可用' : String(broken.length) + ' 项失效',
756
+ },
757
+ broken.length > 0 ? React.createElement('ul', { className: 'edrv-qm-error-list' },
758
+ broken.map((entry) => React.createElement('li', { key: entry.key }, entry.label))) : null,
759
+ broken.length > 0 ? React.createElement('div', { className: 'edrv-qm-btn-row' },
760
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn', disabled: busy, onClick: () => { void onClean() } }, '清理失效绑定')) : null),
761
+ error !== '' ? React.createElement('div', { className: 'edrv-qm-error-list' }, error) : null,
762
+ React.createElement('div', { className: 'edrv-qm-field-hint' }, '左侧按钮 / 菜单项'
763
+ + (host.store.isMounted() ? '(当前界面已挂载)' : '(当前 DSH 未提供浮层槽,按钮不显示)')),
764
+ )
765
+
766
+ return React.createElement(ModalShell, {
767
+ dialogClass: 'edrv-qm-dialog', zIndex: 220, onClose: props.onClose, closeOnMask: false,
768
+ }, header, body,
769
+ React.createElement('div', { className: 'edrv-qm-actions' },
770
+ React.createElement('span', { className: 'edrv-qm-actions-grow' }),
771
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: props.onClose }, '取消'),
772
+ React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--primary', disabled: busy, onClick: () => { void onSave() } }, busy ? '保存中…' : '保存')))
773
+ }
774
+
775
+ /**
776
+ * 收集全菜单的失效绑定(设置弹窗顶部汇总)。
777
+ * @author ddj 2026年10月30号
778
+ * @param doc 文档
779
+ * @param ctx 校验上下文
780
+ * @returns 失效条目文案
781
+ */
782
+ function collectBroken(doc: QuickMenuDoc, ctx: ValidateContext): Array<{ key: string; label: string }> {
783
+ const out: Array<{ key: string; label: string }> = []
784
+ for (const item of doc.items) {
785
+ if (item.type !== 'button') continue
786
+ const status = itemStatus(item, ctx)
787
+ status.errors.concat(status.warnings).forEach((reason, index) => {
788
+ out.push({ key: item.id + ':' + index, label: item.name + ' → ' + reason })
789
+ })
790
+ }
791
+ return out
792
+ }
793
+
794
+ /**
795
+ * 下载当前文档为 JSON(浏览器侧 Blob,不经 host)。
796
+ * @author ddj 2026年10月30号
797
+ * @param doc 文档
798
+ */
799
+ function downloadDoc(doc: QuickMenuDoc): void {
800
+ const blob = new Blob([serializeDoc(doc)], { type: 'application/json' })
801
+ const url = URL.createObjectURL(blob)
802
+ const anchor = document.createElement('a')
803
+ anchor.href = url
804
+ anchor.download = 'quick-menu.json'
805
+ anchor.click()
806
+ setTimeout(() => URL.revokeObjectURL(url), 2000)
807
+ }
808
+
809
+ /**
810
+ * 导入 JSON(解析失败/校验不通过时给错误文案)。
811
+ * @author ddj 2026年10月30号
812
+ * @param event 文件输入事件
813
+ * @param host 宿主
814
+ * @param setError 错误回写
815
+ */
816
+ async function onImport(event: React.ChangeEvent<HTMLInputElement>, host: QuickMenuHost, setError: (text: string) => void): Promise<void> {
817
+ const file = event.target.files?.[0]
818
+ event.target.value = ''
819
+ if (!file) return
820
+ let text = ''
821
+ try {
822
+ text = await file.text()
823
+ } catch (error) {
824
+ setError('读取文件失败:' + String(error))
825
+ return
826
+ }
827
+ const parsed = parseDoc(text)
828
+ if (parsed.warning) {
829
+ setError(parsed.warning)
830
+ return
831
+ }
832
+ const result = await host.store.save(parsed.doc)
833
+ if (!result.ok) setError(result.error ?? '导入失败')
834
+ else host.notify('已导入快捷菜单配置')
835
+ }