dsh-vscode-mode 0.12.0 → 0.13.1

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 (71) hide show
  1. package/README.md +148 -112
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +30185 -29711
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +3889 -2336
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/addToConversation.ts +4 -2
  14. package/src/client/ai/inlineProvider.ts +1 -8
  15. package/src/client/commandBridge.ts +6 -2
  16. package/src/client/externalOpen.ts +40 -19
  17. package/src/client/index.ts +17 -15
  18. package/src/client/keybindings.ts +18 -1
  19. package/src/client/monaco/loader.ts +3 -1
  20. package/src/client/openFlow.ts +36 -28
  21. package/src/client/openReceipt.ts +64 -0
  22. package/src/client/panelCommands.ts +101 -0
  23. package/src/client/pdf/pdfLoader.ts +4 -2
  24. package/src/client/receiptState.ts +120 -0
  25. package/src/client/rpc.ts +30 -4
  26. package/src/client/shortcutsOfficial.ts +39 -4
  27. package/src/client/sidebarBridge.ts +7 -0
  28. package/src/client/styles/mcp.css +1 -19
  29. package/src/client/ui/DiffBadge.ts +2 -1
  30. package/src/client/ui/EditorView.ts +237 -55
  31. package/src/client/ui/McpSettings.ts +32 -7
  32. package/src/client/ui/OfficialSideTab.ts +5 -2
  33. package/src/client/ui/QuickOpen.ts +10 -9
  34. package/src/client/ui/commandCatalog.ts +5 -4
  35. package/src/client/useOpenReceipt.ts +128 -0
  36. package/src/compat.ts +5 -3
  37. package/src/dap/manager.ts +46 -13
  38. package/src/devForm.ts +252 -92
  39. package/src/externalHandoff.ts +93 -52
  40. package/src/fileOpenSettings.ts +1 -24
  41. package/src/hostImport.ts +19 -0
  42. package/src/httpGuard.ts +101 -0
  43. package/src/index.ts +106 -97
  44. package/src/integrate.ts +56 -50
  45. package/src/lsp/transport.ts +84 -127
  46. package/src/mcp.ts +58 -9
  47. package/src/mcpIsolation.ts +211 -102
  48. package/src/mcpProject.ts +220 -133
  49. package/src/mcpRuntime.ts +343 -0
  50. package/src/openBridge.ts +69 -0
  51. package/src/openInbox.ts +207 -0
  52. package/src/paths.ts +8 -5
  53. package/src/processTree.ts +63 -0
  54. package/src/reveal.ts +5 -3
  55. package/src/routes.ts +156 -154
  56. package/src/rpc.ts +14 -13
  57. package/src/shared/appUrl.ts +19 -0
  58. package/src/shared/externalOpen.ts +71 -0
  59. package/src/shared/integration.ts +2 -0
  60. package/src/shared/keybindings.ts +8 -5
  61. package/src/shared/mcp.ts +4 -1
  62. package/src/shared/rpc.ts +7 -4
  63. package/src/shared/svn.ts +6 -8
  64. package/src/svnPlanTool.ts +51 -0
  65. package/src/unityBridge.ts +26 -3
  66. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  67. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  68. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  69. package/unity/com.dsh.editor/README.md +16 -25
  70. package/unity/com.dsh.editor/package.json +2 -2
  71. package/src/client/ui/ShortcutSettings.ts +0 -294
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import { applyOfficial, registerThemes } from './theme.js'
8
8
 
9
- export const MONACO_BASE = '/edrv/vendor/monaco/vs'
9
+ import { appUrl } from '../../shared/appUrl.js'
10
+
11
+ export const MONACO_BASE = appUrl('/edrv/vendor/monaco/vs')
10
12
  let monacoPromise = null
11
13
  let monacoStage = { phase: 'idle', progress: 0, message: '准备加载 Monaco…' }
12
14
  const stageListeners = new Set()
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { rpc } from './rpc.js'
10
10
  import { chooseWorkspace } from './chooseDialog.js'
11
- import { emitOpenAt, openEditorView } from './events.js'
11
+ import { requestOpen } from './openReceipt.js'
12
12
  import { toastDom } from './externalOpen.js'
13
13
  import { createAddToConversation } from './addToConversation.js'
14
14
  import type { AddOutcome, CtxLike } from './addToConversation.js'
@@ -47,7 +47,7 @@ export interface OpenDeps {
47
47
  openSession(id: string): void
48
48
  reference(sessionId: string, path: string, appearance: 'file' | 'folder'): Promise<boolean>
49
49
  choose(title: string, folder: string): Promise<'recent' | 'create' | null>
50
- openEditor(path: string | null, line?: number, column?: number): void
50
+ openEditor(path: string | null, line?: number, column?: number, sessionId?: string): Promise<boolean>
51
51
  schedule(fn: () => void, ms: number): void
52
52
  notify(text: string): void
53
53
  }
@@ -57,8 +57,6 @@ const READY_TIMEOUT_MS = 15000
57
57
  const READY_TICK_MS = 300
58
58
  const REF_RETRY_MAX = 20
59
59
  const REF_RETRY_MS = 300
60
- const REST_STEP_MS = 150
61
- const LINE_AT_DELAY_MS = 200
62
60
 
63
61
  /**
64
62
  * 路径规范化:`\→/`、小写、去尾分隔符(Windows 大小写不敏感前缀匹配用)。
@@ -124,11 +122,11 @@ export function pickRule(kind: PathKind, matched: boolean, hasSession: boolean):
124
122
  * @param params 深链参数(首路径驱动规则)
125
123
  * @param overrides 依赖覆盖(测试注入)
126
124
  */
127
- export async function openDeepLink(ctx: unknown, params: OpenParams, overrides: Partial<OpenDeps> = {}): Promise<void> {
125
+ export async function openDeepLink(ctx: unknown, params: OpenParams, overrides: Partial<OpenDeps> = {}): Promise<boolean> {
128
126
  const deps = realDeps(ctx, overrides)
129
127
  if (!(await waitListsReady(deps))) {
130
128
  deps.notify('DSH 会话列表未就绪,请稍后重试')
131
- return
129
+ return false
132
130
  }
133
131
  const primary = params.paths[0]
134
132
  const kind = await deps.stat(primary).catch(() => 'missing' as PathKind)
@@ -137,14 +135,16 @@ export async function openDeepLink(ctx: unknown, params: OpenParams, overrides:
137
135
  try {
138
136
  if (rule === 'abort') {
139
137
  deps.notify('路径不存在:' + primary)
140
- return
138
+ return false
141
139
  }
142
- if (rule === 'choose') return await runChoose(deps, primary, params)
143
- if (rule === 'folderNew') return await runFolderNew(deps, primary, params)
144
- if (rule === 'fileRecent') return await runFileRecent(deps, primary, params)
145
- return await runFileBootstrap(deps, primary, params)
140
+ if (rule === 'choose') await runChoose(deps, primary, params)
141
+ else if (rule === 'folderNew') await runFolderNew(deps, primary, params)
142
+ else if (rule === 'fileRecent') await runFileRecent(deps, primary, params)
143
+ else await runFileBootstrap(deps, primary, params)
144
+ return true
146
145
  } catch (error) {
147
146
  deps.notify('打开失败:' + String((error as Error)?.message ?? error))
147
+ return false
148
148
  }
149
149
  }
150
150
 
@@ -162,12 +162,12 @@ async function runChoose(deps: OpenDeps, folder: string, params: OpenParams): Pr
162
162
  const matches = matchWorkspaces(deps.workspaces().items, folder)
163
163
  const best = bestMatch(deps, matches)
164
164
  const choice = await deps.choose(best?.title ?? '工作区', folder)
165
- if (choice === null) return
165
+ if (choice === null) throw new Error('用户取消打开')
166
166
  if (choice === 'create') return runFolderNew(deps, folder, params)
167
167
  const ws = best ?? deps.workspaces().items[0]
168
168
  const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
169
169
  await settleRef(deps, sessionId, folder, 'folder')
170
- deps.openEditor(null)
170
+ await openChecked(deps, sessionId, null)
171
171
  await openRest(deps, sessionId, params, 1)
172
172
  }
173
173
 
@@ -178,21 +178,21 @@ async function runFileRecent(deps: OpenDeps, file: string, params: OpenParams):
178
178
  const anchor = recentSessionIn(deps, ws)
179
179
  if (anchor) {
180
180
  deps.openSession(anchor.id)
181
- deps.openEditor(file, params.line, params.column)
181
+ await openChecked(deps, anchor.id, file, params.line, params.column)
182
182
  await openRest(deps, anchor.id, params, 1)
183
183
  return
184
184
  }
185
185
  // 文件所属工作区存在但无会话 → 在该工作区新建对话 + 文件引用
186
186
  const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
187
187
  await settleRef(deps, sessionId, file, 'file')
188
- deps.openEditor(file, params.line, params.column)
188
+ await openChecked(deps, sessionId, file, params.line, params.column)
189
189
  await openRest(deps, sessionId, params, 1)
190
190
  return
191
191
  }
192
192
  const anchor = recentSession(deps.sessions().byId, deps.sessions().ids)
193
193
  if (!anchor) return runFileBootstrap(deps, file, params)
194
194
  deps.openSession(anchor.id)
195
- deps.openEditor(file, params.line, params.column)
195
+ await openChecked(deps, anchor.id, file, params.line, params.column)
196
196
  await openRest(deps, anchor.id, params, 1)
197
197
  }
198
198
 
@@ -228,7 +228,7 @@ async function runFileBootstrap(deps: OpenDeps, file: string, params: OpenParams
228
228
  const ws = await deps.createWorkspace(dirnameOf(file))
229
229
  const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
230
230
  await settleRef(deps, sessionId, file, 'file')
231
- deps.openEditor(file, params.line, params.column)
231
+ await openChecked(deps, sessionId, file, params.line, params.column)
232
232
  await openRest(deps, sessionId, params, 1)
233
233
  }
234
234
 
@@ -237,7 +237,7 @@ async function runFolderNew(deps: OpenDeps, folder: string, params: OpenParams):
237
237
  const ws = await deps.createWorkspace(folder)
238
238
  const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
239
239
  await settleRef(deps, sessionId, folder, 'folder')
240
- deps.openEditor(null)
240
+ await openChecked(deps, sessionId, null)
241
241
  await openRest(deps, sessionId, params, 1)
242
242
  }
243
243
 
@@ -264,17 +264,20 @@ async function settleRef(deps: OpenDeps, sessionId: string, path: string, appear
264
264
  if (await deps.reference(sessionId, path, appearance)) return
265
265
  await sleep(deps, REF_RETRY_MS)
266
266
  }
267
+ throw new Error('引用插入未完成')
267
268
  }
268
269
 
269
270
  /** 其余路径:文件进编辑器(步进),文件夹补文件夹引用。 */
270
271
  async function openRest(deps: OpenDeps, sessionId: string | null, params: OpenParams, from: number): Promise<void> {
271
272
  const rest = params.paths.slice(from)
272
- for (const [index, path] of rest.entries()) {
273
+ for (const path of rest) {
273
274
  const kind = await deps.stat(path).catch(() => 'missing' as PathKind)
274
275
  if (kind === 'file') {
275
- deps.schedule(() => deps.openEditor(path), REST_STEP_MS * (index + 1))
276
+ await openChecked(deps, sessionId ?? undefined, path)
276
277
  } else if (kind === 'directory' && sessionId) {
277
278
  await settleRef(deps, sessionId, path, 'folder')
279
+ } else {
280
+ throw new Error('路径不存在:' + path)
278
281
  }
279
282
  }
280
283
  }
@@ -316,16 +319,21 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
316
319
  return outcome === 'ok' || outcome === 'busy'
317
320
  },
318
321
  choose: (title, folder) => chooseWorkspace(title, folder),
319
- openEditor: (path, line, column) => {
320
- if (path == null) {
321
- openEditorView(null)
322
- return
323
- }
324
- openEditorView(path)
325
- if (line != null) setTimeout(() => emitOpenAt(path, line, column), LINE_AT_DELAY_MS)
326
- },
322
+ openEditor: requestOpen,
327
323
  schedule: (fn, ms) => setTimeout(fn, ms),
328
324
  notify: (text) => toastDom(text),
329
325
  }
330
326
  return { ...base, ...overrides }
331
327
  }
328
+
329
+ /** @private @author ddj 2026年09月28号
330
+ * Await actual editor completion and propagate failure to the request result.
331
+ * @param deps Open dependencies.
332
+ * @param sessionId Target session.
333
+ * @param path Target file or editor mount.
334
+ * @param line Optional line.
335
+ * @param column Optional column.
336
+ */
337
+ async function openChecked(deps: OpenDeps, sessionId: string | undefined, path: string | null, line?: number, column?: number): Promise<void> {
338
+ if (!(await deps.openEditor(path, line, column, sessionId))) throw new Error('编辑器打开未完成:' + (path ?? '工作区'))
339
+ }
@@ -0,0 +1,64 @@
1
+ import { openEditorView } from './events.js'
2
+
3
+ export interface EditorRequest { requestId: string; sessionId: string; path: string | null; line?: number; column?: number }
4
+ interface Waiter { request: EditorRequest; finish: (success: boolean) => void; claimed?: boolean }
5
+ const waiters = new Map<string, Waiter>()
6
+
7
+ /** @public @author ddj 2026年09月28号
8
+ * Atomically claim the pending request for this editor's session on mount.
9
+ * @param sessionId Owning session.
10
+ * @returns Unclaimed request, if any.
11
+ */
12
+ export function peekOpen(sessionId: string): EditorRequest | undefined {
13
+ const row = [...waiters.values()].find((item) => item.request.sessionId === sessionId && !item.claimed)
14
+ if (!row) return undefined
15
+ row.claimed = true
16
+ return row.request
17
+ }
18
+
19
+ /** @public @author ddj 2026年09月28号
20
+ * Complete an actual editor open by its unguessable local request ID.
21
+ * @param requestId Claimed request identity.
22
+ * @param success Whether content and navigation are ready.
23
+ * @param error Optional diagnostic for the caller.
24
+ */
25
+ export function finishOpen(requestId: string, success: boolean, error?: string): void {
26
+ const row = waiters.get(requestId)
27
+ if (row?.claimed) row.finish(success === true)
28
+ }
29
+
30
+ /** @public @author ddj 2026年09月28号
31
+ * Route an editor open and await a correlated model-ready completion.
32
+ * @param path File path, or null to mount the editor.
33
+ * @param line Optional target line.
34
+ * @param column Optional target column.
35
+ * @param sessionId Owning session, required for routing.
36
+ * @returns Actual completion; timeout and disposal are false.
37
+ */
38
+ export function requestOpen(path: string | null, line?: number, column?: number, sessionId = ''): Promise<boolean> {
39
+ if (typeof window === 'undefined' || !sessionId) return Promise.resolve(false)
40
+ const request: EditorRequest = { requestId: crypto.randomUUID(), sessionId, path, line, column }
41
+ return new Promise((resolve) => {
42
+ let timer: ReturnType<typeof setTimeout>
43
+ /** @private @author ddj 2026年09月28号 Complete once and release the deadline. */
44
+ function finish(success: boolean): void {
45
+ clearTimeout(timer)
46
+ waiters.delete(request.requestId)
47
+ resolve(success)
48
+ }
49
+ waiters.set(request.requestId, { request, finish })
50
+ timer = setTimeout(() => finish(false), 20_000)
51
+ openEditorView(null)
52
+ window.dispatchEvent(new CustomEvent('edrv:open-request', { detail: request }))
53
+ })
54
+ }
55
+
56
+ /** @public @author ddj 2026年09月28号
57
+ * Check that an editor receipt is still awaited after timeout or disposal.
58
+ * @param requestId Local request identity.
59
+ * @returns Whether this request may still be completed.
60
+ */
61
+ export function hasOpen(requestId: string): boolean { return waiters.has(requestId) }
62
+
63
+ /** @public @author ddj 2026年09月28号 Cancel outstanding receipts when the client plugin unloads. */
64
+ export function disposeOpenWait(): void { for (const waiter of [...waiters.values()]) waiter.finish(false) }
@@ -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
- /** vendor 根(routes.ts /edrv/vendor 前缀路由分发 assets/vendor/*)。 */
11
- export const PDFJS_BASE = '/edrv/vendor/pdfjs'
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
- /** 同源 RPC 调用(host /edrv/rpc 精确路由)。 */
39
- export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Promise<RpcResult<M>> {
40
- return fetch(RPC_PATH, {
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
- }).then((res) => res.json()) as Promise<RpcResult<M>>
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
  /**
@@ -8,7 +8,7 @@
8
8
  * 作者 ddj 2026年10月
9
9
  */
10
10
  import { bindingToChord, chordToBinding, defaultKeybindings, normalizeKeybindings } from '../shared/keybindings.js'
11
- import { matchEvent, parseChords } from './keybindings.js'
11
+ import { applyKeyAria, matchEvent, parseChords } from './keybindings.js'
12
12
  import type { OfficialBinding, ShortcutProfileKey } from '../shared/keybindings.js'
13
13
  import { log } from './log.js'
14
14
 
@@ -193,7 +193,9 @@ export function installLegacyKeys(
193
193
  ): () => void {
194
194
  if (!target) return () => {}
195
195
  const cache = new Map<string, ReturnType<typeof parseChords>>()
196
+ let disposed = false
196
197
  const onKey = (event: Event): void => {
198
+ if (disposed) return
197
199
  const key = event as KeyboardEvent
198
200
  const current = chords()
199
201
  for (const def of defs) {
@@ -211,8 +213,12 @@ export function installLegacyKeys(
211
213
  return
212
214
  }
213
215
  }
214
- target.addEventListener('keydown', onKey, true)
215
- return () => target.removeEventListener('keydown', onKey, true)
216
+ target.addEventListener('keydown', onKey, { capture: true })
217
+ return () => {
218
+ if (disposed) return
219
+ disposed = true
220
+ target.removeEventListener('keydown', onKey, { capture: true })
221
+ }
216
222
  }
217
223
 
218
224
  /**
@@ -283,22 +289,51 @@ export function registerOfficialShortcuts(
283
289
  export function bindCatalogChords(service: ShortcutsServiceLike, onChange: (chords: Readonly<Record<string, string>>) => void): () => void {
284
290
  const sync = (): void => {
285
291
  const chords: Record<string, string> = {}
292
+ const aria: Record<string, string | undefined> = {}
286
293
  try {
287
294
  for (const row of service.catalog.getSnapshot()) {
288
295
  if (!row.id.startsWith('edrv.') || row.binding === null) continue
289
296
  if (row.issue !== null) continue
290
- const chord = bindingToChord(row.binding)
297
+ const chord = row.keys.length ? row.keys.join(' ') : bindingToChord(row.binding)
291
298
  if (chord !== null) chords[row.id] = chord
299
+ aria[row.id] = row.aria
292
300
  }
293
301
  } catch (error) {
294
302
  bridgeLog.warn('官方目录弦表重建失败:' + String(error))
295
303
  }
304
+ applyKeyAria(aria)
296
305
  onChange(chords)
297
306
  }
298
307
  sync()
299
308
  return service.catalog.subscribe(sync)
300
309
  }
301
310
 
311
+ /**
312
+ * 唤起官方快捷键弹窗(VSCodeMode 通用页的跳转入口用;插件不再自建录键界面)。
313
+ * 官方入口不可用(旧版 DSH / registry 缺失)或唤起失败时返回可读说明,
314
+ * 供设置页原样展示:不抛错、不阻断设置渲染。
315
+ * @author ddj 2026年09月28号
316
+ * @param service 官方 shortcuts 服务(可为 null:运行时探测未就绪)
317
+ * @returns 空串 = 已成功唤起;否则为给用户看的失败说明
318
+ */
319
+ export function openOfficialShortcuts(service: ShortcutsServiceLike | null): string {
320
+ const manual = '可按 Ctrl+/ 或到 DSH 设置 → 通用 → 快捷键 打开'
321
+ if (!service || !service.registry || typeof service.registry.invoke !== 'function') {
322
+ return '官方快捷键弹窗入口不可用(当前 DSH 版本或外壳未提供):' + manual
323
+ }
324
+ try {
325
+ service.registry.invoke('shortcuts.open', {
326
+ region: 'page',
327
+ modal: null,
328
+ target: typeof document === 'undefined' ? null : document.activeElement,
329
+ })
330
+ return ''
331
+ } catch (error) {
332
+ bridgeLog.warn('打开官方快捷键弹窗失败:' + String(error))
333
+ return '打开官方快捷键弹窗失败:' + String(error) + ';' + manual
334
+ }
335
+ }
336
+
302
337
  //#region 旧设置迁移
303
338
 
304
339
  /** 旧设置 scope 最小形状(compat settingsBridge 绑定产物的最小面,结构与 SettingsScopeLike 兼容)。 */
@@ -133,6 +133,13 @@ export function takePendingSideOpen(sessionId?: string): { path: string | null;
133
133
  * @param focusDiff 是否聚焦首个差异
134
134
  * @returns 是否路由成功
135
135
  */
136
+ /**
137
+ * Check the installed editor route without opening any UI.
138
+ * @author ddj 2026年09月28号
139
+ * @returns Whether a mounted sidebar adapter owns editor navigation.
140
+ */
141
+ export function hasSideRoute(): boolean { return ensureSideEditor !== null }
142
+
136
143
  export function routeSideEditor(path: string | null, focusDiff: boolean): boolean {
137
144
  if (typeof ensureSideEditor !== 'function') return false
138
145
  try {
@@ -18,7 +18,7 @@
18
18
  .vsm-mcp-subtabs button.active{color:var(--dsw-alias-label-primary,#fff);font-weight:500}
19
19
  .vsm-mcp-subtabs button.active::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--dsw-alias-brand-primary,#6d55ff);border-radius:2px}
20
20
 
21
- /* 顶层 Tab:通用 / 快捷键 / MCP 管理 / 语言服务器 / 性能优化 / 兼容性 */
21
+ /* 顶层 Tab:通用 / MCP 管理 / 语言服务器 / AI 补全 / 性能优化 / 兼容性 */
22
22
  .vsm-mcp-tabs{display:flex;gap:24px;border-bottom:1px solid var(--dsw-alias-border-l2,#3a3a3a)}
23
23
  .vsm-mcp-tabs button{border:0;background:none;color:var(--dsw-alias-label-tertiary,#9aa5b1);padding:9px 2px 11px;cursor:pointer;font-size:14px;position:relative}
24
24
  .vsm-mcp-tabs button:hover{color:var(--dsw-alias-label-secondary,#c8c8c8)}
@@ -128,24 +128,6 @@
128
128
  .vsm-general-row button:disabled{opacity:.55;cursor:default}
129
129
  .vsm-general-row button.vsm-devform-close{color:var(--dsw-alias-state-error-primary,#f07878);border-color:var(--dsw-alias-state-error-secondary,#d9534f55)}
130
130
 
131
- /* keyboard shortcuts page */
132
- .vsm-kb-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}
133
- .vsm-kb-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#555);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#161616)}
134
- .vsm-kb-row.rec{border-color:var(--dsw-alias-brand-primary,#4f8cff)}
135
- .vsm-kb-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;color:var(--dsw-alias-label-primary,#eee);font-size:13px}
136
- .vsm-kb-warn{color:var(--dsw-alias-state-warn-primary,#e5b93f);font-size:11px}
137
- .vsm-kb-key{min-width:96px;text-align:center;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;background:var(--dsw-alias-bg-base,#111);color:var(--dsw-alias-label-primary,#eee);font-size:12px;font-family:var(--ds-font-family-code,ui-monospace,monospace)}
138
- .vsm-kb-key.warn{border-color:var(--dsw-alias-state-warn-primary,#e5b93f);color:var(--dsw-alias-state-warn-primary,#e5b93f)}
139
- .vsm-kb-key.invalid{border-color:var(--dsw-alias-state-error-primary,#d9534f);color:var(--dsw-alias-state-error-primary,#d9534f)}
140
- .vsm-kb-rec{font-size:12px;color:var(--dsw-alias-label-secondary,#9aa5b1)}
141
- .vsm-kb-actions{display:flex;gap:6px}
142
- .vsm-kb-actions button{border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;padding:4px 10px;background:none;color:var(--dsw-alias-label-primary,#eee);cursor:pointer;font-size:12px;white-space:nowrap}
143
- .vsm-kb-actions button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
144
- .vsm-kb-actions button:disabled{opacity:.55;cursor:default}
145
- .vsm-kb-actionsbar{display:flex;align-items:center;gap:10px;margin-top:12px}
146
- .vsm-kb-dirty{flex:1;font-size:12px;color:var(--dsw-alias-state-warn-primary,#e5b93f)}
147
- .vsm-kb-message{font-size:12px;color:var(--dsw-alias-brand-primary,#4f8cff);margin-top:8px}
148
-
149
131
  /* 语言服务器(LSP)设置子 Tab */
150
132
  .vsm-lsp-form{display:flex;flex-direction:column;gap:6px;margin-top:8px}
151
133
  .vsm-lsp-form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa5b1)}