dsh-vscode-mode 0.2.0 → 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.
@@ -10,7 +10,7 @@
10
10
  import React from 'react'
11
11
  import { dbg, rpc } from '../rpc.js'
12
12
  import { emitRefresh } from '../events.js'
13
- import { langOf, loadMonaco } from '../monaco/loader.js'
13
+ import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
14
14
  import { dataUrlOf, isImagePath, isSvgPath } from '../imagePreview.js'
15
15
  import { base64ToBytes, isPdfPath } from '../pdfPreview.js'
16
16
  import { createPdfPanel } from '../pdf/pdfPanel.js'
@@ -20,6 +20,7 @@ import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
20
20
  import { diffRegions } from '../state/regions.js'
21
21
  import { QuickOpen } from './QuickOpen.js'
22
22
  import { CommandPalette } from './CommandPalette.js'
23
+ import { closeCommandPalette } from '../commandPaletteStore.js'
23
24
  import { DiffLauncher } from './DiffLauncher.js'
24
25
  import { SidebarView } from '../sidebar/SidebarView.js'
25
26
  import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
@@ -39,6 +40,27 @@ import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOver
39
40
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
40
41
  import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
41
42
  import { setupAiInline, trackAiEditor, aiInlineEnabled } from '../ai/inlineProvider.js'
43
+ import { SnippetsPicker } from './SnippetsPicker.js'
44
+ import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
45
+
46
+ /**
47
+ * 从 Monaco 语言目录取可选语言 id 列表(代码片段新建时的语言下拉来源)。
48
+ * 旧版 Monaco / 目录不可读时返回 undefined,由调用方回落 shared 的内置常量。
49
+ * @author ddj 2026年09月10号
50
+ * @param monaco window.monaco(可能未加载)
51
+ * @returns 语言 id 数组或 undefined
52
+ */
53
+ function snippetLanguageIds(monaco) {
54
+ try {
55
+ const languages = monaco?.languages?.getLanguages?.()
56
+ if (!Array.isArray(languages) || !languages.length) return undefined
57
+ return languages
58
+ .map((item) => (item && typeof item.id === 'string' ? item.id : ''))
59
+ .filter(Boolean)
60
+ } catch (error) {
61
+ return undefined
62
+ }
63
+ }
42
64
 
43
65
  /**
44
66
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -148,6 +170,7 @@ export function EditorView(props) {
148
170
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
149
171
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
150
172
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
173
+ const [snippetPicker, setSnippetPicker] = React.useState(null) // 'configure' | 'insert' | null(代码片段浮层)
151
174
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
152
175
  const onEditRef = React.useRef(null) // 编辑置脏的最新闭包(Monaco 内容变化监听经 ref 调用,防首帧 active=null 陈旧闭包)
153
176
  const saveViewStateRef = React.useRef(null) // 视图状态保存的最新闭包(卸载清理读取,避免过期 active)
@@ -569,6 +592,16 @@ export function EditorView(props) {
569
592
  }
570
593
  }, [])
571
594
 
595
+ // 代码片段:会话切换让补全按当前工作区叠加项目片段;配置文件保存后失效缓存即时生效
596
+ React.useEffect(() => {
597
+ setSnippetsSession(sessionId)
598
+ }, [sessionId])
599
+ React.useEffect(() => {
600
+ const onChanged = () => invalidateSnippets()
601
+ window.addEventListener('edrv:snippets-changed', onChanged)
602
+ return () => window.removeEventListener('edrv:snippets-changed', onChanged)
603
+ }, [])
604
+
572
605
  React.useEffect(() => {
573
606
  const onOpen = (e) => {
574
607
  const p = e?.detail?.path
@@ -851,6 +884,24 @@ export function EditorView(props) {
851
884
  if (!path) { setStatus('无活动文件'); return }
852
885
  menuHandlersRef.current?.openInExplorer?.(path)
853
886
  }],
887
+ // 代码片段:配置(选择器)与插入(当前语言条目);选区引用(Ctrl+U)。
888
+ // 打开前先关命令栏:命令栏是「执行一条命令」的一次性入口,执行完即关,
889
+ // 否则会残留一个已打开(可能被浮窗遮住)的命令栏,使后续 Ctrl+Shift+P 看似失效。
890
+ ['edrv.command.configureSnippets', () => { closeCommandPalette(); setSnippetPicker('configure') }],
891
+ ['edrv.command.insertSnippet', () => { closeCommandPalette(); setSnippetPicker('insert') }],
892
+ ['edrv.command.addSelectionRef', () => {
893
+ const ed = editorRef.current
894
+ const sel = ed?.getSelection?.()
895
+ const path = activeRef.current
896
+ if (!path) { setStatus('无活动文件'); return }
897
+ if (!sel || (sel.startLineNumber === sel.endLineNumber && sel.startColumn === sel.endColumn)) {
898
+ setStatus('请先选中内容再添加引用')
899
+ return
900
+ }
901
+ const range = { startLine: sel.startLineNumber, endLine: sel.endLineNumber }
902
+ if (!addToConversation) { setStatus('添加到对话不可用'); return }
903
+ addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加选中内容为引用')))
904
+ }],
854
905
  ]
855
906
  const byName = new Map(handlers)
856
907
  const onCommand = (event) => {
@@ -942,6 +993,8 @@ export function EditorView(props) {
942
993
  setMonaco(m)
943
994
  // AI 内联补全 provider 注册 + 开关初始化(幂等)
944
995
  setupAiInline(m)
996
+ // 代码片段补全 provider 注册(幂等;条目按会话工作区懒加载)
997
+ setupSnippets(m)
945
998
  }).catch((e) => {
946
999
  if (alive) {
947
1000
  setMonacoErr(String(e?.message ?? e))
@@ -1017,6 +1070,8 @@ export function EditorView(props) {
1017
1070
  setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
1018
1071
  refreshRecords()
1019
1072
  emitRefresh()
1073
+ // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1074
+ if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1020
1075
  } else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
1021
1076
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
1022
1077
  }
@@ -1563,6 +1618,26 @@ export function EditorView(props) {
1563
1618
  // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
1564
1619
  menuHandlersRef.current = { addRefToChat, openInExplorer }
1565
1620
 
1621
+ /**
1622
+ * 在光标处按片段语法展开插入(Monaco 原生 snippet 控制器解析 ${1:占位} 与 $TM_* 变量)。
1623
+ * @author ddj 2026年09月10号
1624
+ * @param entry 片段条目
1625
+ */
1626
+ const insertSnippetAtCursor = (entry) => {
1627
+ const ed = editorRef.current
1628
+ if (!ed || !entry) { setStatus('无活动编辑器'); return }
1629
+ try {
1630
+ ed.focus()
1631
+ const controller = ed.getContribution?.('snippetController2')
1632
+ if (controller?.insert) controller.insert(String(entry.body ?? ''))
1633
+ else ed.executeEdits('edrv-snippet', [{ range: ed.getSelection(), text: String(entry.body ?? '') }])
1634
+ setStatus('已插入代码片段:' + (entry.prefix || entry.key))
1635
+ } catch (error) {
1636
+ setStatus('插入代码片段失败')
1637
+ setError('插入代码片段失败:' + String(error))
1638
+ }
1639
+ }
1640
+
1566
1641
  const openFile = (path, focusDiff) => {
1567
1642
  if (!path) return
1568
1643
  recordNav()
@@ -1847,6 +1922,23 @@ export function EditorView(props) {
1847
1922
  React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
1848
1923
  : null
1849
1924
 
1925
+ // 代码片段浮层(命令栏「代码片段:配置代码片段」/「插入代码片段」):portal 到 body,随会话注入 cwd/语言
1926
+ const snippetPickerEl = snippetPicker
1927
+ ? React.createElement(SnippetsPicker, {
1928
+ mode: snippetPicker,
1929
+ sessionId,
1930
+ cwd: cwd ?? null,
1931
+ // 片段文件本身的语言要按 `<语言>.code-snippets` 命名约定推导(不能用 langOf:
1932
+ // 那会把 .code-snippets 当成 json 源文件,默认名会错成 json.code-snippets)
1933
+ language: active ? snippetLanguageOf(active) : '',
1934
+ languages: snippetLanguageIds(monacoRef.current),
1935
+ // 显式给 line=1:走 openFileAt 的直接定位分支(并清掉 pendingFocus,不残留跳转意图)
1936
+ onOpenFile: (absPath) => { openFileAt(absPath, 1); setStatus('已打开代码片段文件') },
1937
+ onInsert: insertSnippetAtCursor,
1938
+ onClose: () => setSnippetPicker(null),
1939
+ })
1940
+ : null
1941
+
1850
1942
  const sidebarPanels = props.sidebarPanels
1851
1943
  const sidebarCtx = {
1852
1944
  sessionId,
@@ -1921,11 +2013,13 @@ export function EditorView(props) {
1921
2013
  editorRow,
1922
2014
  menuBackdrop,
1923
2015
  tabMenuEl,
1924
- paletteEl)
2016
+ paletteEl,
2017
+ snippetPickerEl)
1925
2018
  : React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
1926
2019
  editorRow,
1927
2020
  menuBackdrop,
1928
2021
  tabMenuEl,
1929
- paletteEl)
2022
+ paletteEl,
2023
+ snippetPickerEl)
1930
2024
  return rootEl
1931
2025
  }
@@ -0,0 +1,296 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 代码片段选择器(VS Code「Configure Snippets」形态)。
4
+ *
5
+ * 形态:**居中浮窗 + 二级弹窗**,复用插件原生模态样式(mcp.css 的 .vsm-mcp-dialog,
6
+ * 与「添加 MCP」「工作区选择」同源),不自造视觉。
7
+ * - 一级弹窗 mode='configure':按语言分组的现有片段文件列表(全局 + 当前工作区项目),
8
+ * 点击在文件编辑界面打开;底部按「当前文件语言」新建,或新建全语言片段。
9
+ * - 二级弹窗:新建片段文件表单(语言下拉 + 文件名),语言决定默认文件名与写入的绑定语义。
10
+ * - 一级弹窗 mode='insert':当前语言生效条目列表,点击在光标处按片段语法展开。
11
+ *
12
+ * ⚠️ 浮层经 createPortal 挂 body,根节点必须带 `data-edrv-view`(与 CommandPalette /
13
+ * ContextMenu 一致):editor.css 的浮层样式全部以 `[data-edrv-view]` 作用域限定,
14
+ * 漏挂会导致样式整块失效、内容以裸流形式铺在页面底部。
15
+ * 作者 ddj 2026年09月10号
16
+ */
17
+ import React from 'react'
18
+ import { createPortal } from 'react-dom'
19
+ import { rpc } from '../rpc.js'
20
+ import { invalidateSnippets, listSnippetsFor } from '../snippets/provider.js'
21
+ import {
22
+ SNIPPET_LANGUAGES, languageLabelOf, normalizeSnippetFileName, snippetFileTemplate, snippetFileNameFor,
23
+ } from '../../shared/snippets.js'
24
+ // 显式声明对原生模态样式的依赖(.vsm-mcp-dialog / .vsm-primary):
25
+ // 「添加 MCP」页当前也会引入它,但本组件不应依赖那一处的引入顺序。
26
+ import '../styles/mcp.css'
27
+
28
+ /** 片段文件行的第二行摘要:语言 + 条目数 + 相对路径。 */
29
+ export function fileMeta(info) {
30
+ return languageLabelOf(info.language) + ' · ' + info.count + ' 条 · [' + info.relHint + info.file + ']'
31
+ }
32
+
33
+ /**
34
+ * 语言下拉选项:全语言 + 语言目录(去重、保持目录序)。
35
+ * @author ddj 2026年09月10号
36
+ * @param languages 语言 id 目录(缺省用 shared 常量)
37
+ * @returns 选项数组({ id, label })
38
+ */
39
+ export function languageOptions(languages) {
40
+ const ids = Array.isArray(languages) && languages.length ? languages : SNIPPET_LANGUAGES
41
+ const seen = new Set()
42
+ const out = [{ id: '', label: '全语言(所有文件生效)' }]
43
+ for (const raw of ids) {
44
+ const id = String(raw ?? '').trim().toLowerCase()
45
+ if (!id || seen.has(id)) continue
46
+ seen.add(id)
47
+ out.push({ id, label: languageLabelOf(id) })
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * 片段选择器(浮窗 / 二级弹窗)。
54
+ * @param props.mode 'configure'(管理文件)| 'insert'(插入条目)
55
+ * @param props.sessionId 会话 id
56
+ * @param props.cwd 当前会话工作区(项目片段分组与新建用)
57
+ * @param props.language 当前编辑器文件绑定的语言 id(新建默认语言 / 插入过滤)
58
+ * @param props.languages 可选语言目录(来自 monaco.languages.getLanguages;缺省用内置常量)
59
+ * @param props.onOpenFile 在编辑界面打开片段文件回调(absPath)
60
+ * @param props.onInsert 插入片段回调(entry)
61
+ * @param props.onClose 关闭回调
62
+ * @returns 浮层 React 元素
63
+ */
64
+ export function SnippetsPicker(props) {
65
+ const mode = props?.mode === 'insert' ? 'insert' : 'configure'
66
+ const sessionId = props?.sessionId
67
+ const cwd = props?.cwd
68
+ const currentLanguage = String(props?.language ?? '').trim().toLowerCase()
69
+ const options = React.useMemo(() => languageOptions(props?.languages), [props?.languages])
70
+ const onOpenFile = props?.onOpenFile
71
+ const onInsert = props?.onInsert
72
+ const onClose = props?.onClose
73
+
74
+ const [data, setData] = React.useState(null)
75
+ const [entries, setEntries] = React.useState(null)
76
+ const [error, setError] = React.useState('')
77
+ const [busy, setBusy] = React.useState(false)
78
+ // 二级弹窗草稿:null=未打开;否则 { scope, language, fileName, edited }
79
+ const [draft, setDraft] = React.useState(null)
80
+ const aliveRef = React.useRef(true)
81
+
82
+ const isInsert = mode === 'insert'
83
+
84
+ /** 拉取数据:管理模式取文件列表,插入模式取当前语言条目。 */
85
+ const load = React.useCallback(() => {
86
+ if (isInsert) {
87
+ listSnippetsFor(currentLanguage).then((list) => {
88
+ if (!aliveRef.current) return
89
+ setEntries(list)
90
+ setError('')
91
+ }).catch((e) => { if (aliveRef.current) setError('读取代码片段失败:' + String(e)) })
92
+ return
93
+ }
94
+ rpc('snippets.list', {}).then((res) => {
95
+ if (!aliveRef.current) return
96
+ if (res && res.ok) { setData(res); setError('') }
97
+ else setError(res?.error ?? '读取代码片段失败')
98
+ }).catch((e) => { if (aliveRef.current) setError('读取代码片段失败:' + String(e)) })
99
+ }, [isInsert, currentLanguage])
100
+
101
+ React.useEffect(() => {
102
+ aliveRef.current = true
103
+ load()
104
+ return () => { aliveRef.current = false }
105
+ }, [load])
106
+
107
+ // Esc:二级弹窗优先关闭(二级 → 一级),避免一次按键直接退出整个流程
108
+ React.useEffect(() => {
109
+ const onKey = (event) => {
110
+ if (event.key !== 'Escape') return
111
+ if (draft) closeDraft()
112
+ else onClose?.()
113
+ }
114
+ window.addEventListener('keydown', onKey, true)
115
+ return () => window.removeEventListener('keydown', onKey, true)
116
+ }, [draft, onClose])
117
+
118
+ /** 打开二级弹窗(按 scope + 语言新建)。 */
119
+ const openDraft = (scope, language) => {
120
+ setDraft({ scope, language, fileName: snippetFileNameFor(language), edited: false })
121
+ setError('')
122
+ }
123
+
124
+ /** 关闭二级弹窗(回到一级浮窗)。 */
125
+ const closeDraft = () => {
126
+ setDraft(null)
127
+ setError('')
128
+ }
129
+
130
+ /** 改语言:文件名未手改过则跟随语言联动(手改过则尊重用户输入)。 */
131
+ const pickLanguage = (language) => {
132
+ setDraft((prev) => (prev ? { ...prev, language, fileName: prev.edited ? prev.fileName : snippetFileNameFor(language) } : prev))
133
+ }
134
+
135
+ /** 改文件名:标记为手改,之后不再被语言联动覆盖。 */
136
+ const editFileName = (value) => {
137
+ setDraft((prev) => (prev ? { ...prev, fileName: value, edited: true } : prev))
138
+ }
139
+
140
+ /**
141
+ * 创建并打开片段文件(二级弹窗提交)。
142
+ * @author ddj 2026年09月10号
143
+ */
144
+ const createFile = () => {
145
+ if (!draft) return
146
+ const name = normalizeSnippetFileName(draft.fileName)
147
+ if (!name) { setError('文件名不能为空'); return }
148
+ if (draft.scope === 'project' && !cwd) { setError('当前会话没有工作区,无法新建项目片段'); return }
149
+ setBusy(true)
150
+ setError('')
151
+ rpc('snippets.save', {
152
+ scope: draft.scope,
153
+ workspacePath: draft.scope === 'project' ? cwd : undefined,
154
+ file: name,
155
+ content: snippetFileTemplate(draft.language),
156
+ }).then((res) => {
157
+ if (!aliveRef.current) return
158
+ if (!res || !res.ok) { setError(res?.error ?? '新建失败'); return }
159
+ invalidateSnippets()
160
+ closeDraft()
161
+ onOpenFile?.(res.file.absPath)
162
+ onClose?.()
163
+ }).catch((e) => { if (aliveRef.current) setError('新建失败:' + String(e)) })
164
+ .finally(() => { if (aliveRef.current) setBusy(false) })
165
+ }
166
+
167
+ /** 打开片段文件(在编辑界面打开,浮窗关闭)。 */
168
+ const openFile = (info) => {
169
+ onOpenFile?.(info.absPath)
170
+ onClose?.()
171
+ }
172
+
173
+ /** 删除片段文件(confirm 后走 snippets.remove)。 */
174
+ const removeFile = (info) => {
175
+ if (!window.confirm('删除代码片段文件 ' + info.file + '?(不可恢复)')) return
176
+ rpc('snippets.remove', { scope: info.scope, workspacePath: info.scope === 'project' ? cwd : undefined, file: info.file })
177
+ .then((res) => {
178
+ if (!aliveRef.current) return
179
+ if (!res || !res.ok) { setError(res?.error ?? '删除失败'); return }
180
+ invalidateSnippets()
181
+ load()
182
+ }).catch((e) => { if (aliveRef.current) setError('删除失败:' + String(e)) })
183
+ }
184
+
185
+ /** 一级浮窗主体:列表 / 空态 / 错误。 */
186
+ const renderBody = () => {
187
+ if (error && !draft) return React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error)
188
+ if (isInsert) {
189
+ const list = entries ?? []
190
+ if (!list.length) {
191
+ return React.createElement('div', { className: 'vsm-mcp-empty' },
192
+ entries === null ? '正在读取代码片段…' : '当前文件没有可用代码片段,可用「代码片段:配置代码片段」新建')
193
+ }
194
+ return React.createElement('div', { className: 'edrv-snip-list' }, list.map((entry) => React.createElement('button', {
195
+ key: entry.scope + ':' + entry.file + ':' + entry.key,
196
+ className: 'edrv-snip-row',
197
+ onClick: () => { onInsert?.(entry); onClose?.() },
198
+ },
199
+ React.createElement('span', { className: 'edrv-snip-row-main' },
200
+ React.createElement('span', { className: 'edrv-snip-label' }, entry.prefix || entry.key),
201
+ React.createElement('span', { className: 'edrv-snip-desc' }, entry.description || entry.key)),
202
+ React.createElement('span', { className: 'edrv-snip-src' }, entry.file))))
203
+ }
204
+ if (!data) return React.createElement('div', { className: 'vsm-mcp-empty' }, '正在读取代码片段…')
205
+ const project = (data.projects ?? []).find((item) => item.workspacePath === cwd) ?? null
206
+ /** 行按语言再按文件名排序,同语言片段聚在一起(便于按文件类型找片段)。 */
207
+ const sortByLanguage = (list) => [...list].sort((a, b) =>
208
+ String(a.language).localeCompare(String(b.language)) || String(a.file).localeCompare(String(b.file)))
209
+ const rows = (list) => sortByLanguage(list).map((info) => React.createElement('div', { key: info.scope + ':' + info.file, className: 'edrv-snip-row' },
210
+ React.createElement('button', { className: 'edrv-snip-row-main', title: info.absPath, onClick: () => openFile(info) },
211
+ React.createElement('span', { className: 'edrv-snip-label' }, info.file),
212
+ React.createElement('span', { className: 'edrv-snip-desc' }, fileMeta(info))),
213
+ info.error ? React.createElement('span', { className: 'vsm-mcp-chip edrv-snip-warn' }, 'JSON 解析失败') : null,
214
+ React.createElement('button', { className: 'edrv-snip-act edrv-snip-danger', title: '删除', onClick: () => removeFile(info) }, '⌫')))
215
+ const globalRows = rows(data.user ?? [])
216
+ const projectRows = project ? rows(project.files ?? []) : []
217
+ return React.createElement('div', { className: 'edrv-snip-list' },
218
+ React.createElement('div', { className: 'edrv-snip-group' }, '全局代码片段(~/.dsh/snippets/)'),
219
+ globalRows.length ? globalRows : React.createElement('div', { className: 'edrv-snip-empty' }, '还没有全局代码片段'),
220
+ React.createElement('div', { className: 'edrv-snip-group' }, '项目代码片段' + (cwd ? '(' + cwd + ')' : '')),
221
+ projectRows.length
222
+ ? projectRows
223
+ : React.createElement('div', { className: 'edrv-snip-empty' }, cwd ? '当前项目没有代码片段' : '当前会话没有工作区'))
224
+ }
225
+
226
+ /** 当前文件语言提示行(让「片段绑定到哪种文件」一目了然)。 */
227
+ const languageBar = React.createElement('div', { className: 'edrv-snip-langbar' },
228
+ currentLanguage
229
+ ? React.createElement(React.Fragment, null,
230
+ '当前文件语言:',
231
+ React.createElement('b', null, languageLabelOf(currentLanguage)),
232
+ '(' + currentLanguage + ')')
233
+ : '当前没有打开文件:可新建全语言片段,或先打开文件再新建对应语言的片段')
234
+
235
+ const dialog = React.createElement('div', { className: 'edrv-snip-mask', onClick: () => onClose?.() },
236
+ React.createElement('div', { className: 'vsm-mcp-dialog edrv-snip-dialog', onClick: (event) => event.stopPropagation() },
237
+ React.createElement('h3', null, isInsert ? '插入代码片段' : '代码片段:配置代码片段'),
238
+ React.createElement('p', { className: 'edrv-snip-hint' }, isInsert
239
+ ? '仅列出对当前文件语言生效的片段(选定后在光标处展开,支持 ${1:占位} 与 $TM_FILENAME 等变量)。'
240
+ : '代码片段按语言绑定:`<语言>.code-snippets` 只对该类文件生效,`global.code-snippets` 对所有文件生效。选中文件即在编辑界面打开。'),
241
+ isInsert ? null : languageBar,
242
+ renderBody(),
243
+ isInsert
244
+ ? null
245
+ : React.createElement('div', { className: 'vsm-mcp-dialog-actions' },
246
+ React.createElement('button', {
247
+ disabled: busy,
248
+ onClick: () => openDraft('user', ''),
249
+ }, '新建全语言片段…'),
250
+ React.createElement('button', {
251
+ className: 'vsm-primary',
252
+ disabled: busy || !currentLanguage,
253
+ title: currentLanguage ? '' : '请先打开一个文件,或改用「新建全语言片段…」',
254
+ onClick: () => openDraft('user', currentLanguage),
255
+ }, currentLanguage ? '新建 ' + languageLabelOf(currentLanguage) + ' 片段文件…' : '新建当前语言片段…'),
256
+ React.createElement('button', {
257
+ disabled: busy || !cwd,
258
+ title: cwd ? '' : '当前会话没有工作区',
259
+ onClick: () => openDraft('project', currentLanguage),
260
+ }, '新建项目片段文件…'))))
261
+
262
+ // 二级弹窗:新建片段文件(语言 + 文件名;叠在一级浮窗之上的独立遮罩)
263
+ const createDialog = draft
264
+ ? React.createElement('div', { className: 'edrv-snip-mask edrv-snip-mask-top', onClick: closeDraft },
265
+ React.createElement('div', { className: 'vsm-mcp-dialog edrv-snip-dialog', onClick: (event) => event.stopPropagation() },
266
+ React.createElement('h3', null, draft.scope === 'project' ? '新建项目代码片段文件' : '新建全局代码片段文件'),
267
+ React.createElement('label', null, '生效语言',
268
+ React.createElement('select', {
269
+ value: draft.language,
270
+ disabled: busy,
271
+ onChange: (event) => { pickLanguage(event.target.value); setError('') },
272
+ }, options.map((item) => React.createElement('option', { key: 'lang-' + item.id, value: item.id }, item.label)))),
273
+ React.createElement('label', null, '文件名',
274
+ React.createElement('input', {
275
+ autoFocus: true,
276
+ spellCheck: false,
277
+ value: draft.fileName,
278
+ placeholder: snippetFileNameFor(draft.language),
279
+ onChange: (event) => { editFileName(event.target.value); setError('') },
280
+ onKeyDown: (event) => { if (event.key === 'Enter') createFile() },
281
+ })),
282
+ React.createElement('div', { className: 'edrv-snip-hint' },
283
+ (draft.scope === 'project' ? '将写入 ' + (cwd ?? '') + '/.dsh/snippets/' : '将写入 ~/.dsh/snippets/')
284
+ + normalizeSnippetFileName(draft.fileName)
285
+ + (draft.language
286
+ ? '(仅对 ' + languageLabelOf(draft.language) + ' 文件生效)'
287
+ : '(对所有文件生效)')),
288
+ error ? React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error) : null,
289
+ React.createElement('div', { className: 'vsm-mcp-dialog-actions' },
290
+ React.createElement('button', { disabled: busy, onClick: closeDraft }, '取消'),
291
+ React.createElement('button', { className: 'vsm-primary', disabled: busy, onClick: createFile }, busy ? '创建中…' : '创建并打开'))))
292
+ : null
293
+
294
+ // 根节点必须带 data-edrv-view:editor.css 浮层样式均以此为作用域前缀
295
+ return createPortal(React.createElement('div', { 'data-edrv-view': '1' }, dialog, createDialog), document.body)
296
+ }
@@ -76,6 +76,20 @@ function prevEditorRowDef(): CommandDef {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * 添加选中内容为引用(把当前选区追加进对话输入框;无选区则状态栏提示)。
81
+ * 有活动编辑器模型才可用,保证对话框内按 Ctrl+U 不被本命令吞掉。
82
+ * @author ddj 2026年09月10号
83
+ * @returns 命令定义
84
+ */
85
+ function addSelectionRefDef(): CommandDef {
86
+ return {
87
+ id: 'edrv.addSelectionRef', label: '添加选中内容为引用', category: '编辑', order: 10,
88
+ keybinding: 'Ctrl+U', available: needsModel,
89
+ run: () => emit('addSelectionRef'),
90
+ }
91
+ }
92
+
79
93
  /**
80
94
  * 编辑器内置指令目录(顺序 = 快捷键设置页展示顺序)。
81
95
  * 前置 8 条的键位由 EditorView / QuickOpen 自行 capture 监听(历史实现),
@@ -143,6 +157,16 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
143
157
  available: needsModel,
144
158
  run: () => emit('openInExplorer'),
145
159
  },
160
+ {
161
+ id: 'edrv.configureSnippets', label: '代码片段:配置代码片段', category: '代码片段', order: 10,
162
+ available: alwaysAvailable,
163
+ run: () => emit('configureSnippets'),
164
+ },
165
+ {
166
+ id: 'edrv.insertSnippet', label: '插入代码片段', category: '代码片段', order: 20,
167
+ available: needsModel,
168
+ run: () => emit('insertSnippet'),
169
+ },
146
170
  ]
147
171
 
148
172
  /**
@@ -153,6 +177,7 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
153
177
  export const BRIDGE_COMMANDS: readonly CommandDef[] = [
154
178
  nextEditorRowDef(),
155
179
  prevEditorRowDef(),
180
+ addSelectionRefDef(),
156
181
  ]
157
182
 
158
183
  /**
package/src/rpc.ts CHANGED
@@ -31,6 +31,7 @@ import type { ContentSearcher } from './search/content.js'
31
31
  import { newContentSearcher } from './search/content.js'
32
32
  import { restoreFile, revertCall, revertHunk } from './revert.js'
33
33
  import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
34
+ import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
34
35
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
35
36
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
36
37
  import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
@@ -86,6 +87,24 @@ async function requireSession(ctx: Ctx, sessionId: string | undefined): Promise<
86
87
  return { session, cwd }
87
88
  }
88
89
 
90
+ /** 片段文件保存时的沙箱策略:用户显式 GUI 写操作,放开到 danger-full-access(镜像 rules.fullPolicy)。 */
91
+ function snippetPolicy(ctx: Ctx): unknown {
92
+ const svc = ctx.get('sandboxPolicy')
93
+ if (!svc || typeof svc.resolve !== 'function') return undefined
94
+ return svc.resolve({ mode: 'danger-full-access' })
95
+ }
96
+
97
+ /**
98
+ * 片段文件路径解析:命中全局片段目录(~/.dsh/snippets)返回归一化绝对路径,否则 null。
99
+ * 全局片段位于工作区之外,edrv.read / edrv.save 需绕开 resolveTarget 的 cwd 语义直读直写。
100
+ * @author ddj 2026年09月10号
101
+ * @param path 客户端请求路径(绝对路径)
102
+ * @returns 归一化后的绝对路径或 null
103
+ */
104
+ function snippetTargetOf(path: string): string | null {
105
+ return isSnippetFilePath(path) ? path.replace(/\\/g, '/') : null
106
+ }
107
+
89
108
  /**
90
109
  * 手动保存收尾(edrv.save / edrv.saveBinary 共用):目录树失效 + 该路径 pending
91
110
  * 文本差异记录标记 superseded 并归档(旧文本 diff 不得再应用到新内容上)。
@@ -258,6 +277,16 @@ export function buildHandlers(
258
277
  return { ok: true, results }
259
278
  },
260
279
  'edrv.read': async (args) => {
280
+ // 全局片段文件(~/.dsh/snippets/*.code-snippets)在工作区之外:直读,不走会话 cwd 解析
281
+ const snippetTarget = snippetTargetOf(args.path)
282
+ if (snippetTarget && args.encoding !== 'base64') {
283
+ try {
284
+ const content = await readFile(snippetTarget, 'utf8')
285
+ return { ok: true, content, size: content.length }
286
+ } catch (error) {
287
+ return { ok: false, error: '读取片段文件失败:' + String(error), resolvedPath: snippetTarget }
288
+ }
289
+ }
261
290
  const sc = await requireSession(ctx, args.sessionId)
262
291
  if ('err' in sc) return { ok: false, error: sc.err }
263
292
  const fs = ctx.get('fs')
@@ -309,6 +338,16 @@ export function buildHandlers(
309
338
  }
310
339
  },
311
340
  'edrv.save': async (args) => {
341
+ // 全局片段文件:直写(工作区外),且不进差异审查(片段变更不是 agent 编辑产物)
342
+ const snippetTarget = snippetTargetOf(args.path)
343
+ if (snippetTarget) {
344
+ try {
345
+ await writeFile(snippetTarget, args.content, 'utf8')
346
+ return { ok: true }
347
+ } catch (error) {
348
+ return { ok: false, error: '保存片段文件失败:' + String(error) }
349
+ }
350
+ }
312
351
  const sc = await requireSession(ctx, args.sessionId)
313
352
  if ('err' in sc) return { ok: false, error: sc.err }
314
353
  const fs = ctx.get('fs')
@@ -770,6 +809,45 @@ export function buildHandlers(
770
809
  return { ok: false, error: '切换规则失败:' + String(error) }
771
810
  }
772
811
  },
812
+ 'snippets.list': async () => {
813
+ try {
814
+ return { ok: true, ...(await snippetsList(ctx)) }
815
+ } catch (error) {
816
+ return { ok: false, error: '读取代码片段失败:' + String(error) }
817
+ }
818
+ },
819
+ 'snippets.read': async (args) => {
820
+ try {
821
+ return { ok: true, content: await snippetsRead(ctx, args) }
822
+ } catch (error) {
823
+ return { ok: false, error: '读取代码片段失败:' + String(error) }
824
+ }
825
+ },
826
+ 'snippets.save': async (args) => {
827
+ try {
828
+ return { ok: true, file: await snippetsSave(ctx, args) }
829
+ } catch (error) {
830
+ return { ok: false, error: '保存代码片段失败:' + String(error) }
831
+ }
832
+ },
833
+ 'snippets.remove': async (args) => {
834
+ try {
835
+ await snippetsRemove(ctx, args)
836
+ return { ok: true }
837
+ } catch (error) {
838
+ return { ok: false, error: '删除代码片段失败:' + String(error) }
839
+ }
840
+ },
841
+ 'snippets.entries': async (args) => {
842
+ try {
843
+ // 当前会话工作区用于叠加项目片段;会话缺失时仅返回全局片段(不报错,补全仍可用)
844
+ const sc = await requireSession(ctx, args.sessionId)
845
+ const cwd = 'err' in sc ? undefined : sc.cwd
846
+ return { ok: true, ...(await snippetsEntries(ctx, cwd)) }
847
+ } catch (error) {
848
+ return { ok: false, error: '读取代码片段条目失败:' + String(error) }
849
+ }
850
+ },
773
851
  }
774
852
  }
775
853
 
@@ -23,6 +23,8 @@ export const KEYBINDING_DEFAULTS: Record<string, string> = {
23
23
  'edrv.showCommands': 'Ctrl+Shift+P|F1',
24
24
  'edrv.nextEditorRow': 'Ctrl+Alt+ArrowDown',
25
25
  'edrv.prevEditorRow': 'Ctrl+Alt+ArrowUp',
26
+ // 添加选中内容为引用:把当前选区追加进对话输入框(Ctrl+U,VS Code 同款)
27
+ 'edrv.addSelectionRef': 'Ctrl+U',
26
28
  }
27
29
 
28
30
  /**