dsh-vscode-mode 0.1.45 → 0.1.47
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 +4 -0
- package/lib/client.js +256 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +590 -34
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/monaco/lsp/providers.ts +10 -1
- package/src/client/monaco/lsp/underline.ts +101 -0
- package/src/client/styles/editor.css +2 -0
- package/src/client/ui/EditorView.ts +2 -0
- package/src/client/ui/LspSettings.ts +83 -5
- package/src/lsp/client.ts +4 -0
- package/src/lsp/config.ts +5 -1
- package/src/lsp/derive.ts +96 -0
- package/src/lsp/dotnetProvision.ts +224 -0
- package/src/lsp/envRequirements.ts +92 -0
- package/src/lsp/providers.ts +198 -25
- package/src/lsp/rpc.ts +24 -1
- package/src/lsp/server.ts +58 -5
- package/src/shared/lsp.ts +75 -0
- package/src/shared/rpc.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.47",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -197,7 +197,16 @@ export async function gotoRefsAt(ed, position) {
|
|
|
197
197
|
if (!path || !position) return
|
|
198
198
|
const all = await findReferences(path, model.getValue(), position, false)
|
|
199
199
|
const others = filterOtherRefs(all, path, position)
|
|
200
|
-
if (!others.length)
|
|
200
|
+
if (!others.length) {
|
|
201
|
+
// 0 条「其它」引用:降级查定义(参数/局部变量只被声明时引用为空,定义可经 host 降级链推导)
|
|
202
|
+
const defs = await findDefinition(path, model.getValue(), position)
|
|
203
|
+
const first = defs[0]
|
|
204
|
+
if (!first) return
|
|
205
|
+
const lspPos = monoToLsp(position.lineNumber, position.column)
|
|
206
|
+
if (sameFile(targetOpenPath(first), path) && posInRange(lspPos, first.lspRange)) return // 定义即自身:无其它可跳,保持静默
|
|
207
|
+
jumpToLocation(first)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
201
210
|
if (others.length === 1) {
|
|
202
211
|
jumpToLocation(others[0])
|
|
203
212
|
return
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — Ctrl+hover 可导航标识符下划线提示。
|
|
4
|
+
* 背景:内嵌 Monaco 构建(0.42.0-dev)无 linkDetection contrib,原生不产生 ctrl+hover 下划线,
|
|
5
|
+
* 插件自绘:Ctrl/Meta 按住悬停在"语义 token 标识符"上时给词加下划线装饰,移出/松开即清除。
|
|
6
|
+
* 数据来源于语义 token(每 model+version 缓存 60s),不做逐词 LSP 查询。
|
|
7
|
+
* 作者 ddj 2026-09-02
|
|
8
|
+
*/
|
|
9
|
+
import { pathOfModel, fetchSemanticTokens } from './lspClient.js'
|
|
10
|
+
import { decodeSemanticTokens, isNavigableTokenType } from '../../../shared/lsp.js'
|
|
11
|
+
|
|
12
|
+
const CACHE_TTL_MS = 60_000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 给一个 Monaco 编辑器绑定 Ctrl+hover 下划线提示(幂等)。
|
|
16
|
+
* @author ddj 2026年09月02号
|
|
17
|
+
* @param ed Monaco 编辑器
|
|
18
|
+
* @param monaco Monaco 实例
|
|
19
|
+
*/
|
|
20
|
+
export function bindLspUnderline(ed, monaco) {
|
|
21
|
+
if (!ed || !monaco || ed.__edrvUnderline) return
|
|
22
|
+
ed.__edrvUnderline = true
|
|
23
|
+
let decoIds = []
|
|
24
|
+
let lastKey = null
|
|
25
|
+
let hoverSeq = 0
|
|
26
|
+
let cache = { path: null, version: -1, ranges: null, at: 0 }
|
|
27
|
+
|
|
28
|
+
const clear = () => {
|
|
29
|
+
if (decoIds.length) {
|
|
30
|
+
ed.deltaDecorations(decoIds, [])
|
|
31
|
+
decoIds = []
|
|
32
|
+
}
|
|
33
|
+
lastKey = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 取当前文档语义 token 范围(模型版本 + TTL 缓存)。 */
|
|
37
|
+
const rangesOf = async (model) => {
|
|
38
|
+
const path = pathOfModel(model)
|
|
39
|
+
const version = model.getVersionId?.() ?? 0
|
|
40
|
+
if (cache.path === path && cache.version === version && cache.ranges && Date.now() - cache.at < CACHE_TTL_MS) {
|
|
41
|
+
return cache.ranges
|
|
42
|
+
}
|
|
43
|
+
const res = await fetchSemanticTokens(path, model.getValue()).catch(() => null)
|
|
44
|
+
const ranges = res && Array.isArray(res.data) ? decodeSemanticTokens(res.data) : []
|
|
45
|
+
cache = { path, version, ranges, at: Date.now() }
|
|
46
|
+
return ranges
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ed.onMouseMove((event) => {
|
|
50
|
+
const native = event?.event
|
|
51
|
+
const ctrl = native?.ctrlKey ?? false
|
|
52
|
+
const meta = native?.metaKey ?? false
|
|
53
|
+
if (!ctrl && !meta) {
|
|
54
|
+
clear()
|
|
55
|
+
hoverSeq++
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const pos = event?.target?.position
|
|
59
|
+
const model = ed.getModel()
|
|
60
|
+
if (!pos || !model) {
|
|
61
|
+
clear()
|
|
62
|
+
hoverSeq++
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const word = model.getWordAtPosition(pos)
|
|
66
|
+
if (!word) {
|
|
67
|
+
clear()
|
|
68
|
+
hoverSeq++
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const seq = ++hoverSeq
|
|
72
|
+
const path = pathOfModel(model)
|
|
73
|
+
void rangesOf(model).then((ranges) => {
|
|
74
|
+
if (seq !== hoverSeq) return // 鼠标已移走,忽略过期结果
|
|
75
|
+
const lineIndex = Math.max(0, word.startLineNumber - 1)
|
|
76
|
+
const startCol = Math.max(0, word.startColumn - 1)
|
|
77
|
+
const endCol = Math.max(startCol, word.endColumn - 1)
|
|
78
|
+
const hit = ranges.some((r) => {
|
|
79
|
+
if (r.start.line !== lineIndex) return false
|
|
80
|
+
return isNavigableTokenType(r.type) && r.start.character <= startCol && r.end.character >= endCol
|
|
81
|
+
})
|
|
82
|
+
if (!hit) {
|
|
83
|
+
if (lastKey !== null) clear()
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const key = String(path) + ':' + word.startColumn + ':' + word.endColumn
|
|
87
|
+
if (key === lastKey) return
|
|
88
|
+
lastKey = key
|
|
89
|
+
decoIds = ed.deltaDecorations(decoIds, [{
|
|
90
|
+
range: new monaco.Range(word.startLineNumber, word.startColumn, word.endLineNumber, word.endColumn),
|
|
91
|
+
options: { inlineClassName: 'edrv-nav-underline' },
|
|
92
|
+
}])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
ed.onMouseLeave(() => clear())
|
|
97
|
+
ed.onDidBlurEditorText?.(() => clear())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 兼容占位(与 providers 的 hideReferencesOverlay 对称,无需真正释放)。 */
|
|
101
|
+
export function disposeLspUnderline() {}
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
[data-edrv-view] .edrv-lsp-status-dot.ready { background: var(--dsw-alias-state-success-primary, #0f9d58); }
|
|
41
41
|
[data-edrv-view] .edrv-lsp-status-dot.busy { background: var(--dsw-alias-state-warn-primary, #d97706); animation: edrv-lsp-pulse 1.2s ease-in-out infinite; }
|
|
42
42
|
@keyframes edrv-lsp-pulse { 50% { opacity: .45; } }
|
|
43
|
+
/* Ctrl+hover 可导航标识符下划线提示(绑定 Monaco inlineClassName 装饰) */
|
|
44
|
+
[data-edrv-view] .edrv-nav-underline { text-decoration: underline; text-decoration-color: var(--dsw-alias-interactive-primary, #4fc1ff); text-underline-offset: 1px; cursor: pointer; }
|
|
43
45
|
[data-edrv-view] .edrv-diffchip { font-size: 11px; padding: 2px 10px; border: 1px solid var(--dsw-alias-border-l1, #333); border-radius: 999px; background: var(--dsw-alias-bg-layer-2, transparent); color: var(--dsw-alias-state-warn-primary, #b7791f); cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
|
44
46
|
[data-edrv-view] .edrv-chip-btn { font-size: 12px; padding: 2px 6px; border: none; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; border-radius: 4px; flex-shrink: 0; }
|
|
45
47
|
[data-edrv-view] .edrv-chip-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
|
|
@@ -26,6 +26,7 @@ import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybi
|
|
|
26
26
|
import { createNavHistory } from '../navHistory.js'
|
|
27
27
|
import { CACHE_KEY } from '../paths.js'
|
|
28
28
|
import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
|
|
29
|
+
import { bindLspUnderline } from '../monaco/lsp/underline.js'
|
|
29
30
|
import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -841,6 +842,7 @@ export function EditorView(props) {
|
|
|
841
842
|
})
|
|
842
843
|
ed.addCommand(m.KeyCode.F12, () => { void runGoToDefinition(ed) })
|
|
843
844
|
bindLspEditor(ed)
|
|
845
|
+
bindLspUnderline(ed, m)
|
|
844
846
|
// hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
|
|
845
847
|
// 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
|
|
846
848
|
// ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
|
|
@@ -11,7 +11,7 @@ import '../styles/mcp.css'
|
|
|
11
11
|
|
|
12
12
|
const LANGUAGES = [
|
|
13
13
|
{ id: 'lua', label: 'Lua(EmmyLua / LuaLS)', hint: '优先使用已安装 EmmyLua;未安装时回退 LuaLS 或 PATH 中的 lua-language-server' },
|
|
14
|
-
{ id: 'csharp', label: 'C#(Roslyn / OmniSharp)', hint: '
|
|
14
|
+
{ id: 'csharp', label: 'C#(Roslyn / DotRush / OmniSharp)', hint: '优先自动发现 ms-dotnettools.csharp / DotRush 扩展(DotRush 需 .NET 10 运行时);也可手动指定' },
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
const PHASE_LABEL = {
|
|
@@ -25,8 +25,18 @@ const TABS = [
|
|
|
25
25
|
{ id: 'updates', label: '更新' },
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
function
|
|
28
|
+
/** 环境安装按钮文案(按安装进行态)。 */
|
|
29
|
+
function envButtonLabel(req, envStates) {
|
|
30
|
+
const state = (envStates ?? []).find((s) => s.id === req.id)
|
|
31
|
+
if (state?.phase === 'downloading') return '下载中…'
|
|
32
|
+
if (state?.phase === 'extracting') return '安装中…'
|
|
33
|
+
if (state?.phase === 'done') return '已完成'
|
|
34
|
+
if (state?.phase === 'failed') return '重试'
|
|
35
|
+
return '一键安装'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 语言卡片:状态 + 缺失环境提示(一键安装/官网下载) + 启用开关 + 命令/路径配置 + 重新检测。 */
|
|
39
|
+
function LangCard({ lang, config, status, busy, envStates, onInstallEnv, onToggle, onSave, onRedetect }) {
|
|
30
40
|
const [pathDraft, setPathDraft] = React.useState(config?.path ?? '')
|
|
31
41
|
const [commandDraft, setCommandDraft] = React.useState(config?.command ?? '')
|
|
32
42
|
React.useEffect(() => { setPathDraft(config?.path ?? ''); setCommandDraft(config?.command ?? '') }, [config?.path, config?.command])
|
|
@@ -44,6 +54,21 @@ function LangCard({ lang, config, status, busy, onToggle, onSave, onRedetect })
|
|
|
44
54
|
status?.version ? ' · v' + status.version : '',
|
|
45
55
|
(phase === 'ready' && status?.root) ? ' · ' + String(status.root).split(/[\\/]/).pop() : ''),
|
|
46
56
|
status?.reason ? React.createElement('div', { className: 'vsm-mcp-error' }, status.reason) : null,
|
|
57
|
+
Array.isArray(status?.missingEnv) && status.missingEnv.length
|
|
58
|
+
? React.createElement('div', { className: 'vsm-lsp-hint' },
|
|
59
|
+
'缺少运行环境(一键安装后自动生效):',
|
|
60
|
+
status.missingEnv.map((req) => React.createElement('div', { key: req.id, className: 'vsm-mcp-actions' },
|
|
61
|
+
React.createElement('span', null, req.label, req.detail ? '(' + req.detail + ')' : ''),
|
|
62
|
+
req.installable ? React.createElement('button', {
|
|
63
|
+
className: 'vsm-primary vsm-small',
|
|
64
|
+
disabled: busy === 'env:' + req.id,
|
|
65
|
+
onClick: () => onInstallEnv(lang.id, req),
|
|
66
|
+
}, envButtonLabel(req, envStates)) : null,
|
|
67
|
+
req.manualUrl ? React.createElement('a', {
|
|
68
|
+
href: req.manualUrl, target: '_blank', rel: 'noreferrer',
|
|
69
|
+
style: { color: 'inherit', textDecoration: 'underline', alignSelf: 'center' },
|
|
70
|
+
}, req.manualLabel ?? '官网下载') : null)))
|
|
71
|
+
: null,
|
|
47
72
|
React.createElement('div', { className: 'vsm-lsp-form' },
|
|
48
73
|
React.createElement('label', null, React.createElement('span', null, '可执行文件路径(绝对路径,优先)'), React.createElement('input', { value: pathDraft, placeholder: '如 C:/.../lua-language-server.exe', onChange: (e) => setPathDraft(e.target.value) })),
|
|
49
74
|
React.createElement('label', null, React.createElement('span', null, '命令名(PATH 内查找)'), React.createElement('input', { value: commandDraft, placeholder: '如 lua-language-server', onChange: (e) => setCommandDraft(e.target.value) })),
|
|
@@ -96,6 +121,8 @@ export function LspSettings() {
|
|
|
96
121
|
const [busy, setBusy] = React.useState('')
|
|
97
122
|
const [error, setError] = React.useState('')
|
|
98
123
|
const [loading, setLoading] = React.useState(true)
|
|
124
|
+
const [envStates, setEnvStates] = React.useState([])
|
|
125
|
+
const [envLang, setEnvLang] = React.useState('')
|
|
99
126
|
|
|
100
127
|
const refreshServers = React.useCallback(() => {
|
|
101
128
|
Promise.all([rpc('edrv.lsp.configGet', {}), rpc('edrv.lsp.status', {})])
|
|
@@ -117,6 +144,15 @@ export function LspSettings() {
|
|
|
117
144
|
.catch((e) => setError(String(e)))
|
|
118
145
|
}, [])
|
|
119
146
|
|
|
147
|
+
const refreshEnvStates = React.useCallback(() => {
|
|
148
|
+
return rpc('edrv.lsp.envState', {})
|
|
149
|
+
.then((res) => {
|
|
150
|
+
if (res?.ok) setEnvStates(res.states ?? [])
|
|
151
|
+
return res?.states ?? []
|
|
152
|
+
})
|
|
153
|
+
.catch(() => [])
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
120
156
|
const refresh = React.useCallback(() => {
|
|
121
157
|
setLoading(true)
|
|
122
158
|
Promise.all([refreshServers(), refreshExt()]).finally(() => setLoading(false))
|
|
@@ -140,10 +176,52 @@ export function LspSettings() {
|
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
/** 重检测后通知编辑器:与 host 重新同步已打开的模型(触发重新 acquire)。 */
|
|
143
|
-
const notifyResync = (languageId) => {
|
|
179
|
+
const notifyResync = React.useCallback((languageId) => {
|
|
144
180
|
window.dispatchEvent(new CustomEvent('edrv:lsp-redetect', { detail: { languageId } }))
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
/** 一键安装缺失环境:启动内置安装器(成功后进入轮询,完成自动重同步)。 */
|
|
184
|
+
const installEnv = (languageId, req) => {
|
|
185
|
+
const key = 'env:' + req.id
|
|
186
|
+
setBusy(key)
|
|
187
|
+
setError('')
|
|
188
|
+
setEnvLang(languageId)
|
|
189
|
+
rpc('edrv.lsp.envInstall', { languageId, id: req.id })
|
|
190
|
+
.then((res) => {
|
|
191
|
+
if (!res?.ok) { setError(res?.error ?? '无法启动安装'); return }
|
|
192
|
+
void refreshEnvStates()
|
|
193
|
+
void refreshServers()
|
|
194
|
+
})
|
|
195
|
+
.catch((e) => setError(String(e)))
|
|
196
|
+
.finally(() => setBusy(''))
|
|
145
197
|
}
|
|
146
198
|
|
|
199
|
+
// 环境安装进行中:2s 轮询进度并刷新状态;某项转入 done 时由下方过渡检测重同步
|
|
200
|
+
React.useEffect(() => {
|
|
201
|
+
const states = envStates ?? []
|
|
202
|
+
const installing = states.some((s) => s.phase === 'downloading' || s.phase === 'extracting')
|
|
203
|
+
if (!installing) return
|
|
204
|
+
const timer = setInterval(() => {
|
|
205
|
+
void refreshEnvStates()
|
|
206
|
+
void refreshServers()
|
|
207
|
+
}, 2000)
|
|
208
|
+
return () => clearInterval(timer)
|
|
209
|
+
}, [envStates, refreshEnvStates, refreshServers])
|
|
210
|
+
|
|
211
|
+
// 环境安装完成过渡检测:刷新状态并通知编辑器重新同步对应语言
|
|
212
|
+
const prevEnvPhases = React.useRef({})
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
const states = envStates ?? []
|
|
215
|
+
for (const s of states) {
|
|
216
|
+
const prev = prevEnvPhases.current[s.id]
|
|
217
|
+
if (prev && prev !== 'done' && s.phase === 'done' && envLang) {
|
|
218
|
+
void refreshServers()
|
|
219
|
+
notifyResync(envLang)
|
|
220
|
+
}
|
|
221
|
+
prevEnvPhases.current[s.id] = s.phase
|
|
222
|
+
}
|
|
223
|
+
}, [envStates, envLang, refreshServers, notifyResync])
|
|
224
|
+
|
|
147
225
|
const saveLang = (languageId, path, command) => {
|
|
148
226
|
setBusy(languageId)
|
|
149
227
|
setError('')
|
|
@@ -271,7 +349,7 @@ export function LspSettings() {
|
|
|
271
349
|
key: lang.id, lang,
|
|
272
350
|
config: config[lang.id] ?? {},
|
|
273
351
|
status: servers.find((s) => s.languageId === lang.id),
|
|
274
|
-
busy, onToggle: toggleLang, onSave: saveLang, onRedetect: redetectLang,
|
|
352
|
+
busy, envStates, onInstallEnv: installEnv, onToggle: toggleLang, onSave: saveLang, onRedetect: redetectLang,
|
|
275
353
|
})),
|
|
276
354
|
React.createElement('div', { className: 'vsm-lsp-hint' }, '提示:打开 Lua/C# 文件即自动(惰性)启动对应语言服务器;未配置时编辑器功能不受影响,大纲回退内置解析。')))
|
|
277
355
|
} else if (tab === 'installed') {
|
package/src/lsp/client.ts
CHANGED
|
@@ -56,6 +56,7 @@ export function createLspClient(
|
|
|
56
56
|
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>()
|
|
57
57
|
let serverCapabilities: LspServerCapabilities = {
|
|
58
58
|
definition: false,
|
|
59
|
+
declaration: false,
|
|
59
60
|
references: false,
|
|
60
61
|
documentSymbol: false,
|
|
61
62
|
workspaceSymbol: false,
|
|
@@ -158,6 +159,7 @@ export function createLspClient(
|
|
|
158
159
|
const init = await client.request<{
|
|
159
160
|
capabilities?: {
|
|
160
161
|
definitionProvider?: unknown
|
|
162
|
+
declarationProvider?: unknown
|
|
161
163
|
referencesProvider?: unknown
|
|
162
164
|
documentSymbolProvider?: unknown
|
|
163
165
|
workspaceSymbolProvider?: unknown
|
|
@@ -179,6 +181,7 @@ export function createLspClient(
|
|
|
179
181
|
textDocument: {
|
|
180
182
|
synchronization: { dynamicRegistration: false, willSave: false, didSave: true },
|
|
181
183
|
definition: { dynamicRegistration: false },
|
|
184
|
+
declaration: { dynamicRegistration: false },
|
|
182
185
|
references: { dynamicRegistration: false },
|
|
183
186
|
documentSymbol: { dynamicRegistration: false },
|
|
184
187
|
hover: { dynamicRegistration: false },
|
|
@@ -196,6 +199,7 @@ export function createLspClient(
|
|
|
196
199
|
const legend = caps.semanticTokensProvider?.legend
|
|
197
200
|
serverCapabilities = {
|
|
198
201
|
definition: Boolean(caps.definitionProvider),
|
|
202
|
+
declaration: Boolean(caps.declarationProvider),
|
|
199
203
|
references: Boolean(caps.referencesProvider),
|
|
200
204
|
documentSymbol: Boolean(caps.documentSymbolProvider),
|
|
201
205
|
workspaceSymbol: Boolean(caps.workspaceSymbolProvider),
|
package/src/lsp/config.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Ctx } from '../store.js'
|
|
7
7
|
import { PROVIDER_RESOLVERS, type LspProviderSpec } from './providers.js'
|
|
8
|
+
import { provisionRuntime } from './dotnetProvision.js'
|
|
8
9
|
|
|
9
10
|
/** 单语言配置。 */
|
|
10
11
|
export interface LspLangConfig {
|
|
@@ -91,7 +92,10 @@ export function resolveProviderSpec(ctx: Ctx, pluginConfig: unknown, languageId:
|
|
|
91
92
|
if (lang && lang.enabled === false) {
|
|
92
93
|
return { languageId, kind: 'none', argv: [], ready: false, reason: '已在设置中禁用' }
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
+
const spec = resolver({ command: lang?.command, path: lang?.path })
|
|
96
|
+
// DotRush 缺运行时:后台自动下载官方 .NET runtime(用户零操作),完成后经监听者重载
|
|
97
|
+
if (spec.provisionRuntime) provisionRuntime(spec.provisionRuntime)
|
|
98
|
+
return spec
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/** 语言 → 扩展名(供客户端判断是否触发 LSP)。 */
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 定义查找降级链的纯推导函数。
|
|
3
|
+
* 背景:EmmyLua 对局部变量/参数/表字段的 textDocument/definition 常返回空,
|
|
4
|
+
* 但 references(includeDeclaration) 能给出包含声明的条目。
|
|
5
|
+
* 本函数从引用列表推导"声明位置":同文件、词文本与点击处一致,且点击位置落在某条目内时,
|
|
6
|
+
* 取列表首个(EmmyLua 对局部/参数把声明排在引用首位;点击处即首个时按"点击了声明"处理)。
|
|
7
|
+
* 词文本必须与点击词一致,否则拒绝(宁可空,不误跳)。
|
|
8
|
+
* 作者 ddj 2026-09-02
|
|
9
|
+
*/
|
|
10
|
+
import type { LspLocation, LspPosition, LspRange } from '../shared/lsp.js'
|
|
11
|
+
|
|
12
|
+
/** file:// URI(或裸路径)→ 归一化绝对路径(小写、/ 分隔)。 */
|
|
13
|
+
function normAbs(uri: string): string {
|
|
14
|
+
const path = String(uri ?? '').replace(/^file:\/\/\/?/, '')
|
|
15
|
+
return decodeURIComponent(path).replace(/\\/g, '/').replace(/^\/([A-Za-z]:)/, '$1').toLowerCase()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 取文档 text 在 LSP range(0-based)内的切片。 */
|
|
19
|
+
function sliceRange(text: string, range: LspRange): string {
|
|
20
|
+
const lines = text.split(/\r\n|\r|\n/)
|
|
21
|
+
const start = range.start
|
|
22
|
+
const end = range.end
|
|
23
|
+
const startLine = Math.max(0, start.line)
|
|
24
|
+
const endLine = Math.max(startLine, end.line)
|
|
25
|
+
if (startLine >= lines.length || endLine >= lines.length) return ''
|
|
26
|
+
const startChar = Math.max(0, start.character)
|
|
27
|
+
if (startLine === endLine) {
|
|
28
|
+
return lines[startLine].slice(startChar, Math.max(startChar, end.character))
|
|
29
|
+
}
|
|
30
|
+
const first = lines[startLine].slice(startChar)
|
|
31
|
+
const middle = lines.slice(startLine + 1, endLine)
|
|
32
|
+
const last = lines[endLine].slice(0, Math.max(0, end.character))
|
|
33
|
+
return [first, ...middle, last].join('\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** LSP position 是否落在 range 内(start 包含、end 排除;零宽按点匹配)。 */
|
|
37
|
+
function posInLspRange(pos: LspPosition, range: LspRange): boolean {
|
|
38
|
+
const start = range.start
|
|
39
|
+
const end = range.end ?? start
|
|
40
|
+
if (start.line === end.line && start.character === end.character) {
|
|
41
|
+
return pos.line === start.line && pos.character === start.character
|
|
42
|
+
}
|
|
43
|
+
if (pos.line < start.line || pos.line > end.line) return false
|
|
44
|
+
if (pos.line === start.line && pos.character < start.character) return false
|
|
45
|
+
if (pos.line === end.line && pos.character >= end.character) return false
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 两个 range 是否完全一致。 */
|
|
50
|
+
function sameRange(a: LspRange, b: LspRange): boolean {
|
|
51
|
+
return a.start.line === b.start.line && a.start.character === b.start.character &&
|
|
52
|
+
a.end.line === b.end.line && a.end.character === b.end.character
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** range 长度(行/列均按 0-based 差估算,仅用于取"最小包裹"排序)。 */
|
|
56
|
+
function rangeSize(r: LspRange): number {
|
|
57
|
+
return (r.end.line - r.start.line) * 1_000_000 + (r.end.line === r.start.line ? r.end.character - r.start.character : r.end.character)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从引用列表推导定义位置。
|
|
62
|
+
* @author ddj 2026年09月02号
|
|
63
|
+
* @param queryPos 查询位置(LSP 0-based)
|
|
64
|
+
* @param locations references(includeDeclaration) 结果
|
|
65
|
+
* @param docText 当前文档全文(词文本一致校验)
|
|
66
|
+
* @param docAbsPath 当前文档绝对路径(root+相对路径,与 loc.uri 归一化后比较)
|
|
67
|
+
* @returns 推导出的定义位置;无法确定返回 null
|
|
68
|
+
*/
|
|
69
|
+
export function deriveDefinitionFromLocations(
|
|
70
|
+
queryPos: LspPosition,
|
|
71
|
+
locations: LspLocation[],
|
|
72
|
+
docText: string,
|
|
73
|
+
docAbsPath: string,
|
|
74
|
+
): LspLocation | null {
|
|
75
|
+
if (!locations.length) return null
|
|
76
|
+
const docAbs = normAbs(docAbsPath)
|
|
77
|
+
|
|
78
|
+
// 点击位置落在的条目(多个时取最小包裹 = 最贴近词的 range)
|
|
79
|
+
const containing = locations
|
|
80
|
+
.filter((loc) => posInLspRange(queryPos, loc.range))
|
|
81
|
+
.sort((a, b) => rangeSize(a.range) - rangeSize(b.range))[0]
|
|
82
|
+
if (!containing) return null
|
|
83
|
+
const queryWord = sliceRange(docText, containing.range)
|
|
84
|
+
if (!queryWord) return null
|
|
85
|
+
|
|
86
|
+
// 同文件 + 词文本一致的候选(按服务器返回顺序)
|
|
87
|
+
const candidates = locations.filter((loc) => {
|
|
88
|
+
return normAbs(loc.uri) === docAbs && sliceRange(docText, loc.range) === queryWord
|
|
89
|
+
})
|
|
90
|
+
if (!candidates.length) return null
|
|
91
|
+
|
|
92
|
+
// 点击处即候选首个(且与查询词重合)→ 点击的就是声明,返回自身
|
|
93
|
+
// 否则取首个(EmmyLua 对局部/参数把声明排在引用首位)
|
|
94
|
+
const first = candidates[0]
|
|
95
|
+
return sameRange(first.range, containing.range) ? containing : first
|
|
96
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — C# 服务器 .NET 运行时自动配置。
|
|
3
|
+
* DotRush 等服务器需要 .NET 运行时;缺失时从官方 release 元数据解析下载地址,
|
|
4
|
+
* sha512 校验后解压到用户级 dotnet 目录(win: %LOCALAPPDATA%\Microsoft\dotnet,
|
|
5
|
+
* unix: ~/.dotnet),成功后通知监听者重载 provider——用户零手动操作。
|
|
6
|
+
* 失败静默降级(保留原 reason 文案),10 分钟退避重试。
|
|
7
|
+
* 安装全程有状态机(envInstallStates)供设置页一键安装与进度轮询使用。
|
|
8
|
+
* 作者 ddj 2026年09月03号
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { dotnetWithRuntime } from './providers.js'
|
|
17
|
+
import type { LspEnvInstallState } from '../shared/lsp.js'
|
|
18
|
+
import { unzip } from './zip.js'
|
|
19
|
+
|
|
20
|
+
/** release 元数据 files 元素子集(官方 releases.json)。 */
|
|
21
|
+
export interface RuntimeAsset {
|
|
22
|
+
name: string
|
|
23
|
+
url: string
|
|
24
|
+
hash: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const META_BASE = 'https://builds.dotnet.microsoft.com/dotnet/release-metadata'
|
|
28
|
+
const RETRY_MS = 10 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
/** 同 major 去重(in-flight)。 */
|
|
31
|
+
const inFlight = new Map<number, Promise<boolean>>()
|
|
32
|
+
/** 失败时间戳(退避重试)。 */
|
|
33
|
+
const failedAt = new Map<number, number>()
|
|
34
|
+
/** 配置成功监听者(重载 provider 缓存与服务器)。 */
|
|
35
|
+
const listeners = new Set<() => void>()
|
|
36
|
+
/** 安装进行态(major → phase/message),供设置页轮询展示。 */
|
|
37
|
+
const installStates = new Map<number, { phase: LspEnvInstallState['phase']; message?: string }>()
|
|
38
|
+
|
|
39
|
+
/** 需求 id(与 envRequirements 的 id 一致)。 */
|
|
40
|
+
export function runtimeRequirementId(major: number): string {
|
|
41
|
+
return 'dotnet-runtime:' + major
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 当前安装进行态快照(edrv.lsp.envState 载荷)。 */
|
|
45
|
+
export function envInstallStates(): LspEnvInstallState[] {
|
|
46
|
+
return [...installStates.entries()].map(([major, state]) => ({
|
|
47
|
+
id: runtimeRequirementId(major),
|
|
48
|
+
phase: state.phase,
|
|
49
|
+
message: state.message,
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 注册配置成功监听(成功后由 rpc 侧清缓存并重置服务器)。 */
|
|
54
|
+
export function onRuntimeProvisioned(listener: () => void): void {
|
|
55
|
+
listeners.add(listener)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 平台 → 官方运行时资产文件名。 */
|
|
59
|
+
function assetNameFor(platform: string, arch: string): string {
|
|
60
|
+
const archPart = arch === 'arm64' ? 'arm64' : 'x64'
|
|
61
|
+
if (platform === 'win32') return 'dotnet-runtime-win-' + archPart + '.zip'
|
|
62
|
+
if (platform === 'darwin') return 'dotnet-runtime-osx-' + archPart + '.tar.gz'
|
|
63
|
+
return 'dotnet-runtime-linux-' + archPart + '.tar.gz'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 用户级 dotnet 安装目录(与 providers 的发现候选位置一致)。 */
|
|
67
|
+
export function userDotnetDir(): string {
|
|
68
|
+
if (process.platform === 'win32') {
|
|
69
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Microsoft', 'dotnet')
|
|
70
|
+
}
|
|
71
|
+
return join(homedir(), '.dotnet')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 从 release 元数据 files 里挑当前平台资产(纯函数,测试可注入平台)。 */
|
|
75
|
+
export function pickRuntimeAsset(files: RuntimeAsset[], platform = process.platform, arch = process.arch): RuntimeAsset | null {
|
|
76
|
+
const name = assetNameFor(platform, arch)
|
|
77
|
+
return files.find((f) => f.name === name) ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 是否允许自动配置(测试隔离 + 显式开关)。 */
|
|
81
|
+
function allowed(): boolean {
|
|
82
|
+
if (process.env.DSH_LSP_AUTO_RUNTIME === '0') return false
|
|
83
|
+
if (process.env.DSH_LSP_EXT_DIRS) return false // 测试环境不下载
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setState(major: number, phase: LspEnvInstallState['phase'], message?: string): void {
|
|
88
|
+
installStates.set(major, { phase, message })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notifyListeners(): void {
|
|
92
|
+
for (const listener of listeners) {
|
|
93
|
+
try {
|
|
94
|
+
listener()
|
|
95
|
+
} catch { /* 单个监听失败不影响其它 */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 触发运行时自动配置(幂等:同 major 去重;失败 10 分钟退避;已满足则直接成功回调)。
|
|
101
|
+
* 静默自动路径与设置页一键安装共用;进度经 envInstallStates 暴露。
|
|
102
|
+
* @author ddj 2026年09月03号
|
|
103
|
+
* @param major 运行时大版本
|
|
104
|
+
*/
|
|
105
|
+
export function provisionRuntime(major: number): void {
|
|
106
|
+
if (inFlight.has(major)) return
|
|
107
|
+
const failed = failedAt.get(major)
|
|
108
|
+
if (failed && Date.now() - failed < RETRY_MS) return
|
|
109
|
+
const task = runProvision(major)
|
|
110
|
+
.catch(() => false)
|
|
111
|
+
.then((ok) => {
|
|
112
|
+
inFlight.delete(major)
|
|
113
|
+
if (ok) {
|
|
114
|
+
failedAt.delete(major)
|
|
115
|
+
setState(major, 'done')
|
|
116
|
+
notifyListeners()
|
|
117
|
+
} else {
|
|
118
|
+
failedAt.set(major, Date.now())
|
|
119
|
+
setState(major, 'failed')
|
|
120
|
+
}
|
|
121
|
+
return ok
|
|
122
|
+
})
|
|
123
|
+
inFlight.set(major, task)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 一键安装入口(设置页按钮):校验允许后触发 provisionRuntime。
|
|
128
|
+
* @author ddj 2026年09月03号
|
|
129
|
+
* @param major 运行时大版本
|
|
130
|
+
* @returns 是否成功启动(已满足/进行中/被禁用时返回 false)
|
|
131
|
+
*/
|
|
132
|
+
export function startEnvInstall(major: number): boolean {
|
|
133
|
+
if (dotnetWithRuntime(major)) {
|
|
134
|
+
setState(major, 'done')
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
if (!allowed() || inFlight.has(major)) return false
|
|
138
|
+
const failed = failedAt.get(major)
|
|
139
|
+
if (failed && Date.now() - failed < RETRY_MS && installStates.get(major)?.phase === 'failed') {
|
|
140
|
+
// 退避期内失败态:允许用户手动重试(清除退避标记)
|
|
141
|
+
failedAt.delete(major)
|
|
142
|
+
}
|
|
143
|
+
provisionRuntime(major)
|
|
144
|
+
return inFlight.has(major)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runProvision(major: number): Promise<boolean> {
|
|
148
|
+
if (dotnetWithRuntime(major)) return true
|
|
149
|
+
setState(major, 'downloading')
|
|
150
|
+
const meta = await fetchJson(META_BASE + '/' + major + '.0/releases.json')
|
|
151
|
+
const release = meta?.releases?.[0]
|
|
152
|
+
const files = release?.runtime?.files
|
|
153
|
+
if (!Array.isArray(files)) return false
|
|
154
|
+
const asset = pickRuntimeAsset(files as RuntimeAsset[])
|
|
155
|
+
if (!asset?.url || !asset.hash) return false
|
|
156
|
+
const buf = await downloadVerified(asset.url, asset.hash)
|
|
157
|
+
if (!buf) return false
|
|
158
|
+
setState(major, 'extracting')
|
|
159
|
+
const dir = userDotnetDir()
|
|
160
|
+
const created = !existsSync(dir)
|
|
161
|
+
try {
|
|
162
|
+
const ok = await extractTo(buf, dir)
|
|
163
|
+
if (!ok) throw new Error('解压失败')
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (created) rmSync(dir, { recursive: true, force: true })
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
return dotnetWithRuntime(major) !== null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 拉取 JSON(30s 超时,失败返回 null)。 */
|
|
172
|
+
async function fetchJson(url: string): Promise<{ releases?: { runtime?: { files?: unknown } }[] } | null> {
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
|
|
175
|
+
if (!res.ok) return null
|
|
176
|
+
return await res.json()
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 下载并按官方 sha512 校验(5 分钟超时,不匹配返回 null)。 */
|
|
183
|
+
async function downloadVerified(url: string, sha512: string): Promise<Buffer | null> {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(300000) })
|
|
186
|
+
if (!res.ok) return null
|
|
187
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
188
|
+
const actual = createHash('sha512').update(buf).digest('hex').toLowerCase()
|
|
189
|
+
return actual === sha512.toLowerCase() ? buf : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 解压到目录:win 走 zip(PK 头),unix 落临时包交系统 tar。 */
|
|
196
|
+
async function extractTo(buf: Buffer, dir: string): Promise<boolean> {
|
|
197
|
+
mkdirSync(dir, { recursive: true })
|
|
198
|
+
if (buf.subarray(0, 2).toString('ascii') === 'PK') {
|
|
199
|
+
for (const entry of unzip(buf)) {
|
|
200
|
+
if (!entry.path || entry.path.startsWith('..')) continue
|
|
201
|
+
const parts = entry.path.split(/[\\/]/).filter((p) => p && p !== '..' && p !== '.')
|
|
202
|
+
if (!parts.length) continue
|
|
203
|
+
const target = join(dir, ...parts)
|
|
204
|
+
if (entry.isDirectory) {
|
|
205
|
+
mkdirSync(target, { recursive: true })
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
209
|
+
writeFileSync(target, entry.data)
|
|
210
|
+
}
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
const tmp = join(tmpdir(), 'dsh-dotnet-' + Date.now() + '.tar.gz')
|
|
214
|
+
try {
|
|
215
|
+
writeFileSync(tmp, buf)
|
|
216
|
+
return await new Promise<boolean>((resolve) => {
|
|
217
|
+
const child = spawn('tar', ['-xzf', tmp, '-C', dir], { stdio: 'ignore' })
|
|
218
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
219
|
+
child.on('error', () => resolve(false))
|
|
220
|
+
})
|
|
221
|
+
} finally {
|
|
222
|
+
rmSync(tmp, { force: true })
|
|
223
|
+
}
|
|
224
|
+
}
|