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.
Files changed (76) hide show
  1. package/README.md +158 -111
  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 +13362 -11806
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +4110 -2507
  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/activePathStore.ts +28 -0
  14. package/src/client/addToConversation.ts +4 -2
  15. package/src/client/ai/inlineProvider.ts +1 -8
  16. package/src/client/commandBridge.ts +14 -42
  17. package/src/client/externalOpen.ts +40 -19
  18. package/src/client/index.ts +101 -18
  19. package/src/client/keybindings.ts +36 -73
  20. package/src/client/monaco/loader.ts +3 -1
  21. package/src/client/openFlow.ts +36 -28
  22. package/src/client/openReceipt.ts +64 -0
  23. package/src/client/panelCommands.ts +101 -0
  24. package/src/client/pdf/pdfLoader.ts +4 -2
  25. package/src/client/receiptState.ts +120 -0
  26. package/src/client/rpc.ts +30 -4
  27. package/src/client/shortcutRecorder.ts +161 -0
  28. package/src/client/shortcutsOfficial.ts +460 -0
  29. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  30. package/src/client/sidebarBridge.ts +7 -0
  31. package/src/client/ui/DiffBadge.ts +2 -1
  32. package/src/client/ui/EditorView.ts +246 -137
  33. package/src/client/ui/McpSettings.ts +10 -8
  34. package/src/client/ui/OfficialSideTab.ts +5 -2
  35. package/src/client/ui/QuickOpen.ts +16 -16
  36. package/src/client/ui/ShortcutSettings.ts +274 -0
  37. package/src/client/ui/commandCatalog.ts +39 -43
  38. package/src/client/useOpenReceipt.ts +128 -0
  39. package/src/compat.ts +5 -3
  40. package/src/dap/manager.ts +46 -13
  41. package/src/devForm.ts +252 -92
  42. package/src/externalHandoff.ts +93 -52
  43. package/src/fileOpenSettings.ts +4 -24
  44. package/src/hostImport.ts +19 -0
  45. package/src/httpGuard.ts +101 -0
  46. package/src/index.ts +106 -97
  47. package/src/integrate.ts +56 -50
  48. package/src/lsp/transport.ts +84 -127
  49. package/src/mcp.ts +58 -9
  50. package/src/mcpIsolation.ts +211 -102
  51. package/src/mcpProject.ts +220 -133
  52. package/src/mcpRuntime.ts +343 -0
  53. package/src/openBridge.ts +69 -0
  54. package/src/openInbox.ts +207 -0
  55. package/src/paths.ts +8 -5
  56. package/src/processTree.ts +63 -0
  57. package/src/reveal.ts +5 -3
  58. package/src/routes.ts +156 -154
  59. package/src/rpc.ts +14 -13
  60. package/src/rules.ts +100 -44
  61. package/src/shared/appUrl.ts +19 -0
  62. package/src/shared/externalOpen.ts +71 -0
  63. package/src/shared/integration.ts +2 -0
  64. package/src/shared/keybindings.ts +256 -0
  65. package/src/shared/mcp.ts +4 -1
  66. package/src/shared/rpc.ts +7 -4
  67. package/src/shared/rules.ts +9 -1
  68. package/src/shared/svn.ts +6 -8
  69. package/src/svnPlanTool.ts +51 -0
  70. package/src/unityBridge.ts +26 -3
  71. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  72. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  73. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  74. package/unity/com.dsh.editor/README.md +16 -25
  75. package/unity/com.dsh.editor/package.json +2 -2
  76. package/src/client/ui/KeybindingsPanel.ts +0 -190
@@ -0,0 +1,460 @@
1
+ /**
2
+ * dsh-vscode-mode client — 官方 shortcuts 服务桥接层。
3
+ * 职责四件:① 运行时探测 ctx.shortcuts(可选服务,有界重试,不进 inject——旧版 DSH 无此服务
4
+ * 时不阻断装配);② safeRegister 逐命令注册(官方 register 对重复 id/保留默认/组合重叠抛错,
5
+ * 冲突时去掉默认键位降级注册,绝不阻断插件加载);③ 官方命令定义构建(commandCatalog → 官方
6
+ * command,resolve 统一走插件注册表 run,可用性为假返回 pass 不吞键);④ 旧 keybindings
7
+ * 设置一次性迁移进官方存储。
8
+ * 作者 ddj 2026年10月
9
+ */
10
+ import { bindingToChord, chordToBinding, defaultKeybindings, normalizeKeybindings } from '../shared/keybindings.js'
11
+ import { applyKeyAria, matchEvent, parseChords } from './keybindings.js'
12
+ import type { OfficialBinding, ShortcutProfileKey } from '../shared/keybindings.js'
13
+ import { log } from './log.js'
14
+
15
+ const bridgeLog = log.child('shortcuts-official')
16
+
17
+ /** 迁移比对用的默认键位表副本(模块态,避免每次调用重建)。 */
18
+ const KEYBINDING_DEFAULTS_OF: Record<string, string> = defaultKeybindings()
19
+
20
+ //#region 官方服务鸭子类型(结构对齐 @deepseek-ai/dsh-client-shortcuts,不 import 官方包)
21
+
22
+ /** 快照存储(createSnapshotStore 形状)。 */
23
+ export interface SnapshotStoreLike<T> {
24
+ getSnapshot(): T
25
+ subscribe(listener: () => void): () => void
26
+ }
27
+
28
+ /** 官方目录行(catalog 快照项)。 */
29
+ export interface ShortcutCatalogRow {
30
+ id: string
31
+ label: string
32
+ binding: OfficialBinding | null
33
+ modified: boolean
34
+ issue: string | null
35
+ conflicts: string[]
36
+ keys: string[]
37
+ aria?: string
38
+ }
39
+
40
+ /** 官方配置快照。 */
41
+ export interface ShortcutConfigSnapshot {
42
+ revision: string
43
+ sequence: number
44
+ status: 'loading' | 'ready' | 'unreadable'
45
+ error: string | null
46
+ usingDefaults: boolean
47
+ }
48
+
49
+ /** 官方派发上下文。 */
50
+ export interface ShortcutInvokeContext {
51
+ region: string
52
+ modal: string | null
53
+ target?: unknown
54
+ source?: string
55
+ }
56
+
57
+ /** 官方 resolve 结果。 */
58
+ export type ShortcutResolution =
59
+ | { status: 'handled'; run: () => void }
60
+ | { status: 'pass' }
61
+ | { status: 'blocked'; reason?: string }
62
+
63
+ /** 官方可编辑命令定义(register 入参)。 */
64
+ export interface ShortcutCommandLike {
65
+ id: string
66
+ label: () => string
67
+ aliases?: string[]
68
+ defaults?: Partial<Record<ShortcutProfileKey, OfficialBinding>>
69
+ regions: string[]
70
+ modals: string[]
71
+ resolve: (context: ShortcutInvokeContext) => ShortcutResolution
72
+ }
73
+
74
+ /** 官方偏好编辑操作。 */
75
+ export type ShortcutEditLike =
76
+ | { type: 'set'; id: string; binding: OfficialBinding }
77
+ | { type: 'reset'; id: string }
78
+ | { type: 'reset-all' }
79
+
80
+ /** 官方 shortcuts 服务(ShortcutsService 结构子集,鸭子类型)。 */
81
+ export interface ShortcutsServiceLike {
82
+ register(command: ShortcutCommandLike): () => void
83
+ describeBinding(binding: OfficialBinding | null): { binding: OfficialBinding | null; keys: string[]; issue: string | null; conflicts: string[] }
84
+ edit(edit: ShortcutEditLike, revision: string): Promise<{ status: string; issue?: string | null; conflicts?: string[] }>
85
+ recording(active: boolean): Promise<void>
86
+ catalog: SnapshotStoreLike<ShortcutCatalogRow[]>
87
+ config: SnapshotStoreLike<ShortcutConfigSnapshot>
88
+ fixedCatalog: SnapshotStoreLike<unknown[]>
89
+ platform: string
90
+ runtime: string
91
+ /** ShortcutRegistry 实例(菜单路径 invoke;用于程序化打开官方弹窗)。 */
92
+ registry?: { invoke(id: string, context: ShortcutInvokeContext): unknown }
93
+ }
94
+
95
+ //#endregion
96
+
97
+ /** 探测节奏(与插件其他可选服务探测一致:15 次 × 2s)。 */
98
+ export interface ProbeOptions {
99
+ schedule: (fn: () => void, ms: number) => void
100
+ attempts?: number
101
+ intervalMs?: number
102
+ }
103
+
104
+ /** 结构探测:get('shortcuts') 命中且具备核心能力才算服务就绪(设置组件运行时自愈探测复用)。 */
105
+ export function detectShortcuts(ctx: { get: (name: string) => unknown }): ShortcutsServiceLike | null {
106
+ try {
107
+ const service = ctx.get('shortcuts') as ShortcutsServiceLike | undefined
108
+ if (service && typeof service.register === 'function' && typeof service.edit === 'function' && service.catalog && service.config) return service
109
+ } catch {
110
+ /* 服务缺失/构造失败按未命中处理 */
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * 探测官方 shortcuts 服务:立即探测一次,未命中按节奏有界重试;命中或用尽均回调一次。
117
+ * @author ddj 2026年10月
118
+ * @param ctx 客户端上下文
119
+ * @param options schedule/重试节奏 + onReady 回调
120
+ * @returns 取消探测的注销函数
121
+ */
122
+ export function awaitShortcutsService(ctx: { get: (name: string) => unknown }, options: ProbeOptions & { onReady: (service: ShortcutsServiceLike | null) => void }): () => void {
123
+ const maxAttempts = options.attempts ?? 15
124
+ const interval = options.intervalMs ?? 2000
125
+ let attempts = 0
126
+ let settled = false
127
+ const tick = (): void => {
128
+ if (settled) return
129
+ const service = detectShortcuts(ctx)
130
+ if (service !== null) {
131
+ settled = true
132
+ options.onReady(service)
133
+ return
134
+ }
135
+ if (attempts >= maxAttempts) {
136
+ settled = true
137
+ options.onReady(null)
138
+ return
139
+ }
140
+ attempts += 1
141
+ options.schedule(tick, interval)
142
+ }
143
+ tick()
144
+ return () => {
145
+ settled = true
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 逐命令安全注册:官方 register 抛错(重复 id/保留默认/组合重叠)时去掉默认键位重试一次,
151
+ * 保证命令仍进官方目录(可被用户手动绑定),注册路径绝不抛错、绝不阻断装配。
152
+ * @author ddj 2026年10月
153
+ * @param service 官方服务
154
+ * @param command 官方命令定义
155
+ * @returns 注销函数(注册失败返回空操作)
156
+ */
157
+ export function safeRegister(service: ShortcutsServiceLike, command: ShortcutCommandLike): () => void {
158
+ try {
159
+ return service.register(command)
160
+ } catch (error) {
161
+ if (!command.defaults || Object.keys(command.defaults).length === 0) {
162
+ bridgeLog.warn('官方快捷键注册失败(' + command.id + '):' + String(error))
163
+ return () => {}
164
+ }
165
+ bridgeLog.warn('官方快捷键默认键位被拒(' + command.id + '),降级为未绑定:' + String(error))
166
+ try {
167
+ return service.register({ ...command, defaults: {} })
168
+ } catch (retryError) {
169
+ bridgeLog.warn('官方快捷键注册失败(' + command.id + '):' + String(retryError))
170
+ return () => {}
171
+ }
172
+ }
173
+ }
174
+
175
+ /** 插件指令注册表执行子集(commandRegistry 形状)。 */
176
+ export interface CommandRunner {
177
+ isAvailable(id: string): boolean
178
+ run(id: string): boolean
179
+ }
180
+
181
+ /**
182
+ * 官方服务未就绪时复用旧版窗口键位语义;服务出现后注销,避免双执行。
183
+ * @author ddj 2026年09月28号
184
+ * @param defs 按目录序排列的插件命令
185
+ * @param runner 指令注册表
186
+ * @param chords 当前旧设置键位获取函数
187
+ * @param target 键盘事件目标(缺省 window,测试可注入)
188
+ * @returns 注销监听的函数
189
+ */
190
+ export function installLegacyKeys(
191
+ defs: ReadonlyArray<{ id: string }>, runner: CommandRunner, chords: () => Record<string, string>,
192
+ target: Pick<EventTarget, 'addEventListener' | 'removeEventListener'> | null = typeof window === 'undefined' ? null : window,
193
+ ): () => void {
194
+ if (!target) return () => {}
195
+ const cache = new Map<string, ReturnType<typeof parseChords>>()
196
+ let disposed = false
197
+ const onKey = (event: Event): void => {
198
+ if (disposed) return
199
+ const key = event as KeyboardEvent
200
+ const current = chords()
201
+ for (const def of defs) {
202
+ const chord = current[def.id]
203
+ if (!chord) continue
204
+ let bindings = cache.get(chord)
205
+ if (!bindings) {
206
+ bindings = parseChords(chord)
207
+ cache.set(chord, bindings)
208
+ }
209
+ if (!matchEvent(key, bindings) || !runner.isAvailable(def.id)) continue
210
+ key.preventDefault()
211
+ key.stopPropagation()
212
+ runner.run(def.id)
213
+ return
214
+ }
215
+ }
216
+ target.addEventListener('keydown', onKey, { capture: true })
217
+ return () => {
218
+ if (disposed) return
219
+ disposed = true
220
+ target.removeEventListener('keydown', onKey, { capture: true })
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 插件命令定义 → 官方命令定义:默认键位取 SHORTCUT_PROFILES(缺省 = 不绑定);
226
+ * resolve 可用性为假返回 pass(不吞键,与旧桥「不可用不执行也不吞键」语义一致),
227
+ * 执行统一走注册表 run(自带可用性复核与异常捕获)。
228
+ * @author ddj 2026年10月
229
+ * @param def 插件命令定义
230
+ * @param runner 插件指令注册表
231
+ * @returns 官方命令定义
232
+ */
233
+ export function officialCommandOf(def: { id: string; label: string }, runner: CommandRunner): ShortcutCommandLike {
234
+ return {
235
+ id: def.id,
236
+ label: () => def.label,
237
+ // 必须为数组:官方弹窗渲染排名列表时 `...row.aliases` 直接展开,undefined 会让弹窗崩溃
238
+ aliases: [],
239
+ defaults: undefined,
240
+ regions: ['page', 'editable'],
241
+ modals: [],
242
+ resolve: () => {
243
+ if (!runner.isAvailable(def.id)) return { status: 'pass' }
244
+ return {
245
+ status: 'handled',
246
+ run: () => {
247
+ runner.run(def.id)
248
+ },
249
+ }
250
+ },
251
+ }
252
+ }
253
+
254
+ /**
255
+ * 注册全部命令进官方服务(逐命令 safeRegister 隔离,defaults 由调用方按 id 注入 SHORTCUT_PROFILES)。
256
+ * @author ddj 2026年10月
257
+ * @param service 官方服务
258
+ * @param defs 插件命令定义(id/label)
259
+ * @param runner 插件指令注册表
260
+ * @param profiles id → 官方 profile 默认键位
261
+ * @returns 聚合注销函数
262
+ */
263
+ export function registerOfficialShortcuts(
264
+ service: ShortcutsServiceLike,
265
+ defs: ReadonlyArray<{ id: string; label: string }>,
266
+ runner: CommandRunner,
267
+ profiles: Readonly<Record<string, Partial<Record<ShortcutProfileKey, OfficialBinding>>>>,
268
+ ): () => void {
269
+ const disposers: Array<() => void> = []
270
+ for (const def of defs) {
271
+ const command = officialCommandOf(def, runner)
272
+ command.defaults = profiles[def.id] ?? {}
273
+ disposers.push(safeRegister(service, command))
274
+ }
275
+ return () => {
276
+ while (disposers.length) disposers.pop()?.()
277
+ }
278
+ }
279
+
280
+ /**
281
+ * 订阅官方目录 → 重建 edrv.* 弦表(chordOf 数据源);目录变化即通知。
282
+ * 只收录 issue 为空的行(conflict 行保留——用户显式改键后 tooltip 仍显示其选择,
283
+ * 冲突状态由设置页徽标呈现)。
284
+ * @author ddj 2026年10月
285
+ * @param service 官方服务
286
+ * @param onChange 弦表回调(每次全量替换)
287
+ * @returns 注销函数
288
+ */
289
+ export function bindCatalogChords(service: ShortcutsServiceLike, onChange: (chords: Readonly<Record<string, string>>) => void): () => void {
290
+ const sync = (): void => {
291
+ const chords: Record<string, string> = {}
292
+ const aria: Record<string, string | undefined> = {}
293
+ try {
294
+ for (const row of service.catalog.getSnapshot()) {
295
+ if (!row.id.startsWith('edrv.') || row.binding === null) continue
296
+ if (row.issue !== null) continue
297
+ const chord = row.keys.length ? row.keys.join(' ') : bindingToChord(row.binding)
298
+ if (chord !== null) chords[row.id] = chord
299
+ aria[row.id] = row.aria
300
+ }
301
+ } catch (error) {
302
+ bridgeLog.warn('官方目录弦表重建失败:' + String(error))
303
+ }
304
+ applyKeyAria(aria)
305
+ onChange(chords)
306
+ }
307
+ sync()
308
+ return service.catalog.subscribe(sync)
309
+ }
310
+
311
+ //#region 旧设置迁移
312
+
313
+ /** 旧设置 scope 最小形状(compat settingsBridge 绑定产物的最小面,结构与 SettingsScopeLike 兼容)。 */
314
+ export interface LegacySettingsScope {
315
+ getSnapshot(): { status?: string; value?: { keybindings?: unknown } }
316
+ }
317
+
318
+ /** 迁移结果:done=true 表示无需/已完成迁移(含「无差异」),false 表示中止待重试。 */
319
+ export interface MigrationResult {
320
+ imported: number
321
+ skipped: number
322
+ done: boolean
323
+ }
324
+
325
+ /**
326
+ * 官方 edit 单次写入;stale 表示其他窗口已更新,不自动用新 revision 重放。
327
+ * @author ddj 2026年09月28号
328
+ * @param service 官方快捷键服务
329
+ * @param edit 本次操作
330
+ * @returns 是否保存成功
331
+ */
332
+ async function editOnce(service: ShortcutsServiceLike, edit: ShortcutEditLike): Promise<boolean> {
333
+ try {
334
+ const result = await service.edit(edit, service.config.getSnapshot().revision)
335
+ if (result.status === 'saved') return true
336
+ bridgeLog.warn('官方快捷键写入未成功(' + edit.type + (edit.type === 'set' ? ' ' + edit.id : '') + '):' + result.status)
337
+ } catch (error) {
338
+ bridgeLog.warn('官方快捷键写入异常(' + edit.type + '):' + String(error))
339
+ }
340
+ return false
341
+ }
342
+
343
+ /**
344
+ * 逐条恢复插件自定义键位;官方 reset-all 会清空所有产品命令的覆盖,不能使用。
345
+ * @author ddj 2026年09月28号
346
+ * @param service 官方快捷键服务
347
+ * @returns 完成数量及首个失败命令(有失败时)
348
+ */
349
+ export async function resetPluginKeys(service: ShortcutsServiceLike): Promise<{ reset: number; failed?: string }> {
350
+ const ids = service.catalog.getSnapshot()
351
+ .filter((row) => row.id.startsWith('edrv.') && row.modified)
352
+ .map((row) => row.id)
353
+ let reset = 0
354
+ for (const id of ids) {
355
+ if (!await editOnce(service, { type: 'reset', id })) return { reset, failed: id }
356
+ reset += 1
357
+ }
358
+ return { reset }
359
+ }
360
+
361
+ const MIGRATION_KEY = 'dsh-vscode-mode.shortcuts-migrated.v1.'
362
+
363
+ /**
364
+ * 当前快捷键运行平台的迁移标记键;不同 Web/Desktop 和操作系统分别处理。
365
+ * @author ddj 2026年09月28号
366
+ * @param service 官方快捷键服务
367
+ * @returns 本机迁移标记键
368
+ */
369
+ function migrationKey(service: ShortcutsServiceLike): string {
370
+ return MIGRATION_KEY + service.runtime + '.' + service.platform
371
+ }
372
+
373
+ /**
374
+ * 检查当前端是否已处理旧设置;存储不可用时继续按目录状态判定。
375
+ * @author ddj 2026年09月28号
376
+ * @param service 官方快捷键服务
377
+ * @returns 是否已处理
378
+ */
379
+ function hasMigrated(service: ShortcutsServiceLike): boolean {
380
+ try {
381
+ return typeof localStorage !== 'undefined' && localStorage.getItem(migrationKey(service)) === '1'
382
+ } catch {
383
+ return false
384
+ }
385
+ }
386
+
387
+ /**
388
+ * 标记当前端已处理;旧设置永不清空,供其他平台及人工恢复使用。
389
+ * @author ddj 2026年09月28号
390
+ * @param service 官方快捷键服务
391
+ */
392
+ function markMigrated(service: ShortcutsServiceLike): void {
393
+ try {
394
+ if (typeof localStorage !== 'undefined') localStorage.setItem(migrationKey(service), '1')
395
+ } catch {
396
+ bridgeLog.warn('无法持久化旧快捷键迁移标记,本次配置仍保留')
397
+ }
398
+ }
399
+
400
+ /**
401
+ * 旧 keybindings 设置按当前 runtime/platform 一次性导入官方存储:
402
+ * - 仅处理与 KEYBINDING_DEFAULTS 有差异的单候选键位;多候选、空键位、冲突或
403
+ * 官方不可表示项不迁移,完整旧值仍保留(官方每条命令当前只允许一个绑定);
404
+ * - 官方已有自定义值时不覆盖,stale 冲突也不自动重放;
405
+ * - 旧配置供其他平台及人工恢复使用,绝不清空;当前端成功处理后记录本地标记,
406
+ * 防止重启后把已由用户重置的官方键位重新导入。
407
+ * @author ddj 2026年10月
408
+ * @param service 官方服务
409
+ * @param scope 旧设置 scope(settings bridge 绑定产物)
410
+ * @returns 迁移结果
411
+ */
412
+ export async function migrateLegacyKeybindings(service: ShortcutsServiceLike, scope: LegacySettingsScope): Promise<MigrationResult> {
413
+ const snapshot = scope.getSnapshot()
414
+ if (snapshot.status !== 'ready') return { imported: 0, skipped: 0, done: false }
415
+ if (service.config.getSnapshot().status !== 'ready') return { imported: 0, skipped: 0, done: false }
416
+ if (hasMigrated(service)) return { imported: 0, skipped: 0, done: true }
417
+ const stored = normalizeKeybindings(snapshot.value?.keybindings)
418
+ const overrides = Object.entries(stored).filter(([id, chord]) => chord !== KEYBINDING_DEFAULTS_OF[id])
419
+ if (overrides.length === 0) return { imported: 0, skipped: 0, done: true }
420
+ let imported = 0
421
+ let skipped = 0
422
+ for (const [id, chord] of overrides) {
423
+ if (!chord.trim() || chord.includes('|')) {
424
+ skipped += 1
425
+ bridgeLog.warn('迁移跳过(空键位或多候选,原值保留):' + id)
426
+ continue
427
+ }
428
+ const binding = chordToBinding(chord)
429
+ if (binding === null) {
430
+ skipped += 1
431
+ bridgeLog.warn('迁移跳过(官方不可表示):' + id + ' = ' + chord)
432
+ continue
433
+ }
434
+ if (service.catalog.getSnapshot().some((row) => row.id === id && row.modified)) {
435
+ skipped += 1
436
+ bridgeLog.warn('迁移跳过(官方存储已有自定义值):' + id)
437
+ continue
438
+ }
439
+ let probe: { conflicts: string[]; issue: string | null }
440
+ try {
441
+ probe = service.describeBinding(binding)
442
+ } catch (error) {
443
+ bridgeLog.warn('迁移中止(describeBinding 异常):' + String(error))
444
+ return { imported, skipped, done: false }
445
+ }
446
+ if (probe.conflicts.length > 0 || probe.issue !== null) {
447
+ skipped += 1
448
+ bridgeLog.warn('迁移跳过(与官方键冲突/保留):' + id + ' = ' + chord)
449
+ continue
450
+ }
451
+ const saved = await editOnce(service, { type: 'set', id, binding })
452
+ if (!saved) return { imported, skipped, done: false }
453
+ imported += 1
454
+ }
455
+ markMigrated(service)
456
+ bridgeLog.info('旧快捷键设置已处理(旧值保留):导入 ' + imported + ' 条,跳过 ' + skipped + ' 条')
457
+ return { imported, skipped, done: true }
458
+ }
459
+
460
+ //#endregion
@@ -152,6 +152,8 @@ function RuleRow(props) {
152
152
  React.createElement('div', { className: 'edrv-rules-line1' },
153
153
  React.createElement('span', { className: 'edrv-rules-file' }, rule.file),
154
154
  !props.narrow ? React.createElement('span', { className: 'edrv-rules-path' }, '[' + rule.relHint + rule.file + ']') : null,
155
+ props.narrow && rule.scope === 'user' && rule.origin === 'legacy'
156
+ ? React.createElement('span', { className: 'edrv-rules-path', title: rule.absPath }, '旧目录') : null,
155
157
  !rule.enabled ? React.createElement('span', { className: 'edrv-rules-offtag' }, '已停用') : null),
156
158
  React.createElement('div', { className: 'edrv-rules-sub' },
157
159
  React.createElement('span', { className: 'edrv-rules-sublabel' }, '类型:'),
@@ -288,7 +290,7 @@ export function RulesPanel(props) {
288
290
  if (scope === 'project' && !cwd) { notify('当前会话没有工作区,无法新建项目规则'); return }
289
291
  const parsed = parseRuleFm(NEW_TEMPLATE)
290
292
  setForm({
291
- mode: 'new', scope, workspacePath: scope === 'project' ? cwd : undefined,
293
+ mode: 'new', scope, origin: scope === 'user' ? 'current' : undefined, workspacePath: scope === 'project' ? cwd : undefined,
292
294
  relHint: scope === 'project' ? '.dsh/rules/' : 'rules/', file: '', content: NEW_TEMPLATE,
293
295
  fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
294
296
  })
@@ -299,12 +301,12 @@ export function RulesPanel(props) {
299
301
  * @param rule 目标规则
300
302
  */
301
303
  const openEdit = (rule) => {
302
- rpc('rules.read', { scope: rule.scope, workspacePath: rule.scope === 'project' ? rule.workspacePath : undefined, file: rule.file })
304
+ rpc('rules.read', { scope: rule.scope, origin: rule.origin, workspacePath: rule.scope === 'project' ? rule.workspacePath : undefined, file: rule.file })
303
305
  .then((res) => {
304
306
  if (!res || !res.ok) { notify(res?.error ?? '读取规则失败'); return }
305
307
  const parsed = parseRuleFm(res.content)
306
308
  setForm({
307
- mode: 'edit', scope: rule.scope, workspacePath: rule.workspacePath, relHint: rule.relHint,
309
+ mode: 'edit', scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, relHint: rule.relHint,
308
310
  file: rule.file, content: res.content,
309
311
  fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
310
312
  })
@@ -342,7 +344,7 @@ export function RulesPanel(props) {
342
344
  if (!file) { setForm({ ...next, saving: false, error: '文件名不能为空' }); return }
343
345
  if (form.fmType === 'auto' && globList(form.globs).length === 0) { setForm({ ...next, saving: false, error: '自动规则需要至少一个 glob(逗号分隔)' }); return }
344
346
  setForm(next)
345
- rpc('rules.save', { scope: form.scope, workspacePath: form.workspacePath, file, content: form.content })
347
+ rpc('rules.save', { scope: form.scope, origin: form.origin, workspacePath: form.workspacePath, file, content: form.content })
346
348
  .then((res) => {
347
349
  if (res && res.ok) {
348
350
  setForm(null)
@@ -357,8 +359,10 @@ export function RulesPanel(props) {
357
359
  * @param rule 目标规则
358
360
  */
359
361
  const removeRule = (rule) => {
360
- if (!window.confirm('删除规则 ' + rule.file + '?(不可恢复)')) return
361
- rpc('rules.remove', { scope: rule.scope, workspacePath: rule.workspacePath, file: rule.file })
362
+ const reveal = rule.scope === 'user' && rule.origin !== 'legacy'
363
+ ? '\n若旧目录有同名规则,删除后它将重新生效。' : ''
364
+ if (!window.confirm('删除规则 ' + rule.absPath + '?(不可恢复)' + reveal)) return
365
+ rpc('rules.remove', { scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, file: rule.file })
362
366
  .then((res) => {
363
367
  if (res && res.ok) { notify('已删除:' + rule.file); load() } else notify(res?.error ?? '删除失败')
364
368
  }).catch((e) => notify('删除异常: ' + String(e)))
@@ -372,13 +376,13 @@ export function RulesPanel(props) {
372
376
  const enabled = !rule.enabled
373
377
  const applyLocal = (on) => setData((prev) => prev ? {
374
378
  ...prev,
375
- user: prev.user.map((r) => (r.scope === 'user' && r.file === rule.file ? { ...r, enabled: on } : r)),
379
+ user: prev.user.map((r) => (rule.scope === 'user' && r.file === rule.file && (r.origin ?? 'current') === (rule.origin ?? 'current') ? { ...r, enabled: on } : r)),
376
380
  projects: prev.projects.map((p) => (p.workspacePath === rule.workspacePath
377
381
  ? { ...p, rules: p.rules.map((r) => (r.file === rule.file ? { ...r, enabled: on } : r)) }
378
382
  : p)),
379
383
  } : prev)
380
384
  applyLocal(enabled)
381
- rpc('rules.toggle', { scope: rule.scope, workspacePath: rule.workspacePath, file: rule.file, enabled })
385
+ rpc('rules.toggle', { scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, file: rule.file, enabled })
382
386
  .then((res) => {
383
387
  if (res && res.ok) { applyLocal(res.rule?.enabled === true); notify(res.rule?.enabled === false ? '已停用:' + rule.file : '已启用:' + rule.file) }
384
388
  else { applyLocal(!enabled); notify(res?.error ?? '切换失败') }
@@ -411,7 +415,7 @@ export function RulesPanel(props) {
411
415
  if (!activeProject()) return '当前工作区未注册为 DSH workspace,暂不能管理其项目规则'
412
416
  return '<工作区>/.dsh/rules/ 还没有规则,点击「+ 新建规则」创建'
413
417
  }
414
- return '还没有用户规则(~/.dsh/rules/),点击「+ 新建规则」创建'
418
+ return '还没有用户规则(当前 DSH_HOME/rules/ 或旧目录 ~/.dsh/rules/),点击「+ 新建规则」创建'
415
419
  }
416
420
 
417
421
  const renderBody = () => {
@@ -433,13 +437,13 @@ export function RulesPanel(props) {
433
437
  onToggle: (rule) => toggleRule({ ...rule, workspacePath: rule.scope === 'project' ? cwd : undefined }),
434
438
  }
435
439
  return React.createElement('div', { className: 'edrv-rules-list' },
436
- rows.map((rule) => React.createElement(RuleRow, { key: rule.scope + ':' + rule.file, rule, ...common })))
440
+ rows.map((rule) => React.createElement(RuleRow, { key: rule.scope + ':' + (rule.origin ?? 'project') + ':' + rule.file, rule, ...common })))
437
441
  }
438
442
 
439
443
  return React.createElement('div', { className: 'edrv-rules-panel', ref: panelRef },
440
444
  renderTabs(),
441
445
  renderWsLine(),
442
446
  React.createElement('div', { className: 'edrv-rules-hint' },
443
- tab === 'user' ? '用户规则对所有会话生效(存放于 ~/.dsh/rules/)' : '项目规则仅对所在工作区的会话生效(存放于 <工作区>/.dsh/rules/)'),
447
+ tab === 'user' ? '读取 DSH_HOME/rules/ 与 ~/.dsh/rules/;同名时当前目录优先,新建保存到当前目录' : '项目规则仅对所在工作区的会话生效(存放于 <工作区>/.dsh/rules/)'),
444
448
  renderBody())
445
449
  }
@@ -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 {
@@ -5,6 +5,7 @@
5
5
  * 作者 ddj 2026-08-20
6
6
  */
7
7
  import React from 'react'
8
+ import { appUrl } from '../../shared/appUrl.js'
8
9
  import { rpc } from '../rpc.js'
9
10
  import { openEditorView, emitShowLauncher } from '../events.js'
10
11
  import { summarize } from '../state/records.js'
@@ -48,6 +49,6 @@ export function DiffBadge(props) {
48
49
  title: n + ' 个文件有差异,点击在编辑区查看',
49
50
  style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer' },
50
51
  },
51
- React.createElement('img', { src: '/edrv/assets/compare-select.png', alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
52
+ React.createElement('img', { src: appUrl('/edrv/assets/compare-select.png'), alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
52
53
  React.createElement('span', { style: { position: 'absolute', top: 1, right: 0, minWidth: 15, height: 15, padding: '0 3px', borderRadius: 8, background: 'var(--dsw-alias-state-warn-primary,#d97706)', color: '#fff', fontSize: 10, lineHeight: '15px', textAlign: 'center', fontWeight: 700, boxSizing: 'border-box' } }, String(n)))
53
54
  }