dsh-vscode-mode 0.1.44 → 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/README.md +7 -1
- package/lib/client.js +481 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +111 -8
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/keybindings.ts +41 -15
- package/src/client/monaco/lsp/providers.ts +10 -1
- package/src/client/monaco/lsp/underline.ts +101 -0
- package/src/client/navHistory.ts +104 -0
- package/src/client/styles/editor.css +4 -0
- package/src/client/ui/EditorView.ts +202 -10
- package/src/client/ui/KeybindingsPanel.ts +3 -3
- package/src/client/ui/QuickOpen.ts +2 -2
- package/src/lsp/client.ts +4 -0
- package/src/lsp/derive.ts +96 -0
- package/src/lsp/server.ts +50 -4
- package/src/shared/keybindings.ts +5 -2
- package/src/shared/lsp.ts +57 -0
|
@@ -22,9 +22,11 @@ import { editorHeight } from '../editorLayout.js'
|
|
|
22
22
|
import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
|
|
23
23
|
import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
|
|
24
24
|
import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
|
|
25
|
-
import {
|
|
25
|
+
import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
|
|
26
|
+
import { createNavHistory } from '../navHistory.js'
|
|
26
27
|
import { CACHE_KEY } from '../paths.js'
|
|
27
28
|
import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
|
|
29
|
+
import { bindLspUnderline } from '../monaco/lsp/underline.js'
|
|
28
30
|
import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -82,6 +84,14 @@ export function EditorView(props) {
|
|
|
82
84
|
const programmaticRef = React.useRef(false)
|
|
83
85
|
const bootRef = React.useRef(false)
|
|
84
86
|
const pendingFocusRef = React.useRef(null) // { path, region } 内容加载后跳转
|
|
87
|
+
// 导航历史(后退/前进):跨文件焦点位置;按会话隔离,会话切换重置
|
|
88
|
+
const navRef = React.useRef(null)
|
|
89
|
+
if (!navRef.current) navRef.current = createNavHistory()
|
|
90
|
+
const navPendingRef = React.useRef(null) // { path,line,column,viewState } 待恢复条目(内容就绪后消费)
|
|
91
|
+
const navCursorTimerRef = React.useRef(null) // 光标位置防抖记录计时器
|
|
92
|
+
const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
|
|
93
|
+
const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
|
|
94
|
+
const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
|
|
85
95
|
const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
|
|
86
96
|
const lineRegionMapRef = React.useRef(new Map()) // 行 → 区域 映射(hover 命中)
|
|
87
97
|
const hoverKeyRef = React.useRef(null) // 当前 hover 区域 key(区域不变不重渲染)
|
|
@@ -164,9 +174,97 @@ export function EditorView(props) {
|
|
|
164
174
|
try { ed.restoreViewState(saved) } catch (e) { /* 恢复失败忽略(行号越界自动兜底) */ }
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 采集当前焦点条目:路径从编辑器 model 实时读取(避免空依赖闭包读到过期 active),
|
|
179
|
+
* 位置/viewState 从编辑器读取;编辑器或模型未就绪 → null。
|
|
180
|
+
* @author ddj 2026年09月04号
|
|
181
|
+
* @returns 导航历史条目或 null
|
|
182
|
+
*/
|
|
183
|
+
const captureNavEntry = () => {
|
|
184
|
+
const ed = editorRef.current
|
|
185
|
+
const model = ed?.getModel?.()
|
|
186
|
+
const uriPath = model?.uri?.path
|
|
187
|
+
if (!ed || !uriPath) return null
|
|
188
|
+
let line = null
|
|
189
|
+
let column = null
|
|
190
|
+
try {
|
|
191
|
+
const pos = ed.getPosition?.()
|
|
192
|
+
if (pos) { line = pos.lineNumber; column = pos.column }
|
|
193
|
+
} catch (e) { /* 位置读取失败降级为仅路径 */ }
|
|
194
|
+
let viewState = null
|
|
195
|
+
try { viewState = ed.saveViewState() } catch (e) { /* 快照失败忽略 */ }
|
|
196
|
+
return { path: decodeURIComponent(String(uriPath).replace(/^\//, '')), line, column, viewState }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 记录当前位置到导航历史(同位置去重由历史模块处理;可用性变化时刷新按钮)。
|
|
201
|
+
* @author ddj 2026年09月04号
|
|
202
|
+
*/
|
|
203
|
+
const recordNav = () => {
|
|
204
|
+
const entry = captureNavEntry()
|
|
205
|
+
if (!entry) return
|
|
206
|
+
const wasBack = navRef.current.canBack()
|
|
207
|
+
const wasForward = navRef.current.canForward()
|
|
208
|
+
navRef.current.record(entry)
|
|
209
|
+
if (navRef.current.canBack() !== wasBack || navRef.current.canForward() !== wasForward) {
|
|
210
|
+
setNavTick((v) => v + 1)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 跳到历史条目:保存当前视图状态后打开目标文件,内容就绪后恢复焦点。
|
|
216
|
+
* @author ddj 2026年09月04号
|
|
217
|
+
* @param entry 目标历史条目
|
|
218
|
+
*/
|
|
219
|
+
const navigateTo = (entry) => {
|
|
220
|
+
if (!entry || !entry.path) return
|
|
221
|
+
flushSave()
|
|
222
|
+
saveViewState(active)
|
|
223
|
+
navPendingRef.current = entry
|
|
224
|
+
addTab(entry.path, true)
|
|
225
|
+
setFocusRequest((value) => value + 1) // 目标已是活动文件时也触发恢复
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 立即落盘待记录的光标位置(后退/前进前调用:<500ms 内刚移动的光标也算入历史)。
|
|
230
|
+
* @author ddj 2026年09月04号
|
|
231
|
+
*/
|
|
232
|
+
const flushNavCursor = () => {
|
|
233
|
+
if (!navCursorTimerRef.current) return
|
|
234
|
+
clearTimeout(navCursorTimerRef.current)
|
|
235
|
+
navCursorTimerRef.current = null
|
|
236
|
+
recordNav()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 后退:恢复上一处焦点(跨文件)。
|
|
241
|
+
* @author ddj 2026年09月04号
|
|
242
|
+
*/
|
|
243
|
+
const navBack = () => {
|
|
244
|
+
flushNavCursor()
|
|
245
|
+
const entry = navRef.current.back()
|
|
246
|
+
if (!entry) return
|
|
247
|
+
navigateTo(entry)
|
|
248
|
+
setNavTick((v) => v + 1)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 前进:恢复下一处焦点(跨文件)。
|
|
253
|
+
* @author ddj 2026年09月04号
|
|
254
|
+
*/
|
|
255
|
+
const navForward = () => {
|
|
256
|
+
flushNavCursor()
|
|
257
|
+
const entry = navRef.current.forward()
|
|
258
|
+
if (!entry) return
|
|
259
|
+
navigateTo(entry)
|
|
260
|
+
setNavTick((v) => v + 1)
|
|
261
|
+
}
|
|
262
|
+
navBackRef.current = navBack
|
|
263
|
+
navForwardRef.current = navForward
|
|
264
|
+
|
|
167
265
|
const closeTab = (path) => {
|
|
168
266
|
flushSave()
|
|
169
|
-
if (path === active) saveViewState(path)
|
|
267
|
+
if (path === active) { saveViewState(path); recordNav() }
|
|
170
268
|
setTabs((prev) => {
|
|
171
269
|
const idx = prev.findIndex((t) => t.path === path)
|
|
172
270
|
if (idx < 0) return prev
|
|
@@ -284,6 +382,7 @@ export function EditorView(props) {
|
|
|
284
382
|
const p = e?.detail?.path
|
|
285
383
|
if (!p) return
|
|
286
384
|
if (e?.detail?.focusDiff === true) {
|
|
385
|
+
recordNav()
|
|
287
386
|
pendingFocusRef.current = { path: p, region: null }
|
|
288
387
|
setFocusRequest((value) => value + 1)
|
|
289
388
|
addTab(p, true)
|
|
@@ -294,6 +393,7 @@ export function EditorView(props) {
|
|
|
294
393
|
openFileAt(p, e?.detail?.line, e?.detail?.column)
|
|
295
394
|
return
|
|
296
395
|
}
|
|
396
|
+
recordNav()
|
|
297
397
|
addTab(p, true)
|
|
298
398
|
}
|
|
299
399
|
const onShowLauncher = (event) => {
|
|
@@ -392,6 +492,14 @@ export function EditorView(props) {
|
|
|
392
492
|
} catch (e) { /* 损坏忽略 */ }
|
|
393
493
|
}, [sessionId])
|
|
394
494
|
|
|
495
|
+
// 导航历史会话隔离:切换会话时重置(历史仅会话内存,不跨会话串扰)
|
|
496
|
+
React.useEffect(() => {
|
|
497
|
+
navRef.current = createNavHistory()
|
|
498
|
+
navPendingRef.current = null
|
|
499
|
+
if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
|
|
500
|
+
setNavTick((v) => v + 1)
|
|
501
|
+
}, [sessionId])
|
|
502
|
+
|
|
395
503
|
React.useEffect(() => {
|
|
396
504
|
if (!sessionId) return
|
|
397
505
|
try { localStorage.setItem(CACHE_KEY.editor + String(sessionId), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
|
|
@@ -423,7 +531,7 @@ export function EditorView(props) {
|
|
|
423
531
|
// 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
|
|
424
532
|
React.useEffect(() => {
|
|
425
533
|
const onKey = (e) => {
|
|
426
|
-
if (!matchEvent(e,
|
|
534
|
+
if (!matchEvent(e, bindingsOf('edrv.toggleSidebar'))) return
|
|
427
535
|
e.preventDefault(); e.stopPropagation()
|
|
428
536
|
setSidebarOn((v) => !v)
|
|
429
537
|
}
|
|
@@ -434,7 +542,7 @@ export function EditorView(props) {
|
|
|
434
542
|
// 保存文件(窗口级):编辑器有活动模型才拦截执行,无文件时放行(浏览器默认行为)
|
|
435
543
|
React.useEffect(() => {
|
|
436
544
|
const onKey = (e) => {
|
|
437
|
-
if (!matchEvent(e,
|
|
545
|
+
if (!matchEvent(e, bindingsOf('edrv.save'))) return
|
|
438
546
|
if (!editorRef.current?.getModel?.()) return
|
|
439
547
|
e.preventDefault(); e.stopPropagation()
|
|
440
548
|
flushSave()
|
|
@@ -447,7 +555,7 @@ export function EditorView(props) {
|
|
|
447
555
|
// Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框
|
|
448
556
|
React.useEffect(() => {
|
|
449
557
|
const onKey = (e) => {
|
|
450
|
-
if (!matchEvent(e,
|
|
558
|
+
if (!matchEvent(e, bindingsOf('edrv.searchInFiles'))) return
|
|
451
559
|
e.preventDefault(); e.stopPropagation()
|
|
452
560
|
setSidebarOn(true)
|
|
453
561
|
setActivePanel('search')
|
|
@@ -457,6 +565,49 @@ export function EditorView(props) {
|
|
|
457
565
|
return () => window.removeEventListener('keydown', onKey, true)
|
|
458
566
|
}, [])
|
|
459
567
|
|
|
568
|
+
// 后退/前进:导航历史(跨文件焦点位置;经 ref 读最新闭包,避免空依赖过期)
|
|
569
|
+
React.useEffect(() => {
|
|
570
|
+
const onKey = (e) => {
|
|
571
|
+
if (!matchEvent(e, bindingsOf('edrv.navigateBack'))) return
|
|
572
|
+
e.preventDefault(); e.stopPropagation()
|
|
573
|
+
if (navBackRef.current) navBackRef.current()
|
|
574
|
+
}
|
|
575
|
+
window.addEventListener('keydown', onKey, true)
|
|
576
|
+
return () => window.removeEventListener('keydown', onKey, true)
|
|
577
|
+
}, [])
|
|
578
|
+
React.useEffect(() => {
|
|
579
|
+
const onKey = (e) => {
|
|
580
|
+
if (!matchEvent(e, bindingsOf('edrv.navigateForward'))) return
|
|
581
|
+
e.preventDefault(); e.stopPropagation()
|
|
582
|
+
if (navForwardRef.current) navForwardRef.current()
|
|
583
|
+
}
|
|
584
|
+
window.addEventListener('keydown', onKey, true)
|
|
585
|
+
return () => window.removeEventListener('keydown', onKey, true)
|
|
586
|
+
}, [])
|
|
587
|
+
|
|
588
|
+
// 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
|
|
589
|
+
// pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
|
|
590
|
+
// mousedown 兜底仅取消浏览器历史导航默认动作(MDN:preventDefault mousedown/pointerdown 可抑制)。
|
|
591
|
+
// @author ddj 2026年09月02号
|
|
592
|
+
React.useEffect(() => {
|
|
593
|
+
const onPointer = (e) => {
|
|
594
|
+
if (e?.button !== 3 && e?.button !== 4) return
|
|
595
|
+
e.preventDefault(); e.stopPropagation()
|
|
596
|
+
const ref = e.button === 3 ? navBackRef : navForwardRef
|
|
597
|
+
if (ref.current) ref.current()
|
|
598
|
+
}
|
|
599
|
+
const onMouse = (e) => {
|
|
600
|
+
if (e?.button !== 3 && e?.button !== 4) return
|
|
601
|
+
e.preventDefault(); e.stopPropagation()
|
|
602
|
+
}
|
|
603
|
+
window.addEventListener('pointerdown', onPointer, true)
|
|
604
|
+
window.addEventListener('mousedown', onMouse, true)
|
|
605
|
+
return () => {
|
|
606
|
+
window.removeEventListener('pointerdown', onPointer, true)
|
|
607
|
+
window.removeEventListener('mousedown', onMouse, true)
|
|
608
|
+
}
|
|
609
|
+
}, [])
|
|
610
|
+
|
|
460
611
|
// 文件 → 待处理差异数 映射(侧边栏角标)
|
|
461
612
|
const pendingByPath = React.useMemo(() => {
|
|
462
613
|
const out = {}
|
|
@@ -629,6 +780,12 @@ export function EditorView(props) {
|
|
|
629
780
|
})
|
|
630
781
|
ed.onDidChangeCursorPosition((e) => {
|
|
631
782
|
setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
|
|
783
|
+
// 导航历史:同文件内光标移动防抖记录(程序化恢复触发的位置与栈顶去重,无副作用)
|
|
784
|
+
if (navCursorTimerRef.current) clearTimeout(navCursorTimerRef.current)
|
|
785
|
+
navCursorTimerRef.current = setTimeout(() => {
|
|
786
|
+
navCursorTimerRef.current = null
|
|
787
|
+
recordNav()
|
|
788
|
+
}, 500)
|
|
632
789
|
})
|
|
633
790
|
// 编辑区右键 → 注入 Monaco 原生 context menu(与 Go to Definition 等同菜单,避免分离浮层)。
|
|
634
791
|
// 有选区时额外显示「选中内容」项:context key edrvSelection 由光标选区变化驱动。
|
|
@@ -685,6 +842,7 @@ export function EditorView(props) {
|
|
|
685
842
|
})
|
|
686
843
|
ed.addCommand(m.KeyCode.F12, () => { void runGoToDefinition(ed) })
|
|
687
844
|
bindLspEditor(ed)
|
|
845
|
+
bindLspUnderline(ed, m)
|
|
688
846
|
// hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
|
|
689
847
|
// 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
|
|
690
848
|
// ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
|
|
@@ -720,12 +878,26 @@ export function EditorView(props) {
|
|
|
720
878
|
editorRef.current = ed
|
|
721
879
|
}, [])
|
|
722
880
|
|
|
723
|
-
//
|
|
881
|
+
// 打开文件后的跳转(导航历史恢复/差异聚焦/搜索行列跳转,内容就绪后执行一次)
|
|
724
882
|
React.useEffect(() => {
|
|
725
|
-
const pf = pendingFocusRef.current
|
|
726
|
-
if (!pf || pf.path !== active || content === null) return
|
|
727
883
|
const ed = editorRef.current
|
|
728
|
-
if (!ed) return
|
|
884
|
+
if (!ed || content === null) return
|
|
885
|
+
// 导航历史恢复优先:viewState 快照恢复滚动/折叠,行列补定位
|
|
886
|
+
const nav = navPendingRef.current
|
|
887
|
+
if (nav && nav.path === active) {
|
|
888
|
+
navPendingRef.current = null
|
|
889
|
+
if (nav.viewState) {
|
|
890
|
+
try { ed.restoreViewState(nav.viewState) } catch (e) { /* 非法快照忽略(行号越界自动兜底) */ }
|
|
891
|
+
}
|
|
892
|
+
if (nav.line != null) {
|
|
893
|
+
ed.revealLineInCenter(Math.max(1, nav.line))
|
|
894
|
+
ed.setPosition({ lineNumber: Math.max(1, nav.line), column: Math.max(1, nav.column ?? 1) })
|
|
895
|
+
}
|
|
896
|
+
ed.focus()
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
const pf = pendingFocusRef.current
|
|
900
|
+
if (!pf || pf.path !== active) return
|
|
729
901
|
if (pf.line != null) {
|
|
730
902
|
// 搜索命中跳转:直接定位到行/列(不依赖差异区域)
|
|
731
903
|
pendingFocusRef.current = null
|
|
@@ -982,6 +1154,7 @@ export function EditorView(props) {
|
|
|
982
1154
|
|
|
983
1155
|
const openFile = (path, focusDiff) => {
|
|
984
1156
|
if (!path) return
|
|
1157
|
+
recordNav()
|
|
985
1158
|
addTab(path, true)
|
|
986
1159
|
if (focusDiff) pendingFocusRef.current = { path, region: null }
|
|
987
1160
|
}
|
|
@@ -995,6 +1168,7 @@ export function EditorView(props) {
|
|
|
995
1168
|
*/
|
|
996
1169
|
const openFileAt = (path, line, column) => {
|
|
997
1170
|
if (!path) return
|
|
1171
|
+
recordNav()
|
|
998
1172
|
addTab(path, true)
|
|
999
1173
|
pendingFocusRef.current = { path, region: null, line: line ?? null, column: column ?? 1 }
|
|
1000
1174
|
setFocusRequest((value) => value + 1)
|
|
@@ -1017,7 +1191,7 @@ export function EditorView(props) {
|
|
|
1017
1191
|
key: t.path,
|
|
1018
1192
|
className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
|
|
1019
1193
|
title: t.path,
|
|
1020
|
-
onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); setActive(t.path) } },
|
|
1194
|
+
onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); recordNav(); setActive(t.path) } },
|
|
1021
1195
|
onContextMenu: (e) => {
|
|
1022
1196
|
e.preventDefault()
|
|
1023
1197
|
e.stopPropagation()
|
|
@@ -1054,11 +1228,29 @@ export function EditorView(props) {
|
|
|
1054
1228
|
lspProgress !== null ? React.createElement('span', { className: 'edrv-sp-progress' }, lspProgress + '%') : null)
|
|
1055
1229
|
: null
|
|
1056
1230
|
|
|
1231
|
+
// 导航历史按钮:目标条目名(tooltip 提示下一步会回到哪个文件)
|
|
1232
|
+
const navBackTarget = navRef.current.peekBack()
|
|
1233
|
+
const navForwardTarget = navRef.current.peekForward()
|
|
1234
|
+
const navNameOf = (entry) => (entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : '')
|
|
1057
1235
|
const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
|
|
1058
1236
|
tabsEl,
|
|
1059
1237
|
(sum.totalFiles > 0
|
|
1060
1238
|
? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
|
|
1061
1239
|
: null),
|
|
1240
|
+
React.createElement('button', {
|
|
1241
|
+
className: 'edrv-chip-btn',
|
|
1242
|
+
title: '后退(' + (chordOf('edrv.navigateBack') ?? '未绑定') + ')' + (navBackTarget ? ' · ' + navNameOf(navBackTarget) : ''),
|
|
1243
|
+
disabled: !navRef.current.canBack(),
|
|
1244
|
+
'aria-label': '后退',
|
|
1245
|
+
onClick: navBack,
|
|
1246
|
+
}, '←'),
|
|
1247
|
+
React.createElement('button', {
|
|
1248
|
+
className: 'edrv-chip-btn',
|
|
1249
|
+
title: '前进(' + (chordOf('edrv.navigateForward') ?? '未绑定') + ')' + (navForwardTarget ? ' · ' + navNameOf(navForwardTarget) : ''),
|
|
1250
|
+
disabled: !navRef.current.canForward(),
|
|
1251
|
+
'aria-label': '前进',
|
|
1252
|
+
onClick: navForward,
|
|
1253
|
+
}, '→'),
|
|
1062
1254
|
React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (' + (chordOf('edrv.toggleSidebar') ?? 'Ctrl+B') + ')', onClick: () => setSidebarOn((v) => !v) }, '☰'),
|
|
1063
1255
|
React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
|
|
1064
1256
|
React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import React from 'react'
|
|
11
11
|
import { SettingsContext } from '../settingsContext.js'
|
|
12
|
-
import { COMMANDS, chordFromEvent,
|
|
12
|
+
import { COMMANDS, chordFromEvent, parseChords } from '../keybindings.js'
|
|
13
13
|
import { defaultKeybindings, KEYBINDING_DEFAULTS, normalizeKeybindings } from '../../shared/keybindings.js'
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -148,7 +148,7 @@ export function KeybindingsPanel() {
|
|
|
148
148
|
const rows = COMMANDS.map((command) => {
|
|
149
149
|
const chord = draft[command.id] ?? null
|
|
150
150
|
const conflict = chord !== null && conflicts.has(command.id)
|
|
151
|
-
const invalid = chord !== null &&
|
|
151
|
+
const invalid = chord !== null && parseChords(chord).length === 0
|
|
152
152
|
const recordingRow = recording === command.id
|
|
153
153
|
let display
|
|
154
154
|
if (recordingRow) {
|
|
@@ -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, '配置编辑器视图的快捷键(保存 / 快速打开 / 侧边栏 / 搜索 / 后退 / 前进)。修改即时写入配置,重新聚焦编辑器后生效。'),
|
|
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),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import React from 'react'
|
|
9
9
|
import { rpc } from '../rpc.js'
|
|
10
|
-
import {
|
|
10
|
+
import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* 快速打开搜索框:Ctrl+P 聚焦,输入 ≥2 字符经 edrv.searchFiles 搜索,
|
|
@@ -50,7 +50,7 @@ export function QuickOpen(props) {
|
|
|
50
50
|
|
|
51
51
|
React.useEffect(() => {
|
|
52
52
|
const onKey = (e) => {
|
|
53
|
-
if (matchEvent(e,
|
|
53
|
+
if (matchEvent(e, bindingsOf('edrv.quickOpen'))) {
|
|
54
54
|
e.preventDefault(); e.stopPropagation()
|
|
55
55
|
inputRef.current?.focus?.()
|
|
56
56
|
setOpen(true)
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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[]> {
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode shared — 快捷键命令目录与默认键位(双面契约)。
|
|
3
3
|
* 纯数据模块:host(settings schema 默认值)与 client(执行匹配/设置页)共用。
|
|
4
|
-
* 键位格式:修饰符 + 主键,`+` 连接,如 `Ctrl+Shift+F`;空串 =
|
|
4
|
+
* 键位格式:修饰符 + 主键,`+` 连接,如 `Ctrl+Shift+F`;空串 = 未绑定;
|
|
5
|
+
* `|` 连接多个候选(任一命中即触发),如 `Alt+ArrowLeft|Ctrl+Alt+-`。
|
|
5
6
|
* 作者 ddj 2026年08月26号
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
/** 命令 id →
|
|
9
|
+
/** 命令 id → 默认键位(可为多候选)。命令目录以此为准,新增命令只需加一项。 */
|
|
9
10
|
export const KEYBINDING_DEFAULTS: Record<string, string> = {
|
|
10
11
|
'edrv.save': 'Ctrl+S',
|
|
11
12
|
'edrv.quickOpen': 'Ctrl+P',
|
|
12
13
|
'edrv.toggleSidebar': 'Ctrl+B',
|
|
13
14
|
'edrv.searchInFiles': 'Ctrl+Shift+F',
|
|
15
|
+
'edrv.navigateBack': 'Alt+ArrowLeft|Ctrl+Alt+-',
|
|
16
|
+
'edrv.navigateForward': 'Alt+ArrowRight|Ctrl+Shift+-',
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/**
|
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
|