dsh-vscode-mode 0.3.1 → 0.3.3

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.
@@ -2,16 +2,19 @@
2
2
  /**
3
3
  * dsh-vscode-mode client — QuickOpen:顶部搜索框(Ctrl+P 打开文件)。
4
4
  * 快捷键随配置(edrv.quickOpen),占位文案同步当前键位。
5
+ * 候选浮窗支持鼠标点击与键盘导航(↑↓ 切换高亮、Enter 打开高亮项、Esc 关闭);
6
+ * 输入法组词中不消费按键(中文候选词确认优先)。
5
7
  * 迁移自原 src/client/index.ts 的 QuickOpen,语义不改。
6
- * 作者 ddj 2026-08-20 / 2026-08-26
8
+ * 作者 ddj 2026-08-20 / 2026-08-26 / 2026年09月11号
7
9
  */
8
10
  import React from 'react'
9
11
  import { rpc } from '../rpc.js'
10
12
  import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
13
+ import { stepIndex } from './searchNav.js'
11
14
 
12
15
  /**
13
16
  * 快速打开搜索框:Ctrl+P 聚焦,输入 ≥2 字符经 edrv.searchFiles 搜索,
14
- * 结果列表点击/回车打开文件。
17
+ * 结果列表点击 / ↑↓ 选择 + 回车打开高亮项。
15
18
  * @param props.sessionId 会话 id
16
19
  * @param props.onOpen 打开回调(path)
17
20
  */
@@ -21,11 +24,19 @@ export function QuickOpen(props) {
21
24
  const [q, setQ] = React.useState('')
22
25
  const [results, setResults] = React.useState(null)
23
26
  const [open, setOpen] = React.useState(false)
27
+ const [active, setActive] = React.useState(0)
24
28
  const inputRef = React.useRef(null)
29
+ const popRef = React.useRef(null)
30
+ const listId = React.useId()
25
31
  const seq = React.useRef(0)
26
32
  const timer = React.useRef(null)
27
33
  useKeybindingsVersion() // 键位变化时刷新占位文案
28
34
 
35
+ const list = Array.isArray(results) ? results : []
36
+ const showPop = open && Array.isArray(results) && q.trim().length >= 2
37
+ // 高亮下标:候选集变化(含异步结果变短)时按当前长度收敛,不再单独改状态
38
+ const activeIdx = list.length ? Math.min(active, list.length - 1) : 0
39
+
29
40
  const search = (query) => {
30
41
  const s = ++seq.current
31
42
  const clean = String(query || '').trim()
@@ -37,14 +48,14 @@ export function QuickOpen(props) {
37
48
  }
38
49
 
39
50
  const onChange = (v) => {
40
- setQ(v); setOpen(true)
51
+ setQ(v); setActive(0); setOpen(true)
41
52
  if (timer.current) clearTimeout(timer.current)
42
53
  timer.current = setTimeout(() => search(v), 180)
43
54
  }
44
55
 
45
56
  const pick = (p) => {
46
57
  onOpen(p)
47
- setQ(''); setResults(null); setOpen(false)
58
+ setQ(''); setResults(null); setActive(0); setOpen(false)
48
59
  inputRef.current?.blur?.()
49
60
  }
50
61
 
@@ -70,20 +81,56 @@ export function QuickOpen(props) {
70
81
  }
71
82
  }, [])
72
83
 
84
+ // 高亮项滚入浮窗可视区(候选集或高亮变化时;nearest 避免整页跳动)
85
+ React.useEffect(() => {
86
+ if (!showPop) return
87
+ const node = popRef.current?.querySelector?.('.edrv-search-item.edrv-search-sel')
88
+ if (typeof node?.scrollIntoView === 'function') node.scrollIntoView({ block: 'nearest' })
89
+ }, [showPop, activeIdx, list.length])
90
+
73
91
  const dirText = (p) => {
74
92
  const base = String(p).split(/[\\/]/).pop() || String(p)
75
93
  return String(p).slice(0, Math.max(0, String(p).length - base.length)).replace(/[\\/]+$/, '') || String(p)
76
94
  }
77
95
 
96
+ /**
97
+ * 输入框按键:浮窗有候选时消费 ↑↓/Enter,其余情况放行(保留原生光标行为)。
98
+ * 输入法组词中一律放行——中文候选词的 Enter 确认不得被当成「打开文件」。
99
+ * @author ddj 2026年09月11号
100
+ * @param e 输入框键盘事件
101
+ */
102
+ const onInputKey = (e) => {
103
+ if (e.nativeEvent?.isComposing) return
104
+ if (e.key === 'Escape') { setOpen(false); return }
105
+ if (!showPop || !list.length) return
106
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
107
+ e.preventDefault()
108
+ setActive((v) => stepIndex(v, e.key === 'ArrowDown' ? 1 : -1, list.length))
109
+ return
110
+ }
111
+ if (e.key === 'Enter') {
112
+ e.preventDefault()
113
+ pick(list[activeIdx])
114
+ }
115
+ }
116
+
78
117
  let pop = null
79
- if (open && Array.isArray(results) && q.trim().length >= 2) {
80
- if (results.length) {
81
- const items = results.map((p) => React.createElement('div', { key: p, className: 'edrv-search-item', onClick: () => pick(p) },
118
+ if (showPop) {
119
+ if (list.length) {
120
+ const items = list.map((p, i) => React.createElement('div', {
121
+ key: p,
122
+ id: listId + '-' + i,
123
+ role: 'option',
124
+ 'aria-selected': i === activeIdx,
125
+ className: 'edrv-search-item' + (i === activeIdx ? ' edrv-search-sel' : ''),
126
+ onMouseEnter: () => setActive(i),
127
+ onClick: () => pick(p),
128
+ },
82
129
  React.createElement('span', { className: 'n' }, String(p).split(/[\\/]/).pop() || p),
83
130
  React.createElement('span', { className: 'd' }, dirText(p))))
84
- pop = React.createElement('div', { className: 'edrv-search-pop' }, ...items)
131
+ pop = React.createElement('div', { ref: popRef, id: listId + '-list', className: 'edrv-search-pop', role: 'listbox' }, ...items)
85
132
  } else {
86
- pop = React.createElement('div', { className: 'edrv-search-pop' }, React.createElement('div', { className: 'edrv-search-empty' }, '无匹配文件'))
133
+ pop = React.createElement('div', { ref: popRef, className: 'edrv-search-pop' }, React.createElement('div', { className: 'edrv-search-empty' }, '无匹配文件'))
87
134
  }
88
135
  }
89
136
 
@@ -91,12 +138,13 @@ export function QuickOpen(props) {
91
138
  React.createElement('input', {
92
139
  ref: inputRef, className: 'edrv-search', placeholder: '搜索文件 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ')',
93
140
  value: q,
141
+ role: 'combobox',
142
+ 'aria-expanded': showPop && list.length > 0,
143
+ 'aria-controls': showPop && list.length ? listId + '-list' : undefined,
144
+ 'aria-activedescendant': showPop && list.length ? listId + '-' + activeIdx : undefined,
94
145
  onChange: (e) => onChange(e.target.value),
95
146
  onFocus: () => { if (q.trim().length >= 2 && !results) setOpen(true) },
96
- onKeyDown: (e) => {
97
- if (e.key === 'Enter' && Array.isArray(results) && results.length) pick(results[0])
98
- if (e.key === 'Escape') setOpen(false)
99
- },
147
+ onKeyDown: onInputKey,
100
148
  }),
101
149
  pop)
102
150
  }
@@ -6,7 +6,7 @@
6
6
  * 与既有 `edrv:` 刷新/主题事件分属不同命名空间,互不干扰)。
7
7
  * 作者 ddj 2026年09月10号
8
8
  */
9
- import { hasEditorModel } from '../editorModelState.js'
9
+ import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
10
10
 
11
11
  /** 一条编辑器指令(展示 + 执行 + 可用性)。 */
12
12
  export interface CommandDef {
@@ -90,6 +90,21 @@ function addSelectionRefDef(): CommandDef {
90
90
  }
91
91
  }
92
92
 
93
+ /**
94
+ * 关闭当前页签(参考图 Ctrl+F4)。
95
+ * 可用性按「是否已打开页签」判定而非编辑器模型:图片/PDF 页签无 Monaco 实例但可关闭;
96
+ * 无页签时判定不可用 → 指令桥放行按键,不吞掉浏览器/系统对 Ctrl+F4 的默认行为。
97
+ * @author ddj 2026年09月11号
98
+ * @returns 命令定义
99
+ */
100
+ function closeTabDef(): CommandDef {
101
+ return {
102
+ id: 'edrv.closeTab', label: '关闭当前页签', category: '文件', order: 40,
103
+ keybinding: 'Ctrl+F4', available: hasOpenTabs,
104
+ run: () => emit('closeTab'),
105
+ }
106
+ }
107
+
93
108
  /**
94
109
  * 编辑器内置指令目录(顺序 = 快捷键设置页展示顺序)。
95
110
  * 前置 8 条的键位由 EditorView / QuickOpen 自行 capture 监听(历史实现),
@@ -178,6 +193,7 @@ export const BRIDGE_COMMANDS: readonly CommandDef[] = [
178
193
  nextEditorRowDef(),
179
194
  prevEditorRowDef(),
180
195
  addSelectionRefDef(),
196
+ closeTabDef(),
181
197
  ]
182
198
 
183
199
  /**
@@ -0,0 +1,24 @@
1
+ /**
2
+ * dsh-vscode-mode client — 候选列表键盘导航纯函数。
3
+ * 从搜索浮窗(QuickOpen)的按键处理中拆出,便于单测;不依赖 React 与 DOM。
4
+ * 导航语义:步进越界**循环**(末位 ↓ 回首位、首位 ↑ 到末位),与命令栏
5
+ * (CommandPalette 的 (min+step) % len)保持一致。
6
+ * 作者 ddj 2026年09月11号
7
+ */
8
+
9
+ /**
10
+ * 计算候选列表的下一选中下标(循环;任何非法输入都收敛到合法下标)。
11
+ * 入参与返回值恒在 `[0, length - 1]`(`length <= 0` 时返回 0)。
12
+ * 作者 ddj 2026年09月11号
13
+ * @param current 当前选中下标(越界/非有限值按首位处理)
14
+ * @param delta 步进(+1 下一项 / -1 上一项)
15
+ * @param length 候选总数
16
+ * @returns 步进后的选中下标;无候选时返回 0
17
+ */
18
+ export function stepIndex(current: number, delta: number, length: number): number {
19
+ if (!Number.isFinite(length) || length <= 0) return 0
20
+ const size = Math.floor(length)
21
+ const from = Math.min(Math.max(Number.isFinite(current) ? Math.floor(current) : 0, 0), size - 1)
22
+ const move = Number.isFinite(delta) ? Math.trunc(delta) : 0
23
+ return ((from + move) % size + size) % size
24
+ }
package/src/compat.ts CHANGED
@@ -13,6 +13,7 @@ import { entriesOf } from './mcp.js'
13
13
  import { loadSettingsDeps, settingsInstallNote, settingsInstallStrategy } from './fileOpenSettings.js'
14
14
  import { readDevForm } from './devForm.js'
15
15
  import { compareDshVersions, detectDshVersion, familyLabel, parseDshVersion } from './dshVersion.js'
16
+ import { SKILL_PREFIXES, skillGroupState } from './skills.js'
16
17
  import type { CompatAdapter, CompatReport } from './shared/compat.js'
17
18
  import type { Ctx } from './store.js'
18
19
  import { ROUTE_PREFIX } from './paths.js'
@@ -55,11 +56,14 @@ export function detectExternal(ctx: Ctx, depsAvailable: boolean): CompatAdapter[
55
56
  const settings = ctx.get('settings') as { describe?: unknown; update?: unknown } | undefined
56
57
  const hasSettings = Boolean(settings?.describe || settings?.update)
57
58
  const sub = ctx.get('subprocess') as { spawn?: unknown } | undefined
59
+ const skills = skillGroupState()
58
60
  return [
59
61
  { name: MCP_PACKAGE, active: mcpCount > 0, note: mcpCount > 0 ? mcpCount + ' 个 MCP 服务条目' : '未检测到 MCP 条目(MCP 管理页显示为空)' },
60
62
  { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装' : '未安装:fileOpenTool 持久化降级为配置值' },
61
63
  { name: 'settings 服务', active: hasSettings, note: hasSettings ? '可读写设置' : '不可用(设置读写走配置回退)' },
62
64
  { name: '文件浏览器打开(subprocess 服务)', active: typeof sub?.spawn === 'function', note: typeof sub?.spawn === 'function' ? '可定位/打开 OS 文件浏览器' : '不可用(右键「在文件浏览器中打开」将提示失败)' },
65
+ // 新增项一律追加在末尾:既有下标被 tests/compat.test.ts 断言,不得前插。
66
+ { name: '插件技能组(' + SKILL_PREFIXES[0] + '*)', active: skills.mounted, note: skills.note + (skills.mounted && skills.dir ? '(' + skills.dir + ')' : '') },
63
67
  ]
64
68
  }
65
69
 
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { createLspRpc } from './lsp/rpc.js'
26
26
  import { disposeAllServers } from './lsp/transport.js'
27
27
  import { createAiRpc } from './ai/rpc.js'
28
28
  import { installRulesSection } from './rules.js'
29
+ import { installSkillGroup } from './skills.js'
29
30
  import type { RpcHandlerMap } from './shared/rpc.js'
30
31
  import type { Registry } from './registry.js'
31
32
  import type { Ctx } from './store.js'
@@ -55,6 +56,9 @@ export function apply(ctx: Ctx, config?: unknown): void {
55
56
  /** 规则注入 section(~/.dsh/rules 与 <工作区>/.dsh/rules;旧版 DSH 无 systemPrompt 时静默降级)。 */
56
57
  const rulesInstalled = installRulesSection(ctx)
57
58
  if (!rulesInstalled) log.warn('未检测到 systemPrompt 服务,规则仅可管理不注入')
59
+ /** 插件自带技能组(<包根>/skills,惰性获取 skills 服务;返回值仅表示"已调度",实际结果见 skillGroupState)。 */
60
+ const skillsDispatched = installSkillGroup(ctx)
61
+ if (!skillsDispatched) log.warn('技能组未调度,插件技能组不可用')
58
62
  /** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
59
63
  const lspRpc = createLspRpc({ ctx, pluginConfig: config, manager: lspManager })
60
64
  const lspHandlers: Partial<RpcHandlerMap> = lspRpc.handlers
@@ -92,7 +96,7 @@ export function apply(ctx: Ctx, config?: unknown): void {
92
96
  disposeAllServers()
93
97
  })
94
98
 
95
- log.info('编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入' + (rulesInstalled ? '已接入' : '未接入') + ')')
99
+ log.info('编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入' + (rulesInstalled ? '已接入' : '未接入') + ',技能组' + (skillsDispatched ? '装配中' : '未装配') + ')')
96
100
  }
97
101
 
98
102
  /**
package/src/paths.ts CHANGED
@@ -173,6 +173,17 @@ export function vendorDirOf(moduleUrl: string): string {
173
173
  return join(assetsDirOf(moduleUrl), 'vendor')
174
174
  }
175
175
 
176
+ /**
177
+ * 插件自带技能组根目录(包根 skills/,与 assets/ 同级;随包发布)。
178
+ * 布局:<根>/<技能名>/SKILL.md(目录式)或 <根>/<技能名>.md(扁平式)。
179
+ * @author ddj 2026年09月11号
180
+ * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
181
+ * @returns 技能组根目录绝对路径
182
+ */
183
+ export function skillsDirOf(moduleUrl: string): string {
184
+ return join(dirname(fileURLToPath(moduleUrl)), '..', 'skills')
185
+ }
186
+
176
187
  /** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
177
188
  export function imageDirOf(config: unknown, moduleUrl: string): string {
178
189
  const cfg = config as { imageDir?: unknown } | undefined
@@ -25,6 +25,9 @@ export const KEYBINDING_DEFAULTS: Record<string, string> = {
25
25
  'edrv.prevEditorRow': 'Ctrl+Alt+ArrowUp',
26
26
  // 添加选中内容为引用:把当前选区追加进对话输入框(Ctrl+U,VS Code 同款)
27
27
  'edrv.addSelectionRef': 'Ctrl+U',
28
+ // 关闭当前页签:VS Code 同款为 Ctrl+W,但浏览器会截获该键(脚本无法 preventDefault),
29
+ // 故取参考图里的第二候选 Ctrl+F4(Chrome/Edge 默认无行为,可安全拦截)。
30
+ 'edrv.closeTab': 'Ctrl+F4',
28
31
  }
29
32
 
30
33
  /**