dsh-vscode-mode 0.11.0 → 0.13.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.
- package/README.md +158 -111
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +13362 -11806
- package/lib/client.js.map +1 -1
- package/lib/index.js +4110 -2507
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/activePathStore.ts +28 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +14 -42
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +101 -18
- package/src/client/keybindings.ts +36 -73
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutRecorder.ts +161 -0
- package/src/client/shortcutsOfficial.ts +460 -0
- package/src/client/sidebar/panels/RulesPanel.ts +15 -11
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +246 -137
- package/src/client/ui/McpSettings.ts +10 -8
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +16 -16
- package/src/client/ui/ShortcutSettings.ts +274 -0
- package/src/client/ui/commandCatalog.ts +39 -43
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +4 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/rules.ts +100 -44
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +256 -0
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/rules.ts +9 -1
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
- package/src/client/ui/KeybindingsPanel.ts +0 -190
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SessionScope } from './sessionScope.js'
|
|
2
|
+
|
|
3
|
+
export type PanelAction = 'quickOpen' | 'searchInFiles'
|
|
4
|
+
export interface PanelRoute {
|
|
5
|
+
readScope(): SessionScope
|
|
6
|
+
canOpen(): boolean
|
|
7
|
+
open(): boolean
|
|
8
|
+
}
|
|
9
|
+
interface PendingPanel extends SessionScope { action: PanelAction; expires: number }
|
|
10
|
+
interface PanelHost { action: PanelAction; scope: SessionScope; run(): void }
|
|
11
|
+
let route: PanelRoute | null = null
|
|
12
|
+
const hosts = new Set<PanelHost>()
|
|
13
|
+
const storage = globalThis as typeof globalThis & { __edrvPanelCommand__?: PendingPanel }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compare the command's session and workspace identity.
|
|
17
|
+
* @author ddj 2026年09月28号
|
|
18
|
+
* @param left Requested scope.
|
|
19
|
+
* @param right Mounted scope.
|
|
20
|
+
* @returns Whether both refer to the same session and known workspace.
|
|
21
|
+
*/
|
|
22
|
+
function sameScope(left: SessionScope, right: SessionScope): boolean {
|
|
23
|
+
return Boolean(left.sessionId && left.sessionId === right.sessionId
|
|
24
|
+
&& (!left.cwd || !right.cwd || left.cwd === right.cwd))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the most recently mounted matching command receiver.
|
|
29
|
+
* @author ddj 2026年09月28号
|
|
30
|
+
* @param action Panel action.
|
|
31
|
+
* @param scope Active session scope.
|
|
32
|
+
* @returns Receiver if mounted.
|
|
33
|
+
*/
|
|
34
|
+
function panelHost(action: PanelAction, scope: SessionScope): PanelHost | undefined {
|
|
35
|
+
return [...hosts].reverse().find((host) => host.action === action && sameScope(scope, host.scope))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Install the existing sidebar route and current-session reader.
|
|
40
|
+
* @author ddj 2026年09月28号
|
|
41
|
+
* @param next Route capabilities; no side effects during availability checks.
|
|
42
|
+
* @returns Identity-safe disposal; pending intent survives plugin reload.
|
|
43
|
+
*/
|
|
44
|
+
export function setPanelRoute(next: PanelRoute): () => void {
|
|
45
|
+
route = next
|
|
46
|
+
return () => { if (route === next) route = null }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check that a command has a mounted receiver or a usable editor route.
|
|
51
|
+
* @author ddj 2026年09月28号
|
|
52
|
+
* @param action Panel action.
|
|
53
|
+
* @returns False when genuinely unavailable, allowing official key dispatch to pass.
|
|
54
|
+
*/
|
|
55
|
+
export function panelAvailable(action: PanelAction): boolean {
|
|
56
|
+
const scope = route?.readScope()
|
|
57
|
+
if (!scope?.sessionId) return false
|
|
58
|
+
return Boolean(panelHost(action, scope) || route?.canOpen())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Deliver now or preserve an intent until the editor mounts.
|
|
63
|
+
* @author ddj 2026年09月28号
|
|
64
|
+
* @param action Panel action.
|
|
65
|
+
* @returns Whether the mounted host or sidebar accepted the request.
|
|
66
|
+
*/
|
|
67
|
+
export function runPanelCommand(action: PanelAction): boolean {
|
|
68
|
+
const scope = route?.readScope()
|
|
69
|
+
if (!scope?.sessionId) return false
|
|
70
|
+
const host = panelHost(action, scope)
|
|
71
|
+
if (host) { host.run(); return true }
|
|
72
|
+
if (!route?.canOpen()) return false
|
|
73
|
+
const pending = { ...scope, action, expires: Date.now() + 30_000 }
|
|
74
|
+
storage.__edrvPanelCommand__ = pending
|
|
75
|
+
try {
|
|
76
|
+
if (route.open()) return true
|
|
77
|
+
} catch { /* A disappearing sidebar leaves the command unhandled. */ }
|
|
78
|
+
if (storage.__edrvPanelCommand__ === pending) delete storage.__edrvPanelCommand__
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attach one scoped receiver and consume a still-current pending intent once.
|
|
84
|
+
* @author ddj 2026年09月28号
|
|
85
|
+
* @param action Panel action.
|
|
86
|
+
* @param scope Mounted session/workspace.
|
|
87
|
+
* @param run Action using the mounted component's state.
|
|
88
|
+
* @returns Receiver disposal.
|
|
89
|
+
*/
|
|
90
|
+
export function bindPanelCommand(action: PanelAction, scope: SessionScope, run: () => void): () => void {
|
|
91
|
+
const host = { action, scope, run }
|
|
92
|
+
hosts.add(host)
|
|
93
|
+
const pending = storage.__edrvPanelCommand__
|
|
94
|
+
if (pending && (pending.expires < Date.now() || !sameScope(pending, route?.readScope() ?? {}))) {
|
|
95
|
+
delete storage.__edrvPanelCommand__
|
|
96
|
+
} else if (pending?.action === action && sameScope(pending, scope)) {
|
|
97
|
+
delete storage.__edrvPanelCommand__
|
|
98
|
+
run()
|
|
99
|
+
}
|
|
100
|
+
return () => { hosts.delete(host) }
|
|
101
|
+
}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* 作者 ddj 2026-09-22
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { appUrl } from '../../shared/appUrl.js'
|
|
11
|
+
|
|
12
|
+
/** vendor 根,保留官方桌面协议及反向代理前缀。 */
|
|
13
|
+
export const PDFJS_BASE = appUrl('/edrv/vendor/pdfjs')
|
|
12
14
|
/** pdf.js Worker 地址(GlobalWorkerOptions.workerSrc)。 */
|
|
13
15
|
export const PDFJS_WORKER_SRC = PDFJS_BASE + '/build/pdf.worker.mjs'
|
|
14
16
|
/** CJK 字符映射目录(中文 PDF 渲染必需,尾斜杠固定)。 */
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { absoluteOf } from './tabActions.js'
|
|
2
|
+
import type { EditorRequest } from './openReceipt.js'
|
|
3
|
+
|
|
4
|
+
/** Identity of one load, committed together with its text or preview state. */
|
|
5
|
+
export interface ReceiptLoad {
|
|
6
|
+
sessionId: string
|
|
7
|
+
cwd: string | null
|
|
8
|
+
path: string
|
|
9
|
+
sequence: number
|
|
10
|
+
status: 'loading' | 'ready' | 'error'
|
|
11
|
+
error?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Current rendered facts; generic save/SVN errors intentionally have no receipt field. */
|
|
15
|
+
export interface ReceiptView {
|
|
16
|
+
sessionId: string
|
|
17
|
+
cwd?: string | null
|
|
18
|
+
active: string | null
|
|
19
|
+
sequence: number
|
|
20
|
+
load: ReceiptLoad | null
|
|
21
|
+
mounted: boolean
|
|
22
|
+
blocked: boolean
|
|
23
|
+
kind: 'text' | 'image' | 'pdf' | 'markdown'
|
|
24
|
+
contentPath: string | null
|
|
25
|
+
contentReady: boolean
|
|
26
|
+
modelMatches: boolean
|
|
27
|
+
positionMatches: boolean
|
|
28
|
+
imageLoaded: boolean
|
|
29
|
+
pdfLoaded: boolean
|
|
30
|
+
engineError?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize identity using the workspace's path syntax, never the browser OS.
|
|
35
|
+
* @public @author ddj 2026年09月28号
|
|
36
|
+
* @param path File path.
|
|
37
|
+
* @param cwd Workspace root, including remote POSIX roots on Windows clients.
|
|
38
|
+
* @returns Absolute normalized identity, case folded only for Windows paths.
|
|
39
|
+
*/
|
|
40
|
+
export function receiptPath(path: string, cwd?: string | null): string {
|
|
41
|
+
const value = absoluteOf(path, cwd)
|
|
42
|
+
return /^(?:[a-z]:\/|\/\/)/i.test(value) ? value.toLowerCase() : value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Preserve POSIX case when converting an external path into a tab path.
|
|
47
|
+
* @public @author ddj 2026年09月28号
|
|
48
|
+
* @param path External file path.
|
|
49
|
+
* @param cwd Workspace root.
|
|
50
|
+
* @returns Relative path only when it is inside the same correctly cased root.
|
|
51
|
+
*/
|
|
52
|
+
export function receiptTabPath(path: string, cwd?: string | null): string {
|
|
53
|
+
const value = path.replace(/\\/g, '/')
|
|
54
|
+
const base = (cwd ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
55
|
+
if (base && receiptPath(value).startsWith(receiptPath(base) + '/')) return value.slice(base.length + 1)
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Match a load to the current request, rendered path, and live load sequence.
|
|
61
|
+
* @private @author ddj 2026年09月28号
|
|
62
|
+
* @param request Claimed request.
|
|
63
|
+
* @param view Current render and load metadata.
|
|
64
|
+
* @returns Whether load facts belong to this exact target and generation.
|
|
65
|
+
*/
|
|
66
|
+
function ownsLoad(request: EditorRequest, view: ReceiptView): boolean {
|
|
67
|
+
const load = view.load
|
|
68
|
+
if (!request.path || !view.active || !load) return false
|
|
69
|
+
const wanted = receiptPath(request.path, view.cwd)
|
|
70
|
+
return request.sessionId === view.sessionId && load.sessionId === view.sessionId
|
|
71
|
+
&& load.sequence === view.sequence && receiptPath(load.path, load.cwd) === wanted
|
|
72
|
+
&& receiptPath(view.active, view.cwd) === wanted
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Confirm actual content/preview/navigation readiness for a correlated generation.
|
|
77
|
+
* @public @author ddj 2026年09月28号
|
|
78
|
+
* @param request Claimed external open.
|
|
79
|
+
* @param view Rendered state, including model identity and DOM readiness.
|
|
80
|
+
* @returns True only when the requested target is genuinely presented.
|
|
81
|
+
*/
|
|
82
|
+
export function receiptReadyFor(request: EditorRequest, view: ReceiptView): boolean {
|
|
83
|
+
if (request.sessionId !== view.sessionId) return false
|
|
84
|
+
if (!request.path) return view.mounted
|
|
85
|
+
if (!ownsLoad(request, view) || view.load?.status !== 'ready' || view.blocked || view.engineError) return false
|
|
86
|
+
if (view.kind === 'image') return view.imageLoaded
|
|
87
|
+
if (view.kind === 'pdf') return view.pdfLoaded
|
|
88
|
+
if (!view.contentReady || !view.contentPath || receiptPath(view.contentPath, view.cwd) !== receiptPath(request.path, view.cwd)) return false
|
|
89
|
+
const positioned = request.line != null || request.column != null
|
|
90
|
+
if (view.kind === 'markdown') return !positioned
|
|
91
|
+
return view.modelMatches && (!positioned || view.positionMatches)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return only errors from this target's current load/render generation.
|
|
96
|
+
* @public @author ddj 2026年09月28号
|
|
97
|
+
* @param request Claimed request.
|
|
98
|
+
* @param view Rendered metadata and editor-engine startup failure.
|
|
99
|
+
* @returns Correlated error, otherwise null; prior files and save errors are ignored.
|
|
100
|
+
*/
|
|
101
|
+
export function receiptErrorFor(request: EditorRequest, view: ReceiptView): string | null {
|
|
102
|
+
if (!ownsLoad(request, view)) return null
|
|
103
|
+
if (view.engineError) return view.engineError
|
|
104
|
+
return view.load?.status === 'error' ? view.load.error ?? '文件加载失败' : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Guard delayed load/render callbacks against a replacement generation.
|
|
109
|
+
* @public @author ddj 2026年09月28号
|
|
110
|
+
* @param load Current committed metadata.
|
|
111
|
+
* @param sessionId Callback owner.
|
|
112
|
+
* @param path Callback target, already normalized as a tab path.
|
|
113
|
+
* @param sequence Callback load sequence.
|
|
114
|
+
* @param currentSequence Live loader sequence.
|
|
115
|
+
* @returns Whether this callback may update the receipt status.
|
|
116
|
+
*/
|
|
117
|
+
export function acceptsLoad(load: ReceiptLoad | null, sessionId: string, path: string, sequence: number, currentSequence: number): boolean {
|
|
118
|
+
return Boolean(load && load.sessionId === sessionId && load.path === path
|
|
119
|
+
&& load.sequence === sequence && sequence === currentSequence)
|
|
120
|
+
}
|
package/src/client/rpc.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 诊断开关运行时化:内存缓存 + localStorage 持久(日志弹窗开关即改即生效,免刷新)。
|
|
5
5
|
* 作者 ddj 2026-08-20 / 2026-09-17
|
|
6
6
|
*/
|
|
7
|
+
import { appUrl } from '../shared/appUrl.js'
|
|
7
8
|
import { RPC_PATH } from '../shared/rpc.js'
|
|
8
9
|
import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
|
|
9
10
|
import type { LoggerLevel } from '../shared/logger.js'
|
|
@@ -35,13 +36,38 @@ export function setDebugEnabled(on: boolean): void {
|
|
|
35
36
|
} catch (e) { /* 无 localStorage 环境:仅内存生效 */ }
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Call the authenticated RPC route using the current application base.
|
|
41
|
+
* @author ddj 2026年09月28号
|
|
42
|
+
* @param method Typed business method.
|
|
43
|
+
* @param args Business arguments.
|
|
44
|
+
* @param signal Optional cancellation signal.
|
|
45
|
+
* @returns A validated business result; transport failures reject with a readable error.
|
|
46
|
+
*/
|
|
47
|
+
export async function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
|
|
48
|
+
const res = await fetch(appUrl(RPC_PATH), {
|
|
41
49
|
method: 'POST',
|
|
50
|
+
credentials: 'same-origin',
|
|
42
51
|
headers: { 'content-type': 'application/json' },
|
|
43
52
|
body: JSON.stringify({ method, args }),
|
|
44
|
-
|
|
53
|
+
signal,
|
|
54
|
+
})
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const message = res.status === 401 ? 'DSH 登录已失效,请重新打开已登录的应用页面'
|
|
57
|
+
: res.status === 403 ? 'DSH 拒绝了当前页面的访问'
|
|
58
|
+
: res.status === 413 ? '请求超过支持的文件大小'
|
|
59
|
+
: res.status === 503 ? 'DSH 认证服务尚未就绪'
|
|
60
|
+
: 'DSH 请求失败(HTTP ' + res.status + ')'
|
|
61
|
+
throw new Error(message)
|
|
62
|
+
}
|
|
63
|
+
if (!/^application\/json(?:\s*;|\s*$)/i.test(res.headers.get('content-type') ?? '')) {
|
|
64
|
+
throw new Error('DSH 返回了非 JSON 响应,请检查应用地址和代理配置')
|
|
65
|
+
}
|
|
66
|
+
const value: unknown = await res.json()
|
|
67
|
+
if (!value || typeof value !== 'object' || typeof (value as { ok?: unknown }).ok !== 'boolean') {
|
|
68
|
+
throw new Error('DSH RPC 响应格式不正确')
|
|
69
|
+
}
|
|
70
|
+
return value as RpcResult<M>
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
/**
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { OfficialBinding, OfficialModifier } from '../shared/keybindings.js'
|
|
2
|
+
import type { ShortcutsServiceLike } from './shortcutsOfficial.js'
|
|
3
|
+
|
|
4
|
+
export interface RecordHooks {
|
|
5
|
+
ready(): void
|
|
6
|
+
save(binding: OfficialBinding | null): void
|
|
7
|
+
cancel(): void
|
|
8
|
+
error(error: unknown): void
|
|
9
|
+
}
|
|
10
|
+
const recorderHost = globalThis as typeof globalThis & { __edrvRecording__?: WeakMap<ShortcutsServiceLike, Promise<void>> }
|
|
11
|
+
const leases = recorderHost.__edrvRecording__ ??= new WeakMap<ShortcutsServiceLike, Promise<void>>()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Serialize native recorder state, including cleanup after a late ready response.
|
|
15
|
+
* @author ddj 2026年09月28号
|
|
16
|
+
* @param service Native/Web shortcut service.
|
|
17
|
+
* @param active Desired native interception state.
|
|
18
|
+
* @returns Completion of the state change; errors remain visible to the caller.
|
|
19
|
+
*/
|
|
20
|
+
function recordState(service: ShortcutsServiceLike, active: boolean): Promise<void> {
|
|
21
|
+
const prior = leases.get(service) ?? Promise.resolve()
|
|
22
|
+
const next = prior.catch(() => {}).then(() => service.recording(active))
|
|
23
|
+
leases.set(service, next)
|
|
24
|
+
return next
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read physical modifiers without converting Control or Meta into logical primary.
|
|
29
|
+
* @author ddj 2026年09月28号
|
|
30
|
+
* @param event Keyboard event.
|
|
31
|
+
* @returns Modifiers in official canonical order.
|
|
32
|
+
*/
|
|
33
|
+
export function eventModifiers(event: KeyboardEvent): OfficialModifier[] {
|
|
34
|
+
const states = { control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, meta: event.metaKey }
|
|
35
|
+
return (Object.keys(states) as Array<keyof typeof states>).filter((key) => states[key])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Physical key collection; native readiness and listeners are owned by startRecording. */
|
|
39
|
+
export class KeyCapture {
|
|
40
|
+
private held = new Set<string>()
|
|
41
|
+
private pending: OfficialBinding | null = null
|
|
42
|
+
private blocked = false
|
|
43
|
+
private dead = false
|
|
44
|
+
private composing = false
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a physical collector with official platform capabilities.
|
|
48
|
+
* @author ddj 2026年09月28号
|
|
49
|
+
* @param service Official binding validator and profile.
|
|
50
|
+
* @param hooks Completion/error handlers.
|
|
51
|
+
*/
|
|
52
|
+
constructor(private service: ShortcutsServiceLike, private hooks: RecordHooks) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reset a gesture on blur or composition transitions.
|
|
56
|
+
* @author ddj 2026年09月28号
|
|
57
|
+
* @param composing Whether IME composition is currently active.
|
|
58
|
+
*/
|
|
59
|
+
reset(composing = false): void {
|
|
60
|
+
this.held.clear(); this.pending = null; this.blocked = false; this.dead = false
|
|
61
|
+
this.composing = composing
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect one physical press; commit only when a constituent key is released.
|
|
66
|
+
* @author ddj 2026年09月28号
|
|
67
|
+
* @param event Keyboard press; guarded IME/AltGraph input is left untouched.
|
|
68
|
+
*/
|
|
69
|
+
down(event: KeyboardEvent): void {
|
|
70
|
+
if (this.composing || event.isComposing || event.keyCode === 229 || event.getModifierState?.('AltGraph')) {
|
|
71
|
+
this.pending = null; this.held.clear(); return
|
|
72
|
+
}
|
|
73
|
+
const commandDead = this.service.runtime === 'web' && this.service.platform === 'macos'
|
|
74
|
+
&& event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey
|
|
75
|
+
if (event.key === 'Dead' && !commandDead) { this.reset(); this.dead = true; return }
|
|
76
|
+
if (this.dead) { this.dead = false; return }
|
|
77
|
+
event.preventDefault(); event.stopPropagation()
|
|
78
|
+
if (event.repeat) return
|
|
79
|
+
const modifiers = eventModifiers(event)
|
|
80
|
+
if (!modifiers.length && event.key === 'Escape') { this.hooks.cancel(); return }
|
|
81
|
+
if (!modifiers.length && (event.key === 'Backspace' || event.key === 'Delete')) { this.hooks.save(null); return }
|
|
82
|
+
if (/^(Control|Alt|Shift|Meta|AltGraph)(Left|Right)?$/u.test(event.code || event.key)) {
|
|
83
|
+
if (this.pending) this.reset()
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const chords = this.service.runtime === 'desktop' && ['macos', 'windows'].includes(this.service.platform)
|
|
87
|
+
if (chords) this.held.add(event.code)
|
|
88
|
+
if (this.blocked) return
|
|
89
|
+
if (this.held.size > 2) {
|
|
90
|
+
this.pending = null; this.blocked = true; this.hooks.error('最多同时录制两个主键'); return
|
|
91
|
+
}
|
|
92
|
+
const codes = chords ? [...this.held] : [event.code]
|
|
93
|
+
try {
|
|
94
|
+
const probe = this.service.describeBinding({ code: codes[0], modifiers, ...(codes[1] ? { secondCode: codes[1] } : {}) })
|
|
95
|
+
if (!probe.binding || probe.issue) throw new Error(probe.issue ?? 'unsupported-key')
|
|
96
|
+
this.pending = probe.binding
|
|
97
|
+
} catch (error) { this.pending = null; this.hooks.error(error) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Commit an accepted physical gesture once at release.
|
|
102
|
+
* @author ddj 2026年09月28号
|
|
103
|
+
* @param event Released key or modifier.
|
|
104
|
+
*/
|
|
105
|
+
up(event: KeyboardEvent): void {
|
|
106
|
+
this.held.delete(event.code)
|
|
107
|
+
const binding = this.pending
|
|
108
|
+
const modifier = event.code.replace(/(Left|Right)$/u, '').toLowerCase() as OfficialModifier
|
|
109
|
+
if (!binding || !(binding.code === event.code || binding.secondCode === event.code || binding.modifiers.includes(modifier))) return
|
|
110
|
+
this.pending = null
|
|
111
|
+
event.preventDefault(); event.stopPropagation()
|
|
112
|
+
this.hooks.save(binding)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Protect recording before listening, and restore interception on every exit path.
|
|
118
|
+
* @author ddj 2026年09月28号
|
|
119
|
+
* @param service Official service whose asynchronous native bridge must be ready.
|
|
120
|
+
* @param target Owning window, injectable for lifecycle tests.
|
|
121
|
+
* @param hooks Ready, save, cancel and failure callbacks.
|
|
122
|
+
* @returns Idempotent disposal, safe before readiness and after failure.
|
|
123
|
+
*/
|
|
124
|
+
export function startRecording(service: ShortcutsServiceLike, target: EventTarget, hooks: RecordHooks): () => void {
|
|
125
|
+
let disposed = false
|
|
126
|
+
let ready = false
|
|
127
|
+
/** @author ddj 2026年09月28号 @param binding Accepted binding; release interception before saving. */
|
|
128
|
+
const save = (binding: OfficialBinding | null): void => { dispose(); hooks.save(binding) }
|
|
129
|
+
/** @author ddj 2026年09月28号 Cancel capture and restore native interception. */
|
|
130
|
+
const cancel = (): void => { dispose(); hooks.cancel() }
|
|
131
|
+
const capture = new KeyCapture(service, { ...hooks, save, cancel })
|
|
132
|
+
/** @author ddj 2026年09月28号 @param event Physical key press. */
|
|
133
|
+
const down = (event: Event): void => { if (ready) capture.down(event as KeyboardEvent) }
|
|
134
|
+
/** @author ddj 2026年09月28号 @param event Physical key release. */
|
|
135
|
+
const up = (event: Event): void => { if (ready) capture.up(event as KeyboardEvent) }
|
|
136
|
+
/** @author ddj 2026年09月28号 Reset incomplete gesture on focus loss. */
|
|
137
|
+
const blur = (): void => capture.reset()
|
|
138
|
+
/** @author ddj 2026年09月28号 Guard the complete IME composition lifecycle. */
|
|
139
|
+
const compose = (): void => capture.reset(true)
|
|
140
|
+
const listeners: Array<[string, EventListener]> = [
|
|
141
|
+
['keydown', down], ['keyup', up], ['blur', blur], ['compositionstart', compose], ['compositionend', blur],
|
|
142
|
+
]
|
|
143
|
+
/** @author ddj 2026年09月28号 Release DOM listeners and serialize native restoration. */
|
|
144
|
+
const dispose = (): void => {
|
|
145
|
+
if (disposed) return
|
|
146
|
+
disposed = true; ready = false
|
|
147
|
+
for (const [name, handler] of listeners) target.removeEventListener(name, handler, { capture: true })
|
|
148
|
+
void recordState(service, false).catch(hooks.error)
|
|
149
|
+
}
|
|
150
|
+
/** @author ddj 2026年09月28号 Activate capture only after native acknowledgment. */
|
|
151
|
+
const activate = (): void => {
|
|
152
|
+
if (disposed) return
|
|
153
|
+
ready = true
|
|
154
|
+
for (const [name, handler] of listeners) target.addEventListener(name, handler, { capture: true })
|
|
155
|
+
hooks.ready()
|
|
156
|
+
}
|
|
157
|
+
/** @author ddj 2026年09月28号 @param error Native activation failure. */
|
|
158
|
+
const fail = (error: unknown): void => { if (!disposed) { dispose(); hooks.error(error) } }
|
|
159
|
+
void recordState(service, true).then(activate, fail)
|
|
160
|
+
return dispose
|
|
161
|
+
}
|