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.
- package/README.md +14 -2
- package/lib/client.js +822 -59
- package/lib/client.js.map +1 -1
- package/lib/index.js +476 -19
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/commandBridge.ts +2 -0
- package/src/client/commandPaletteStore.ts +56 -9
- package/src/client/commandRegistry.ts +6 -0
- package/src/client/monaco/loader.ts +24 -0
- package/src/client/snippets/provider.ts +172 -0
- package/src/client/styles/editor.css +39 -2
- package/src/client/ui/CommandPalette.ts +43 -7
- package/src/client/ui/EditorView.ts +97 -3
- package/src/client/ui/SnippetsPicker.ts +296 -0
- package/src/client/ui/commandCatalog.ts +25 -0
- package/src/rpc.ts +78 -0
- package/src/shared/keybindings.ts +2 -0
- package/src/shared/rpc.ts +11 -0
- package/src/shared/snippets.ts +156 -0
- package/src/snippets.ts +427 -0
|
@@ -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
|
/**
|