dsh-vscode-mode 0.1.46 → 0.1.48
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/lib/client.js +260 -179
- package/lib/client.js.map +1 -1
- package/lib/index.js +485 -29
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +32 -19
- package/src/client/sidebar/menuItems.ts +23 -2
- package/src/client/sidebar/panels/FileExplorer.ts +2 -4
- package/src/client/sidebar/types.ts +3 -0
- package/src/client/ui/EditorView.ts +3 -9
- package/src/client/ui/LspSettings.ts +83 -5
- package/src/client/ui/menuPosition.ts +2 -28
- package/src/lsp/config.ts +5 -1
- 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 +8 -1
- package/src/shared/lsp.ts +18 -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.48",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -15,6 +15,21 @@ export interface RefRange {
|
|
|
15
15
|
/** 引用动作的返回状态。 */
|
|
16
16
|
export type AddOutcome = 'ok' | 'busy' | 'unavailable'
|
|
17
17
|
|
|
18
|
+
/** 引用外观类型:文件/文件夹(DSH reference source 均支持)。 */
|
|
19
|
+
export type RefAppearance = 'file' | 'folder'
|
|
20
|
+
|
|
21
|
+
/** 把追加引用结果映射为可读文案(ok 用 okText;busy 提示已降级纯文本;unavailable 提示不可用)。
|
|
22
|
+
* @author ddj 2026年09月03号
|
|
23
|
+
* @param outcome 追加结果状态
|
|
24
|
+
* @param okText 成功文案(如「已添加文件引用」/「已添加文件夹引用」)
|
|
25
|
+
* @returns 状态栏/通知用文案
|
|
26
|
+
*/
|
|
27
|
+
export function statusOfAdd(outcome: AddOutcome, okText: string): string {
|
|
28
|
+
if (outcome === 'ok') return okText
|
|
29
|
+
if (outcome === 'busy') return okText + '(输入框忙,已降级纯文本)'
|
|
30
|
+
return '无法添加到对话(无会话或输入框不可用)'
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
/** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
|
|
19
34
|
const REF_SOURCE = 'reference'
|
|
20
35
|
|
|
@@ -61,11 +76,17 @@ export function refOf(mention: string, range: RefRange | undefined): string {
|
|
|
61
76
|
: `${mention} L${range.startLine}-${range.endLine}`
|
|
62
77
|
}
|
|
63
78
|
|
|
64
|
-
/** 构造 DSH 文件引用插入载荷(source=reference,发送序列化为 mention(+range) 文本)。
|
|
79
|
+
/** 构造 DSH 文件引用插入载荷(source=reference,发送序列化为 mention(+range) 文本)。
|
|
80
|
+
* @param path 目标路径(相对 cwd 或绝对)
|
|
81
|
+
* @param cwd 会话工作区目录(可选)
|
|
82
|
+
* @param range 行区间(可选)
|
|
83
|
+
* @param appearance 引用外观:'file'(默认)| 'folder'
|
|
84
|
+
*/
|
|
65
85
|
export function buildFileRef(
|
|
66
86
|
path: string,
|
|
67
87
|
cwd: string | undefined,
|
|
68
88
|
range?: RefRange,
|
|
89
|
+
appearance: RefAppearance = 'file',
|
|
69
90
|
): { reference: ReferenceInsertLike; mention: string } {
|
|
70
91
|
const mention = mentionOf(path, cwd)
|
|
71
92
|
return {
|
|
@@ -74,7 +95,7 @@ export function buildFileRef(
|
|
|
74
95
|
source: REF_SOURCE,
|
|
75
96
|
ref: refOf(mention, range),
|
|
76
97
|
label: labelOf(path, range),
|
|
77
|
-
appearance
|
|
98
|
+
appearance,
|
|
78
99
|
clipboardText: mention,
|
|
79
100
|
},
|
|
80
101
|
}
|
|
@@ -85,7 +106,6 @@ export interface InputLike {
|
|
|
85
106
|
state: { getSnapshot: () => { draft: string; draftRev: number } }
|
|
86
107
|
insertReference: (ref: ReferenceInsertLike, span: TokenSpanLike) => boolean
|
|
87
108
|
setDraft: (text: string) => void
|
|
88
|
-
notify: (level: 'info' | 'error', text: string) => void
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
/** ReferenceInsert 结构形状(与 dsh-client-ui-input-trigger 类型一致)。 */
|
|
@@ -111,8 +131,13 @@ export interface CtxLike {
|
|
|
111
131
|
|
|
112
132
|
/** 注入器产物:给 EditorView 用的动作集合。 */
|
|
113
133
|
export interface AddToConversation {
|
|
114
|
-
/**
|
|
115
|
-
appendReference(
|
|
134
|
+
/** 追加文件/文件夹引用 chip(@path [Lstart-end]);忙态自动降级纯文本。 */
|
|
135
|
+
appendReference(
|
|
136
|
+
sessionId: string | undefined,
|
|
137
|
+
path: string,
|
|
138
|
+
range?: RefRange,
|
|
139
|
+
appearance?: RefAppearance,
|
|
140
|
+
): Promise<AddOutcome>
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
/**
|
|
@@ -161,16 +186,6 @@ function cwdOf(ctx: CtxLike, sessionId: string | undefined): string | undefined
|
|
|
161
186
|
}
|
|
162
187
|
}
|
|
163
188
|
|
|
164
|
-
/** 写入成功后的 composer 内联提示(门面可用时;失败静默)。
|
|
165
|
-
* @author ddj 2026年08月25号 */
|
|
166
|
-
function notify(input: InputLike, text: string): void {
|
|
167
|
-
try {
|
|
168
|
-
input.notify?.('info', text)
|
|
169
|
-
} catch {
|
|
170
|
-
/* 提示失败忽略 */
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
189
|
/** 输入门面插入引用(拦截异常视作未应用,走降级)。
|
|
175
190
|
* @author ddj 2026年08月25号 */
|
|
176
191
|
function safeInsert(input: InputLike, reference: ReferenceInsertLike, span: TokenSpanLike): boolean {
|
|
@@ -188,22 +203,20 @@ function safeInsert(input: InputLike, reference: ReferenceInsertLike, span: Toke
|
|
|
188
203
|
* @returns 动作集
|
|
189
204
|
*/
|
|
190
205
|
export function createAddToConversation(ctx: CtxLike): AddToConversation {
|
|
191
|
-
const appendReference: AddToConversation['appendReference'] = async (sessionId, path, range) => {
|
|
206
|
+
const appendReference: AddToConversation['appendReference'] = async (sessionId, path, range, appearance) => {
|
|
192
207
|
const input = inputFor(ctx, sessionId)
|
|
193
208
|
if (!input) return 'unavailable'
|
|
194
209
|
const cur = draftCursor(input)
|
|
195
210
|
if (!cur) return 'unavailable'
|
|
196
|
-
const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range)
|
|
211
|
+
const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range, appearance)
|
|
197
212
|
const span: TokenSpanLike = { start: cur.draft.length, end: cur.draft.length, draftRev: cur.draftRev }
|
|
198
213
|
const ok = safeInsert(input, reference, span)
|
|
199
214
|
if (!ok) {
|
|
200
215
|
// 忙态(adjudicating/submitting)或 CAS 失败:降级纯文本追加,保证动作有落点。
|
|
201
216
|
const gap = cur.draft.length > 0 && !/\s$/.test(cur.draft) ? ' ' : ''
|
|
202
217
|
input.setDraft(cur.draft + gap + mention + ' ')
|
|
203
|
-
notify(input, '已添加文件引用 ' + mention)
|
|
204
218
|
return 'busy'
|
|
205
219
|
}
|
|
206
|
-
notify(input, '已添加文件引用 ' + mention)
|
|
207
220
|
return 'ok'
|
|
208
221
|
}
|
|
209
222
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 文件管理右键菜单内置项。
|
|
3
|
-
*
|
|
3
|
+
* 内置项:「在文件浏览器中打开」(文件→OS Explorer 定位选中、目录→打开目录);
|
|
4
|
+
* 「添加引用到对话」(文件/文件夹引用注入当前会话对话输入框)。
|
|
4
5
|
* 反馈统一走 ctx.notify(由 EditorView 提供,落到编辑区路径栏状态)。
|
|
5
|
-
* 作者 ddj 2026-08-27
|
|
6
|
+
* 作者 ddj 2026-08-27 / 2026-09-03
|
|
6
7
|
*/
|
|
7
8
|
import { revealInExplorer } from '../fileReveal.js'
|
|
9
|
+
import { statusOfAdd } from '../addToConversation.js'
|
|
8
10
|
import type { TreeMenuItem } from './contextMenu.js'
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -24,5 +26,24 @@ export function createDefaultFileMenuItems(): TreeMenuItem[] {
|
|
|
24
26
|
})
|
|
25
27
|
},
|
|
26
28
|
},
|
|
29
|
+
{
|
|
30
|
+
id: 'add-to-conversation',
|
|
31
|
+
label: '添加引用到对话',
|
|
32
|
+
order: 1,
|
|
33
|
+
// 排除根目录空白区(path==='' 无意义)、无会话、动作集缺失时隐藏
|
|
34
|
+
visible: (target, ctx) => Boolean(target.path && ctx.sessionId && ctx.addToConversation),
|
|
35
|
+
run: (target, ctx) => {
|
|
36
|
+
const isDir = target.type === 'directory'
|
|
37
|
+
const add = ctx.addToConversation
|
|
38
|
+
if (!add) {
|
|
39
|
+
ctx.notify?.('添加到对话不可用')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const okText = isDir ? '已添加文件夹引用' : '已添加文件引用'
|
|
43
|
+
void add.appendReference(ctx.sessionId, target.path, undefined, isDir ? 'folder' : 'file').then((outcome) => {
|
|
44
|
+
ctx.notify?.(statusOfAdd(outcome, okText))
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
},
|
|
27
48
|
]
|
|
28
49
|
}
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import React from 'react'
|
|
12
12
|
import { rpc } from '../../rpc.js'
|
|
13
13
|
import { ContextMenu } from '../../ui/ContextMenu.js'
|
|
14
|
-
import { rowMenuPosition } from '../../ui/menuPosition.js'
|
|
15
14
|
import { buildTreeMenu } from '../contextMenu.js'
|
|
16
15
|
import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
|
|
17
16
|
import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
|
|
@@ -219,9 +218,8 @@ export function FileExplorer(props) {
|
|
|
219
218
|
onContextMenu: (ev) => {
|
|
220
219
|
ev.preventDefault()
|
|
221
220
|
ev.stopPropagation()
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
setMenu({ x: position.left, y: position.top, target: { path: e.path, type: e.type } })
|
|
221
|
+
// 菜单左上角对齐鼠标点击位置(视口坐标,ContextMenu 内再做 viewport clamp)
|
|
222
|
+
setMenu({ x: ev.clientX, y: ev.clientY, target: { path: e.path, type: e.type } })
|
|
225
223
|
},
|
|
226
224
|
},
|
|
227
225
|
React.createElement('span', { className: 'edrv-tree-chev' },
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 作者 ddj 2026-08-26
|
|
5
5
|
*/
|
|
6
6
|
import type { OutlineSourceRegistry } from '../outline/types.js'
|
|
7
|
+
import type { AddToConversation } from '../addToConversation.js'
|
|
7
8
|
import type { TreeMenuRegistry } from './contextMenu.js'
|
|
8
9
|
|
|
9
10
|
/** 面板可用的共享上下文(由 SidebarView 从 EditorView 注入,面板不直碰其内部)。 */
|
|
@@ -25,6 +26,8 @@ export interface SidebarCtx {
|
|
|
25
26
|
outlineSources?: OutlineSourceRegistry
|
|
26
27
|
/** 文件右键菜单项注册表(公开 provide 为 edrvFileContextMenuItems;文件管理面板构建菜单用)。 */
|
|
27
28
|
fileMenuItems?: TreeMenuRegistry
|
|
29
|
+
/** 「添加到对话」动作集(文件/文件夹引用注入对话输入框;缺省时菜单项降级提示)。 */
|
|
30
|
+
addToConversation?: AddToConversation
|
|
28
31
|
/** 面板动作反馈(如右键菜单操作结果 → 编辑区路径栏状态)。 */
|
|
29
32
|
notify?: (message: string) => void
|
|
30
33
|
}
|
|
@@ -24,6 +24,7 @@ import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
|
|
|
24
24
|
import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
|
|
25
25
|
import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
|
|
26
26
|
import { createNavHistory } from '../navHistory.js'
|
|
27
|
+
import { statusOfAdd } from '../addToConversation.js'
|
|
27
28
|
import { CACHE_KEY } from '../paths.js'
|
|
28
29
|
import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
|
|
29
30
|
import { bindLspUnderline } from '../monaco/lsp/underline.js'
|
|
@@ -1119,15 +1120,7 @@ export function EditorView(props) {
|
|
|
1119
1120
|
setTabMenu(null)
|
|
1120
1121
|
}
|
|
1121
1122
|
|
|
1122
|
-
/**
|
|
1123
|
-
* @author ddj 2026年08月25号 */
|
|
1124
|
-
const statusOfAdd = (outcome, okText) => {
|
|
1125
|
-
if (outcome === 'ok') return okText
|
|
1126
|
-
if (outcome === 'busy') return okText + '(输入框忙,已降级纯文本)'
|
|
1127
|
-
return '无法添加到对话(无会话或输入框不可用)'
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
/** 添加文件/选中区引用到对话(异步,状态栏反馈)。 */
|
|
1123
|
+
/** 添加文件/选中区引用到对话(异步,状态栏反馈;状态文案走共享 statusOfAdd)。 */
|
|
1131
1124
|
const addRefToChat = (path, range) => {
|
|
1132
1125
|
if (!path) { setStatus('无活动文件'); return }
|
|
1133
1126
|
if (!addToConversation) { setStatus('添加到对话不可用'); return }
|
|
@@ -1406,6 +1399,7 @@ export function EditorView(props) {
|
|
|
1406
1399
|
editor: () => editorRef.current,
|
|
1407
1400
|
outlineSources: props.outlineSources,
|
|
1408
1401
|
fileMenuItems: props.fileMenuItems,
|
|
1402
|
+
addToConversation,
|
|
1409
1403
|
notify: (message) => setStatus(message),
|
|
1410
1404
|
}
|
|
1411
1405
|
|
|
@@ -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') {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 右键菜单 viewport 定位纯函数。
|
|
3
|
-
*
|
|
4
|
-
* 作者 ddj 2026-08-28
|
|
3
|
+
* 将视口边界 clamp 从 React 渲染层拆出,便于复用和单测。
|
|
4
|
+
* 作者 ddj 2026-08-28 / 2026-09-03
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export interface MenuPosition {
|
|
@@ -9,13 +9,6 @@ export interface MenuPosition {
|
|
|
9
9
|
top: number
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface MenuAnchorRect {
|
|
13
|
-
top: number
|
|
14
|
-
right: number
|
|
15
|
-
width: number
|
|
16
|
-
height: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
/**
|
|
20
13
|
* 将菜单坐标限制在 viewport 内。
|
|
21
14
|
* @author ddj 2026年08月28号
|
|
@@ -42,22 +35,3 @@ export function clampMenuPosition(
|
|
|
42
35
|
top: Math.max(4, Math.min(y, maxTop)),
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 将文件树行右键菜单锚定到目标行右侧;无法测量时回退到鼠标坐标。
|
|
48
|
-
* @author ddj 2026年08月28号
|
|
49
|
-
* @param rect 目标行的布局矩形
|
|
50
|
-
* @param fallbackX 鼠标 viewport x 坐标
|
|
51
|
-
* @param fallbackY 鼠标 viewport y 坐标
|
|
52
|
-
* @returns 菜单初始位置
|
|
53
|
-
*/
|
|
54
|
-
export function rowMenuPosition(
|
|
55
|
-
rect: MenuAnchorRect | null | undefined,
|
|
56
|
-
fallbackX: number,
|
|
57
|
-
fallbackY: number,
|
|
58
|
-
): MenuPosition {
|
|
59
|
-
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
60
|
-
return { left: fallbackX, top: fallbackY }
|
|
61
|
-
}
|
|
62
|
-
return { left: rect.right + 4, top: rect.top }
|
|
63
|
-
}
|
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,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
|
+
}
|