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/README.md +3 -1
- package/lib/client.js +314 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +6 -3
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/keybindings.ts +41 -15
- package/src/client/navHistory.ts +104 -0
- package/src/client/styles/editor.css +2 -0
- package/src/client/ui/EditorView.ts +200 -10
- package/src/client/ui/KeybindingsPanel.ts +3 -3
- package/src/client/ui/QuickOpen.ts +2 -2
- package/src/shared/keybindings.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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] =
|
|
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
|
-
*
|
|
76
|
+
* 当前命令的解析键位集合(未绑定/非法 → 空数组;含多候选)。
|
|
74
77
|
* @author ddj 2026年08月26号
|
|
75
78
|
* @param id 命令 id
|
|
76
|
-
* @returns
|
|
79
|
+
* @returns 解析键位数组(可能为空)
|
|
77
80
|
*/
|
|
78
|
-
export function
|
|
79
|
-
return parsed[id] ??
|
|
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
|
|
160
|
+
* @param bindings 解析键位或键位数组(null/空 = 未绑定,永不命中)
|
|
138
161
|
* @returns 是否命中
|
|
139
162
|
*/
|
|
140
|
-
export function matchEvent(e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string },
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|
|
@@ -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
|
/**
|