dsh-vscode-mode 0.1.63 → 0.2.0

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.
@@ -2,11 +2,15 @@
2
2
  * dsh-vscode-mode client — 快捷键配置模块(解析/匹配/状态同步)。
3
3
  * 纯逻辑(parseChord/parseChords/formatChord/matchEvent/chordFromEvent/normalizeKey)不依赖 DOM,可单测;
4
4
  * 模块状态由 settings 订阅驱动(client/index.ts 调 keybindingsApply)。
5
- * 键位语义:Ctrl 与 Cmd 互认(延续现有 Ctrl+P/Ctrl+B 捕获行为)。
6
- * 作者 ddj 2026年08月26号
5
+ * 键位语义:Ctrl 与 Cmd 互认(延续 Ctrl+P/Ctrl+B 捕获行为)。
6
+ * 命令目录(COMMANDS)派生自指令目录 commandCatalog(指令 = 单一数据源,设置页自动跟随);
7
+ * 另支持运行时键位表(第三方命令 register 时声明,不落设置 schema,注销即失效)。
8
+ * 作者 ddj 2026年08月26号 / 2026年09月10号
7
9
  */
8
10
  import React from 'react'
9
11
  import { KEYBINDING_DEFAULTS, normalizeKeybindings } from '../shared/keybindings.js'
12
+ import { BRIDGE_COMMANDS, EDITOR_COMMANDS, showCommandsDef } from './ui/commandCatalog.js'
13
+ import { log } from './log.js'
10
14
 
11
15
  /** 解析后的键位(修饰符 + 规范化主键)。 */
12
16
  export interface Binding {
@@ -19,24 +23,39 @@ export interface Binding {
19
23
 
20
24
  /** 命令目录(设置页展示标签;执行按目录序先匹配先执行,冲突键位确定性)。 */
21
25
  export const COMMANDS: Array<{ id: string; label: string }> = [
22
- { id: 'edrv.save', label: '保存文件' },
23
- { id: 'edrv.quickOpen', label: '快速打开文件' },
24
- { id: 'edrv.toggleSidebar', label: '切换侧边栏' },
25
- { id: 'edrv.searchInFiles', label: '在工作区中搜索' },
26
- { id: 'edrv.navigateBack', label: '后退(导航历史)' },
27
- { id: 'edrv.navigateForward', label: '前进(导航历史)' },
28
- { id: 'edrv.nextTab', label: '下一个页签' },
29
- { id: 'edrv.prevTab', label: '上一个页签' },
26
+ ...EDITOR_COMMANDS.map((command) => ({ id: command.id, label: command.label })),
27
+ { id: 'edrv.showCommands', label: '显示所有命令' },
28
+ ...BRIDGE_COMMANDS.map((command) => ({ id: command.id, label: command.label })),
30
29
  ]
31
30
 
31
+ /** 命令栏命令定义(键位自检与目录展示共用;run 不参与键位逻辑)。 */
32
+ function paletteCommand(): { id: string; label: string; keybinding?: string } {
33
+ return showCommandsDef(() => {})
34
+ }
35
+
36
+ /**
37
+ * 目录与默认键位表一致性自检(仅告警不中断):新指令漏写共享表时第一时间可见。
38
+ * @author ddj 2026年09月10号
39
+ */
40
+ function checkDefaultsDrift(): void {
41
+ for (const command of [...EDITOR_COMMANDS, paletteCommand()]) {
42
+ if (!command.keybinding) continue
43
+ if (KEYBINDING_DEFAULTS[command.id] === command.keybinding) continue
44
+ log.warn('指令默认键位与共享表不一致:' + command.id
45
+ + ' 目录=' + command.keybinding + ' 共享表=' + String(KEYBINDING_DEFAULTS[command.id]))
46
+ }
47
+ }
48
+
32
49
  const MODIFIERS: Record<string, 'ctrl' | 'shift' | 'alt' | 'meta'> = {
33
50
  ctrl: 'ctrl', cmd: 'meta', meta: 'meta', shift: 'shift', alt: 'alt',
34
51
  }
35
52
 
36
53
  let current: Record<string, string> = { ...KEYBINDING_DEFAULTS }
37
- let parsed: Record<string, Binding[]> = {}
54
+ const runtime = new Map<string, Binding[]>()
38
55
  const listeners = new Set<() => void>()
39
56
 
57
+ checkDefaultsDrift()
58
+
40
59
  /**
41
60
  * 应用设置快照(与默认值合并;未知 id 丢弃;空对象 = 全部默认)。
42
61
  * 每个命令可含多候选键位(`|` 分隔),任一命中即触发。
@@ -45,8 +64,6 @@ const listeners = new Set<() => void>()
45
64
  */
46
65
  export function keybindingsApply(raw: unknown): void {
47
66
  current = { ...KEYBINDING_DEFAULTS, ...normalizeKeybindings(raw) }
48
- parsed = {}
49
- for (const id of Object.keys(current)) parsed[id] = parseChords(current[id])
50
67
  for (const listener of listeners) {
51
68
  try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
52
69
  }
@@ -64,24 +81,61 @@ export function subscribeKeybindings(listener: () => void): () => void {
64
81
  }
65
82
 
66
83
  /**
67
- * 当前键位弦(未绑定/空 → null)。
68
- * @author ddj 2026年08月26号
84
+ * 注册运行时键位(第三方命令;优先于设置值,注销即失效)。
85
+ * @author ddj 2026年09月10号
86
+ * @param id 命令 id
87
+ * @param chord 键位弦(空/非法按未绑定处理)
88
+ * @returns 注销函数(幂等)
89
+ */
90
+ export function addRuntimeKeybinding(id: string, chord: string): () => void {
91
+ runtime.set(id, parseChords(chord))
92
+ notifyKeybindings()
93
+ return () => removeRuntimeKeybinding(id)
94
+ }
95
+
96
+ /**
97
+ * 移除运行时键位。
98
+ * @author ddj 2026年09月10号
99
+ * @param id 命令 id
100
+ */
101
+ export function removeRuntimeKeybinding(id: string): void {
102
+ if (!runtime.delete(id)) return
103
+ notifyKeybindings()
104
+ }
105
+
106
+ /** 通知键位订阅者(异常隔离)。 */
107
+ function notifyKeybindings(): void {
108
+ for (const listener of listeners) {
109
+ try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 当前命令的键位弦(运行时键位优先;未绑定/空 → null)。
115
+ * @author ddj 2026年08月26号 / 2026年09月10号
69
116
  * @param id 命令 id
70
117
  * @returns 键位弦或 null
71
118
  */
72
119
  export function chordOf(id: string): string | null {
120
+ if (runtime.has(id)) {
121
+ const chords = runtime.get(id) ?? []
122
+ return chords.length ? chords.map(formatChord).join('|') : null
123
+ }
73
124
  const chord = current[id]
74
125
  return typeof chord === 'string' && chord.trim() !== '' ? chord : null
75
126
  }
76
127
 
77
128
  /**
78
129
  * 当前命令的解析键位集合(未绑定/非法 → 空数组;含多候选)。
79
- * @author ddj 2026年08月26号
130
+ * @author ddj 2026年08月26号 / 2026年09月10号
80
131
  * @param id 命令 id
81
132
  * @returns 解析键位数组(可能为空)
82
133
  */
83
134
  export function bindingsOf(id: string): Binding[] {
84
- return parsed[id] ?? []
135
+ const override = runtime.get(id)
136
+ if (override) return override
137
+ const chord = current[id]
138
+ return typeof chord === 'string' ? parseChords(chord) : []
85
139
  }
86
140
 
87
141
  /**
@@ -369,5 +369,19 @@
369
369
  [data-edrv-view] .edrv-rules-save { height: 24px; padding: 0 14px; border: none; border-radius: 6px; background: var(--dsw-alias-brand-primary, #0f9d58); color: var(--dsw-alias-label-primary-inverted, #fff); font-size: 12px; cursor: pointer; }
370
370
  [data-edrv-view] .edrv-rules-save:disabled { opacity: .5; cursor: default; }
371
371
 
372
+ /* 命令栏(Ctrl+Shift+P / F1):body 级 portal 浮层,故不依赖编辑区几何 */
373
+ [data-edrv-view] .edrv-palette-mask { position: fixed; inset: 0; z-index: 190; background: rgba(0, 0, 0, .28); }
374
+ [data-edrv-view] .edrv-palette { position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index: 191; width: min(580px, calc(100vw - 32px)); max-height: 64vh; display: flex; flex-direction: column; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 12px 34px rgba(31, 41, 51, .24); overflow: hidden; }
375
+ [data-edrv-view] .edrv-palette-input { height: 38px; flex: 0 0 auto; padding: 0 12px; border: none; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: transparent; color: var(--dsw-alias-label-primary, #1f2933); font-size: 13px; outline: none; }
376
+ [data-edrv-view] .edrv-palette-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px; }
377
+ [data-edrv-view] .edrv-palette-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 12px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; }
378
+ [data-edrv-view] .edrv-palette-row.edrv-palette-sel { background: var(--dsw-alias-interactive-bg-hover, rgba(15, 157, 88, .10)); }
379
+ [data-edrv-view] .edrv-palette-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
380
+ [data-edrv-view] .edrv-palette-cat { flex: 0 0 auto; font-size: 10px; padding: 1px 7px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); color: var(--dsw-alias-label-tertiary, #7b8794); }
381
+ [data-edrv-view] .edrv-palette-key { flex: 0 0 auto; font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 10px; padding: 1px 6px; border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 5px; background: var(--dsw-alias-bg-layer-1, #f7f9fa); color: var(--dsw-alias-label-secondary, #52606d); }
382
+ [data-edrv-view] .edrv-palette-empty { padding: 14px 10px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #7b8794); text-align: center; }
383
+ [data-edrv-view] .edrv-palette-notice { flex: 0 0 auto; padding: 6px 10px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 11px; color: var(--dsw-alias-state-warn-primary, #b7791f); }
384
+
385
+
372
386
 
373
387
 
@@ -0,0 +1,158 @@
1
+ /**
2
+ * dsh-vscode-mode client — 命令栏浮层(Ctrl+Shift+P / F1)。
3
+ * 订阅 commandPaletteStore 的开关状态;候选来自注入的指令注册表(window.dsh.edrvCommands),
4
+ * 过滤/排序复用 commandSearch 纯函数;↑↓ 选择、Enter 执行、Esc 或点击遮罩关闭。
5
+ * 浮层经 createPortal 渲染到 body,故不受编辑区三种形态(官方侧栏/better-sidebar/中央页签)影响;
6
+ * 宿主单实例由 store 的 claimPaletteHost 保证(另一个宿主自动返回 null)。
7
+ * 作者 ddj 2026年09月10号
8
+ */
9
+ import React from 'react'
10
+ import { createPortal } from 'react-dom'
11
+ import { REGISTRY_GLOBAL } from '../commandGlobals.js'
12
+ import { filterCommands } from '../commandSearch.js'
13
+ import { createCommandRegistry } from '../commandRegistry.js'
14
+ import type { CommandRegistry } from '../commandRegistry.js'
15
+ import { chordOf } from '../keybindings.js'
16
+ import {
17
+ claimPaletteHost, closeCommandPalette, isPaletteOpen, registryRef, releasePaletteHost,
18
+ runPaletteCommand, subscribePalette,
19
+ } from '../commandPaletteStore.js'
20
+ import type { CommandDef } from './commandCatalog.js'
21
+
22
+ /** 空注册表(宿主未注入时的安全降级:命令栏可开但无候选)。 */
23
+ let fallbackRegistry: CommandRegistry | null = null
24
+
25
+ /**
26
+ * 读取指令注册表:① 装配期存入的模块引用(首选,不依赖全局)
27
+ * ② window[REGISTRY_GLOBAL] 镜像(第三方/调试场景)③ 空表降级(仅装配异常时)。
28
+ * @author ddj 2026年09月10号
29
+ * @returns 指令注册表
30
+ */
31
+ function useRegistry(): CommandRegistry {
32
+ const injected = registryRef()
33
+ if (injected) return injected
34
+ const mirrored = typeof window === 'undefined'
35
+ ? undefined
36
+ : (window as unknown as Record<string, CommandRegistry | undefined>)[REGISTRY_GLOBAL]
37
+ if (mirrored) return mirrored
38
+ if (!fallbackRegistry) fallbackRegistry = createCommandRegistry()
39
+ return fallbackRegistry
40
+ }
41
+
42
+ /** 命令栏每一行的展示信息。 */
43
+ interface PaletteRow {
44
+ command: CommandDef
45
+ chord: string | null
46
+ }
47
+
48
+ /**
49
+ * 组装候选行(先按可用性过滤,再按查询排序)。
50
+ * @author ddj 2026年09月10号
51
+ * @param registry 指令注册表
52
+ * @param query 用户输入
53
+ * @returns 候选行
54
+ */
55
+ function rowsOf(registry: CommandRegistry, query: string): PaletteRow[] {
56
+ return filterCommands(registry.available(), query)
57
+ .map((command) => ({ command, chord: chordOf(command.id) }))
58
+ }
59
+
60
+ /**
61
+ * 命令栏浮层(未展开/非宿主时渲染 null)。
62
+ * @author ddj 2026年09月10号
63
+ * @returns 浮层 React 元素或 null
64
+ */
65
+ export function CommandPalette(): React.ReactElement | null {
66
+ const open = React.useSyncExternalStore(subscribePalette, isPaletteOpen)
67
+ const registry = useRegistry()
68
+ const [token] = React.useState(() => (claimPaletteHost() ? {} : null))
69
+ const [query, setQuery] = React.useState('')
70
+ const [selected, setSelected] = React.useState(0)
71
+ const [notice, setNotice] = React.useState('')
72
+ const inputRef = React.useRef<HTMLInputElement | null>(null)
73
+ const host = token !== null
74
+
75
+ React.useEffect(() => () => releasePaletteHost(token), [token])
76
+ React.useEffect(() => {
77
+ if (!open) return undefined
78
+ setQuery('')
79
+ setSelected(0)
80
+ setNotice('')
81
+ const timer = setTimeout(() => inputRef.current?.focus(), 0)
82
+ return () => clearTimeout(timer)
83
+ }, [open])
84
+
85
+ const rows = React.useMemo(
86
+ () => (open && host ? rowsOf(registry, query) : []),
87
+ [open, host, registry, query],
88
+ )
89
+ const active = rows.length ? Math.min(selected, rows.length - 1) : 0
90
+
91
+ React.useEffect(() => {
92
+ if (!open) return
93
+ const node = document.querySelector('.edrv-palette-row.edrv-palette-sel')
94
+ const scroll = (node as HTMLElement | null)?.scrollIntoView
95
+ if (typeof scroll === 'function') scroll.call(node, { block: 'nearest' })
96
+ }, [open, active, rows.length])
97
+
98
+ /** 执行候选行(执行器内部会做可用性校验与异常上报)。 */
99
+ const execute = (row: PaletteRow | undefined): void => {
100
+ if (!row) return
101
+ closeCommandPalette()
102
+ if (!runPaletteCommand(row.command.id)) setNotice('命令未执行:' + row.command.label)
103
+ }
104
+
105
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
106
+ event.stopPropagation()
107
+ if (event.key === 'Escape') {
108
+ event.preventDefault()
109
+ closeCommandPalette()
110
+ return
111
+ }
112
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
113
+ event.preventDefault()
114
+ if (!rows.length) return
115
+ const step = event.key === 'ArrowDown' ? 1 : rows.length - 1
116
+ setSelected((value) => (Math.min(value, rows.length - 1) + step) % rows.length)
117
+ return
118
+ }
119
+ if (event.key === 'Enter') {
120
+ event.preventDefault()
121
+ execute(rows[active])
122
+ }
123
+ }
124
+
125
+ if (!open || !host || typeof document === 'undefined') return null
126
+ const list = rows.length
127
+ ? rows.map((row, index) => React.createElement('div', {
128
+ key: row.command.id,
129
+ className: 'edrv-palette-row' + (index === active ? ' edrv-palette-sel' : ''),
130
+ onMouseEnter: () => setSelected(index),
131
+ onClick: () => execute(row),
132
+ },
133
+ React.createElement('span', { className: 'edrv-palette-label' }, row.command.label),
134
+ React.createElement('span', { className: 'edrv-palette-cat' }, row.command.category),
135
+ row.chord ? React.createElement('kbd', { className: 'edrv-palette-key' }, row.chord) : null))
136
+ : React.createElement('div', { className: 'edrv-palette-empty' }, '无匹配命令')
137
+
138
+ const panel = React.createElement('div', { className: 'edrv-palette', 'data-edrv-palette': '1', onKeyDown },
139
+ React.createElement('input', {
140
+ ref: inputRef,
141
+ className: 'edrv-palette-input',
142
+ value: query,
143
+ placeholder: '输入命令名称…(↑↓ 选择 · Enter 执行 · Esc 关闭)',
144
+ spellCheck: false,
145
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
146
+ setQuery(event.target.value)
147
+ setSelected(0)
148
+ setNotice('')
149
+ },
150
+ }),
151
+ React.createElement('div', { className: 'edrv-palette-list' }, list),
152
+ notice ? React.createElement('div', { className: 'edrv-palette-notice' }, notice) : null)
153
+
154
+ const overlay = React.createElement('div', { 'data-edrv-view': '1' },
155
+ React.createElement('div', { className: 'edrv-palette-mask', onClick: () => closeCommandPalette() }),
156
+ panel)
157
+ return createPortal(overlay, document.body)
158
+ }
@@ -11,6 +11,7 @@ import { summarize } from '../state/records.js'
11
11
  import { nextDiffPath } from '../diffDock.js'
12
12
  import { readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
13
13
  import { DiffBox } from './DiffBox.js'
14
+ import { CommandPalette } from './CommandPalette.js'
14
15
 
15
16
  const nextIndexBySession = new Map()
16
17
 
@@ -60,12 +61,17 @@ export function ConversationDiffDock(props) {
60
61
  }, content)
61
62
 
62
63
  const editorSnapshot = readDiffDock(sessionId)
64
+ // 命令栏浮层的兜底宿主:本 dock 是每会话常驻 slot(编辑区未挂载时仍能唤起命令栏);
65
+ // 与 EditorView 同时挂载时由 commandPaletteStore 的单实例认领保证只渲染一份。
66
+ const palette = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
63
67
  if (editorSnapshot) {
64
- if (editorSnapshot.mode === 'editor-empty' && !editorSnapshot.fileTotal) return null
65
- return renderDock(React.createElement(DiffBox, Object.assign({}, editorSnapshot, { dock: true })), sessionId)
68
+ if (editorSnapshot.mode === 'editor-empty' && !editorSnapshot.fileTotal) return palette
69
+ return React.createElement(React.Fragment, null,
70
+ renderDock(React.createElement(DiffBox, Object.assign({}, editorSnapshot, { dock: true })), sessionId),
71
+ palette)
66
72
  }
67
73
 
68
- if (!sessionId || !summary?.pendingFiles?.length) return null
74
+ if (!sessionId || !summary?.pendingFiles?.length) return palette
69
75
 
70
76
  const paths = summary.pendingFiles.map((file) => file.path)
71
77
  const click = () => {
@@ -75,10 +81,12 @@ export function ConversationDiffDock(props) {
75
81
  if (next.path) openDiffView(next.path)
76
82
  }
77
83
 
78
- return renderDock(React.createElement(DiffBox, {
79
- mode: 'chat',
80
- dock: true,
81
- fileTotal: paths.length,
82
- onOpenNextFile: click,
83
- }), sessionId || 'chat')
84
+ return React.createElement(React.Fragment, null,
85
+ renderDock(React.createElement(DiffBox, {
86
+ mode: 'chat',
87
+ dock: true,
88
+ fileTotal: paths.length,
89
+ onOpenNextFile: click,
90
+ }), sessionId || 'chat'),
91
+ palette)
84
92
  }
@@ -19,6 +19,7 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
19
19
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
20
20
  import { diffRegions } from '../state/regions.js'
21
21
  import { QuickOpen } from './QuickOpen.js'
22
+ import { CommandPalette } from './CommandPalette.js'
22
23
  import { DiffLauncher } from './DiffLauncher.js'
23
24
  import { SidebarView } from '../sidebar/SidebarView.js'
24
25
  import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
@@ -131,6 +132,10 @@ export function EditorView(props) {
131
132
  const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
132
133
  const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
133
134
  const cycleTabRef = React.useRef(null) // 页签循环动作最新闭包(Ctrl+Alt+←/→、Ctrl+PgUp/PgDn)
135
+ const rowNavColRef = React.useRef(null) // 整行上下移动的期望列(连续移动保持列位)
136
+ const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
137
+ const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
138
+ activeRef.current = active
134
139
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
135
140
  const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
136
141
  const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
@@ -799,6 +804,66 @@ export function EditorView(props) {
799
804
  return () => window.removeEventListener('keydown', onKey, true)
800
805
  }, [])
801
806
 
807
+ // 整行上下移动的实现见下方「指令系统接线」effect(moveRow 单点定义,命令栏与键位共用)。
808
+
809
+ /**
810
+ * 指令系统接线:命令栏/键位/第三方派发的 `edrv.command.*` 事件落到编辑器动作。
811
+ * 全部动作经最新闭包执行(与窗口级键位监听同源);事件名逐条字面书写,
812
+ * 便于与指令目录(ui/commandCatalog)静态对照(tests/commands.test.ts 有断言)。
813
+ * @author ddj 2026年09月10号
814
+ */
815
+ React.useEffect(() => {
816
+ /** 整行上下移动:Monaco 内置 cursorUp/Down 会丢列位,故按期望列自行定位(连续移动保持同列)。 */
817
+ const moveRow = (step) => {
818
+ const ed = editorRef.current
819
+ const model = ed?.getModel?.()
820
+ const pos = ed?.getPosition?.()
821
+ if (!ed || !model || !pos) return
822
+ const col = rowNavColRef.current ?? pos.column
823
+ const line = Math.max(1, Math.min(model.getLineCount(), pos.lineNumber + step))
824
+ if (line === pos.lineNumber) return
825
+ const maxCol = model.getLineMaxColumn(line)
826
+ rowNavColRef.current = col
827
+ rowNavMoveRef.current = true
828
+ ed.setPosition({ lineNumber: line, column: Math.max(1, Math.min(col, maxCol)) })
829
+ ed.revealLineInCenterIfOutsideViewport?.(line)
830
+ }
831
+ const handlers = [
832
+ ['edrv.command.save', () => { if (editorRef.current?.getModel?.()) { flushSave(); doSaveRef.current?.(false) } }],
833
+ // 快速打开由 QuickOpen 自己接该事件(它持有搜索框 ref),此处不重复实现
834
+ ['edrv.command.toggleSidebar', () => setSidebarOn((v) => !v)],
835
+ ['edrv.command.searchInFiles', () => {
836
+ setSidebarOn(true)
837
+ setActivePanel('search')
838
+ setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
839
+ }],
840
+ ['edrv.command.navigateBack', () => navBackRef.current?.()],
841
+ ['edrv.command.navigateForward', () => navForwardRef.current?.()],
842
+ ['edrv.command.nextTab', () => cycleTabRef.current?.(1)],
843
+ ['edrv.command.prevTab', () => cycleTabRef.current?.(-1)],
844
+ ['edrv.command.nextEditorRow', () => moveRow(1)],
845
+ ['edrv.command.prevEditorRow', () => moveRow(-1)],
846
+ ['edrv.command.goToDefinition', () => { const ed = editorRef.current; if (ed) void runGoToDefinition(ed) }],
847
+ ['edrv.command.findReferences', () => { const ed = editorRef.current; if (ed) void runFindReferences(ed) }],
848
+ ['edrv.command.triggerAi', () => editorRef.current?.trigger?.('edrv-ai', 'editor.action.inlineSuggest.trigger', null)],
849
+ ['edrv.command.openInExplorer', () => {
850
+ const path = activeRef.current
851
+ if (!path) { setStatus('无活动文件'); return }
852
+ menuHandlersRef.current?.openInExplorer?.(path)
853
+ }],
854
+ ]
855
+ const byName = new Map(handlers)
856
+ const onCommand = (event) => {
857
+ const action = byName.get(event.type)
858
+ if (!action) return
859
+ try { action() } catch (error) { dbg(sessionId, '指令 ' + event.type + ' 失败:' + String(error)) }
860
+ }
861
+ for (const [eventName] of handlers) window.addEventListener(eventName, onCommand)
862
+ return () => {
863
+ for (const [eventName] of handlers) window.removeEventListener(eventName, onCommand)
864
+ }
865
+ }, [sessionId])
866
+
802
867
  // 活动页签滚动可见(页签栏溢出时键盘切换/打开文件后把当前页签带回视野):
803
868
  // 只调整页签栏自身 scrollLeft,不触发页面滚动。
804
869
  React.useEffect(() => {
@@ -1065,6 +1130,9 @@ export function EditorView(props) {
1065
1130
  })
1066
1131
  ed.onDidChangeCursorPosition((e) => {
1067
1132
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
1133
+ // 整行移动(↓↑)刚定位时保留期望列;其余光标移动(点击/打字/方向键)清空期望列
1134
+ if (rowNavMoveRef.current) rowNavMoveRef.current = false
1135
+ else rowNavColRef.current = null
1068
1136
  // 导航历史:同文件内光标移动防抖记录(程序化恢复触发的位置与栈顶去重,无副作用)
1069
1137
  if (navCursorTimerRef.current) clearTimeout(navCursorTimerRef.current)
1070
1138
  navCursorTimerRef.current = setTimeout(() => {
@@ -1126,6 +1194,20 @@ export function EditorView(props) {
1126
1194
  run: (edx) => { void runFindReferences(edx) },
1127
1195
  })
1128
1196
  ed.addCommand(m.KeyCode.F12, () => { void runGoToDefinition(ed) })
1197
+ // 整行上下移动 + 命令栏:只登记 Monaco 右键菜单入口(不绑键位)。
1198
+ // 键位由指令桥统一 capture 派发 `edrv.command.*`,此处再绑一次会双执行。
1199
+ ed.addAction({
1200
+ id: 'edrv.prevEditorRow', label: '上一编辑行', contextMenuGroupId: '1_edrv',
1201
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.prevEditorRow')),
1202
+ })
1203
+ ed.addAction({
1204
+ id: 'edrv.nextEditorRow', label: '下一编辑行', contextMenuGroupId: '1_edrv',
1205
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.nextEditorRow')),
1206
+ })
1207
+ ed.addAction({
1208
+ id: 'edrv.showCommands', label: '显示所有命令', contextMenuGroupId: '1_edrv',
1209
+ run: () => window.dispatchEvent(new CustomEvent('edrv.command.showCommands')),
1210
+ })
1129
1211
  bindLspEditor(ed)
1130
1212
  bindLspUnderline(ed, m)
1131
1213
  // AI 补全:编辑器实例登记(差异静默判定用)+ Alt+\ 手动触发 ghost text
@@ -1652,7 +1734,7 @@ export function EditorView(props) {
1652
1734
  } else if (!active) {
1653
1735
  body = React.createElement('div', { className: 'edrv-empty' },
1654
1736
  React.createElement('div', null, '暂无打开的文件'),
1655
- React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;agent 修改文件后顶部会出现差异角标'))
1737
+ React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;' + (chordOf('edrv.showCommands') ?? 'Ctrl+Shift+P') + ' 打开命令栏;agent 修改文件后顶部会出现差异角标'))
1656
1738
  } else if (content === null && loadError) {
1657
1739
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
1658
1740
  setLoadError(null)
@@ -1831,14 +1913,19 @@ export function EditorView(props) {
1831
1913
  mainCol)
1832
1914
 
1833
1915
  const baseStyle = { minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' }
1916
+ // 命令栏浮层(Ctrl+Shift+P / F1):portal 到 body,但需挂在插件自己的 React 树里;
1917
+ // 三种布局形态都渲染 EditorView,故这里挂载即可,多宿主由 store 单实例认领兜底。
1918
+ const paletteEl = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
1834
1919
  const rootEl = layout === 'side'
1835
1920
  ? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
1836
1921
  editorRow,
1837
1922
  menuBackdrop,
1838
- tabMenuEl)
1923
+ tabMenuEl,
1924
+ paletteEl)
1839
1925
  : React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
1840
1926
  editorRow,
1841
1927
  menuBackdrop,
1842
- tabMenuEl)
1928
+ tabMenuEl,
1929
+ paletteEl)
1843
1930
  return rootEl
1844
1931
  }
@@ -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, '配置编辑器视图的快捷键(命令栏 / 保存 / 快速打开 / 侧边栏 / 搜索 / 后退前进 / 页签与编辑行导航)。修改即时写入配置,重新聚焦编辑器后生效;命令栏(默认 Ctrl+Shift+P 或 F1)可搜索并执行下列全部命令。'),
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),
@@ -49,17 +49,25 @@ export function QuickOpen(props) {
49
49
  }
50
50
 
51
51
  React.useEffect(() => {
52
+ const openBox = () => {
53
+ inputRef.current?.focus?.()
54
+ setOpen(true)
55
+ }
52
56
  const onKey = (e) => {
53
57
  if (matchEvent(e, bindingsOf('edrv.quickOpen'))) {
54
58
  e.preventDefault(); e.stopPropagation()
55
- inputRef.current?.focus?.()
56
- setOpen(true)
59
+ openBox()
57
60
  } else if (e.key === 'Escape') {
58
61
  setOpen(false); inputRef.current?.blur?.()
59
62
  }
60
63
  }
61
64
  window.addEventListener('keydown', onKey, true)
62
- return () => window.removeEventListener('keydown', onKey, true)
65
+ // 指令系统入口(命令栏「快速打开文件」):与键位复用同一动作
66
+ window.addEventListener('edrv.command.quickOpen', openBox)
67
+ return () => {
68
+ window.removeEventListener('keydown', onKey, true)
69
+ window.removeEventListener('edrv.command.quickOpen', openBox)
70
+ }
63
71
  }, [])
64
72
 
65
73
  const dirText = (p) => {