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,577 @@
1
+ /**
2
+ * dsh-vscode-mode client — 快捷菜单配置弹窗的通用选择控件。
3
+ *
4
+ * 提供三件控件:多选下拉(技能/规则)、限定工作区下拉(全部/当前/更多)、设置项开关与分段。
5
+ * 弹层用 position: fixed 挂在 body 之上的坐标,因此不受弹窗 body 的 overflow 裁剪;
6
+ * 组件仍用 React.createElement(项目内无 JSX),样式落在 quickmenu.css 的 edrv-qm-pick* 类上。
7
+ * 作者 ddj 2026年10月30号
8
+ */
9
+ import React from 'react'
10
+ import { selectionLabel, toggleWorkspace, workspaceLabel } from '../../shared/quickPick.js'
11
+
12
+ /** 下拉里一个可选项。 */
13
+ export interface PickOption {
14
+ /** 稳定键:技能名 / 工作区绝对路径 / 规则 bindingKey。 */
15
+ key: string
16
+ label: string
17
+ /** 次要说明(来源目录、绝对路径等)。 */
18
+ hint?: string
19
+ /** 校验状态:ok 不显示圆点,warn / error 显示对应颜色圆点。 */
20
+ state?: 'ok' | 'warn' | 'error'
21
+ /** 状态原因(作为 title 提示)。 */
22
+ reason?: string
23
+ }
24
+
25
+ /** 一组可选项(技能按来源、规则按作用域分组)。 */
26
+ export interface PickGroup {
27
+ key: string
28
+ label: string
29
+ options: PickOption[]
30
+ }
31
+
32
+ /** 一个工作区选项。 */
33
+ export interface PickWorkspaceItem {
34
+ path: string
35
+ title: string
36
+ }
37
+
38
+ /** 分段控件中的一个值。 */
39
+ export interface PickSegOption {
40
+ value: string
41
+ label: string
42
+ }
43
+
44
+ /** 弹层位置:靠下展开给 top,向上展开给 bottom。 */
45
+ interface PanelPos {
46
+ left: number
47
+ width: number
48
+ maxHeight: number
49
+ top?: number
50
+ bottom?: number
51
+ }
52
+
53
+ /** 定位弹层需要的句柄。 */
54
+ interface Anchored {
55
+ open: boolean
56
+ style: React.CSSProperties
57
+ triggerRef: React.RefObject<HTMLButtonElement>
58
+ panelRef: React.RefObject<HTMLDivElement>
59
+ toggle(): void
60
+ close(): void
61
+ }
62
+
63
+ /** 弹层最小/最大高度(决定向上还是向下展开)。 */
64
+ const PANEL_MIN = 180
65
+ const PANEL_MAX = 320
66
+ /** 弹层与触发器/视口边缘的间距。 */
67
+ const PANEL_GAP = 6
68
+ const EDGE_GAP = 8
69
+
70
+ /**
71
+ * 拼接 className,过滤假值。
72
+ * @author ddj 2026年10月30号
73
+ * @param names 候选类名(false / undefined 会被忽略)
74
+ * @returns 空格分隔的 className
75
+ */
76
+ function classes(...names: Array<string | false | undefined>): string {
77
+ return names.filter(Boolean).join(' ')
78
+ }
79
+
80
+ /**
81
+ * 按触发器位置计算弹层坐标,空间不足时向上展开。
82
+ * @author ddj 2026年10月30号
83
+ * @param trigger 触发器元素
84
+ * @returns 弹层定位参数
85
+ */
86
+ function placePanel(trigger: HTMLElement): PanelPos {
87
+ const rect = trigger.getBoundingClientRect()
88
+ const width = Math.max(rect.width, 280)
89
+ const below = window.innerHeight - rect.bottom - PANEL_GAP - EDGE_GAP
90
+ const above = rect.top - PANEL_GAP - EDGE_GAP
91
+ const useAbove = below < PANEL_MIN && above > below
92
+ const maxHeight = Math.max(PANEL_MIN, Math.min(PANEL_MAX, useAbove ? above : below))
93
+ const maxLeft = Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)
94
+ const left = Math.min(Math.max(EDGE_GAP, rect.left), maxLeft)
95
+
96
+ if (useAbove) {
97
+ return { left, width, maxHeight, bottom: window.innerHeight - rect.top + PANEL_GAP }
98
+ }
99
+ return { left, width, maxHeight, top: rect.bottom + PANEL_GAP }
100
+ }
101
+
102
+ /**
103
+ * 受控弹层的位置与开合状态。
104
+ *
105
+ * Esc 的处理依赖「子组件 effect 先于父组件 effect 执行」:本 hook 在 ModalShell 内部的
106
+ * 子组件里挂载,因此先注册 window 捕获监听,才能在弹层打开时拦下 Esc,避免连外层弹窗一起关掉。
107
+ * @author ddj 2026年10月30号
108
+ * @returns 弹层句柄
109
+ */
110
+ function useAnchored(): Anchored {
111
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
112
+ const panelRef = React.useRef<HTMLDivElement>(null)
113
+ const [open, setOpen] = React.useState(false)
114
+ const [pos, setPos] = React.useState<PanelPos | null>(null)
115
+ const openRef = React.useRef(false)
116
+ openRef.current = open
117
+
118
+ const close = React.useCallback(() => {
119
+ setOpen(false)
120
+ setPos(null)
121
+ }, [])
122
+
123
+ const toggle = React.useCallback(() => {
124
+ if (openRef.current) {
125
+ close()
126
+ return
127
+ }
128
+
129
+ const trigger = triggerRef.current
130
+ setPos(trigger ? placePanel(trigger) : null)
131
+ setOpen(true)
132
+ }, [close])
133
+
134
+ React.useEffect(() => {
135
+ const onKey = (event: KeyboardEvent) => {
136
+ if (event.key !== 'Escape' || !openRef.current) return
137
+ // 只收掉弹层:ModalShell 同样在 window 捕获阶段监听 Esc,必须阻止它继续执行
138
+ event.stopImmediatePropagation()
139
+ event.preventDefault()
140
+ close()
141
+ }
142
+
143
+ window.addEventListener('keydown', onKey, true)
144
+ return () => window.removeEventListener('keydown', onKey, true)
145
+ }, [close])
146
+
147
+ React.useEffect(() => {
148
+ if (!open) return
149
+
150
+ const reposition = () => {
151
+ const trigger = triggerRef.current
152
+ if (trigger) setPos(placePanel(trigger))
153
+ }
154
+ const onDown = (event: MouseEvent) => {
155
+ const target = event.target as Node | null
156
+ if (!target) return
157
+ if (triggerRef.current?.contains(target)) return
158
+ if (panelRef.current?.contains(target)) return
159
+ close()
160
+ }
161
+ const onScroll = (event: Event) => {
162
+ const target = event.target as Node | null
163
+ if (target && panelRef.current?.contains(target)) return
164
+ reposition()
165
+ }
166
+
167
+ window.addEventListener('mousedown', onDown, true)
168
+ window.addEventListener('resize', reposition)
169
+ window.addEventListener('scroll', onScroll, true)
170
+ return () => {
171
+ window.removeEventListener('mousedown', onDown, true)
172
+ window.removeEventListener('resize', reposition)
173
+ window.removeEventListener('scroll', onScroll, true)
174
+ }
175
+ }, [open, close])
176
+
177
+ const style: React.CSSProperties = pos
178
+ ? { left: pos.left, top: pos.top, bottom: pos.bottom, width: pos.width, maxHeight: pos.maxHeight }
179
+ : {}
180
+
181
+ return { open, style, triggerRef, panelRef, toggle, close }
182
+ }
183
+
184
+ /**
185
+ * 收集所有分组的选项键→标签映射。
186
+ * @author ddj 2026年10月30号
187
+ * @param groups 分组选项
188
+ * @returns 选项键到标签的映射
189
+ */
190
+ function labelsOf(groups: PickGroup[]): Map<string, string> {
191
+ const labels = new Map<string, string>()
192
+ for (const group of groups) {
193
+ for (const option of group.options) labels.set(option.key, option.label)
194
+ }
195
+ return labels
196
+ }
197
+
198
+ /**
199
+ * 统计已选项里带警告/错误状态的数量。
200
+ * @author ddj 2026年10月30号
201
+ * @param groups 分组选项
202
+ * @param selected 已选键
203
+ * @returns 异常状态条目数
204
+ */
205
+ function issueCount(groups: PickGroup[], selected: string[]): number {
206
+ const picked = new Set(selected)
207
+ let count = 0
208
+ for (const group of groups) {
209
+ for (const option of group.options) {
210
+ if (picked.has(option.key) && option.state && option.state !== 'ok') count += 1
211
+ }
212
+ }
213
+ return count
214
+ }
215
+
216
+ /** 触发器按钮的属性。 */
217
+ interface TriggerProps {
218
+ triggerRef: React.RefObject<HTMLButtonElement>
219
+ buttonId?: string
220
+ summary: string
221
+ open: boolean
222
+ disabled: boolean
223
+ issueCount?: number
224
+ hint?: string
225
+ onClick(): void
226
+ }
227
+
228
+ /**
229
+ * 下拉触发器:显示当前选择摘要与展开箭头。
230
+ * @author ddj 2026年10月30号
231
+ * @param props 触发器属性
232
+ * @returns 触发器按钮
233
+ */
234
+ function PickTrigger(props: TriggerProps) {
235
+ return React.createElement(
236
+ 'button',
237
+ {
238
+ ref: props.triggerRef,
239
+ id: props.buttonId,
240
+ type: 'button',
241
+ className: classes('edrv-qm-pick-trigger', props.open && 'edrv-qm-pick-trigger--open'),
242
+ disabled: props.disabled,
243
+ title: props.hint,
244
+ 'aria-haspopup': 'listbox',
245
+ 'aria-expanded': props.open,
246
+ onClick: props.onClick,
247
+ },
248
+ React.createElement('span', { className: 'edrv-qm-pick-summary' }, props.summary),
249
+ props.issueCount
250
+ ? React.createElement('span', { className: 'edrv-qm-pick-badge' }, String(props.issueCount))
251
+ : null,
252
+ React.createElement('span', { className: 'edrv-qm-pick-caret', 'aria-hidden': 'true' }),
253
+ )
254
+ }
255
+
256
+ /** 弹层属性。 */
257
+ interface PanelProps {
258
+ panelRef: React.RefObject<HTMLDivElement>
259
+ style: React.CSSProperties
260
+ labelledBy?: string
261
+ children?: React.ReactNode
262
+ }
263
+
264
+ /**
265
+ * 绝对定位的弹层容器。
266
+ * @author ddj 2026年10月30号
267
+ * @param props 弹层属性
268
+ * @returns 弹层节点
269
+ */
270
+ function PickPanel(props: PanelProps) {
271
+ return React.createElement(
272
+ 'div',
273
+ {
274
+ ref: props.panelRef,
275
+ className: 'edrv-qm-pick-panel',
276
+ style: props.style,
277
+ role: 'listbox',
278
+ 'aria-labelledby': props.labelledBy,
279
+ },
280
+ props.children,
281
+ )
282
+ }
283
+
284
+ /** 单个可选项的属性。 */
285
+ interface OptionProps {
286
+ option: PickOption
287
+ picked: boolean
288
+ onToggle(key: string): void
289
+ }
290
+
291
+ /**
292
+ * 可勾选的多选项行。
293
+ * @author ddj 2026年10月30号
294
+ * @param props 选项属性
295
+ * @returns 选项按钮
296
+ */
297
+ function PickOptionRow(props: OptionProps) {
298
+ const option = props.option
299
+ const state = option.state ?? 'ok'
300
+
301
+ return React.createElement(
302
+ 'button',
303
+ {
304
+ type: 'button',
305
+ role: 'option',
306
+ className: classes('edrv-qm-pick-option', props.picked && 'edrv-qm-pick-option--on'),
307
+ 'aria-selected': props.picked,
308
+ title: option.reason ?? option.hint,
309
+ onClick: () => props.onToggle(option.key),
310
+ },
311
+ React.createElement(
312
+ 'span',
313
+ {
314
+ className: classes('edrv-qm-pick-check', props.picked && 'edrv-qm-pick-check--on'),
315
+ 'aria-hidden': 'true',
316
+ },
317
+ props.picked ? '✓' : '',
318
+ ),
319
+ React.createElement('span', { className: 'edrv-qm-pick-name' }, option.label),
320
+ state === 'ok'
321
+ ? null
322
+ : React.createElement('span', {
323
+ className: classes('edrv-qm-pick-dot', 'edrv-qm-pick-dot--' + state),
324
+ 'aria-hidden': 'true',
325
+ }),
326
+ option.hint ? React.createElement('span', { className: 'edrv-qm-pick-hint' }, option.hint) : null,
327
+ )
328
+ }
329
+
330
+ /** 多选下拉的属性。 */
331
+ export interface PickMultiProps {
332
+ buttonId?: string
333
+ groups: PickGroup[]
334
+ selected: string[]
335
+ /** 摘要计数单位,如「个技能」「条规则」。 */
336
+ unit: string
337
+ emptyText: string
338
+ disabled?: boolean
339
+ disabledHint?: string
340
+ onToggle(key: string): void
341
+ }
342
+
343
+ /**
344
+ * 多选下拉:技能与规则共用,按来源分组展示。
345
+ * @author ddj 2026年10月30号
346
+ * @param props 多选下拉属性
347
+ * @returns 多选下拉组件
348
+ */
349
+ export function PickMulti(props: PickMultiProps) {
350
+ const anchor = useAnchored()
351
+ const labels = labelsOf(props.groups)
352
+ const summaryNames = props.selected.map((key) => labels.get(key) ?? key)
353
+ const summary = selectionLabel(summaryNames, props.unit)
354
+ const issues = issueCount(props.groups, props.selected)
355
+ const groups = props.groups.filter((group) => group.options.length > 0)
356
+
357
+ const rows = groups.map((group) =>
358
+ React.createElement(
359
+ 'div',
360
+ { key: group.key, className: 'edrv-qm-pick-group' },
361
+ React.createElement('div', { className: 'edrv-qm-pick-group-title' }, group.label),
362
+ group.options.map((option) =>
363
+ React.createElement(PickOptionRow, {
364
+ key: option.key,
365
+ option,
366
+ picked: props.selected.includes(option.key),
367
+ onToggle: props.onToggle,
368
+ }),
369
+ ),
370
+ ),
371
+ )
372
+
373
+ return React.createElement(
374
+ 'div',
375
+ { className: 'edrv-qm-pick' },
376
+ React.createElement(PickTrigger, {
377
+ triggerRef: anchor.triggerRef,
378
+ buttonId: props.buttonId,
379
+ summary,
380
+ open: anchor.open,
381
+ disabled: props.disabled === true,
382
+ issueCount: issues,
383
+ hint: props.disabled ? props.disabledHint : undefined,
384
+ onClick: anchor.toggle,
385
+ }),
386
+ anchor.open
387
+ ? React.createElement(
388
+ PickPanel,
389
+ { panelRef: anchor.panelRef, style: anchor.style, labelledBy: props.buttonId },
390
+ rows.length > 0
391
+ ? React.createElement('div', { className: 'edrv-qm-pick-list' }, rows)
392
+ : React.createElement('div', { className: 'edrv-qm-pick-empty' }, props.emptyText),
393
+ )
394
+ : null,
395
+ )
396
+ }
397
+
398
+ /** 限定工作区下拉的属性。 */
399
+ export interface PickWorkspaceProps {
400
+ buttonId?: string
401
+ workspaces: PickWorkspaceItem[]
402
+ selected: string[] | null
403
+ cwd?: string
404
+ inherited?: boolean
405
+ inheritedHint?: string
406
+ onChange(next: string[] | null): void
407
+ }
408
+
409
+ /**
410
+ * 限定工作区下拉:全部 / 当前 / 更多(更多展开全部工作区多选)。
411
+ * @author ddj 2026年10月30号
412
+ * @param props 工作区下拉属性
413
+ * @returns 工作区下拉组件
414
+ */
415
+ export function PickWorkspace(props: PickWorkspaceProps) {
416
+ const anchor = useAnchored()
417
+ const [more, setMore] = React.useState(false)
418
+ const summary = workspaceLabel(props.selected, props.cwd)
419
+ const picked = props.selected ?? []
420
+ const disabled = props.inherited === true
421
+ const currentPath = props.cwd
422
+
423
+ // 全部/当前是「一个值」的快捷项;「更多」不再高亮,避免与下方多选冲突
424
+ const isAll = picked.length === 0
425
+ const isCurrent = picked.length === 1 && !!currentPath && summary.mode === 'current'
426
+
427
+ const onPickAll = () => {
428
+ props.onChange(null)
429
+ anchor.close()
430
+ }
431
+ const onPickCurrent = () => {
432
+ if (!currentPath) return
433
+ props.onChange([currentPath])
434
+ anchor.close()
435
+ }
436
+
437
+ const quick = [
438
+ React.createElement(PickOptionRow, {
439
+ key: '__all__',
440
+ option: { key: '__all__', label: '全部工作区', hint: '菜单在所有工作区可见' },
441
+ picked: isAll,
442
+ onToggle: onPickAll,
443
+ }),
444
+ React.createElement(PickOptionRow, {
445
+ key: '__current__',
446
+ option: {
447
+ key: '__current__',
448
+ label: '当前工作区',
449
+ hint: currentPath || '当前没有活跃工作区',
450
+ },
451
+ picked: isCurrent,
452
+ onToggle: onPickCurrent,
453
+ }),
454
+ ]
455
+
456
+ const rows = props.workspaces.map((workspace) =>
457
+ React.createElement(PickOptionRow, {
458
+ key: workspace.path,
459
+ option: { key: workspace.path, label: workspace.title, hint: workspace.path },
460
+ picked: picked.some((path) => path === workspace.path),
461
+ onToggle: (path: string) => props.onChange(toggleWorkspace(props.selected, path)),
462
+ }),
463
+ )
464
+
465
+ const moreToggle = React.createElement(
466
+ 'button',
467
+ {
468
+ type: 'button',
469
+ className: classes('edrv-qm-pick-more', more && 'edrv-qm-pick-more--on'),
470
+ 'aria-expanded': more,
471
+ onClick: () => setMore(!more),
472
+ },
473
+ more ? '收起工作区列表' : '更多工作区(' + String(props.workspaces.length) + ')',
474
+ )
475
+
476
+ return React.createElement(
477
+ 'div',
478
+ { className: 'edrv-qm-pick' },
479
+ React.createElement(PickTrigger, {
480
+ triggerRef: anchor.triggerRef,
481
+ buttonId: props.buttonId,
482
+ summary: summary.label,
483
+ open: anchor.open,
484
+ disabled,
485
+ hint: disabled ? props.inheritedHint : '点开选择生效范围',
486
+ onClick: anchor.toggle,
487
+ }),
488
+ anchor.open
489
+ ? React.createElement(
490
+ PickPanel,
491
+ { panelRef: anchor.panelRef, style: anchor.style, labelledBy: props.buttonId },
492
+ React.createElement('div', { className: 'edrv-qm-pick-list' }, quick),
493
+ moreToggle,
494
+ more ? React.createElement('div', { className: 'edrv-qm-pick-list' }, rows) : null,
495
+ )
496
+ : null,
497
+ )
498
+ }
499
+
500
+ /** 开关属性。 */
501
+ export interface PickSwitchProps {
502
+ buttonId?: string
503
+ label: string
504
+ hint?: string
505
+ checked: boolean
506
+ disabled?: boolean
507
+ onChange(next: boolean): void
508
+ }
509
+
510
+ /**
511
+ * 开关行:左侧标题与说明,右侧滑动开关。
512
+ * @author ddj 2026年10月30号
513
+ * @param props 开关属性
514
+ * @returns 开关行
515
+ */
516
+ export function PickSwitch(props: PickSwitchProps) {
517
+ return React.createElement(
518
+ 'div',
519
+ { className: 'edrv-qm-switch-row' },
520
+ React.createElement(
521
+ 'div',
522
+ { className: 'edrv-qm-switch-text' },
523
+ React.createElement('div', { className: 'edrv-qm-pick-name' }, props.label),
524
+ props.hint ? React.createElement('div', { className: 'edrv-qm-pick-hint' }, props.hint) : null,
525
+ ),
526
+ React.createElement(
527
+ 'button',
528
+ {
529
+ id: props.buttonId,
530
+ type: 'button',
531
+ role: 'switch',
532
+ className: classes('edrv-qm-switch', props.checked && 'edrv-qm-switch--on'),
533
+ 'aria-checked': props.checked,
534
+ 'aria-label': props.label,
535
+ disabled: props.disabled === true,
536
+ onClick: () => props.onChange(!props.checked),
537
+ },
538
+ React.createElement('span', { className: 'edrv-qm-switch-knob', 'aria-hidden': 'true' }),
539
+ ),
540
+ )
541
+ }
542
+
543
+ /** 分段控件属性。 */
544
+ export interface PickSegProps {
545
+ options: PickSegOption[]
546
+ value: string
547
+ disabled?: boolean
548
+ label?: string
549
+ onChange(next: string): void
550
+ }
551
+
552
+ /**
553
+ * 分段控件:等宽按钮组,用于 1–5 层数这类小范围枚举。
554
+ * @author ddj 2026年10月30号
555
+ * @param props 分段控件属性
556
+ * @returns 分段控件
557
+ */
558
+ export function PickSeg(props: PickSegProps) {
559
+ return React.createElement(
560
+ 'div',
561
+ { className: 'edrv-qm-seg', role: 'group', 'aria-label': props.label },
562
+ props.options.map((option) =>
563
+ React.createElement(
564
+ 'button',
565
+ {
566
+ key: option.value,
567
+ type: 'button',
568
+ className: classes('edrv-qm-seg-btn', option.value === props.value && 'edrv-qm-seg-btn--on'),
569
+ 'aria-pressed': option.value === props.value,
570
+ disabled: props.disabled === true,
571
+ onClick: () => props.onChange(option.value),
572
+ },
573
+ option.label,
574
+ ),
575
+ ),
576
+ )
577
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
4
  import { installCapture } from './officialCapture.js'
5
5
  import { installEvidence } from './reviewCapture.js'
6
- import { installPendingCapture } from './pending/capture.js'
6
+ import { installPending } from './pending/capture.js'
7
7
  import { recordWrite } from './pending/store.js'
8
8
  import { trackPendingCover } from './pendingCompat.js'
9
9
  import { closeArchiveDbs } from './archiveDb.js'
@@ -75,7 +75,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
75
75
  const evidence = installEvidence(ctx)
76
76
  const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
77
77
  // 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
78
- const pending = installPendingCapture(ctx, (write) => {
78
+ const pending = installPending(ctx, (write) => {
79
79
  try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
80
80
  try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
81
81
  })
package/src/paths.ts CHANGED
@@ -204,6 +204,16 @@ export function archiveDbFile(cwd: string, home = dshHome()): string {
204
204
  return join(home, PLUGIN_ID, 'data', 'archive', hashOf(cwd) + '.sqlite')
205
205
  }
206
206
 
207
+ /**
208
+ * 快捷菜单配置文本(用户数据,不受缓存清理预算约束,跨工作区共享一份)。
209
+ * @author ddj 2026年10月30号
210
+ * @param home DSH home(测试可注入)
211
+ * @returns 快捷菜单配置绝对路径
212
+ */
213
+ export function quickMenuFile(home = dshHome()): string {
214
+ return join(home, PLUGIN_ID, 'data', 'quick-menu.json')
215
+ }
216
+
207
217
  /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
208
218
  export function assetsDirOf(moduleUrl: string): string {
209
219
  return join(dirname(fileURLToPath(moduleUrl)), '..', 'assets')