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.
@@ -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 { bindingOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
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, bindingOf('edrv.toggleSidebar'))) return
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, bindingOf('edrv.save'))) return
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, bindingOf('edrv.searchInFiles'))) return
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, parseChord } from '../keybindings.js'
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 && parseChord(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 { bindingOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
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, bindingOf('edrv.quickOpen'))) {
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 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[]> {
@@ -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