dsh-vscode-mode 0.1.45 → 0.1.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
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) return
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 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
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),
@@ -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
+ }
package/src/lsp/server.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { spawnServer, type Transport } from './transport.js'
8
8
  import { createLspClient, type LspClient } from './client.js'
9
9
  import { pathToFileUri } from './uri.js'
10
+ import { deriveDefinitionFromLocations } from './derive.js'
10
11
  import type { LspProviderSpec } from './providers.js'
11
12
  import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from '../shared/lsp.js'
12
13
  import type {
@@ -64,6 +65,7 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
64
65
  let client: LspClient | null = null
65
66
  let capabilities: LspServerCapabilities = {
66
67
  definition: false,
68
+ declaration: false,
67
69
  references: false,
68
70
  documentSymbol: false,
69
71
  workspaceSymbol: false,
@@ -231,11 +233,55 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
231
233
  async definition(path: string, line: number, character: number): Promise<LspLocation[]> {
232
234
  const doc = docs.get(path)
233
235
  if (!doc || !(await readyWait)) return []
234
- const result = await ensureClient().request<unknown>(
235
- 'textDocument/definition',
236
- { textDocument: { uri: doc.uri }, position: { line, character } },
236
+ const client = ensureClient()
237
+ const position = { line, character }
238
+ // ① 标准定义(方法/跨文件命名空间等)
239
+ const direct = normalizeLocations(
240
+ await client.request<unknown>(
241
+ 'textDocument/definition',
242
+ { textDocument: { uri: doc.uri }, position },
243
+ ),
237
244
  )
238
- return normalizeLocations(result)
245
+ if (direct.length) return direct
246
+ // ② 声明(服务器能力支持时):EmmyLua 对局部变量/参数/表字段 definition 常返回空,declaration 更准
247
+ if (capabilities.declaration) {
248
+ try {
249
+ const decl = normalizeLocations(
250
+ await client.request<unknown>(
251
+ 'textDocument/declaration',
252
+ { textDocument: { uri: doc.uri }, position },
253
+ ),
254
+ )
255
+ if (decl.length) {
256
+ log('definition fallback → declaration (' + path + ':' + line + ':' + character + ')')
257
+ return decl
258
+ }
259
+ } catch (error) {
260
+ log('declaration request failed: ' + String(error))
261
+ }
262
+ }
263
+ // ③ 引用推导:definition/declaration 均空时,从 references(includeDeclaration) 推导声明
264
+ // (EmmyLua 局部/参数把声明排在引用首位;词文本不一致则拒绝,宁可空不误跳)
265
+ try {
266
+ const refs = normalizeLocations(
267
+ await client.request<unknown>(
268
+ 'textDocument/references',
269
+ {
270
+ textDocument: { uri: doc.uri },
271
+ position,
272
+ context: { includeDeclaration: true },
273
+ },
274
+ ),
275
+ )
276
+ const derived = deriveDefinitionFromLocations(position, refs, doc.text, doc.uri)
277
+ if (derived) {
278
+ log('definition fallback → references-derived (' + path + ':' + line + ':' + character + ')')
279
+ return [derived]
280
+ }
281
+ } catch (error) {
282
+ log('references fallback failed: ' + String(error))
283
+ }
284
+ return []
239
285
  },
240
286
 
241
287
  async references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]> {
package/src/shared/lsp.ts CHANGED
@@ -69,9 +69,66 @@ export interface LspSemanticTokens {
69
69
  resultId?: string
70
70
  }
71
71
 
72
+ /** 语义 token 解码后的绝对范围(LSP 0-based;type 为插件固定 legend 与 type 名)。 */
73
+ export interface LspSemanticTokenRange {
74
+ start: LspPosition
75
+ end: LspPosition
76
+ type: string
77
+ modifiers: number
78
+ }
79
+
80
+ /** 语义 token 中可作为"可导航标识符"的类型(ctrl+hover 下划线判定用)。 */
81
+ export const LSP_NAVIGABLE_TOKEN_TYPES: ReadonlySet<string> = new Set([
82
+ 'variable', 'parameter', 'property', 'function', 'method', 'class', 'enum',
83
+ 'struct', 'interface', 'namespace', 'typeParameter', 'enumMember', 'event', 'type',
84
+ ])
85
+
86
+ /**
87
+ * 语义 token 是否属于可导航标识符。
88
+ * @author ddj 2026年09月02号
89
+ * @param type 语义 token 类型名(插件固定 legend)
90
+ * @returns 是否可导航
91
+ */
92
+ export function isNavigableTokenType(type: string): boolean {
93
+ return LSP_NAVIGABLE_TOKEN_TYPES.has(type)
94
+ }
95
+
96
+ /**
97
+ * 解码 LSP delta 编码的语义 token 流为绝对范围数组。
98
+ * @author ddj 2026年09月02号
99
+ * @param data 5 元组 delta 流(host normalizeSemanticData 产物,类型索引对应固定 legend)
100
+ * @param types 固定 legend 类型表(缺省 LSP_SEMANTIC_TOKEN_TYPES)
101
+ * @returns 绝对范围列表(保持文档顺序)
102
+ */
103
+ export function decodeSemanticTokens(
104
+ data: number[],
105
+ types: readonly string[] = LSP_SEMANTIC_TOKEN_TYPES,
106
+ ): LspSemanticTokenRange[] {
107
+ const out: LspSemanticTokenRange[] = []
108
+ let line = 0
109
+ let character = 0
110
+ for (let i = 0; i + 4 < data.length; i += 5) {
111
+ const deltaLine = data[i] ?? 0
112
+ const deltaCharacter = data[i + 1] ?? 0
113
+ const length = Math.max(0, data[i + 2] ?? 0)
114
+ const typeIndex = data[i + 3] ?? 0
115
+ const modifiers = data[i + 4] ?? 0
116
+ line += deltaLine
117
+ character = deltaLine === 0 ? character + deltaCharacter : deltaCharacter
118
+ out.push({
119
+ start: { line, character },
120
+ end: { line, character: character + length },
121
+ type: types[typeIndex] ?? 'variable',
122
+ modifiers,
123
+ })
124
+ }
125
+ return out
126
+ }
127
+
72
128
  /** LSP 服务器能力子集:本插件会用到的方法。 */
73
129
  export interface LspServerCapabilities {
74
130
  definition: boolean
131
+ declaration: boolean
75
132
  references: boolean
76
133
  documentSymbol: boolean
77
134
  workspaceSymbol: boolean