dsh-vscode-mode 0.1.28 → 0.1.29

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.
@@ -21,6 +21,7 @@ import { editorHeight } from '../editorLayout.js'
21
21
  import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
22
22
  import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
23
23
  import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
24
+ import { bindingOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
24
25
 
25
26
  /**
26
27
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -61,6 +62,7 @@ export function EditorView(props) {
61
62
  const [sidebarW, setSidebarW] = React.useState(240) // 侧边栏宽度
62
63
  const [activePanel, setActivePanel] = React.useState('explorer') // 激活面板 id
63
64
  const [focusRequest, setFocusRequest] = React.useState(0) // 外部差异聚焦请求版本
65
+ const kbVersion = useKeybindingsVersion() // 快捷键配置版本(tooltip 文案随键位刷新)
64
66
  const [hintDismissed, setHintDismissed] = React.useState(() => {
65
67
  try { return localStorage.getItem('edrv.side-hint-dismissed') === '1' } catch { return false }
66
68
  }) // 侧边栏引导条是否已关闭
@@ -85,6 +87,7 @@ export function EditorView(props) {
85
87
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
86
88
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
87
89
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
90
+ const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
88
91
  const diffRendererRef = React.useRef(null)
89
92
  const layoutRef = React.useRef(layout) // ensureEditor 空依赖闭包读取的稳定布局
90
93
  layoutRef.current = layout
@@ -392,13 +395,38 @@ export function EditorView(props) {
392
395
  catch (e) { /* 忽略 */ }
393
396
  }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
394
397
 
395
- // Ctrl+B 切换侧边栏(capture 抢占,避免与 DSH 全局冲突)
398
+ // 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
396
399
  React.useEffect(() => {
397
400
  const onKey = (e) => {
398
- if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 'b') {
399
- e.preventDefault(); e.stopPropagation()
400
- setSidebarOn((v) => !v)
401
- }
401
+ if (!matchEvent(e, bindingOf('edrv.toggleSidebar'))) return
402
+ e.preventDefault(); e.stopPropagation()
403
+ setSidebarOn((v) => !v)
404
+ }
405
+ window.addEventListener('keydown', onKey, true)
406
+ return () => window.removeEventListener('keydown', onKey, true)
407
+ }, [])
408
+
409
+ // 保存文件(窗口级):编辑器有活动模型才拦截执行,无文件时放行(浏览器默认行为)
410
+ React.useEffect(() => {
411
+ const onKey = (e) => {
412
+ if (!matchEvent(e, bindingOf('edrv.save'))) return
413
+ if (!editorRef.current?.getModel?.()) return
414
+ e.preventDefault(); e.stopPropagation()
415
+ flushSave()
416
+ doSaveRef.current(false)
417
+ }
418
+ window.addEventListener('keydown', onKey, true)
419
+ return () => window.removeEventListener('keydown', onKey, true)
420
+ }, [])
421
+
422
+ // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框
423
+ React.useEffect(() => {
424
+ const onKey = (e) => {
425
+ if (!matchEvent(e, bindingOf('edrv.searchInFiles'))) return
426
+ e.preventDefault(); e.stopPropagation()
427
+ setSidebarOn(true)
428
+ setActivePanel('search')
429
+ setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
402
430
  }
403
431
  window.addEventListener('keydown', onKey, true)
404
432
  return () => window.removeEventListener('keydown', onKey, true)
@@ -482,6 +510,7 @@ export function EditorView(props) {
482
510
  } else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
483
511
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
484
512
  }
513
+ doSaveRef.current = doSave
485
514
 
486
515
  const onEdit = () => {
487
516
  const ed = editorRef.current
@@ -551,7 +580,7 @@ export function EditorView(props) {
551
580
  cursorBlinking: 'smooth',
552
581
  padding: { top: side ? 6 : 8 },
553
582
  })
554
- ed.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => { flushSave(); doSave(false) })
583
+ // 保存改由窗口级快捷键监听执行(键位可配置;见上方 edrv.save 监听)
555
584
  ed.onDidChangeModelContent(() => {
556
585
  if (!ed.getModel() || programmaticRef.current) return
557
586
  onEdit()
@@ -634,13 +663,22 @@ export function EditorView(props) {
634
663
  editorRef.current = ed
635
664
  }, [])
636
665
 
637
- // 打开文件后的差异聚焦跳转(内容/差异就绪后执行一次)
666
+ // 打开文件后的跳转(差异聚焦/搜索行列跳转,内容就绪后执行一次)
638
667
  React.useEffect(() => {
639
668
  const pf = pendingFocusRef.current
640
669
  if (!pf || pf.path !== active || content === null) return
641
- const target = pf.region || pendingRegions[0]
642
670
  const ed = editorRef.current
643
- if (!target || !ed) return
671
+ if (!ed) return
672
+ if (pf.line != null) {
673
+ // 搜索命中跳转:直接定位到行/列(不依赖差异区域)
674
+ pendingFocusRef.current = null
675
+ ed.revealLineInCenter(Math.max(1, pf.line))
676
+ ed.setPosition({ lineNumber: Math.max(1, pf.line), column: Math.max(1, pf.column ?? 1) })
677
+ ed.focus()
678
+ return
679
+ }
680
+ const target = pf.region || pendingRegions[0]
681
+ if (!target) return
644
682
  pendingFocusRef.current = null
645
683
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
646
684
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
@@ -883,6 +921,20 @@ export function EditorView(props) {
883
921
  if (focusDiff) pendingFocusRef.current = { path, region: null }
884
922
  }
885
923
 
924
+ /**
925
+ * 打开文件并跳转到指定行列(搜索面板命中跳转)。
926
+ * @author ddj 2026年08月26号
927
+ * @param path 文件路径
928
+ * @param line 目标行(缺省仅打开不跳转)
929
+ * @param column 目标列(缺省 1)
930
+ */
931
+ const openFileAt = (path, line, column) => {
932
+ if (!path) return
933
+ addTab(path, true)
934
+ pendingFocusRef.current = { path, region: null, line: line ?? null, column: column ?? 1 }
935
+ setFocusRequest((value) => value + 1)
936
+ }
937
+
886
938
  const openPath = () => {
887
939
  const p = (pathDraft || '').trim()
888
940
  if (!p) return
@@ -916,7 +968,7 @@ export function EditorView(props) {
916
968
 
917
969
  const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
918
970
  React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
919
- React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (Ctrl+P) 打开文件'),
971
+ React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开文件'),
920
972
  (active && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
921
973
  (cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
922
974
  (status ? React.createElement('span', { className: 'edrv-pb-meta edrv-pb-status' }, status) : null))
@@ -926,7 +978,7 @@ export function EditorView(props) {
926
978
  (sum.totalFiles > 0
927
979
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
928
980
  : null),
929
- React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (Ctrl+B)', onClick: () => setSidebarOn((v) => !v) }, '☰'),
981
+ React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (' + (chordOf('edrv.toggleSidebar') ?? 'Ctrl+B') + ')', onClick: () => setSidebarOn((v) => !v) }, '☰'),
930
982
  React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
931
983
  React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
932
984
 
@@ -964,7 +1016,7 @@ export function EditorView(props) {
964
1016
  } else if (!active) {
965
1017
  body = React.createElement('div', { className: 'edrv-empty' },
966
1018
  React.createElement('div', null, '暂无打开的文件'),
967
- React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (Ctrl+P) 打开工作区文件;agent 修改文件后顶部会出现差异角标'))
1019
+ React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;agent 修改文件后顶部会出现差异角标'))
968
1020
  } else if (content === null && loadError) {
969
1021
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
970
1022
  setLoadError(null)
@@ -1073,6 +1125,7 @@ export function EditorView(props) {
1073
1125
  const sidebarCtx = {
1074
1126
  sessionId,
1075
1127
  openFile: (p) => openFile(p, false),
1128
+ openFileAt,
1076
1129
  activePath: active,
1077
1130
  pendingByPath,
1078
1131
  sum: { totalFiles: sum.totalFiles, files: sum.files },
@@ -0,0 +1,187 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — VSCodeMode 设置区「快捷键」管理页。
4
+ * 命令目录来自 client/keybindings 的 COMMANDS;当前键位读 SettingsContext scope;
5
+ * 编辑:行内录制(捕获下一次组合键),Esc 取消、Backspace/Delete 清空(未绑定);
6
+ * 草稿 + 显式保存(scope.set('keybindings', draft));行级恢复 + 页级「恢复默认」;
7
+ * 冲突键位(两命令同键位)标红警告;scope 外部变更且无未保存修改时回同步草稿。
8
+ * 作者 ddj 2026-08-26
9
+ */
10
+ import React from 'react'
11
+ import { SettingsContext } from '../settingsContext.js'
12
+ import { COMMANDS, chordFromEvent, parseChord } from '../keybindings.js'
13
+ import { defaultKeybindings, KEYBINDING_DEFAULTS, normalizeKeybindings } from '../../shared/keybindings.js'
14
+
15
+ /**
16
+ * 草稿化:默认值 + 设置值合并,空串归一为 null(未绑定)。
17
+ * @param raw 设置 scope 的 keybindings 字段
18
+ * @returns 草稿映射(id → 键位弦或 null)
19
+ */
20
+ export function draftOf(raw) {
21
+ const merged = { ...defaultKeybindings(), ...normalizeKeybindings(raw) }
22
+ const out = {}
23
+ for (const [id, chord] of Object.entries(merged)) out[id] = chord === '' ? null : chord
24
+ return out
25
+ }
26
+
27
+ /**
28
+ * 存库化:null(未绑定)转空串。
29
+ * @param draft 草稿映射
30
+ * @returns 可存储的键位映射
31
+ */
32
+ export function storeOf(draft) {
33
+ const out = {}
34
+ for (const [id, chord] of Object.entries(draft)) out[id] = chord === null ? '' : chord
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * 冲突检测:同一键位被多个命令占用 → 返回冲突命令 id 集合。
40
+ * @param draft 草稿映射
41
+ * @returns 冲突命令 id 集合
42
+ */
43
+ export function conflictsOf(draft) {
44
+ const byChord = new Map()
45
+ for (const [id, chord] of Object.entries(draft)) {
46
+ if (!chord) continue
47
+ const list = byChord.get(chord) ?? []
48
+ list.push(id)
49
+ byChord.set(chord, list)
50
+ }
51
+ const out = new Set()
52
+ for (const list of byChord.values()) {
53
+ if (list.length > 1) for (const id of list) out.add(id)
54
+ }
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * 快捷键管理页主体。
60
+ */
61
+ export function KeybindingsPanel() {
62
+ const settings = React.useContext(SettingsContext)
63
+ const snapshot = settings?.getSnapshot?.()
64
+ const unavailable = !settings || snapshot?.status === 'unavailable'
65
+ const notReady = snapshot?.status !== 'ready'
66
+ const [draft, setDraft] = React.useState(() => draftOf(snapshot?.value?.keybindings))
67
+ const [recording, setRecording] = React.useState(null) // 正在录制的命令 id
68
+ const [dirty, setDirty] = React.useState(false)
69
+ const [busy, setBusy] = React.useState(false)
70
+ const [error, setError] = React.useState('')
71
+ const [message, setMessage] = React.useState('')
72
+ const dirtyRef = React.useRef(false)
73
+ dirtyRef.current = dirty
74
+ const lastKeyRef = React.useRef(JSON.stringify(draft)) // 草稿内容指纹(内容未变不 setState,防外部快照引用不稳导致循环)
75
+ const snapshotValue = snapshot?.value?.keybindings
76
+
77
+ // scope 外部变更(如重置/其他端写入)且无未保存修改时回同步草稿
78
+ React.useEffect(() => {
79
+ if (dirtyRef.current) return
80
+ const next = draftOf(snapshotValue)
81
+ const key = JSON.stringify(next)
82
+ if (key === lastKeyRef.current) return
83
+ lastKeyRef.current = key
84
+ setDraft(next)
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [snapshotValue])
87
+
88
+ // 录制态:窗口捕获下一次组合键(preventDefault 防浏览器默认动作)
89
+ React.useEffect(() => {
90
+ if (!recording) return
91
+ const onKey = (e) => {
92
+ e.preventDefault()
93
+ e.stopPropagation()
94
+ if (e.key === 'Escape') { setRecording(null); return }
95
+ if (e.key === 'Backspace' || e.key === 'Delete') {
96
+ setDraft((prev) => Object.assign({}, prev, { [recording]: null }))
97
+ setRecording(null)
98
+ setDirty(true)
99
+ return
100
+ }
101
+ const chord = chordFromEvent(e)
102
+ if (!chord) return
103
+ setDraft((prev) => Object.assign({}, prev, { [recording]: chord }))
104
+ setRecording(null)
105
+ setDirty(true)
106
+ }
107
+ window.addEventListener('keydown', onKey, true)
108
+ return () => window.removeEventListener('keydown', onKey, true)
109
+ }, [recording])
110
+
111
+ const conflicts = conflictsOf(draft)
112
+ const save = () => {
113
+ if (!settings?.set) { setError('设置服务不可用'); return }
114
+ setBusy(true)
115
+ setError('')
116
+ setMessage('')
117
+ settings.set('keybindings', storeOf(draft)).then(() => {
118
+ setDirty(false)
119
+ lastKeyRef.current = JSON.stringify(draft)
120
+ setMessage('已保存')
121
+ }).catch((e) => setError(String(e))).finally(() => setBusy(false))
122
+ }
123
+
124
+ /**
125
+ * 恢复全部默认(确认后直接保存,一步到位)。
126
+ */
127
+ const resetAll = () => {
128
+ if (!settings?.set) { setError('设置服务不可用'); return }
129
+ if (!window.confirm('恢复全部快捷键为默认值?')) return
130
+ setBusy(true)
131
+ setError('')
132
+ setMessage('')
133
+ settings.set('keybindings', { ...defaultKeybindings() }).then(() => {
134
+ const next = draftOf({})
135
+ setDraft(next)
136
+ lastKeyRef.current = JSON.stringify(next)
137
+ setDirty(false)
138
+ setMessage('已恢复默认')
139
+ }).catch((e) => setError(String(e))).finally(() => setBusy(false))
140
+ }
141
+
142
+ /** 行级恢复该项默认值(进草稿,随保存提交)。 */
143
+ const resetRow = (id) => {
144
+ setDraft((prev) => Object.assign({}, prev, { [id]: KEYBINDING_DEFAULTS[id] ?? null }))
145
+ setDirty(true)
146
+ }
147
+
148
+ const rows = COMMANDS.map((command) => {
149
+ const chord = draft[command.id] ?? null
150
+ const conflict = chord !== null && conflicts.has(command.id)
151
+ const invalid = chord !== null && parseChord(chord) === null
152
+ const recordingRow = recording === command.id
153
+ let display
154
+ if (recordingRow) {
155
+ display = React.createElement('span', { className: 'vsm-kb-rec' }, '按下组合键…(Esc 取消 / Backspace 清除)')
156
+ } else {
157
+ display = React.createElement('kbd', { className: 'vsm-kb-key' + (conflict ? ' warn' : '') + (invalid ? ' invalid' : '') },
158
+ chord ?? '未绑定')
159
+ }
160
+ return React.createElement('div', { key: command.id, className: 'vsm-kb-row' + (recordingRow ? ' rec' : '') },
161
+ React.createElement('div', { className: 'vsm-kb-label' },
162
+ React.createElement('span', null, command.label),
163
+ conflict && React.createElement('small', { className: 'vsm-kb-warn' }, '与另一命令冲突'),
164
+ invalid && React.createElement('small', { className: 'vsm-kb-warn' }, '键位格式无效')),
165
+ display,
166
+ React.createElement('div', { className: 'vsm-kb-actions' },
167
+ recordingRow
168
+ ? React.createElement('button', { onClick: () => setRecording(null) }, '取消')
169
+ : React.createElement(React.Fragment, null,
170
+ React.createElement('button', { disabled: unavailable || notReady || busy || snapshot?.writable === false, onClick: () => { setRecording(command.id); setMessage('') } }, '编辑'),
171
+ React.createElement('button', { disabled: busy, onClick: () => resetRow(command.id) }, '恢复')),
172
+ ))
173
+ })
174
+
175
+ return React.createElement('section', { className: 'vsm-general-page' },
176
+ React.createElement('h2', null, '快捷键'),
177
+ React.createElement('p', null, '配置编辑器视图的快捷键(保存 / 快速打开 / 侧边栏 / 搜索)。修改即时写入配置,重新聚焦编辑器后生效。'),
178
+ unavailable && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, '设置服务暂不可用,当前使用默认键位。'),
179
+ error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
180
+ message && React.createElement('div', { className: 'vsm-kb-message' }, message),
181
+ React.createElement('div', { className: 'vsm-kb-list' }, rows),
182
+ React.createElement('div', { className: 'vsm-kb-actionsbar' },
183
+ React.createElement('span', { className: 'vsm-kb-dirty' }, dirty ? '有未保存的修改' : ''),
184
+ React.createElement('button', { className: 'vsm-primary vsm-small', disabled: unavailable || notReady || busy || snapshot?.writable === false || !dirty, onClick: save }, busy ? '保存中…' : '保存'),
185
+ React.createElement('button', { className: 'vsm-small', disabled: unavailable || notReady || busy || snapshot?.writable === false, onClick: resetAll }, '恢复默认')),
186
+ )
187
+ }
@@ -1,8 +1,9 @@
1
1
  // @ts-nocheck
2
2
  /**
3
- * dsh-vscode-mode client — VSCodeMode 设置区内的 MCP 管理。
4
- * 子 Tab:我的 MCP(profile 全局)/ 项目 MCP(各项目 .mcp.json)/ MCP 市场(占位)。
5
- * 作者 ddj 2026年08月22号
3
+ * dsh-vscode-mode client — VSCodeMode 设置区:通用 / 快捷键 / MCP 管理。
4
+ * 子 Tab:通用(文件打开工具/开发形态)/ 快捷键(编辑器键位配置,可重置)/
5
+ * 我的 MCP(profile 全局)/ 项目 MCP(各项目 .mcp.json)/ MCP 市场(占位)/ 兼容性。
6
+ * 作者 ddj 2026年08月22号 / 2026年08月26号
6
7
  */
7
8
  import React from 'react'
8
9
  import { rpc } from '../rpc.js'
@@ -10,6 +11,7 @@ import type { MpcProject, MpcServer } from '../../shared/mcp.js'
10
11
  import '../styles/mcp.css'
11
12
  import { availableOpeners, AUTO_OPEN_TOOL } from '../fileOpeners.js'
12
13
  import { SettingsContext } from '../settingsContext.js'
14
+ import { KeybindingsPanel } from './KeybindingsPanel.js'
13
15
 
14
16
  const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '' }
15
17
 
@@ -315,6 +317,7 @@ export function McpSettings(props) {
315
317
  let body
316
318
  if (loading) body = React.createElement('div', { className: 'vsm-mcp-empty' }, '正在读取 MCP 服务…')
317
319
  else if (tab === 'general') body = React.createElement(GeneralSettings, { registry: openerRegistry })
320
+ else if (tab === 'keys') body = React.createElement(KeybindingsPanel, null)
318
321
  else if (tab === 'mine') body = React.createElement('div', null, servers.length ? servers.map((s) => React.createElement(ServerCard, { key: s.id, server: s, onRefresh: refreshGlobal, onToggle: toggleGlobal, onRemove: removeGlobal })) : React.createElement('div', { className: 'vsm-mcp-empty' }, '还没有配置全局 MCP'))
319
322
  else if (tab === 'projects') body = projectBody
320
323
  else if (tab === 'compat') body = React.createElement(CompatSection, { getSummary: compatSummary })
@@ -322,7 +325,7 @@ export function McpSettings(props) {
322
325
  const form = showForm ? React.createElement(McpForm, { title: '添加全局 MCP', draft, busy, edit, save: saveGlobal, close: () => setShowForm(false) }) : projectForm ? React.createElement(McpForm, { title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: saveProject, close: () => setProjectForm(null) }) : null
323
326
  return React.createElement('section', { className: 'vsm-mcp-page' },
324
327
  React.createElement('header', { className: 'vsm-mcp-header' }, React.createElement('div', null, React.createElement('h2', null, 'VSCodeMode'), React.createElement('p', null, '管理当前 profile 与各项目的 Model Context Protocol 服务。')), React.createElement('button', { className: 'vsm-primary', onClick: () => { setDraft(EMPTY); setShowForm(true) } }, '+ 添加全局 MCP')),
325
- React.createElement('nav', { className: 'vsm-mcp-tabs' }, React.createElement('button', { className: tab === 'general' ? 'active' : '', onClick: () => setTab('general') }, '通用'), React.createElement('button', { className: tab === 'mine' ? 'active' : '', onClick: () => setTab('mine') }, '我的 MCP'), React.createElement('button', { className: tab === 'projects' ? 'active' : '', onClick: () => setTab('projects') }, '项目 MCP'), React.createElement('button', { className: tab === 'market' ? 'active' : '', onClick: () => setTab('market') }, 'MCP 市场'), React.createElement('button', { className: tab === 'compat' ? 'active' : '', onClick: () => setTab('compat') }, '兼容性')),
328
+ React.createElement('nav', { className: 'vsm-mcp-tabs' }, React.createElement('button', { className: tab === 'general' ? 'active' : '', onClick: () => setTab('general') }, '通用'), React.createElement('button', { className: tab === 'keys' ? 'active' : '', onClick: () => setTab('keys') }, '快捷键'), React.createElement('button', { className: tab === 'mine' ? 'active' : '', onClick: () => setTab('mine') }, '我的 MCP'), React.createElement('button', { className: tab === 'projects' ? 'active' : '', onClick: () => setTab('projects') }, '项目 MCP'), React.createElement('button', { className: tab === 'market' ? 'active' : '', onClick: () => setTab('market') }, 'MCP 市场'), React.createElement('button', { className: tab === 'compat' ? 'active' : '', onClick: () => setTab('compat') }, '兼容性')),
326
329
  error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
327
330
  body,
328
331
  form,
@@ -1,11 +1,13 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * dsh-vscode-mode client — QuickOpen:顶部搜索框(Ctrl+P 打开文件)。
4
+ * 快捷键随配置(edrv.quickOpen),占位文案同步当前键位。
4
5
  * 迁移自原 src/client/index.ts 的 QuickOpen,语义不改。
5
- * 作者 ddj 2026-08-20
6
+ * 作者 ddj 2026-08-20 / 2026-08-26
6
7
  */
7
8
  import React from 'react'
8
9
  import { rpc } from '../rpc.js'
10
+ import { bindingOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
9
11
 
10
12
  /**
11
13
  * 快速打开搜索框:Ctrl+P 聚焦,输入 ≥2 字符经 edrv.searchFiles 搜索,
@@ -22,6 +24,7 @@ export function QuickOpen(props) {
22
24
  const inputRef = React.useRef(null)
23
25
  const seq = React.useRef(0)
24
26
  const timer = React.useRef(null)
27
+ useKeybindingsVersion() // 键位变化时刷新占位文案
25
28
 
26
29
  const search = (query) => {
27
30
  const s = ++seq.current
@@ -47,7 +50,7 @@ export function QuickOpen(props) {
47
50
 
48
51
  React.useEffect(() => {
49
52
  const onKey = (e) => {
50
- if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 'p') {
53
+ if (matchEvent(e, bindingOf('edrv.quickOpen'))) {
51
54
  e.preventDefault(); e.stopPropagation()
52
55
  inputRef.current?.focus?.()
53
56
  setOpen(true)
@@ -78,7 +81,7 @@ export function QuickOpen(props) {
78
81
 
79
82
  return React.createElement('div', { className: 'edrv-search-wrap' },
80
83
  React.createElement('input', {
81
- ref: inputRef, className: 'edrv-search', placeholder: '搜索文件 (Ctrl+P)',
84
+ ref: inputRef, className: 'edrv-search', placeholder: '搜索文件 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ')',
82
85
  value: q,
83
86
  onChange: (e) => onChange(e.target.value),
84
87
  onFocus: () => { if (q.trim().length >= 2 && !results) setOpen(true) },
@@ -1,10 +1,11 @@
1
1
  /**
2
- * dsh-vscode-mode host — 文件链接打开工具的持久化设置。
2
+ * dsh-vscode-mode host — 文件链接打开工具 + 快捷键的持久化设置。
3
3
  * 依赖守卫:@deepseek-ai/dsh-settings / schemastery 以动态加载引入,
4
4
  * 缺失时插件仍可加载(fileOpenTool 降级为配置值,compat 报告可见)。
5
- * 作者 ddj 2026年08月24号
5
+ * 作者 ddj 2026年08月24号 / 2026年08月26号
6
6
  */
7
7
  import type { Ctx } from './store.js'
8
+ import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
8
9
 
9
10
  export const FILE_OPEN_SETTINGS_NS = 'dsh-vscode-mode'
10
11
  export const FILE_OPEN_DEFAULT = 'auto'
@@ -21,7 +22,7 @@ type SettingsProvider = {
21
22
  export interface SettingsDeps {
22
23
  installSettingsSection: (ctx: Ctx, ns: string, schema: unknown, entry: object, hooks: object) => void
23
24
  z: {
24
- object: (shape: Record<string, unknown>) => unknown
25
+ object: (shape: Record<string, unknown>) => { default: (value: Record<string, unknown>) => unknown }
25
26
  string: () => { default: (value: string) => unknown }
26
27
  }
27
28
  }
@@ -30,6 +31,18 @@ export type SettingsDepsLoader = () => Promise<SettingsDeps | null>
30
31
 
31
32
  let depsPromise: Promise<SettingsDeps | null> | undefined
32
33
 
34
+ /**
35
+ * 按命令目录构建 keybindings 显式键形状(每键独立默认值)。
36
+ * @author ddj 2026年08月26号
37
+ * @param z schemastery 命名空间
38
+ * @returns keybindings 字段形状
39
+ */
40
+ function keybindingsShape(z: SettingsDeps['z']): Record<string, unknown> {
41
+ const shape: Record<string, unknown> = {}
42
+ for (const [id, chord] of Object.entries(KEYBINDING_DEFAULTS)) shape[id] = z.string().default(chord)
43
+ return shape
44
+ }
45
+
33
46
  /**
34
47
  * 动态加载设置依赖(模块级缓存;任一缺失返回 null 而非抛错)。
35
48
  * @author ddj 2026年08月24号
@@ -75,7 +88,10 @@ export async function installOpenSettingsSection(
75
88
  ): Promise<boolean> {
76
89
  const deps = await loader()
77
90
  if (!deps) return false
78
- const schema = deps.z.object({ fileOpenTool: deps.z.string().default(FILE_OPEN_DEFAULT) })
91
+ const schema = deps.z.object({
92
+ fileOpenTool: deps.z.string().default(FILE_OPEN_DEFAULT),
93
+ keybindings: deps.z.object(keybindingsShape(deps.z)).default({ ...KEYBINDING_DEFAULTS }),
94
+ })
79
95
  deps.installSettingsSection(ctx, ns, schema, entry, hooks)
80
96
  return true
81
97
  }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { registerRoutes } from './routes.js'
10
10
  import { captureToolResult } from './capture.js'
11
11
  import { handleRpc } from './rpc.js'
12
12
  import { newSearcher } from './search/orchestrator.js'
13
+ import { newContentSearcher } from './search/content.js'
13
14
  import { installIsolation } from './mcpIsolation.js'
14
15
  import { dropFileIndex } from './workspace.js'
15
16
  import { cwdOf } from './registry.js'
@@ -30,6 +31,7 @@ export const inject = ['sessions', 'fs', 'webServer', 'loader', 'tools', 'worksp
30
31
  export function apply(ctx: Ctx, config?: unknown): void {
31
32
  const registry: Registry = new Map()
32
33
  const searcher = newSearcher(ctx)
34
+ const contentSearcher = newContentSearcher(ctx)
33
35
  /** 兼容性警告收集(route 护栏等写入,启动日志一并输出)。 */
34
36
  const warnings: string[] = []
35
37
  setupOpenSettings(ctx, config, () => {})
@@ -44,10 +46,11 @@ export function apply(ctx: Ctx, config?: unknown): void {
44
46
  registry.delete(cwd)
45
47
  dropFileIndex(cwd)
46
48
  searcher.dispose(cwd)
49
+ contentSearcher.dispose(cwd)
47
50
  }
48
51
  })
49
52
 
50
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher), (warning) => warnings.push(warning))
53
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher), (warning) => warnings.push(warning))
51
54
  installIsolation(ctx)
52
55
  void logCompatSummary(ctx, warnings)
53
56
 
package/src/rpc.ts CHANGED
@@ -23,6 +23,8 @@ import type { Registry } from './registry.js'
23
23
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
24
24
  import type { SearchOrchestrator } from './search/orchestrator.js'
25
25
  import { newSearcher } from './search/orchestrator.js'
26
+ import type { ContentSearcher } from './search/content.js'
27
+ import { newContentSearcher } from './search/content.js'
26
28
  import { restoreFile, revertCall, revertHunk } from './revert.js'
27
29
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
28
30
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
@@ -117,7 +119,7 @@ async function applyDecisions(
117
119
  }
118
120
 
119
121
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
120
- export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx)): RpcHandlerMap {
122
+ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)): RpcHandlerMap {
121
123
  return {
122
124
  'edrv.list': async (args) => {
123
125
  const sc = await requireSession(ctx, args.sessionId)
@@ -323,6 +325,27 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
323
325
  const result = await searcher.search({ session: sc.session, cwd: sc.cwd, query: args.query, activePaths })
324
326
  return { ok: true, ...result }
325
327
  },
328
+ 'edrv.searchContent': async (args) => {
329
+ // 工作区内容搜索:rg --json 主路径;provider 失败转错误响应(无 fallback)。
330
+ const sc = await requireSession(ctx, args.sessionId)
331
+ if ('err' in sc) return { ok: false, error: sc.err }
332
+ try {
333
+ const result = await contentSearcher.search({
334
+ session: sc.session,
335
+ cwd: sc.cwd,
336
+ query: args.query,
337
+ matchCase: args.matchCase,
338
+ wholeWord: args.wholeWord,
339
+ regex: args.regex,
340
+ maxResults: args.maxResults,
341
+ include: args.include,
342
+ exclude: args.exclude,
343
+ })
344
+ return { ok: true, ...result }
345
+ } catch (error) {
346
+ return { ok: false, error: '搜索失败:' + String(error) }
347
+ }
348
+ },
326
349
  'edrv.listDir': async (args) => {
327
350
  // 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
328
351
  const sc = await requireSession(ctx, args.sessionId)
@@ -433,7 +456,7 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
433
456
 
434
457
  /**
435
458
  * 统一入口:按方法分发到 handler 表。
436
- * @author ddj 2026年08月20号
459
+ * @author ddj 2026年08月20号 / 2026年08月26号
437
460
  */
438
461
  export async function handleRpc<M extends RpcMethod>(
439
462
  ctx: Ctx,
@@ -441,8 +464,9 @@ export async function handleRpc<M extends RpcMethod>(
441
464
  method: M,
442
465
  args: RpcRequestMap[M],
443
466
  searcher = newSearcher(ctx),
467
+ contentSearcher = newContentSearcher(ctx),
444
468
  ): Promise<RpcResult<M>> {
445
- const handlers = buildHandlers(ctx, registry, searcher)
469
+ const handlers = buildHandlers(ctx, registry, searcher, contentSearcher)
446
470
  const handler = handlers[method]
447
471
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
448
472
  return handler(args)