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.
@@ -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'
@@ -19,6 +19,8 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
19
19
  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
+ import { CommandPalette } from './CommandPalette.js'
23
+ import { closeCommandPalette } from '../commandPaletteStore.js'
22
24
  import { DiffLauncher } from './DiffLauncher.js'
23
25
  import { SidebarView } from '../sidebar/SidebarView.js'
24
26
  import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
@@ -38,6 +40,27 @@ import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOver
38
40
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
39
41
  import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
40
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
+ }
41
64
 
42
65
  /**
43
66
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -131,6 +154,10 @@ export function EditorView(props) {
131
154
  const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
132
155
  const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
133
156
  const cycleTabRef = React.useRef(null) // 页签循环动作最新闭包(Ctrl+Alt+←/→、Ctrl+PgUp/PgDn)
157
+ const rowNavColRef = React.useRef(null) // 整行上下移动的期望列(连续移动保持列位)
158
+ const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
159
+ const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
160
+ activeRef.current = active
134
161
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
135
162
  const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
136
163
  const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
@@ -143,6 +170,7 @@ export function EditorView(props) {
143
170
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
144
171
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
145
172
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
173
+ const [snippetPicker, setSnippetPicker] = React.useState(null) // 'configure' | 'insert' | null(代码片段浮层)
146
174
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
147
175
  const onEditRef = React.useRef(null) // 编辑置脏的最新闭包(Monaco 内容变化监听经 ref 调用,防首帧 active=null 陈旧闭包)
148
176
  const saveViewStateRef = React.useRef(null) // 视图状态保存的最新闭包(卸载清理读取,避免过期 active)
@@ -564,6 +592,16 @@ export function EditorView(props) {
564
592
  }
565
593
  }, [])
566
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
+
567
605
  React.useEffect(() => {
568
606
  const onOpen = (e) => {
569
607
  const p = e?.detail?.path
@@ -799,6 +837,84 @@ export function EditorView(props) {
799
837
  return () => window.removeEventListener('keydown', onKey, true)
800
838
  }, [])
801
839
 
840
+ // 整行上下移动的实现见下方「指令系统接线」effect(moveRow 单点定义,命令栏与键位共用)。
841
+
842
+ /**
843
+ * 指令系统接线:命令栏/键位/第三方派发的 `edrv.command.*` 事件落到编辑器动作。
844
+ * 全部动作经最新闭包执行(与窗口级键位监听同源);事件名逐条字面书写,
845
+ * 便于与指令目录(ui/commandCatalog)静态对照(tests/commands.test.ts 有断言)。
846
+ * @author ddj 2026年09月10号
847
+ */
848
+ React.useEffect(() => {
849
+ /** 整行上下移动:Monaco 内置 cursorUp/Down 会丢列位,故按期望列自行定位(连续移动保持同列)。 */
850
+ const moveRow = (step) => {
851
+ const ed = editorRef.current
852
+ const model = ed?.getModel?.()
853
+ const pos = ed?.getPosition?.()
854
+ if (!ed || !model || !pos) return
855
+ const col = rowNavColRef.current ?? pos.column
856
+ const line = Math.max(1, Math.min(model.getLineCount(), pos.lineNumber + step))
857
+ if (line === pos.lineNumber) return
858
+ const maxCol = model.getLineMaxColumn(line)
859
+ rowNavColRef.current = col
860
+ rowNavMoveRef.current = true
861
+ ed.setPosition({ lineNumber: line, column: Math.max(1, Math.min(col, maxCol)) })
862
+ ed.revealLineInCenterIfOutsideViewport?.(line)
863
+ }
864
+ const handlers = [
865
+ ['edrv.command.save', () => { if (editorRef.current?.getModel?.()) { flushSave(); doSaveRef.current?.(false) } }],
866
+ // 快速打开由 QuickOpen 自己接该事件(它持有搜索框 ref),此处不重复实现
867
+ ['edrv.command.toggleSidebar', () => setSidebarOn((v) => !v)],
868
+ ['edrv.command.searchInFiles', () => {
869
+ setSidebarOn(true)
870
+ setActivePanel('search')
871
+ setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
872
+ }],
873
+ ['edrv.command.navigateBack', () => navBackRef.current?.()],
874
+ ['edrv.command.navigateForward', () => navForwardRef.current?.()],
875
+ ['edrv.command.nextTab', () => cycleTabRef.current?.(1)],
876
+ ['edrv.command.prevTab', () => cycleTabRef.current?.(-1)],
877
+ ['edrv.command.nextEditorRow', () => moveRow(1)],
878
+ ['edrv.command.prevEditorRow', () => moveRow(-1)],
879
+ ['edrv.command.goToDefinition', () => { const ed = editorRef.current; if (ed) void runGoToDefinition(ed) }],
880
+ ['edrv.command.findReferences', () => { const ed = editorRef.current; if (ed) void runFindReferences(ed) }],
881
+ ['edrv.command.triggerAi', () => editorRef.current?.trigger?.('edrv-ai', 'editor.action.inlineSuggest.trigger', null)],
882
+ ['edrv.command.openInExplorer', () => {
883
+ const path = activeRef.current
884
+ if (!path) { setStatus('无活动文件'); return }
885
+ menuHandlersRef.current?.openInExplorer?.(path)
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
+ }],
905
+ ]
906
+ const byName = new Map(handlers)
907
+ const onCommand = (event) => {
908
+ const action = byName.get(event.type)
909
+ if (!action) return
910
+ try { action() } catch (error) { dbg(sessionId, '指令 ' + event.type + ' 失败:' + String(error)) }
911
+ }
912
+ for (const [eventName] of handlers) window.addEventListener(eventName, onCommand)
913
+ return () => {
914
+ for (const [eventName] of handlers) window.removeEventListener(eventName, onCommand)
915
+ }
916
+ }, [sessionId])
917
+
802
918
  // 活动页签滚动可见(页签栏溢出时键盘切换/打开文件后把当前页签带回视野):
803
919
  // 只调整页签栏自身 scrollLeft,不触发页面滚动。
804
920
  React.useEffect(() => {
@@ -877,6 +993,8 @@ export function EditorView(props) {
877
993
  setMonaco(m)
878
994
  // AI 内联补全 provider 注册 + 开关初始化(幂等)
879
995
  setupAiInline(m)
996
+ // 代码片段补全 provider 注册(幂等;条目按会话工作区懒加载)
997
+ setupSnippets(m)
880
998
  }).catch((e) => {
881
999
  if (alive) {
882
1000
  setMonacoErr(String(e?.message ?? e))
@@ -952,6 +1070,8 @@ export function EditorView(props) {
952
1070
  setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
953
1071
  refreshRecords()
954
1072
  emitRefresh()
1073
+ // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1074
+ if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
955
1075
  } else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
956
1076
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
957
1077
  }
@@ -1065,6 +1185,9 @@ export function EditorView(props) {
1065
1185
  })
1066
1186
  ed.onDidChangeCursorPosition((e) => {
1067
1187
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
1188
+ // 整行移动(↓↑)刚定位时保留期望列;其余光标移动(点击/打字/方向键)清空期望列
1189
+ if (rowNavMoveRef.current) rowNavMoveRef.current = false
1190
+ else rowNavColRef.current = null
1068
1191
  // 导航历史:同文件内光标移动防抖记录(程序化恢复触发的位置与栈顶去重,无副作用)
1069
1192
  if (navCursorTimerRef.current) clearTimeout(navCursorTimerRef.current)
1070
1193
  navCursorTimerRef.current = setTimeout(() => {
@@ -1126,6 +1249,20 @@ export function EditorView(props) {
1126
1249
  run: (edx) => { void runFindReferences(edx) },
1127
1250
  })
1128
1251
  ed.addCommand(m.KeyCode.F12, () => { void runGoToDefinition(ed) })
1252
+ // 整行上下移动 + 命令栏:只登记 Monaco 右键菜单入口(不绑键位)。
1253
+ // 键位由指令桥统一 capture 派发 `edrv.command.*`,此处再绑一次会双执行。
1254
+ ed.addAction({
1255
+ id: 'edrv.prevEditorRow', label: '上一编辑行', contextMenuGroupId: '1_edrv',
1256
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.prevEditorRow')),
1257
+ })
1258
+ ed.addAction({
1259
+ id: 'edrv.nextEditorRow', label: '下一编辑行', contextMenuGroupId: '1_edrv',
1260
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.nextEditorRow')),
1261
+ })
1262
+ ed.addAction({
1263
+ id: 'edrv.showCommands', label: '显示所有命令', contextMenuGroupId: '1_edrv',
1264
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.showCommands')),
1265
+ })
1129
1266
  bindLspEditor(ed)
1130
1267
  bindLspUnderline(ed, m)
1131
1268
  // AI 补全:编辑器实例登记(差异静默判定用)+ Alt+\ 手动触发 ghost text
@@ -1481,6 +1618,26 @@ export function EditorView(props) {
1481
1618
  // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
1482
1619
  menuHandlersRef.current = { addRefToChat, openInExplorer }
1483
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
+
1484
1641
  const openFile = (path, focusDiff) => {
1485
1642
  if (!path) return
1486
1643
  recordNav()
@@ -1652,7 +1809,7 @@ export function EditorView(props) {
1652
1809
  } else if (!active) {
1653
1810
  body = React.createElement('div', { className: 'edrv-empty' },
1654
1811
  React.createElement('div', null, '暂无打开的文件'),
1655
- React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;agent 修改文件后顶部会出现差异角标'))
1812
+ React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;' + (chordOf('edrv.showCommands') ?? 'Ctrl+Shift+P') + ' 打开命令栏;agent 修改文件后顶部会出现差异角标'))
1656
1813
  } else if (content === null && loadError) {
1657
1814
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
1658
1815
  setLoadError(null)
@@ -1765,6 +1922,23 @@ export function EditorView(props) {
1765
1922
  React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
1766
1923
  : null
1767
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
+
1768
1942
  const sidebarPanels = props.sidebarPanels
1769
1943
  const sidebarCtx = {
1770
1944
  sessionId,
@@ -1831,14 +2005,21 @@ export function EditorView(props) {
1831
2005
  mainCol)
1832
2006
 
1833
2007
  const baseStyle = { minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' }
2008
+ // 命令栏浮层(Ctrl+Shift+P / F1):portal 到 body,但需挂在插件自己的 React 树里;
2009
+ // 三种布局形态都渲染 EditorView,故这里挂载即可,多宿主由 store 单实例认领兜底。
2010
+ const paletteEl = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
1834
2011
  const rootEl = layout === 'side'
1835
2012
  ? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
1836
2013
  editorRow,
1837
2014
  menuBackdrop,
1838
- tabMenuEl)
2015
+ tabMenuEl,
2016
+ paletteEl,
2017
+ snippetPickerEl)
1839
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%)' }) },
1840
2019
  editorRow,
1841
2020
  menuBackdrop,
1842
- tabMenuEl)
2021
+ tabMenuEl,
2022
+ paletteEl,
2023
+ snippetPickerEl)
1843
2024
  return rootEl
1844
2025
  }
@@ -174,7 +174,7 @@ export function KeybindingsPanel() {
174
174
 
175
175
  return React.createElement('section', { className: 'vsm-general-page' },
176
176
  React.createElement('h2', null, '快捷键'),
177
- React.createElement('p', null, '配置编辑器视图的快捷键(保存 / 快速打开 / 侧边栏 / 搜索 / 后退 / 前进)。修改即时写入配置,重新聚焦编辑器后生效。'),
177
+ React.createElement('p', null, '配置编辑器视图的快捷键(命令栏 / 保存 / 快速打开 / 侧边栏 / 搜索 / 后退前进 / 页签与编辑行导航)。修改即时写入配置,重新聚焦编辑器后生效;命令栏(默认 Ctrl+Shift+P 或 F1)可搜索并执行下列全部命令。'),
178
178
  unavailable && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, '设置服务暂不可用,当前使用默认键位。'),
179
179
  error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
180
180
  message && React.createElement('div', { className: 'vsm-kb-message' }, message),
@@ -49,17 +49,25 @@ export function QuickOpen(props) {
49
49
  }
50
50
 
51
51
  React.useEffect(() => {
52
+ const openBox = () => {
53
+ inputRef.current?.focus?.()
54
+ setOpen(true)
55
+ }
52
56
  const onKey = (e) => {
53
57
  if (matchEvent(e, bindingsOf('edrv.quickOpen'))) {
54
58
  e.preventDefault(); e.stopPropagation()
55
- inputRef.current?.focus?.()
56
- setOpen(true)
59
+ openBox()
57
60
  } else if (e.key === 'Escape') {
58
61
  setOpen(false); inputRef.current?.blur?.()
59
62
  }
60
63
  }
61
64
  window.addEventListener('keydown', onKey, true)
62
- return () => window.removeEventListener('keydown', onKey, true)
65
+ // 指令系统入口(命令栏「快速打开文件」):与键位复用同一动作
66
+ window.addEventListener('edrv.command.quickOpen', openBox)
67
+ return () => {
68
+ window.removeEventListener('keydown', onKey, true)
69
+ window.removeEventListener('edrv.command.quickOpen', openBox)
70
+ }
63
71
  }, [])
64
72
 
65
73
  const dirText = (p) => {
@@ -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
+ }