dsh-vscode-mode 0.5.3 → 0.6.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.
Files changed (65) hide show
  1. package/README.md +55 -2
  2. package/lib/client.js +5173 -355
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2356 -126
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/dap/BpWidget.ts +262 -0
  8. package/src/client/dap/bpMenu.ts +91 -0
  9. package/src/client/dap/bpRowMenu.ts +53 -0
  10. package/src/client/dap/breakpoints.ts +222 -0
  11. package/src/client/dap/decorate.ts +128 -0
  12. package/src/client/dap/hintLine.ts +29 -0
  13. package/src/client/dap/hover.ts +166 -0
  14. package/src/client/dap/hoverMode.ts +159 -0
  15. package/src/client/dap/hoverTree.ts +722 -0
  16. package/src/client/dap/launchInsert.ts +229 -0
  17. package/src/client/dap/launchSnippetProvider.ts +206 -0
  18. package/src/client/dap/modelPath.ts +23 -0
  19. package/src/client/dap/panelSplit.ts +105 -0
  20. package/src/client/dap/pidPick.ts +24 -0
  21. package/src/client/dap/store.ts +571 -0
  22. package/src/client/dap/toolbarDrag.ts +51 -0
  23. package/src/client/dap/trace.ts +22 -0
  24. package/src/client/dap/variableTree.ts +62 -0
  25. package/src/client/index.ts +9 -0
  26. package/src/client/monaco/lsp/index.ts +15 -0
  27. package/src/client/monaco/lsp/providers.ts +2 -0
  28. package/src/client/monaco/theme.ts +15 -0
  29. package/src/client/sidebar/panels/DebugPanel.ts +613 -0
  30. package/src/client/sidebar/panels/SvnPanel.ts +167 -23
  31. package/src/client/sidebar/panels/index.ts +19 -0
  32. package/src/client/sidebar/types.ts +2 -0
  33. package/src/client/styles/editor.css +176 -0
  34. package/src/client/svnActions.ts +18 -1
  35. package/src/client/svnLog.ts +3 -3
  36. package/src/client/svnStatus.ts +140 -2
  37. package/src/client/ui/EditorView.ts +544 -16
  38. package/src/client/ui/SideBySideDiff.tsx +214 -29
  39. package/src/client/ui/SvnDiffPanel.ts +21 -8
  40. package/src/client/ui/SvnLogDialog.ts +44 -2
  41. package/src/client/ui/SvnPatchDialog.ts +63 -0
  42. package/src/client/ui/SvnSumDialog.ts +77 -0
  43. package/src/client/ui/commandCatalog.ts +49 -0
  44. package/src/client/ui/svnExport.ts +71 -0
  45. package/src/dap/configSnippets.ts +269 -0
  46. package/src/dap/discovery.ts +198 -0
  47. package/src/dap/launchConfig.ts +127 -0
  48. package/src/dap/manager.ts +588 -0
  49. package/src/dap/processList.ts +76 -0
  50. package/src/dap/protocol.ts +80 -0
  51. package/src/dap/provider.ts +49 -0
  52. package/src/dap/rpc.ts +192 -0
  53. package/src/dap/sourcePath.ts +82 -0
  54. package/src/index.ts +7 -3
  55. package/src/lsp/providers.ts +3 -2
  56. package/src/reveal.ts +31 -20
  57. package/src/rpc.ts +11 -3
  58. package/src/search/ripgrep.ts +127 -11
  59. package/src/shared/dap.ts +262 -0
  60. package/src/shared/keybindings.ts +7 -0
  61. package/src/shared/rpc.ts +40 -2
  62. package/src/shared/svn.ts +147 -0
  63. package/src/shared/svnActions.ts +20 -0
  64. package/src/svn.ts +320 -11
  65. package/src/workspace.ts +0 -75
@@ -0,0 +1,128 @@
1
+ /**
2
+ * dsh-vscode-mode client — Monaco 调试装饰:断点圆点(gutter)与停帧行高亮。
3
+ * 装饰 id 挂在 editor 实例属性上(与 diffRender/underline/navFlash 同款隔离模式),
4
+ * 独立 collection 互不干扰;range 用 IRange 形状普通对象(Monaco 内部归一化,
5
+ * 与 navFlashRangeOf 产物同口径);编辑器销毁时装饰随实例消失,无需显式清理。
6
+ * 作者 ddj 2026年09月29号
7
+ */
8
+
9
+ /** editor 上的装饰 id 槽位 key。 */
10
+ const BP_KEY = '__edrvDapBpDecorations'
11
+ const PAUSED_KEY = '__edrvDapPausedDecorations'
12
+ const EDITING_KEY = '__edrvDapEditingDecorations'
13
+ const HINT_KEY = '__edrvDapHintDecorations'
14
+ /** 当前 hover 预览行(避免同一行重复设置装饰)。 */
15
+ const HINT_LINE = '__edrvDapHintLine'
16
+
17
+ /** Monaco editor 最小子集(deltaDecorations 旧 API,工程内既有口径)。 */
18
+ interface DecoEditor {
19
+ deltaDecorations(oldIds: string[], next: unknown[]): string[]
20
+ }
21
+
22
+ /** IRange 形状普通对象。 */
23
+ function lineRange(line: number): unknown {
24
+ return { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }
25
+ }
26
+
27
+ /**
28
+ * 应用断点装饰(全量替换:启用行红点 + 未验证行空心红圈 + 禁用行灰点)。
29
+ * 未验证 = 适配器真实回执 verified:false(如该行无可执行代码),对齐 VS Code 空心圆语义。
30
+ * @author ddj 2026年09月29号 / 2026年09月21号
31
+ * @param editor Monaco 编辑器
32
+ * @param enabledLines 启用断点行
33
+ * @param disabledLines 禁用断点行
34
+ * @param unverifiedLines 启用但未验证的行(缺省视为全部已验证)
35
+ */
36
+ export function applyBpDecorations(editor: unknown, enabledLines: number[], disabledLines: number[], unverifiedLines: readonly number[] = []): void {
37
+ const ed = editor as DecoEditor | null
38
+ if (!ed?.deltaDecorations) return
39
+ const make = (line: number, cls: string) => ({
40
+ range: lineRange(line),
41
+ options: { isWholeLine: true, glyphMarginClassName: cls, glyphMarginHoverMessage: { value: '断点(点击行首圆点处切换)' } },
42
+ })
43
+ const next = [
44
+ ...enabledLines.map((line) => make(line, unverifiedLines.includes(line) ? 'edrv-dap-bp-unverified' : 'edrv-dap-bp')),
45
+ ...disabledLines.map((line) => make(line, 'edrv-dap-bp-off')),
46
+ ]
47
+ const slot = ed as unknown as Record<string, unknown>
48
+ const prev = slot[BP_KEY] as string[] | undefined
49
+ slot[BP_KEY] = ed.deltaDecorations(prev ?? [], next)
50
+ }
51
+
52
+ /**
53
+ * 设置停帧行装饰(null/0 清除;整行浅底 + gutter 箭头)。
54
+ * @author ddj 2026年09月29号
55
+ * @param editor Monaco 编辑器
56
+ * @param line 停帧行
57
+ */
58
+ export function setPausedLine(editor: unknown, line: number | null): void {
59
+ const ed = editor as DecoEditor | null
60
+ if (!ed?.deltaDecorations) return
61
+ const slot = ed as unknown as Record<string, unknown>
62
+ const prev = slot[PAUSED_KEY] as string[] | undefined
63
+ if (!line) {
64
+ if (prev?.length) ed.deltaDecorations(prev, [])
65
+ slot[PAUSED_KEY] = []
66
+ return
67
+ }
68
+ slot[PAUSED_KEY] = ed.deltaDecorations(prev ?? [], [{
69
+ range: lineRange(line),
70
+ options: {
71
+ isWholeLine: true,
72
+ className: 'edrv-dap-paused-line',
73
+ glyphMarginClassName: 'edrv-dap-paused-glyph',
74
+ },
75
+ }])
76
+ }
77
+
78
+ /**
79
+ * 设置「断点编辑中」目标行高亮(打开行内浮层期间;null 清除)。
80
+ * 对齐 VS Code:编辑断点时目标行整行高亮,明确正在编辑哪一行。
81
+ * @author ddj 2026年09月29号
82
+ * @param editor Monaco 编辑器
83
+ * @param line 目标行(null 清除)
84
+ */
85
+ export function setEditingLine(editor: unknown, line: number | null): void {
86
+ const ed = editor as DecoEditor | null
87
+ if (!ed?.deltaDecorations) return
88
+ const slot = ed as unknown as Record<string, unknown>
89
+ const prev = slot[EDITING_KEY] as string[] | undefined
90
+ if (!line) {
91
+ if (prev?.length) ed.deltaDecorations(prev, [])
92
+ slot[EDITING_KEY] = []
93
+ return
94
+ }
95
+ slot[EDITING_KEY] = ed.deltaDecorations(prev ?? [], [{
96
+ range: lineRange(line),
97
+ options: { isWholeLine: true, className: 'edrv-dap-editing-line' },
98
+ }])
99
+ }
100
+
101
+ /**
102
+ * 设置 glyph 区 hover 预览断点(半透明小红点;null 清除)。
103
+ * 对齐 VS Code:鼠标悬停在可下断点的行首区域时,显示低透明度断点预览 + pointer 光标
104
+ * (其 `.codicon-debug-hint { cursor: pointer; opacity: .4 }` 同源语义)。
105
+ * 同一行重复调用直接返回(onMouseMove 高频,避免无谓装饰抖动)。
106
+ * @author ddj 2026年09月29号
107
+ * @param editor Monaco 编辑器
108
+ * @param line 预览行(null/0 清除)
109
+ */
110
+ export function setHintLine(editor: unknown, line: number | null): void {
111
+ const ed = editor as DecoEditor | null
112
+ if (!ed?.deltaDecorations) return
113
+ const slot = ed as unknown as Record<string, unknown>
114
+ const current = (slot[HINT_LINE] as number | undefined) ?? 0
115
+ const next = line ?? 0
116
+ if (current === next) return
117
+ slot[HINT_LINE] = next
118
+ const prev = slot[HINT_KEY] as string[] | undefined
119
+ if (!next) {
120
+ if (prev?.length) ed.deltaDecorations(prev, [])
121
+ slot[HINT_KEY] = []
122
+ return
123
+ }
124
+ slot[HINT_KEY] = ed.deltaDecorations(prev ?? [], [{
125
+ range: lineRange(next),
126
+ options: { isWholeLine: true, glyphMarginClassName: 'edrv-dap-bp-hint' },
127
+ }])
128
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * dsh-vscode-mode client — 断点 hover 预览判定(纯函数,可单测)。
3
+ * 对齐 VS Code `breakpointEditorContribution` 的 onMouseMove 分支:
4
+ * `(target.type === GUTTER_GLYPH_MARGIN || target.type === GUTTER_LINE_NUMBERS) &&
5
+ * canSetBreakpointsIn(model) && marginFreeFromNonDebugDecorations(line)` → 显示 hint。
6
+ * 本插件口径:glyph 区(type=2)与行号区(type=3)且该行**尚无断点**时给出预览。
7
+ * 作者 ddj 2026年09月29号
8
+ */
9
+
10
+ /** Monaco MouseTargetType 相关值(实测 0.42-dev:GUTTER_GLYPH_MARGIN=2 / GUTTER_LINE_NUMBERS=3)。 */
11
+ export const TARGET_GUTTER_GLYPH = 2
12
+ export const TARGET_GUTTER_LINE = 3
13
+
14
+ /**
15
+ * 判定 hover 是否应显示断点预览,返回预览行号(0 = 不显示)。
16
+ * @author ddj 2026年09月29号
17
+ * @param targetType Monaco 鼠标目标类型
18
+ * @param line 目标行号(0/undefined = 无)
19
+ * @param existingLines 当前文件已有断点的行号集合
20
+ * @returns 预览行号;0 表示不显示
21
+ */
22
+ export function hintLineFor(targetType: number | undefined, line: number | undefined, existingLines: readonly number[]): number {
23
+ if (targetType !== TARGET_GUTTER_GLYPH && targetType !== TARGET_GUTTER_LINE) return 0
24
+ const target = Math.floor(Number(line) || 0)
25
+ if (target < 1) return 0
26
+ // 已有断点的行不显示预览(避免与实心红点重叠)
27
+ if (existingLines.includes(target)) return 0
28
+ return target
29
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * dsh-vscode-mode client — 暂停态 Lua DAP hover。
3
+ * provider 只在 DAP paused 时工作,按当前 selectedFrameId 求值,并把「表达式值 + 类型 + 可折叠
4
+ * 变量树」渲染成**单个 HTML 块**(`[data-edrv-dap]`);Alt 切换 LSP 信息由 hover 层做行级显隐
5
+ * (见 hoverTree.ts),因此这里不按 Alt 改变内容,避免依赖 Monaco 的 hover 重算路径。
6
+ * 作者 ddj 2026年09月29号 / 2026年09月21号
7
+ */
8
+ import { dapStore } from './store.js'
9
+ import { pathOfModel } from '../monaco/lsp/lspClient.js'
10
+ import { BLOCK_ATTR, EQ_ATTR, EXPR_ATTR, HEAD_ATTR, TYPE_ATTR, VAL_ATTR, createTreeState, escapeHtml, initialHtml } from './hoverTree.js'
11
+
12
+ const GLOBAL_KEY = '__edrvDapHoverProvider__'
13
+
14
+ /** Monaco hover 依赖的最小模型接口。 */
15
+ interface HoverModel {
16
+ getWordAtPosition: (position: HoverPosition) => { word?: string; startColumn: number; endColumn: number } | null
17
+ getLineContent?: (line: number) => string
18
+ getLanguageId?: () => string
19
+ }
20
+
21
+ /** 光标位置(1-based)。 */
22
+ interface HoverPosition {
23
+ lineNumber: number
24
+ column: number
25
+ }
26
+
27
+ /**
28
+ * 树状态键:表达式 + 模型路径 + 光标范围(同一次 hover 内复用展开状态与缓存)。
29
+ * @author ddj 2026年09月21号
30
+ * @param target 光标处表达式与范围
31
+ * @param path 模型工作区路径
32
+ * @returns 状态键
33
+ */
34
+ function treeKeyOf(target: { expression: string; lineNumber: number; startColumn: number; endColumn: number }, path: string): string {
35
+ return target.expression + '@' + path + ':' + target.lineNumber + ':' + target.startColumn + '-' + target.endColumn
36
+ }
37
+
38
+ /** Lua 保留字集合(保留字不是可求值表达式,避免 adapter 返回 syntax err 噪声)。 */
39
+ const LUA_KEYWORDS = new Set([
40
+ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'goto',
41
+ 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while',
42
+ ])
43
+
44
+ /** 可求值路径表达式:标识符链,成员用 . 或 : 连接(注释里的中文文本等一律不匹配)。 */
45
+ const EVAL_EXPR = /^[A-Za-z_][A-Za-z0-9_]*(?:[.:][A-Za-z_][A-Za-z0-9_]*)*$/
46
+
47
+ /**
48
+ * 是否可交给调试器求值:标识符链且非保留字。
49
+ * @author ddj 2026年09月21号
50
+ * @param text 候选表达式
51
+ * @returns 是否可求值
52
+ */
53
+ function isEvalExpr(text: string): boolean {
54
+ return EVAL_EXPR.test(text) && !LUA_KEYWORDS.has(text)
55
+ }
56
+
57
+ /**
58
+ * 从光标处提取 Lua 标识符/成员表达式(如 this.model、self:GetName)。
59
+ * 光标在链首段时只取该段(悬停 this 求 this,而不是 this.canUpdate);
60
+ * 光标在成员段时取整条链(悬停 canUpdate 求 this.canUpdate)。
61
+ * @author ddj 2026年09月29号 / 2026年09月21号
62
+ * @param model Monaco 文本模型
63
+ * @param position 光标位置
64
+ */
65
+ export function expressionAt(model: HoverModel, position: HoverPosition): { expression: string; lineNumber: number; startColumn: number; endColumn: number } | null {
66
+ const word = model.getWordAtPosition(position)
67
+ if (!word) return null
68
+ const line = model.getLineContent?.(position.lineNumber) ?? word.word ?? ''
69
+ const start = Math.max(0, word.startColumn - 1)
70
+ const end = Math.max(start, word.endColumn - 1)
71
+ const isPart = (char: string): boolean => /[A-Za-z0-9_.:]/.test(char)
72
+ let left = start
73
+ while (left > 0 && isPart(line[left - 1])) left -= 1
74
+ // 光标词即链首段:只返回该段(向右扩展会把 this 吃成 this.canUpdate)
75
+ if (left === start) {
76
+ const head = line.slice(start, end).trim()
77
+ return head ? { expression: head, lineNumber: position.lineNumber, startColumn: start + 1, endColumn: end + 1 } : null
78
+ }
79
+ let right = end
80
+ while (right < line.length && isPart(line[right])) right += 1
81
+ const expression = line.slice(left, right).trim()
82
+ return expression ? { expression, lineNumber: position.lineNumber, startColumn: left + 1, endColumn: right + 1 } : null
83
+ }
84
+
85
+ /**
86
+ * 组装调试块 HTML(头行 + 类型 + 变量树;全部动态文本转义后拼进 supportHtml 片段)。
87
+ * ⚠️ 钩子一律用 data-*:markdown 渲染会剥掉 class(见 hoverTree.ts 常量区注释)。
88
+ * @author ddj 2026年09月21号
89
+ * @param expression 光标处表达式
90
+ * @param value 求值结果
91
+ * @param type 值类型(可空)
92
+ * @param treeHtml 变量树片段(可空)
93
+ * @returns 单块 HTML
94
+ */
95
+ export function blockHtml(expression: string, value: string, type: string, treeHtml: string): string {
96
+ const head = '<div ' + HEAD_ATTR + '="1">'
97
+ + '<span ' + EXPR_ATTR + '="1">' + escapeHtml(expression) + '</span>'
98
+ + '<span ' + EQ_ATTR + '="1"> = </span>'
99
+ + '<code ' + VAL_ATTR + '="1">' + escapeHtml(value) + '</code>'
100
+ + '</div>'
101
+ const typeLine = type ? '<div ' + TYPE_ATTR + '="1">type: <code>' + escapeHtml(type) + '</code></div>' : ''
102
+ return '<div ' + BLOCK_ATTR + '="1">' + head + typeLine + treeHtml + '</div>'
103
+ }
104
+
105
+ /**
106
+ * 暂停态求值并组装 hover 内容(单个 HTML 块)。
107
+ * @author ddj 2026年09月21号
108
+ * @param monaco Monaco 命名空间
109
+ * @param model 文本模型
110
+ * @param position 光标位置
111
+ * @param token Monaco 取消令牌
112
+ * @returns hover 结果;非暂停、取不到表达式/路径、被取消时为 null
113
+ */
114
+ async function dapHoverAt(monaco: any, model: HoverModel, position: HoverPosition, token: any): Promise<{ contents: Array<{ value: string; supportHtml?: boolean }>; range: unknown } | null> {
115
+ const snapshot = dapStore.getSnapshot()
116
+ if (snapshot.phase !== 'paused' || token?.isCancellationRequested) return null
117
+ const target = expressionAt(model, position)
118
+ if (!target || !isEvalExpr(target.expression)) return null
119
+ const path = pathOfModel(model)
120
+ if (!path) return null
121
+ const result = await dapStore.evaluate(target.expression, snapshot.selectedFrameId)
122
+ if (/^syntax err/i.test(result.result)) return null
123
+ if (token?.isCancellationRequested) return null
124
+ let treeHtml = ''
125
+ if (result.ref > 0) {
126
+ const children = await dapStore.variables(result.ref)
127
+ if (token?.isCancellationRequested) return null
128
+ // 可展开树:首层随 hover 取回,点「▸」才懒加载下一层(绑定见 hoverTree.ts)
129
+ treeHtml = initialHtml(createTreeState(treeKeyOf(target, path), children))
130
+ }
131
+ return {
132
+ contents: [{ value: blockHtml(target.expression, result.result, result.type ?? '', treeHtml), supportHtml: true }],
133
+ // 必须 new:Monaco 的 Range 是 ES class(createMonacoBaseAPI 导出类本体),
134
+ // 无 new 调用抛 TypeError,被 getHover 的逐 provider try/catch 吞掉 →
135
+ // 表现为「暂停态只有 LSP 浮窗、没有调试值行」。
136
+ range: new monaco.Range(target.lineNumber, target.startColumn, target.lineNumber, target.endColumn),
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 注册暂停态 DAP hover provider(幂等,跨插件重载会先清理旧 provider)。
142
+ * @author ddj 2026年09月29号
143
+ * @param monaco Monaco 命名空间
144
+ */
145
+ export function registerDapHover(monaco: any): void {
146
+ if (!monaco?.languages?.registerHoverProvider) return
147
+ const host = typeof window === 'undefined' ? null : window as any
148
+ host?.[GLOBAL_KEY]?.dispose?.()
149
+ const disposable = monaco.languages.registerHoverProvider(['lua'], {
150
+ provideHover: async (model: HoverModel, position: HoverPosition, token: any) => {
151
+ try {
152
+ return await dapHoverAt(monaco, model, position, token)
153
+ } catch {
154
+ return null // 异常不外抛:Monaco 会把整个 provider 的结果丢弃
155
+ }
156
+ },
157
+ })
158
+ if (host) host[GLOBAL_KEY] = { dispose: () => disposable?.dispose?.() }
159
+ }
160
+
161
+ /** 注销 DAP hover provider。 */
162
+ export function disposeDapHover(): void {
163
+ const host = typeof window === 'undefined' ? null : window as any
164
+ host?.[GLOBAL_KEY]?.dispose?.()
165
+ if (host) delete host[GLOBAL_KEY]
166
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dsh-vscode-mode client — hover 修饰键(Alt)状态。
3
+ * 对齐 CodeBuddy/VS Code 调试浮窗语义:暂停态默认显示调试值,按住 Alt 切换为 LSP 信息。
4
+ * 切换走**显示层行级显隐**(hoverTree.applyAltToHovers):不重算 hover、不重跑 provider,
5
+ * 因此不受 Monaco「同 anchor 直接早退」影响;状态同步不依赖单一键事件——任意带 altKey 的输入
6
+ * 事件都会校正;且**调试浮层可见时消费裸 Alt**——否则 Chromium/宿主把 Alt 当菜单键会把焦点
7
+ * 抢出页面(实测:每次 Alt 后约 4ms 页面失焦、要 4~5s 才回来),此后键事件到不了页面,
8
+ * 切换只能等下一次鼠标移动自愈,观感就是「按下/松开都要等一会儿」。
9
+ * 只读监听(capture 阶段);安装幂等 + 可卸载,跨插件重载经 window 标记认领。
10
+ * 作者 ddj 2026年09月21号
11
+ */
12
+ import { applyAltToHovers, hasDebugHover } from './hoverTree.js'
13
+
14
+ // --region 常量与状态
15
+ /** 跨插件重载认领标记(存卸载函数)。 */
16
+ const MODE_GLOBAL = '__edrvHoverModeWatch__'
17
+ /** Alt 引发的抢焦点窗口(ms):该窗口内的 blur 不回退状态(见 onBlur)。 */
18
+ const BLUR_GRACE_MS = 500
19
+ /** Alt 是否按住(供 hover provider 同步判定,无需异步等待)。 */
20
+ let altHeld = false
21
+ /** 最近一次「Alt 按住」键事件时间戳(blur 条件复位用)。 */
22
+ let lastAltAt = 0
23
+ /** Alt 状态变化订阅者。 */
24
+ const altListeners = new Set<(held: boolean) => void>()
25
+ // --endregion
26
+
27
+ /**
28
+ * 更新 Alt 状态:仅在真正变化时同步显示层并通知订阅者。
29
+ * @author ddj 2026年09月21号
30
+ * @param next 新状态
31
+ */
32
+ function setAlt(next: boolean): void {
33
+ if (altHeld === next) return
34
+ altHeld = next
35
+ applyAltToHovers(next)
36
+ for (const fn of Array.from(altListeners)) {
37
+ try { fn(next) } catch { /* 单个订阅异常不影响其它订阅者 */ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 订阅 Alt 状态变化。
43
+ * @author ddj 2026年09月21号
44
+ * @param fn 变化回调(参数为当前是否按住)
45
+ * @returns 退订函数
46
+ */
47
+ export function onAltChange(fn: (held: boolean) => void): () => void {
48
+ altListeners.add(fn)
49
+ return () => { altListeners.delete(fn) }
50
+ }
51
+
52
+ /**
53
+ * Alt 是否按住。
54
+ * @author ddj 2026年09月21号
55
+ * @returns 是否按住
56
+ */
57
+ export function isAltHeld(): boolean {
58
+ return altHeld
59
+ }
60
+
61
+ /**
62
+ * 设置 Alt 状态(测试注入用;运行期由监听器维护)。
63
+ * @author ddj 2026年09月21号
64
+ * @param on 是否按住
65
+ */
66
+ export function setAltHeld(on: boolean): void {
67
+ setAlt(Boolean(on))
68
+ }
69
+
70
+ /**
71
+ * 是否应消费裸 Alt:仅 Alt 单键(无 Ctrl/Shift/Meta)且有调试浮层可见。
72
+ * 消费 = preventDefault + stopPropagation,阻止浏览器/宿主把 Alt 当菜单键抢走焦点
73
+ * (焦点一旦移出页面,后续键事件到不了页面,切换只能等下一次鼠标移动自愈)。
74
+ * @author ddj 2026年09月21号
75
+ * @param ev 键盘事件(仅取修饰键字段,便于单测)
76
+ * @param debugHoverVisible 是否有可见的调试浮层
77
+ * @returns 是否消费该事件
78
+ */
79
+ export function consumesBareAlt(ev: { key: string; ctrlKey?: boolean; shiftKey?: boolean; metaKey?: boolean }, debugHoverVisible: boolean): boolean {
80
+ return ev.key === 'Alt' && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey && debugHoverVisible
81
+ }
82
+
83
+ /**
84
+ * Alt 键事件:消费(必要时)+ 记录时间(供 blur 条件复位判断)+ 同步状态。
85
+ * @author ddj 2026年09月21号
86
+ * @param ev 键盘事件
87
+ */
88
+ function onAltKey(ev: KeyboardEvent): void {
89
+ if (ev.key !== 'Alt') return
90
+ if (consumesBareAlt(ev, hasDebugHover())) {
91
+ ev.preventDefault()
92
+ ev.stopPropagation()
93
+ }
94
+ lastAltAt = Date.now()
95
+ setAlt(ev.type === 'keydown')
96
+ }
97
+
98
+ /**
99
+ * 任意输入事件的 altKey 校正(键事件被吞时的兜底)。
100
+ * @author ddj 2026年09月21号
101
+ * @param ev 鼠标/键盘事件
102
+ */
103
+ function onAnyInput(ev: Event): void {
104
+ const held = Boolean((ev as MouseEvent).altKey)
105
+ if (held) lastAltAt = Date.now()
106
+ setAlt(held)
107
+ }
108
+
109
+ /**
110
+ * 窗口失焦复位(条件):刚看到 Alt 键事件则不复位
111
+ * (既可能是宿主菜单抢焦点的 blur,也可能是紧邻 Alt 的页内元素级 blur)。
112
+ * @author ddj 2026年09月21号
113
+ */
114
+ function onBlur(): void {
115
+ const since = lastAltAt ? Date.now() - lastAltAt : -1
116
+ if (since >= 0 && since < BLUR_GRACE_MS) return
117
+ setAlt(false)
118
+ }
119
+
120
+ /**
121
+ * 安装 Alt 跟踪(幂等)。
122
+ * keydown/keyup 记 Alt;任意带 altKey 的输入事件(含 mousemove/mousedown/wheel)校正——
123
+ * 宿主吞掉 Alt 键事件时也能随下一次输入自愈;blur 仅在「不是刚按 Alt」时复位。
124
+ * @author ddj 2026年09月21号
125
+ */
126
+ export function installHoverMode(): void {
127
+ if (typeof window === 'undefined') return
128
+ const host = window as unknown as Record<string, unknown>
129
+ if (typeof host[MODE_GLOBAL] === 'function') return // 上一代已安装(window 跨重载存活)
130
+ const bound: Array<[string, EventListener]> = [
131
+ ['keydown', onAltKey as EventListener],
132
+ ['keyup', onAltKey as EventListener],
133
+ ['keydown', onAnyInput as EventListener],
134
+ ['mousemove', onAnyInput as EventListener],
135
+ ['mousedown', onAnyInput as EventListener],
136
+ ['wheel', onAnyInput as EventListener],
137
+ ['blur', onBlur as EventListener],
138
+ ]
139
+ for (const [type, fn] of bound) window.addEventListener(type, fn, true)
140
+ applyAltToHovers(altHeld) // 已存在的 hover 按当前状态对齐
141
+ host[MODE_GLOBAL] = (): void => {
142
+ for (const [type, fn] of bound) window.removeEventListener(type, fn, true)
143
+ altHeld = false // 卸载路径直接复位,不再触发重算
144
+ }
145
+ }
146
+
147
+ /**
148
+ * 卸载 Alt 跟踪(插件重载/卸载时调用)。
149
+ * @author ddj 2026年09月21号
150
+ */
151
+ export function disposeHoverMode(): void {
152
+ if (typeof window === 'undefined') return
153
+ const host = window as unknown as Record<string, unknown>
154
+ const off = host[MODE_GLOBAL]
155
+ if (typeof off === 'function') off()
156
+ delete host[MODE_GLOBAL]
157
+ altHeld = false
158
+ lastAltAt = 0
159
+ }