dsh-vscode-mode 0.1.44 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 快捷键配置模块(解析/匹配/状态同步)。
3
- * 纯逻辑(parseChord/formatChord/matchEvent/chordFromEvent/normalizeKey)不依赖 DOM,可单测;
3
+ * 纯逻辑(parseChord/parseChords/formatChord/matchEvent/chordFromEvent/normalizeKey)不依赖 DOM,可单测;
4
4
  * 模块状态由 settings 订阅驱动(client/index.ts 调 keybindingsApply)。
5
5
  * 键位语义:Ctrl 与 Cmd 互认(延续现有 Ctrl+P/Ctrl+B 捕获行为)。
6
6
  * 作者 ddj 2026年08月26号
@@ -23,6 +23,8 @@ export const COMMANDS: Array<{ id: string; label: string }> = [
23
23
  { id: 'edrv.quickOpen', label: '快速打开文件' },
24
24
  { id: 'edrv.toggleSidebar', label: '切换侧边栏' },
25
25
  { id: 'edrv.searchInFiles', label: '在工作区中搜索' },
26
+ { id: 'edrv.navigateBack', label: '后退(导航历史)' },
27
+ { id: 'edrv.navigateForward', label: '前进(导航历史)' },
26
28
  ]
27
29
 
28
30
  const MODIFIERS: Record<string, 'ctrl' | 'shift' | 'alt' | 'meta'> = {
@@ -30,18 +32,19 @@ const MODIFIERS: Record<string, 'ctrl' | 'shift' | 'alt' | 'meta'> = {
30
32
  }
31
33
 
32
34
  let current: Record<string, string> = { ...KEYBINDING_DEFAULTS }
33
- let parsed: Record<string, Binding | null> = {}
35
+ let parsed: Record<string, Binding[]> = {}
34
36
  const listeners = new Set<() => void>()
35
37
 
36
38
  /**
37
39
  * 应用设置快照(与默认值合并;未知 id 丢弃;空对象 = 全部默认)。
40
+ * 每个命令可含多候选键位(`|` 分隔),任一命中即触发。
38
41
  * @author ddj 2026年08月26号
39
42
  * @param raw 设置 scope 的 keybindings 字段
40
43
  */
41
44
  export function keybindingsApply(raw: unknown): void {
42
45
  current = { ...KEYBINDING_DEFAULTS, ...normalizeKeybindings(raw) }
43
46
  parsed = {}
44
- for (const id of Object.keys(current)) parsed[id] = parseChord(current[id])
47
+ for (const id of Object.keys(current)) parsed[id] = parseChords(current[id])
45
48
  for (const listener of listeners) {
46
49
  try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
47
50
  }
@@ -70,13 +73,13 @@ export function chordOf(id: string): string | null {
70
73
  }
71
74
 
72
75
  /**
73
- * 当前命令的解析键位(未绑定/非法 → null)。
76
+ * 当前命令的解析键位集合(未绑定/非法 → 空数组;含多候选)。
74
77
  * @author ddj 2026年08月26号
75
78
  * @param id 命令 id
76
- * @returns 解析键位或 null
79
+ * @returns 解析键位数组(可能为空)
77
80
  */
78
- export function bindingOf(id: string): Binding | null {
79
- return parsed[id] ?? null
81
+ export function bindingsOf(id: string): Binding[] {
82
+ return parsed[id] ?? []
80
83
  }
81
84
 
82
85
  /**
@@ -114,6 +117,26 @@ export function parseChord(chord: string): Binding | null {
114
117
  return binding.key ? binding : null
115
118
  }
116
119
 
120
+ /**
121
+ * 解析多候选键位弦(`|` 分隔):逐段解析,丢弃非法段,去重。
122
+ * @author ddj 2026年08月26号
123
+ * @param chord 键位弦(如 `Alt+ArrowLeft|Ctrl+Alt+-`)
124
+ * @returns 解析键位数组(纯 `|`/空/非法 → 空数组)
125
+ */
126
+ export function parseChords(chord: string): Binding[] {
127
+ const out: Binding[] = []
128
+ const seen = new Set<string>()
129
+ for (const part of String(chord ?? '').split('|')) {
130
+ const binding = parseChord(part)
131
+ if (!binding) continue
132
+ const key = formatChord(binding)
133
+ if (seen.has(key)) continue
134
+ seen.add(key)
135
+ out.push(binding)
136
+ }
137
+ return out
138
+ }
139
+
117
140
  /**
118
141
  * 格式化键位(Ctrl/Cmd/Shift/Alt + 主键;meta 显示 Cmd)。
119
142
  * @author ddj 2026年08月26号
@@ -131,18 +154,21 @@ export function formatChord(binding: Binding): string {
131
154
  }
132
155
 
133
156
  /**
134
- * 事件是否命中键位(Ctrl 与 Cmd 互认)。
157
+ * 事件是否命中键位(Ctrl 与 Cmd 互认;多候选任一命中即 true)。
135
158
  * @author ddj 2026年08月26号
136
159
  * @param e 键盘事件(鸭子类型,便于单测)
137
- * @param binding 解析键位(null = 未绑定,永不命中)
160
+ * @param bindings 解析键位或键位数组(null/空 = 未绑定,永不命中)
138
161
  * @returns 是否命中
139
162
  */
140
- export function matchEvent(e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string }, binding: Binding | null): boolean {
141
- if (!binding) return false
142
- if ((e.ctrlKey || e.metaKey) !== (binding.ctrl || binding.meta)) return false
143
- if (Boolean(e.shiftKey) !== binding.shift) return false
144
- if (Boolean(e.altKey) !== binding.alt) return false
145
- return normalizeKey(e.key ?? '') === binding.key
163
+ export function matchEvent(e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string }, bindings: Binding | Binding[] | null): boolean {
164
+ const list = Array.isArray(bindings) ? bindings : bindings ? [bindings] : []
165
+ for (const binding of list) {
166
+ if (Boolean(e.ctrlKey || e.metaKey) !== (binding.ctrl || binding.meta)) continue
167
+ if (Boolean(e.shiftKey) !== binding.shift) continue
168
+ if (Boolean(e.altKey) !== binding.alt) continue
169
+ if (normalizeKey(e.key ?? '') === binding.key) return true
170
+ }
171
+ return false
146
172
  }
147
173
 
148
174
  /**
@@ -0,0 +1,104 @@
1
+ /**
2
+ * dsh-vscode-mode client — 编辑器导航历史(后退/前进)纯逻辑。
3
+ * 对齐 VSCode Go Back / Go Forward(navigation history):记录「文件 + 焦点位置」访问轨迹,
4
+ * 后退回到上一处焦点,前进回到下一处,跨文件生效。
5
+ * 数据模型:past(末位 = 当前)+ future 两个栈;record 压栈并清空 future;
6
+ * 与栈顶同 path+line+column 视为同一位置:仅刷新 viewState 快照,不压栈、不清 future。
7
+ * 纯逻辑无 DOM 依赖,可单测。
8
+ * 作者 ddj 2026年09月04号
9
+ */
10
+
11
+ /** 导航历史单条记录:文件 + 焦点位置(+ 可选的 viewState 快照用于恢复滚动/折叠)。 */
12
+ export interface NavEntry {
13
+ /** 工作区相对路径 */
14
+ path: string
15
+ /** 焦点行(1-based) */
16
+ line?: number
17
+ /** 焦点列(1-based) */
18
+ column?: number
19
+ /** 该位置保存的 Monaco viewState(恢复滚动/折叠) */
20
+ viewState?: unknown
21
+ }
22
+
23
+ /** 导航历史接口。 */
24
+ export interface NavHistory {
25
+ /** 记录当前位置(压栈 + 清空 future;与栈顶同位置时仅更新 viewState)。 */
26
+ record(entry: NavEntry): void
27
+ /** 后退:返回将要恢复的前一条目;历史不足 2 条 → null。 */
28
+ back(): NavEntry | null
29
+ /** 前进:返回将要恢复的条目;future 空 → null。 */
30
+ forward(): NavEntry | null
31
+ /** 是否可后退。 */
32
+ canBack(): boolean
33
+ /** 是否可前进。 */
34
+ canForward(): boolean
35
+ /** 后退目标条目(不改变状态;不可退 → null)。 */
36
+ peekBack(): NavEntry | null
37
+ /** 前进目标条目(不改变状态;不可进 → null)。 */
38
+ peekForward(): NavEntry | null
39
+ }
40
+
41
+ /** 后退栈默认容量上限(超出逐出最旧)。 */
42
+ export const NAV_HISTORY_CAP = 200
43
+
44
+ /** 路径一致(容忍相对路径大小写差异,对齐既有 sameFile 语义常用写法)。 */
45
+ function samePath(a: string, b: string): boolean {
46
+ if (a === b) return true
47
+ return String(a).replace(/\\/g, '/').toLowerCase() === String(b).replace(/\\/g, '/').toLowerCase()
48
+ }
49
+
50
+ /** 位置一致(无焦点时按 null 归一,`undefined`/`null` 视为相等)。 */
51
+ function samePos(a: NavEntry, b: NavEntry): boolean {
52
+ if (!samePath(a.path, b.path)) return false
53
+ return (a.line ?? null) === (b.line ?? null) && (a.column ?? null) === (b.column ?? null)
54
+ }
55
+
56
+ /**
57
+ * 创建导航历史实例(容量裁剪到 cap)。
58
+ * @author ddj 2026年09月04号
59
+ * @param cap 后退栈容量上限(默认 NAV_HISTORY_CAP)
60
+ * @returns 导航历史实例
61
+ */
62
+ export function createNavHistory(cap = NAV_HISTORY_CAP): NavHistory {
63
+ const past: NavEntry[] = []
64
+ const future: NavEntry[] = []
65
+ return {
66
+ record(entry: NavEntry): void {
67
+ if (!entry || !entry.path) return
68
+ const top = past[past.length - 1]
69
+ if (top && samePos(top, entry)) {
70
+ // 同一位置:只更新 viewState 快照(滚动/折叠可能变化),栈与 future 不动
71
+ if (top.viewState !== undefined || entry.viewState !== undefined) top.viewState = entry.viewState
72
+ return
73
+ }
74
+ past.push({ path: entry.path, line: entry.line, column: entry.column, viewState: entry.viewState })
75
+ if (past.length > cap) past.splice(0, past.length - cap)
76
+ future.length = 0
77
+ },
78
+ back(): NavEntry | null {
79
+ if (past.length < 2) return null
80
+ const current = past.pop()
81
+ if (!current) return null
82
+ future.push(current)
83
+ return past[past.length - 1]
84
+ },
85
+ forward(): NavEntry | null {
86
+ const entry = future.pop()
87
+ if (!entry) return null
88
+ past.push(entry)
89
+ return entry
90
+ },
91
+ canBack(): boolean {
92
+ return past.length >= 2
93
+ },
94
+ canForward(): boolean {
95
+ return future.length > 0
96
+ },
97
+ peekBack(): NavEntry | null {
98
+ return past.length >= 2 ? past[past.length - 2] : null
99
+ },
100
+ peekForward(): NavEntry | null {
101
+ return future.length ? future[future.length - 1] : null
102
+ },
103
+ }
104
+ }
@@ -43,6 +43,8 @@
43
43
  [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
44
  [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
45
  [data-edrv-view] .edrv-chip-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
46
+ [data-edrv-view] .edrv-chip-btn:disabled { opacity: .35; cursor: default; }
47
+ [data-edrv-view] .edrv-chip-btn:disabled:hover { background: transparent; }
46
48
  [data-edrv-view] .edrv-mn-add-line { background: rgba(15,157,88,.10); }
47
49
  [data-edrv-view] .edrv-mn-gutter-add { color: #0f9d58 !important; font-weight: 700; display: flex !important; align-items: center; justify-content: center; }
48
50
  [data-edrv-view] .edrv-mn-gutter-add::before { content: "+"; }
@@ -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
  /**