dsh-vscode-mode 0.1.43 → 0.1.45

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,7 +22,8 @@ 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'
28
29
  import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
@@ -82,6 +83,14 @@ export function EditorView(props) {
82
83
  const programmaticRef = React.useRef(false)
83
84
  const bootRef = React.useRef(false)
84
85
  const pendingFocusRef = React.useRef(null) // { path, region } 内容加载后跳转
86
+ // 导航历史(后退/前进):跨文件焦点位置;按会话隔离,会话切换重置
87
+ const navRef = React.useRef(null)
88
+ if (!navRef.current) navRef.current = createNavHistory()
89
+ const navPendingRef = React.useRef(null) // { path,line,column,viewState } 待恢复条目(内容就绪后消费)
90
+ const navCursorTimerRef = React.useRef(null) // 光标位置防抖记录计时器
91
+ const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
92
+ const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
93
+ const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
85
94
  const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
86
95
  const lineRegionMapRef = React.useRef(new Map()) // 行 → 区域 映射(hover 命中)
87
96
  const hoverKeyRef = React.useRef(null) // 当前 hover 区域 key(区域不变不重渲染)
@@ -164,9 +173,97 @@ export function EditorView(props) {
164
173
  try { ed.restoreViewState(saved) } catch (e) { /* 恢复失败忽略(行号越界自动兜底) */ }
165
174
  }
166
175
 
176
+ /**
177
+ * 采集当前焦点条目:路径从编辑器 model 实时读取(避免空依赖闭包读到过期 active),
178
+ * 位置/viewState 从编辑器读取;编辑器或模型未就绪 → null。
179
+ * @author ddj 2026年09月04号
180
+ * @returns 导航历史条目或 null
181
+ */
182
+ const captureNavEntry = () => {
183
+ const ed = editorRef.current
184
+ const model = ed?.getModel?.()
185
+ const uriPath = model?.uri?.path
186
+ if (!ed || !uriPath) return null
187
+ let line = null
188
+ let column = null
189
+ try {
190
+ const pos = ed.getPosition?.()
191
+ if (pos) { line = pos.lineNumber; column = pos.column }
192
+ } catch (e) { /* 位置读取失败降级为仅路径 */ }
193
+ let viewState = null
194
+ try { viewState = ed.saveViewState() } catch (e) { /* 快照失败忽略 */ }
195
+ return { path: decodeURIComponent(String(uriPath).replace(/^\//, '')), line, column, viewState }
196
+ }
197
+
198
+ /**
199
+ * 记录当前位置到导航历史(同位置去重由历史模块处理;可用性变化时刷新按钮)。
200
+ * @author ddj 2026年09月04号
201
+ */
202
+ const recordNav = () => {
203
+ const entry = captureNavEntry()
204
+ if (!entry) return
205
+ const wasBack = navRef.current.canBack()
206
+ const wasForward = navRef.current.canForward()
207
+ navRef.current.record(entry)
208
+ if (navRef.current.canBack() !== wasBack || navRef.current.canForward() !== wasForward) {
209
+ setNavTick((v) => v + 1)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 跳到历史条目:保存当前视图状态后打开目标文件,内容就绪后恢复焦点。
215
+ * @author ddj 2026年09月04号
216
+ * @param entry 目标历史条目
217
+ */
218
+ const navigateTo = (entry) => {
219
+ if (!entry || !entry.path) return
220
+ flushSave()
221
+ saveViewState(active)
222
+ navPendingRef.current = entry
223
+ addTab(entry.path, true)
224
+ setFocusRequest((value) => value + 1) // 目标已是活动文件时也触发恢复
225
+ }
226
+
227
+ /**
228
+ * 立即落盘待记录的光标位置(后退/前进前调用:<500ms 内刚移动的光标也算入历史)。
229
+ * @author ddj 2026年09月04号
230
+ */
231
+ const flushNavCursor = () => {
232
+ if (!navCursorTimerRef.current) return
233
+ clearTimeout(navCursorTimerRef.current)
234
+ navCursorTimerRef.current = null
235
+ recordNav()
236
+ }
237
+
238
+ /**
239
+ * 后退:恢复上一处焦点(跨文件)。
240
+ * @author ddj 2026年09月04号
241
+ */
242
+ const navBack = () => {
243
+ flushNavCursor()
244
+ const entry = navRef.current.back()
245
+ if (!entry) return
246
+ navigateTo(entry)
247
+ setNavTick((v) => v + 1)
248
+ }
249
+
250
+ /**
251
+ * 前进:恢复下一处焦点(跨文件)。
252
+ * @author ddj 2026年09月04号
253
+ */
254
+ const navForward = () => {
255
+ flushNavCursor()
256
+ const entry = navRef.current.forward()
257
+ if (!entry) return
258
+ navigateTo(entry)
259
+ setNavTick((v) => v + 1)
260
+ }
261
+ navBackRef.current = navBack
262
+ navForwardRef.current = navForward
263
+
167
264
  const closeTab = (path) => {
168
265
  flushSave()
169
- if (path === active) saveViewState(path)
266
+ if (path === active) { saveViewState(path); recordNav() }
170
267
  setTabs((prev) => {
171
268
  const idx = prev.findIndex((t) => t.path === path)
172
269
  if (idx < 0) return prev
@@ -284,6 +381,7 @@ export function EditorView(props) {
284
381
  const p = e?.detail?.path
285
382
  if (!p) return
286
383
  if (e?.detail?.focusDiff === true) {
384
+ recordNav()
287
385
  pendingFocusRef.current = { path: p, region: null }
288
386
  setFocusRequest((value) => value + 1)
289
387
  addTab(p, true)
@@ -294,6 +392,7 @@ export function EditorView(props) {
294
392
  openFileAt(p, e?.detail?.line, e?.detail?.column)
295
393
  return
296
394
  }
395
+ recordNav()
297
396
  addTab(p, true)
298
397
  }
299
398
  const onShowLauncher = (event) => {
@@ -392,6 +491,14 @@ export function EditorView(props) {
392
491
  } catch (e) { /* 损坏忽略 */ }
393
492
  }, [sessionId])
394
493
 
494
+ // 导航历史会话隔离:切换会话时重置(历史仅会话内存,不跨会话串扰)
495
+ React.useEffect(() => {
496
+ navRef.current = createNavHistory()
497
+ navPendingRef.current = null
498
+ if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
499
+ setNavTick((v) => v + 1)
500
+ }, [sessionId])
501
+
395
502
  React.useEffect(() => {
396
503
  if (!sessionId) return
397
504
  try { localStorage.setItem(CACHE_KEY.editor + String(sessionId), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
@@ -423,7 +530,7 @@ export function EditorView(props) {
423
530
  // 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
424
531
  React.useEffect(() => {
425
532
  const onKey = (e) => {
426
- if (!matchEvent(e, bindingOf('edrv.toggleSidebar'))) return
533
+ if (!matchEvent(e, bindingsOf('edrv.toggleSidebar'))) return
427
534
  e.preventDefault(); e.stopPropagation()
428
535
  setSidebarOn((v) => !v)
429
536
  }
@@ -434,7 +541,7 @@ export function EditorView(props) {
434
541
  // 保存文件(窗口级):编辑器有活动模型才拦截执行,无文件时放行(浏览器默认行为)
435
542
  React.useEffect(() => {
436
543
  const onKey = (e) => {
437
- if (!matchEvent(e, bindingOf('edrv.save'))) return
544
+ if (!matchEvent(e, bindingsOf('edrv.save'))) return
438
545
  if (!editorRef.current?.getModel?.()) return
439
546
  e.preventDefault(); e.stopPropagation()
440
547
  flushSave()
@@ -447,7 +554,7 @@ export function EditorView(props) {
447
554
  // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框
448
555
  React.useEffect(() => {
449
556
  const onKey = (e) => {
450
- if (!matchEvent(e, bindingOf('edrv.searchInFiles'))) return
557
+ if (!matchEvent(e, bindingsOf('edrv.searchInFiles'))) return
451
558
  e.preventDefault(); e.stopPropagation()
452
559
  setSidebarOn(true)
453
560
  setActivePanel('search')
@@ -457,6 +564,49 @@ export function EditorView(props) {
457
564
  return () => window.removeEventListener('keydown', onKey, true)
458
565
  }, [])
459
566
 
567
+ // 后退/前进:导航历史(跨文件焦点位置;经 ref 读最新闭包,避免空依赖过期)
568
+ React.useEffect(() => {
569
+ const onKey = (e) => {
570
+ if (!matchEvent(e, bindingsOf('edrv.navigateBack'))) return
571
+ e.preventDefault(); e.stopPropagation()
572
+ if (navBackRef.current) navBackRef.current()
573
+ }
574
+ window.addEventListener('keydown', onKey, true)
575
+ return () => window.removeEventListener('keydown', onKey, true)
576
+ }, [])
577
+ React.useEffect(() => {
578
+ const onKey = (e) => {
579
+ if (!matchEvent(e, bindingsOf('edrv.navigateForward'))) return
580
+ e.preventDefault(); e.stopPropagation()
581
+ if (navForwardRef.current) navForwardRef.current()
582
+ }
583
+ window.addEventListener('keydown', onKey, true)
584
+ return () => window.removeEventListener('keydown', onKey, true)
585
+ }, [])
586
+
587
+ // 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
588
+ // pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
589
+ // mousedown 兜底仅取消浏览器历史导航默认动作(MDN:preventDefault mousedown/pointerdown 可抑制)。
590
+ // @author ddj 2026年09月02号
591
+ React.useEffect(() => {
592
+ const onPointer = (e) => {
593
+ if (e?.button !== 3 && e?.button !== 4) return
594
+ e.preventDefault(); e.stopPropagation()
595
+ const ref = e.button === 3 ? navBackRef : navForwardRef
596
+ if (ref.current) ref.current()
597
+ }
598
+ const onMouse = (e) => {
599
+ if (e?.button !== 3 && e?.button !== 4) return
600
+ e.preventDefault(); e.stopPropagation()
601
+ }
602
+ window.addEventListener('pointerdown', onPointer, true)
603
+ window.addEventListener('mousedown', onMouse, true)
604
+ return () => {
605
+ window.removeEventListener('pointerdown', onPointer, true)
606
+ window.removeEventListener('mousedown', onMouse, true)
607
+ }
608
+ }, [])
609
+
460
610
  // 文件 → 待处理差异数 映射(侧边栏角标)
461
611
  const pendingByPath = React.useMemo(() => {
462
612
  const out = {}
@@ -629,6 +779,12 @@ export function EditorView(props) {
629
779
  })
630
780
  ed.onDidChangeCursorPosition((e) => {
631
781
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
782
+ // 导航历史:同文件内光标移动防抖记录(程序化恢复触发的位置与栈顶去重,无副作用)
783
+ if (navCursorTimerRef.current) clearTimeout(navCursorTimerRef.current)
784
+ navCursorTimerRef.current = setTimeout(() => {
785
+ navCursorTimerRef.current = null
786
+ recordNav()
787
+ }, 500)
632
788
  })
633
789
  // 编辑区右键 → 注入 Monaco 原生 context menu(与 Go to Definition 等同菜单,避免分离浮层)。
634
790
  // 有选区时额外显示「选中内容」项:context key edrvSelection 由光标选区变化驱动。
@@ -720,12 +876,26 @@ export function EditorView(props) {
720
876
  editorRef.current = ed
721
877
  }, [])
722
878
 
723
- // 打开文件后的跳转(差异聚焦/搜索行列跳转,内容就绪后执行一次)
879
+ // 打开文件后的跳转(导航历史恢复/差异聚焦/搜索行列跳转,内容就绪后执行一次)
724
880
  React.useEffect(() => {
725
- const pf = pendingFocusRef.current
726
- if (!pf || pf.path !== active || content === null) return
727
881
  const ed = editorRef.current
728
- if (!ed) return
882
+ if (!ed || content === null) return
883
+ // 导航历史恢复优先:viewState 快照恢复滚动/折叠,行列补定位
884
+ const nav = navPendingRef.current
885
+ if (nav && nav.path === active) {
886
+ navPendingRef.current = null
887
+ if (nav.viewState) {
888
+ try { ed.restoreViewState(nav.viewState) } catch (e) { /* 非法快照忽略(行号越界自动兜底) */ }
889
+ }
890
+ if (nav.line != null) {
891
+ ed.revealLineInCenter(Math.max(1, nav.line))
892
+ ed.setPosition({ lineNumber: Math.max(1, nav.line), column: Math.max(1, nav.column ?? 1) })
893
+ }
894
+ ed.focus()
895
+ return
896
+ }
897
+ const pf = pendingFocusRef.current
898
+ if (!pf || pf.path !== active) return
729
899
  if (pf.line != null) {
730
900
  // 搜索命中跳转:直接定位到行/列(不依赖差异区域)
731
901
  pendingFocusRef.current = null
@@ -982,6 +1152,7 @@ export function EditorView(props) {
982
1152
 
983
1153
  const openFile = (path, focusDiff) => {
984
1154
  if (!path) return
1155
+ recordNav()
985
1156
  addTab(path, true)
986
1157
  if (focusDiff) pendingFocusRef.current = { path, region: null }
987
1158
  }
@@ -995,6 +1166,7 @@ export function EditorView(props) {
995
1166
  */
996
1167
  const openFileAt = (path, line, column) => {
997
1168
  if (!path) return
1169
+ recordNav()
998
1170
  addTab(path, true)
999
1171
  pendingFocusRef.current = { path, region: null, line: line ?? null, column: column ?? 1 }
1000
1172
  setFocusRequest((value) => value + 1)
@@ -1017,7 +1189,7 @@ export function EditorView(props) {
1017
1189
  key: t.path,
1018
1190
  className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
1019
1191
  title: t.path,
1020
- onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); setActive(t.path) } },
1192
+ onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); recordNav(); setActive(t.path) } },
1021
1193
  onContextMenu: (e) => {
1022
1194
  e.preventDefault()
1023
1195
  e.stopPropagation()
@@ -1054,11 +1226,29 @@ export function EditorView(props) {
1054
1226
  lspProgress !== null ? React.createElement('span', { className: 'edrv-sp-progress' }, lspProgress + '%') : null)
1055
1227
  : null
1056
1228
 
1229
+ // 导航历史按钮:目标条目名(tooltip 提示下一步会回到哪个文件)
1230
+ const navBackTarget = navRef.current.peekBack()
1231
+ const navForwardTarget = navRef.current.peekForward()
1232
+ const navNameOf = (entry) => (entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : '')
1057
1233
  const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
1058
1234
  tabsEl,
1059
1235
  (sum.totalFiles > 0
1060
1236
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
1061
1237
  : null),
1238
+ React.createElement('button', {
1239
+ className: 'edrv-chip-btn',
1240
+ title: '后退(' + (chordOf('edrv.navigateBack') ?? '未绑定') + ')' + (navBackTarget ? ' · ' + navNameOf(navBackTarget) : ''),
1241
+ disabled: !navRef.current.canBack(),
1242
+ 'aria-label': '后退',
1243
+ onClick: navBack,
1244
+ }, '←'),
1245
+ React.createElement('button', {
1246
+ className: 'edrv-chip-btn',
1247
+ title: '前进(' + (chordOf('edrv.navigateForward') ?? '未绑定') + ')' + (navForwardTarget ? ' · ' + navNameOf(navForwardTarget) : ''),
1248
+ disabled: !navRef.current.canForward(),
1249
+ 'aria-label': '前进',
1250
+ onClick: navForward,
1251
+ }, '→'),
1062
1252
  React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (' + (chordOf('edrv.toggleSidebar') ?? 'Ctrl+B') + ')', onClick: () => setSidebarOn((v) => !v) }, '☰'),
1063
1253
  React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
1064
1254
  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)
@@ -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
  /**