dsh-vscode-mode 0.1.63 → 0.3.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.
@@ -2,11 +2,15 @@
2
2
  * dsh-vscode-mode client — 快捷键配置模块(解析/匹配/状态同步)。
3
3
  * 纯逻辑(parseChord/parseChords/formatChord/matchEvent/chordFromEvent/normalizeKey)不依赖 DOM,可单测;
4
4
  * 模块状态由 settings 订阅驱动(client/index.ts 调 keybindingsApply)。
5
- * 键位语义:Ctrl 与 Cmd 互认(延续现有 Ctrl+P/Ctrl+B 捕获行为)。
6
- * 作者 ddj 2026年08月26号
5
+ * 键位语义:Ctrl 与 Cmd 互认(延续 Ctrl+P/Ctrl+B 捕获行为)。
6
+ * 命令目录(COMMANDS)派生自指令目录 commandCatalog(指令 = 单一数据源,设置页自动跟随);
7
+ * 另支持运行时键位表(第三方命令 register 时声明,不落设置 schema,注销即失效)。
8
+ * 作者 ddj 2026年08月26号 / 2026年09月10号
7
9
  */
8
10
  import React from 'react'
9
11
  import { KEYBINDING_DEFAULTS, normalizeKeybindings } from '../shared/keybindings.js'
12
+ import { BRIDGE_COMMANDS, EDITOR_COMMANDS, showCommandsDef } from './ui/commandCatalog.js'
13
+ import { log } from './log.js'
10
14
 
11
15
  /** 解析后的键位(修饰符 + 规范化主键)。 */
12
16
  export interface Binding {
@@ -19,24 +23,39 @@ export interface Binding {
19
23
 
20
24
  /** 命令目录(设置页展示标签;执行按目录序先匹配先执行,冲突键位确定性)。 */
21
25
  export const COMMANDS: Array<{ id: string; label: string }> = [
22
- { id: 'edrv.save', label: '保存文件' },
23
- { id: 'edrv.quickOpen', label: '快速打开文件' },
24
- { id: 'edrv.toggleSidebar', label: '切换侧边栏' },
25
- { id: 'edrv.searchInFiles', label: '在工作区中搜索' },
26
- { id: 'edrv.navigateBack', label: '后退(导航历史)' },
27
- { id: 'edrv.navigateForward', label: '前进(导航历史)' },
28
- { id: 'edrv.nextTab', label: '下一个页签' },
29
- { id: 'edrv.prevTab', label: '上一个页签' },
26
+ ...EDITOR_COMMANDS.map((command) => ({ id: command.id, label: command.label })),
27
+ { id: 'edrv.showCommands', label: '显示所有命令' },
28
+ ...BRIDGE_COMMANDS.map((command) => ({ id: command.id, label: command.label })),
30
29
  ]
31
30
 
31
+ /** 命令栏命令定义(键位自检与目录展示共用;run 不参与键位逻辑)。 */
32
+ function paletteCommand(): { id: string; label: string; keybinding?: string } {
33
+ return showCommandsDef(() => {})
34
+ }
35
+
36
+ /**
37
+ * 目录与默认键位表一致性自检(仅告警不中断):新指令漏写共享表时第一时间可见。
38
+ * @author ddj 2026年09月10号
39
+ */
40
+ function checkDefaultsDrift(): void {
41
+ for (const command of [...EDITOR_COMMANDS, paletteCommand()]) {
42
+ if (!command.keybinding) continue
43
+ if (KEYBINDING_DEFAULTS[command.id] === command.keybinding) continue
44
+ log.warn('指令默认键位与共享表不一致:' + command.id
45
+ + ' 目录=' + command.keybinding + ' 共享表=' + String(KEYBINDING_DEFAULTS[command.id]))
46
+ }
47
+ }
48
+
32
49
  const MODIFIERS: Record<string, 'ctrl' | 'shift' | 'alt' | 'meta'> = {
33
50
  ctrl: 'ctrl', cmd: 'meta', meta: 'meta', shift: 'shift', alt: 'alt',
34
51
  }
35
52
 
36
53
  let current: Record<string, string> = { ...KEYBINDING_DEFAULTS }
37
- let parsed: Record<string, Binding[]> = {}
54
+ const runtime = new Map<string, Binding[]>()
38
55
  const listeners = new Set<() => void>()
39
56
 
57
+ checkDefaultsDrift()
58
+
40
59
  /**
41
60
  * 应用设置快照(与默认值合并;未知 id 丢弃;空对象 = 全部默认)。
42
61
  * 每个命令可含多候选键位(`|` 分隔),任一命中即触发。
@@ -45,8 +64,6 @@ const listeners = new Set<() => void>()
45
64
  */
46
65
  export function keybindingsApply(raw: unknown): void {
47
66
  current = { ...KEYBINDING_DEFAULTS, ...normalizeKeybindings(raw) }
48
- parsed = {}
49
- for (const id of Object.keys(current)) parsed[id] = parseChords(current[id])
50
67
  for (const listener of listeners) {
51
68
  try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
52
69
  }
@@ -64,24 +81,61 @@ export function subscribeKeybindings(listener: () => void): () => void {
64
81
  }
65
82
 
66
83
  /**
67
- * 当前键位弦(未绑定/空 → null)。
68
- * @author ddj 2026年08月26号
84
+ * 注册运行时键位(第三方命令;优先于设置值,注销即失效)。
85
+ * @author ddj 2026年09月10号
86
+ * @param id 命令 id
87
+ * @param chord 键位弦(空/非法按未绑定处理)
88
+ * @returns 注销函数(幂等)
89
+ */
90
+ export function addRuntimeKeybinding(id: string, chord: string): () => void {
91
+ runtime.set(id, parseChords(chord))
92
+ notifyKeybindings()
93
+ return () => removeRuntimeKeybinding(id)
94
+ }
95
+
96
+ /**
97
+ * 移除运行时键位。
98
+ * @author ddj 2026年09月10号
99
+ * @param id 命令 id
100
+ */
101
+ export function removeRuntimeKeybinding(id: string): void {
102
+ if (!runtime.delete(id)) return
103
+ notifyKeybindings()
104
+ }
105
+
106
+ /** 通知键位订阅者(异常隔离)。 */
107
+ function notifyKeybindings(): void {
108
+ for (const listener of listeners) {
109
+ try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 当前命令的键位弦(运行时键位优先;未绑定/空 → null)。
115
+ * @author ddj 2026年08月26号 / 2026年09月10号
69
116
  * @param id 命令 id
70
117
  * @returns 键位弦或 null
71
118
  */
72
119
  export function chordOf(id: string): string | null {
120
+ if (runtime.has(id)) {
121
+ const chords = runtime.get(id) ?? []
122
+ return chords.length ? chords.map(formatChord).join('|') : null
123
+ }
73
124
  const chord = current[id]
74
125
  return typeof chord === 'string' && chord.trim() !== '' ? chord : null
75
126
  }
76
127
 
77
128
  /**
78
129
  * 当前命令的解析键位集合(未绑定/非法 → 空数组;含多候选)。
79
- * @author ddj 2026年08月26号
130
+ * @author ddj 2026年08月26号 / 2026年09月10号
80
131
  * @param id 命令 id
81
132
  * @returns 解析键位数组(可能为空)
82
133
  */
83
134
  export function bindingsOf(id: string): Binding[] {
84
- return parsed[id] ?? []
135
+ const override = runtime.get(id)
136
+ if (override) return override
137
+ const chord = current[id]
138
+ return typeof chord === 'string' ? parseChords(chord) : []
85
139
  }
86
140
 
87
141
  /**
@@ -65,6 +65,8 @@ export const LANG_BY_EXT = {
65
65
  lua51: 'lua', luac: 'lua',
66
66
  gitattributes: 'ini', editorconfig: 'ini', env: 'ini', properties: 'ini',
67
67
  json5: 'jsonc', log: 'plaintext', txt: 'plaintext',
68
+ // 代码片段文件按 JSON 高亮(VS Code 同款:.code-snippets 是带注释的 JSON)
69
+ 'code-snippets': 'json',
68
70
  }
69
71
 
70
72
  /**
@@ -81,6 +83,28 @@ export function langOf(path) {
81
83
  return LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? 'plaintext'
82
84
  }
83
85
 
86
+ /** 片段文件后缀(判断「文件自身是片段文件」而非普通源码)。 */
87
+ const SNIPPET_FILE_SUFFIX = '.code-snippets'
88
+
89
+ /**
90
+ * 片段文件 → 它绑定的语言 id(VS Code 文件名约定 `<language>.code-snippets`)。
91
+ *
92
+ * 为什么不能直接用 {@link langOf}:`.code-snippets` 本身在 LANG_BY_EXT 里映射为 json
93
+ * (编辑片段文件时要按 JSON 高亮),于是 langOf('lua.code-snippets') 会得到 'json' 而不是
94
+ * 'lua' —— 曾导致「新建」默认文件名被算成 `json.code-snippets`。故这里按片段命名约定
95
+ * 单独解析:去掉 `.code-snippets` 后缀取语言前缀。
96
+ *
97
+ * @author ddj 2026年09月10号
98
+ * @param path 片段文件路径
99
+ * @returns 语言 id(`global.code-snippets` / 无法识别 → 空串 = 全语言)
100
+ */
101
+ export function snippetLanguageOf(path) {
102
+ const base = String(path || '').split(/[\\/]/).pop() || ''
103
+ if (!base.toLowerCase().endsWith(SNIPPET_FILE_SUFFIX)) return langOf(path)
104
+ const lang = base.slice(0, -SNIPPET_FILE_SUFFIX.length).toLowerCase()
105
+ return lang === 'global' ? '' : lang
106
+ }
107
+
84
108
  /**
85
109
  * 加载 Monaco Editor(AMD 构建,随插件包离线分发):注入 loader.js → require.config → editor.main。
86
110
  * @author ddj 2026年08月20号
@@ -0,0 +1,172 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 代码片段补全 provider(Monaco IntelliSense)。
4
+ * 数据源:host snippets.entries(全局 ~/.dsh/snippets + 当前工作区 <工作区>/.dsh/snippets),
5
+ * 条目按 model 语言过滤(language 空串 = 全语言);条目主体交给 Monaco 片段语法解析
6
+ * (${1:占位} / $TM_FILENAME 等由 InsertAsSnippet 规则展开)。
7
+ * 性能:条目模块级缓存 + TTL,不在每次按键打 RPC(与 ai/inlineProvider 的去抖策略异曲同工,
8
+ * 但片段是本地小表,缓存足矣);edrv:snippets-changed(配置文件保存后)强制失效。
9
+ * 作者 ddj 2026年09月10号
10
+ */
11
+ import { rpc } from '../rpc.js'
12
+
13
+ /** 条目缓存 TTL(毫秒):片段文件本就只在用户编辑时变化。 */
14
+ const CACHE_TTL_MS = 10_000
15
+
16
+ /** 补全候选上限(超大片段库下防拖慢渲染)。 */
17
+ const MAX_ITEMS = 200
18
+
19
+ /** 已加载条目缓存(null = 未加载/已失效)。 */
20
+ let cache = null
21
+ let cacheAt = 0
22
+ let pending = null
23
+ let registered = false
24
+ let disposer = null
25
+
26
+ /** 当前会话 id(补全按会话工作区叠加项目片段;EditorView 装配时注入)。 */
27
+ let sessionId = null
28
+
29
+ /**
30
+ * 设置当前会话(会话切换时更新;缓存一并失效,使项目片段跟随工作区)。
31
+ * @author ddj 2026年09月10号
32
+ * @param id 会话 id
33
+ */
34
+ export function setSnippetsSession(id) {
35
+ if (sessionId === id) return
36
+ sessionId = id
37
+ invalidateSnippets()
38
+ }
39
+
40
+ /**
41
+ * 失效条目缓存(配置文件保存/会话切换后调用)。
42
+ * @author ddj 2026年09月10号
43
+ */
44
+ export function invalidateSnippets() {
45
+ cache = null
46
+ cacheAt = 0
47
+ pending = null
48
+ }
49
+
50
+ /**
51
+ * 读取条目(带缓存与在飞去重);失败返回空表(补全静默降级,不打断输入)。
52
+ * @author ddj 2026年09月10号
53
+ * @returns 片段条目数组
54
+ */
55
+ async function loadEntries() {
56
+ if (cache && Date.now() - cacheAt < CACHE_TTL_MS) return cache
57
+ if (pending) return pending
58
+ pending = rpc('snippets.entries', { sessionId })
59
+ .then((res) => {
60
+ const entries = res && res.ok && Array.isArray(res.entries) ? res.entries : []
61
+ cache = entries
62
+ cacheAt = Date.now()
63
+ return entries
64
+ })
65
+ .catch(() => {
66
+ cache = []
67
+ cacheAt = Date.now()
68
+ return cache
69
+ })
70
+ .finally(() => { pending = null })
71
+ return pending
72
+ }
73
+
74
+ /**
75
+ * 按语言过滤条目:条目语言为空(全语言)或与 model 语言一致即命中。
76
+ * 项目片段(scope=project)排在全局之后去重,同名同前缀时项目优先。
77
+ * @author ddj 2026年09月10号
78
+ * @param entries 全部条目
79
+ * @param languageId 当前模型语言
80
+ * @returns 生效条目(项目优先,已去重)
81
+ */
82
+ export function entriesForLanguage(entries, languageId) {
83
+ const lang = String(languageId ?? '').toLowerCase()
84
+ const global = []
85
+ const project = []
86
+ for (const entry of entries) {
87
+ const entryLang = String(entry?.language ?? '').toLowerCase()
88
+ if (entryLang && entryLang !== lang) continue
89
+ if (entry?.scope === 'project') project.push(entry)
90
+ else global.push(entry)
91
+ }
92
+ // 项目条目覆盖同 key 的全局条目(工作区定制优先)
93
+ const projectKeys = new Set(project.map((entry) => entry.key))
94
+ const merged = global.filter((entry) => !projectKeys.has(entry.key)).concat(project)
95
+ return merged.slice(0, MAX_ITEMS)
96
+ }
97
+
98
+ /**
99
+ * 注册代码片段补全 provider(幂等;Monaco 就绪后调用一次)。
100
+ * @author ddj 2026年09月10号
101
+ * @param monaco window.monaco
102
+ */
103
+ export function registerSnippetProvider(monaco) {
104
+ if (registered || !monaco?.languages?.registerCompletionItemProvider) return
105
+ const snippetKind = monaco.languages.CompletionItemKind?.Snippet
106
+ const asSnippet = monaco.languages.CompletionItemInsertTextRule?.InsertAsSnippet
107
+ // 缺少片段枚举(精简版 Monaco)时不注册:否则候选项会退化为纯文本插入,误导用户
108
+ if (typeof snippetKind !== 'number' || typeof asSnippet !== 'number') return
109
+ registered = true
110
+ disposer = monaco.languages.registerCompletionItemProvider('*', {
111
+ async provideCompletionItems(model, position) {
112
+ const entries = await loadEntries()
113
+ if (!entries.length) return { suggestions: [] }
114
+ const items = entriesForLanguage(entries, model?.getLanguageId?.())
115
+ if (!items.length) return { suggestions: [] }
116
+ const word = model.getWordUntilPosition(position)
117
+ const range = {
118
+ startLineNumber: position.lineNumber,
119
+ endLineNumber: position.lineNumber,
120
+ startColumn: word.startColumn,
121
+ endColumn: word.endColumn,
122
+ }
123
+ return {
124
+ suggestions: items.map((entry) => ({
125
+ label: entry.prefix || entry.key,
126
+ // 无前缀条目不经键入触发(靠「插入代码片段」命令使用),补全里不展示空标签
127
+ insertText: entry.body,
128
+ detail: (entry.scope === 'project' ? '项目片段 · ' : '全局片段 · ') + entry.file,
129
+ documentation: entry.description || entry.key,
130
+ kind: snippetKind,
131
+ insertTextRules: asSnippet,
132
+ filterText: entry.prefix || entry.key,
133
+ range,
134
+ })).filter((item) => Boolean(item.label)),
135
+ }
136
+ },
137
+ })
138
+ }
139
+
140
+ /**
141
+ * 装配:Monaco 就绪后注册 provider(幂等)。
142
+ * @author ddj 2026年09月10号
143
+ * @param monaco window.monaco
144
+ */
145
+ export function setupSnippets(monaco) {
146
+ registerSnippetProvider(monaco)
147
+ }
148
+
149
+ /**
150
+ * 卸载:注销 provider(插件热重载/卸载时调用)。
151
+ * @author ddj 2026年09月10号
152
+ */
153
+ export function disposeSnippets() {
154
+ if (typeof disposer === 'function') {
155
+ try { disposer.dispose?.() } catch { /* 已注销 */ }
156
+ }
157
+ disposer = null
158
+ registered = false
159
+ invalidateSnippets()
160
+ }
161
+
162
+ /**
163
+ * 读取可插入条目(「插入代码片段」命令用;含无前缀条目)。
164
+ * @author ddj 2026年09月10号
165
+ * @param languageId 当前模型语言(缺省返回全语言 + 该语言条目)
166
+ * @returns 生效条目数组
167
+ */
168
+ export async function listSnippetsFor(languageId) {
169
+ const entries = await loadEntries()
170
+ if (!languageId) return entries.slice(0, MAX_ITEMS)
171
+ return entriesForLanguage(entries, languageId)
172
+ }
@@ -369,5 +369,56 @@
369
369
  [data-edrv-view] .edrv-rules-save { height: 24px; padding: 0 14px; border: none; border-radius: 6px; background: var(--dsw-alias-brand-primary, #0f9d58); color: var(--dsw-alias-label-primary-inverted, #fff); font-size: 12px; cursor: pointer; }
370
370
  [data-edrv-view] .edrv-rules-save:disabled { opacity: .5; cursor: default; }
371
371
 
372
+ /* 命令栏(Ctrl+Shift+P / F1):body 级 portal 浮层,故不依赖编辑区几何。
373
+ ⚠️ 浮层 z-index 阶梯(自下而上;改动务必保持顺序,tests/overlayZOrder.test.ts 守约):
374
+ 编辑器内浮层 60(.edrv-search-pop) / 71(.edrv-ctxmenu)
375
+ < 190 命令栏遮罩 < 200 命令栏
376
+ < 195 片段浮窗遮罩 < 197 片段二级弹窗遮罩
377
+ 命令栏必须**高于**片段浮窗:曾因片段遮罩(192) 压过命令栏(191),导致片段浮窗打开时
378
+ 命令栏被整体盖住,按 Ctrl+Shift+P 因「已打开即早退」而毫无反应(表现为快捷键失效)。 */
379
+ [data-edrv-view] .edrv-palette-mask { position: fixed; inset: 0; z-index: 190; background: rgba(0, 0, 0, .28); }
380
+ [data-edrv-view] .edrv-palette { position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index: 200; width: min(580px, calc(100vw - 32px)); max-height: 64vh; display: flex; flex-direction: column; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 12px 34px rgba(31, 41, 51, .24); overflow: hidden; }
381
+ [data-edrv-view] .edrv-palette-input { height: 38px; flex: 0 0 auto; padding: 0 12px; border: none; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: transparent; color: var(--dsw-alias-label-primary, #1f2933); font-size: 13px; outline: none; }
382
+ [data-edrv-view] .edrv-palette-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px; }
383
+ [data-edrv-view] .edrv-palette-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 12px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; }
384
+ [data-edrv-view] .edrv-palette-row.edrv-palette-sel { background: var(--dsw-alias-interactive-bg-hover, rgba(15, 157, 88, .10)); }
385
+ [data-edrv-view] .edrv-palette-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
+ [data-edrv-view] .edrv-palette-cat { flex: 0 0 auto; font-size: 10px; padding: 1px 7px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); color: var(--dsw-alias-label-tertiary, #7b8794); }
387
+ [data-edrv-view] .edrv-palette-key { flex: 0 0 auto; font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 10px; padding: 1px 6px; border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 5px; background: var(--dsw-alias-bg-layer-1, #f7f9fa); color: var(--dsw-alias-label-secondary, #52606d); }
388
+ [data-edrv-view] .edrv-palette-empty { padding: 14px 10px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #7b8794); text-align: center; }
389
+ [data-edrv-view] .edrv-palette-notice { flex: 0 0 auto; padding: 6px 10px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 11px; color: var(--dsw-alias-state-warn-primary, #b7791f); }
390
+
391
+ /* 代码片段选择器(配置 / 插入):居中浮窗 + 新建二级弹窗。
392
+ 卡片复用 mcp.css 的 .vsm-mcp-dialog(与「添加 MCP」「工作区选择」同一视觉语言),
393
+ 此处只补遮罩、尺寸与列表布局。⚠️ 根节点须带 data-edrv-view,否则这些规则全部失配。 */
394
+ [data-edrv-view] .edrv-snip-mask { position: fixed; inset: 0; z-index: 195; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; background: var(--dsw-alias-bg-mask-drop, rgba(0, 0, 0, .55)); }
395
+ /* 二级弹窗叠在一级浮窗之上(仍在命令栏之下,见上方阶梯说明) */
396
+ [data-edrv-view] .edrv-snip-mask-top { z-index: 197; }
397
+ [data-edrv-view] .edrv-snip-dialog { display: flex; flex-direction: column; overflow: hidden; width: min(620px, calc(100vw - 48px)); }
398
+ [data-edrv-view] .edrv-snip-hint { margin: 0 0 12px; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
399
+ [data-edrv-view] .edrv-snip-list { display: flex; flex-direction: column; gap: 1px; min-height: 0; max-height: 46vh; overflow: auto; }
400
+ [data-edrv-view] .edrv-snip-group { padding: 12px 2px 4px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary, #aaa); }
401
+ [data-edrv-view] .edrv-snip-row { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box; padding: 2px; border: none; border-radius: 8px; background: transparent; text-align: left; color: var(--dsw-alias-label-primary, #e6e6e6); font: inherit; }
402
+ [data-edrv-view] .edrv-snip-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .06)); }
403
+ [data-edrv-view] button.edrv-snip-row { cursor: pointer; }
404
+ [data-edrv-view] .edrv-snip-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: none; border-radius: 6px; background: transparent; text-align: left; cursor: pointer; color: inherit; font: inherit; }
405
+ [data-edrv-view] .edrv-snip-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e6e6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
406
+ [data-edrv-view] .edrv-snip-desc { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
407
+ [data-edrv-view] .edrv-snip-src { flex: 0 0 auto; padding-right: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
408
+ [data-edrv-view] .edrv-snip-warn { flex: 0 0 auto; font-size: 11px; padding: 2px 7px; border-radius: 999px; background: var(--dsw-alias-state-warn-tertiary, rgba(229, 185, 63, .16)); color: var(--dsw-alias-state-warn-primary, #e5b93f); white-space: nowrap; }
409
+ [data-edrv-view] .edrv-snip-act { flex: 0 0 auto; width: 26px; height: 26px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 13px; line-height: 1; cursor: pointer; }
410
+ [data-edrv-view] .edrv-snip-act:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217, 83, 79, .16)); color: var(--dsw-alias-state-error-primary, #f07878); }
411
+ [data-edrv-view] .edrv-snip-empty { padding: 12px 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); line-height: 1.6; }
412
+ /* 当前文件语言提示条(片段按语言绑定,明确告知生效范围) */
413
+ [data-edrv-view] .edrv-snip-langbar { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1, #2b2b2b); font-size: 12px; color: var(--dsw-alias-label-secondary, #aaa); }
414
+ [data-edrv-view] .edrv-snip-langbar b { color: var(--dsw-alias-label-primary, #e6e6e6); }
415
+ /* 二级弹窗的动作按钮(原生 .vsm-mcp-dialog-actions 已提供排版与按钮样式) */
416
+ [data-edrv-view] .edrv-snip-dialog .vsm-mcp-dialog-actions { flex-wrap: wrap; }
417
+ /* 删除图标按钮:.vsm-danger 的规则全部限定在 .vsm-mcp-actions button 之下,裸用无样式,此处补自有类 */
418
+ [data-edrv-view] .edrv-snip-act.edrv-snip-danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217, 83, 79, .16)); color: var(--dsw-alias-state-error-primary, #f07878); }
419
+ /* 浮窗内的原生空态(.vsm-mcp-empty 默认为整页尺寸)压缩为对话框尺寸 */
420
+ [data-edrv-view] .edrv-snip-dialog .vsm-mcp-empty { padding: 26px 16px; border: 0; }
421
+
422
+
372
423
 
373
424
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * dsh-vscode-mode client — 命令栏浮层(Ctrl+Shift+P / F1)。
3
+ * 订阅 commandPaletteStore 的开关状态;候选来自注入的指令注册表(window.dsh.edrvCommands),
4
+ * 过滤/排序复用 commandSearch 纯函数;↑↓ 选择、Enter 执行、Esc 或点击遮罩关闭。
5
+ * 浮层经 createPortal 渲染到 body,故不受编辑区三种形态(官方侧栏/better-sidebar/中央页签)影响;
6
+ * 宿主单实例由 store 的 claimPaletteHost 保证(另一个宿主自动返回 null)。
7
+ * 作者 ddj 2026年09月10号
8
+ */
9
+ import React from 'react'
10
+ import { createPortal } from 'react-dom'
11
+ import { REGISTRY_GLOBAL } from '../commandGlobals.js'
12
+ import { filterCommands } from '../commandSearch.js'
13
+ import { createCommandRegistry } from '../commandRegistry.js'
14
+ import type { CommandRegistry } from '../commandRegistry.js'
15
+ import { chordOf } from '../keybindings.js'
16
+ import {
17
+ claimPaletteHost, closeCommandPalette, isPaletteOpen, paletteHostRev, paletteOpenSeq, registryRef,
18
+ releasePaletteHost, runPaletteCommand, subscribePalette,
19
+ } from '../commandPaletteStore.js'
20
+ import type { CommandDef } from './commandCatalog.js'
21
+
22
+ /** 空注册表(宿主未注入时的安全降级:命令栏可开但无候选)。 */
23
+ let fallbackRegistry: CommandRegistry | null = null
24
+
25
+ /**
26
+ * 读取指令注册表:① 装配期存入的模块引用(首选,不依赖全局)
27
+ * ② window[REGISTRY_GLOBAL] 镜像(第三方/调试场景)③ 空表降级(仅装配异常时)。
28
+ * @author ddj 2026年09月10号
29
+ * @returns 指令注册表
30
+ */
31
+ function useRegistry(): CommandRegistry {
32
+ const injected = registryRef()
33
+ if (injected) return injected
34
+ const mirrored = typeof window === 'undefined'
35
+ ? undefined
36
+ : (window as unknown as Record<string, CommandRegistry | undefined>)[REGISTRY_GLOBAL]
37
+ if (mirrored) return mirrored
38
+ if (!fallbackRegistry) fallbackRegistry = createCommandRegistry()
39
+ return fallbackRegistry
40
+ }
41
+
42
+ /** 命令栏每一行的展示信息。 */
43
+ interface PaletteRow {
44
+ command: CommandDef
45
+ chord: string | null
46
+ }
47
+
48
+ /**
49
+ * 组装候选行(先按可用性过滤,再按查询排序)。
50
+ * @author ddj 2026年09月10号
51
+ * @param registry 指令注册表
52
+ * @param query 用户输入
53
+ * @returns 候选行
54
+ */
55
+ function rowsOf(registry: CommandRegistry, query: string): PaletteRow[] {
56
+ return filterCommands(registry.available(), query)
57
+ .map((command) => ({ command, chord: chordOf(command.id) }))
58
+ }
59
+
60
+ /**
61
+ * 命令栏浮层(未展开/非宿主时渲染 null)。
62
+ * @author ddj 2026年09月10号
63
+ * @returns 浮层 React 元素或 null
64
+ */
65
+ export function CommandPalette(): React.ReactElement | null {
66
+ const open = React.useSyncExternalStore(subscribePalette, isPaletteOpen)
67
+ // 唤起序号:已打开时每次快捷键再触发都会递增 → 重新聚焦输入框(否则看似"没反应")
68
+ const openSeq = React.useSyncExternalStore(subscribePalette, paletteOpenSeq)
69
+ // 宿主令牌版本:宿主释放时递增,驱动本实例重试认领(避免宿主更替后无人渲染浮层)
70
+ const hostRev = React.useSyncExternalStore(subscribePalette, paletteHostRev)
71
+ const registry = useRegistry()
72
+ const [token, setToken] = React.useState<object | null>(null)
73
+ const [query, setQuery] = React.useState('')
74
+ const [selected, setSelected] = React.useState(0)
75
+ const [notice, setNotice] = React.useState('')
76
+ const inputRef = React.useRef<HTMLInputElement | null>(null)
77
+ const tokenRef = React.useRef<object | null>(null)
78
+ const host = token !== null
79
+
80
+ /**
81
+ * 认领宿主:放在 effect(不在 render 期做副作用 —— StrictMode 双调用会让首次认领
82
+ * 被判定失败而永久无人渲染)。hostRev 变化(有实例释放了宿主)时重试认领。
83
+ *
84
+ * ⚠️ 此处**不**做 cleanup 释放:releasePaletteHost 会通知订阅者(hostRev 变化),
85
+ * 若在依赖 hostRev 的 effect 里释放,会「释放→通知→重跑→再释放」自激循环。
86
+ * 释放统一交给下面的「仅卸载时」effect。
87
+ */
88
+ React.useEffect(() => {
89
+ if (tokenRef.current) return
90
+ const claimed = claimPaletteHost()
91
+ if (!claimed) return
92
+ tokenRef.current = claimed
93
+ setToken(claimed)
94
+ }, [hostRev])
95
+
96
+ /** 仅卸载时释放宿主;释放后 store 通知其余实例接管(自愈)。 */
97
+ React.useEffect(() => () => {
98
+ releasePaletteHost(tokenRef.current)
99
+ tokenRef.current = null
100
+ }, [])
101
+ // 新开(open false→true)时重置查询/选择/提示
102
+ React.useEffect(() => {
103
+ if (!open) return
104
+ setQuery('')
105
+ setSelected(0)
106
+ setNotice('')
107
+ }, [open])
108
+ // 每次唤起(含已打开时的重复唤起 openSeq 变化)重新聚焦输入框 ——
109
+ // 否则命令栏被其它浮层短暂遮挡后,快捷键再按会「看似没反应」。
110
+ React.useEffect(() => {
111
+ if (!open) return undefined
112
+ const timer = setTimeout(() => {
113
+ const input = inputRef.current
114
+ if (!input) return
115
+ input.focus()
116
+ input.select?.()
117
+ }, 0)
118
+ return () => clearTimeout(timer)
119
+ }, [open, openSeq])
120
+
121
+ const rows = React.useMemo(
122
+ () => (open && host ? rowsOf(registry, query) : []),
123
+ [open, host, registry, query],
124
+ )
125
+ const active = rows.length ? Math.min(selected, rows.length - 1) : 0
126
+
127
+ React.useEffect(() => {
128
+ if (!open) return
129
+ const node = document.querySelector('.edrv-palette-row.edrv-palette-sel')
130
+ const scroll = (node as HTMLElement | null)?.scrollIntoView
131
+ if (typeof scroll === 'function') scroll.call(node, { block: 'nearest' })
132
+ }, [open, active, rows.length])
133
+
134
+ /** 执行候选行(执行器内部会做可用性校验与异常上报)。 */
135
+ const execute = (row: PaletteRow | undefined): void => {
136
+ if (!row) return
137
+ closeCommandPalette()
138
+ if (!runPaletteCommand(row.command.id)) setNotice('命令未执行:' + row.command.label)
139
+ }
140
+
141
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
142
+ event.stopPropagation()
143
+ if (event.key === 'Escape') {
144
+ event.preventDefault()
145
+ closeCommandPalette()
146
+ return
147
+ }
148
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
149
+ event.preventDefault()
150
+ if (!rows.length) return
151
+ const step = event.key === 'ArrowDown' ? 1 : rows.length - 1
152
+ setSelected((value) => (Math.min(value, rows.length - 1) + step) % rows.length)
153
+ return
154
+ }
155
+ if (event.key === 'Enter') {
156
+ event.preventDefault()
157
+ execute(rows[active])
158
+ }
159
+ }
160
+
161
+ if (!open || !host || typeof document === 'undefined') return null
162
+ const list = rows.length
163
+ ? rows.map((row, index) => React.createElement('div', {
164
+ key: row.command.id,
165
+ className: 'edrv-palette-row' + (index === active ? ' edrv-palette-sel' : ''),
166
+ onMouseEnter: () => setSelected(index),
167
+ onClick: () => execute(row),
168
+ },
169
+ React.createElement('span', { className: 'edrv-palette-label' }, row.command.label),
170
+ React.createElement('span', { className: 'edrv-palette-cat' }, row.command.category),
171
+ row.chord ? React.createElement('kbd', { className: 'edrv-palette-key' }, row.chord) : null))
172
+ : React.createElement('div', { className: 'edrv-palette-empty' }, '无匹配命令')
173
+
174
+ const panel = React.createElement('div', { className: 'edrv-palette', 'data-edrv-palette': '1', onKeyDown },
175
+ React.createElement('input', {
176
+ ref: inputRef,
177
+ className: 'edrv-palette-input',
178
+ value: query,
179
+ placeholder: '输入命令名称…(↑↓ 选择 · Enter 执行 · Esc 关闭)',
180
+ spellCheck: false,
181
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
182
+ setQuery(event.target.value)
183
+ setSelected(0)
184
+ setNotice('')
185
+ },
186
+ }),
187
+ React.createElement('div', { className: 'edrv-palette-list' }, list),
188
+ notice ? React.createElement('div', { className: 'edrv-palette-notice' }, notice) : null)
189
+
190
+ const overlay = React.createElement('div', { 'data-edrv-view': '1' },
191
+ React.createElement('div', { className: 'edrv-palette-mask', onClick: () => closeCommandPalette() }),
192
+ panel)
193
+ return createPortal(overlay, document.body)
194
+ }
@@ -11,6 +11,7 @@ import { summarize } from '../state/records.js'
11
11
  import { nextDiffPath } from '../diffDock.js'
12
12
  import { readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
13
13
  import { DiffBox } from './DiffBox.js'
14
+ import { CommandPalette } from './CommandPalette.js'
14
15
 
15
16
  const nextIndexBySession = new Map()
16
17
 
@@ -60,12 +61,17 @@ export function ConversationDiffDock(props) {
60
61
  }, content)
61
62
 
62
63
  const editorSnapshot = readDiffDock(sessionId)
64
+ // 命令栏浮层的兜底宿主:本 dock 是每会话常驻 slot(编辑区未挂载时仍能唤起命令栏);
65
+ // 与 EditorView 同时挂载时由 commandPaletteStore 的单实例认领保证只渲染一份。
66
+ const palette = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
63
67
  if (editorSnapshot) {
64
- if (editorSnapshot.mode === 'editor-empty' && !editorSnapshot.fileTotal) return null
65
- return renderDock(React.createElement(DiffBox, Object.assign({}, editorSnapshot, { dock: true })), sessionId)
68
+ if (editorSnapshot.mode === 'editor-empty' && !editorSnapshot.fileTotal) return palette
69
+ return React.createElement(React.Fragment, null,
70
+ renderDock(React.createElement(DiffBox, Object.assign({}, editorSnapshot, { dock: true })), sessionId),
71
+ palette)
66
72
  }
67
73
 
68
- if (!sessionId || !summary?.pendingFiles?.length) return null
74
+ if (!sessionId || !summary?.pendingFiles?.length) return palette
69
75
 
70
76
  const paths = summary.pendingFiles.map((file) => file.path)
71
77
  const click = () => {
@@ -75,10 +81,12 @@ export function ConversationDiffDock(props) {
75
81
  if (next.path) openDiffView(next.path)
76
82
  }
77
83
 
78
- return renderDock(React.createElement(DiffBox, {
79
- mode: 'chat',
80
- dock: true,
81
- fileTotal: paths.length,
82
- onOpenNextFile: click,
83
- }), sessionId || 'chat')
84
+ return React.createElement(React.Fragment, null,
85
+ renderDock(React.createElement(DiffBox, {
86
+ mode: 'chat',
87
+ dock: true,
88
+ fileTotal: paths.length,
89
+ onOpenNextFile: click,
90
+ }), sessionId || 'chat'),
91
+ palette)
84
92
  }