dsh-vscode-mode 0.11.0 → 0.12.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/src/rules.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * dsh-vscode-mode host — 规则管理(Codebuddy/Cursor 式 .mdc 规则)。
3
- * - 存储:用户规则 ~/.dsh/rules/*.mdc;项目规则 <工作区>/.dsh/rules/*.mdc(随仓库共享)。
3
+ * - 存储:用户规则 DSH_HOME/rules + ~/.dsh/rules(当前目录优先);项目规则 <工作区>/.dsh/rules。
4
4
  * - 格式:frontmatter(description / alwaysApply / globs / enabled)+ markdown 正文;
5
5
  * 类型映射 总是=always / 自动=auto(globs) / 手动=manual(仅索引);enabled 为本插件扩展字段,缺省 true。
6
6
  * - 生效:host 注册 systemPrompt.section(order 400),每次装配同步读取(mtime 缓存),
@@ -11,9 +11,10 @@
11
11
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
12
12
  import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
13
13
  import { Buffer } from 'node:buffer'
14
- import { join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { join, resolve } from 'node:path'
15
16
  import { dshHome } from './paths.js'
16
- import type { RuleInfo, RuleProject, RuleRefInput, RuleScope, RuleSaveInput } from './shared/rules.js'
17
+ import type { RuleInfo, RuleProject, RuleRefInput, RuleScope, RuleSaveInput, UserRuleOrigin } from './shared/rules.js'
17
18
  import type { Ctx } from './store.js'
18
19
 
19
20
  // --region 常量与目录定位
@@ -33,7 +34,7 @@ const SECTION_NAME = 'dsh-vscode-mode:rules'
33
34
  const SECTION_ORDER = 400
34
35
 
35
36
  /**
36
- * 用户规则目录(~/.dsh/rules)。
37
+ * 当前用户规则目录(DSH_HOME/rules)。
37
38
  * @author ddj 2026年09月03号
38
39
  * @param home DSH home(缺省自动解析)
39
40
  * @returns 绝对路径
@@ -42,6 +43,32 @@ export function userRulesDir(home = dshHome()): string {
42
43
  return join(home, 'rules')
43
44
  }
44
45
 
46
+ export interface UserRulesDirs {
47
+ current: string
48
+ legacy?: string
49
+ }
50
+
51
+ /** DSH_HOME 改变时仍读取系统用户目录的旧规则;相同路径不重复加载。 */
52
+ export function userRuleDirs(activeHome = dshHome(), systemHome = homedir()): UserRulesDirs {
53
+ const current = resolve(userRulesDir(activeHome))
54
+ const legacy = resolve(userRulesDir(join(systemHome, '.dsh')))
55
+ const key = (path: string) => process.platform === 'win32' ? path.toLowerCase() : path
56
+ return key(current) === key(legacy) ? { current } : { current, legacy }
57
+ }
58
+
59
+ function userDirOf(origin: UserRuleOrigin | undefined, dirs: UserRulesDirs): string {
60
+ if (origin === undefined || origin === 'current') return dirs.current
61
+ if (origin === 'legacy' && dirs.legacy) return dirs.legacy
62
+ throw new Error('无效的用户规则来源')
63
+ }
64
+
65
+ /** 同名时当前目录优先;禁用的当前规则也应遮蔽旧目录规则。 */
66
+ function mergeUserRules<T>(current: T[], legacy: T[], fileOf: (rule: T) => string): T[] {
67
+ const key = (rule: T) => process.platform === 'win32' ? fileOf(rule).toLowerCase() : fileOf(rule)
68
+ const seen = new Set(current.map(key))
69
+ return [...current, ...legacy.filter((rule) => !seen.has(key(rule)))].sort((a, b) => fileOf(a).localeCompare(fileOf(b)))
70
+ }
71
+
45
72
  /**
46
73
  * 项目规则目录(<工作区>/.dsh/rules)。
47
74
  * @author ddj 2026年09月03号
@@ -261,20 +288,33 @@ export function renderRulesSection(user: LoadedRule[], project: LoadedRule[], wo
261
288
 
262
289
  // --region IO(列表 / 读 / 存 / 删 / 开关)
263
290
  /** 单目录规则元信息列表(仅 *.mdc,最多 RULE_DIR_CAP 个;目录缺失返回空)。 */
264
- async function listRulesDir(dir: string, scope: RuleScope): Promise<RuleInfo[]> {
265
- const names = await readdir(dir).catch(() => [] as string[])
291
+ async function listRulesDir(dir: string, scope: RuleScope, origin?: UserRuleOrigin): Promise<RuleInfo[]> {
292
+ const names = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
293
+ if (error.code === 'ENOENT') return [] as string[]
294
+ throw error
295
+ })
266
296
  const mdcs = names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)
267
297
  const out: RuleInfo[] = []
268
298
  for (const file of mdcs) {
269
299
  const absPath = join(dir, file)
270
- const info = await stat(absPath).catch(() => null)
300
+ const info = await stat(absPath).catch((error: NodeJS.ErrnoException) => {
301
+ if (error.code === 'ENOENT') return null
302
+ throw error
303
+ })
271
304
  if (!info || !info.isFile()) continue
272
- const parsed = parseRuleMdc(await readFile(absPath, 'utf8').catch(() => ''))
273
- out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs))
305
+ const parsed = parseRuleMdc(await readFile(absPath, 'utf8'))
306
+ out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs, origin))
274
307
  }
275
308
  return out
276
309
  }
277
310
 
311
+ /** 列表与注入共用当前目录优先的合并约定。 */
312
+ export async function listUserRules(dirs = userRuleDirs()): Promise<RuleInfo[]> {
313
+ const current = await listRulesDir(dirs.current, 'user', 'current')
314
+ const legacy = dirs.legacy ? await listRulesDir(dirs.legacy, 'user', 'legacy') : []
315
+ return mergeUserRules(current, legacy, (rule) => rule.file)
316
+ }
317
+
278
318
  /** 已注册 workspace 列表(项目 Tab 与写入校验共用)。 */
279
319
  function workspaceList(ctx: Ctx): Array<{ path: string; title?: string }> {
280
320
  const list = ctx.get('workspaceRegistry')?.list?.() ?? []
@@ -297,10 +337,16 @@ function requireWorkspace(ctx: Ctx, workspacePath: string | undefined): { path:
297
337
  return workspace as { path: string; title?: string }
298
338
  }
299
339
 
300
- /** 按作用域解析规则目录(project 先过 requireWorkspace)。 */
301
- function dirOf(ctx: Ctx, scope: RuleScope, workspacePath?: string): string {
302
- if (scope === 'project') return projectRulesDir(requireWorkspace(ctx, workspacePath).path)
303
- return userRulesDir()
340
+ /** 按作用域解析规则目录;客户端路径和未识别的来源均不作为目标使用。 */
341
+ function dirOf(ctx: Ctx, ref: RuleRefInput, dirs: UserRulesDirs): string {
342
+ const invalid = validateRuleFile(ref.file)
343
+ if (invalid) throw new Error(invalid)
344
+ if (ref.scope === 'project') {
345
+ if (ref.origin !== undefined) throw new Error('项目规则不能指定用户规则来源')
346
+ return projectRulesDir(requireWorkspace(ctx, ref.workspacePath).path)
347
+ }
348
+ if (ref.scope !== 'user') throw new Error('无效的规则作用域')
349
+ return userDirOf(ref.origin, dirs)
304
350
  }
305
351
 
306
352
  /**
@@ -309,8 +355,8 @@ function dirOf(ctx: Ctx, scope: RuleScope, workspacePath?: string): string {
309
355
  * @param ctx DSH 上下文
310
356
  * @returns rules.list 载荷
311
357
  */
312
- export async function rulesList(ctx: Ctx): Promise<{ user: RuleInfo[]; projects: RuleProject[] }> {
313
- const user = await listRulesDir(userRulesDir(), 'user')
358
+ export async function rulesList(ctx: Ctx, dirs = userRuleDirs()): Promise<{ user: RuleInfo[]; projects: RuleProject[] }> {
359
+ const user = await listUserRules(dirs)
314
360
  const projects: RuleProject[] = []
315
361
  for (const workspace of workspaceList(ctx)) {
316
362
  const dir = projectRulesDir(workspace.path)
@@ -328,8 +374,8 @@ export async function rulesList(ctx: Ctx): Promise<{ user: RuleInfo[]; projects:
328
374
  * @param ref 作用域 + 文件名(+ 项目工作区)
329
375
  * @returns 文件全文
330
376
  */
331
- export async function rulesRead(ctx: Ctx, ref: RuleRefInput): Promise<string> {
332
- const dir = dirOf(ctx, ref.scope, ref.workspacePath)
377
+ export async function rulesRead(ctx: Ctx, ref: RuleRefInput, dirs = userRuleDirs()): Promise<string> {
378
+ const dir = dirOf(ctx, ref, dirs)
333
379
  return readFile(join(dir, ref.file), 'utf8')
334
380
  }
335
381
 
@@ -344,10 +390,10 @@ export async function rulesRead(ctx: Ctx, ref: RuleRefInput): Promise<string> {
344
390
  * @param mtime 修改时间毫秒
345
391
  * @returns 规则元信息
346
392
  */
347
- function toRuleInfo(scope: RuleScope, file: string, absPath: string, parsed: ParsedRule, size: number, mtime: number): RuleInfo {
393
+ function toRuleInfo(scope: RuleScope, file: string, absPath: string, parsed: ParsedRule, size: number, mtime: number, origin?: UserRuleOrigin): RuleInfo {
348
394
  return {
349
- scope, file, absPath,
350
- relHint: scope === 'project' ? '.dsh/rules/' : 'rules/',
395
+ scope, file, absPath, ...(scope === 'user' ? { origin: origin ?? 'current' } : {}),
396
+ relHint: scope === 'project' ? '.dsh/rules/' : origin === 'legacy' ? '~/.dsh/rules/' : 'rules/',
351
397
  description: parsed.description, type: ruleTypeOf(parsed), globs: parsed.globs, enabled: parsed.enabled,
352
398
  size, mtime, error: parsed.error,
353
399
  }
@@ -360,10 +406,8 @@ function toRuleInfo(scope: RuleScope, file: string, absPath: string, parsed: Par
360
406
  * @param input 保存入参
361
407
  * @returns 保存后的规则元信息
362
408
  */
363
- export async function rulesSave(ctx: Ctx, input: RuleSaveInput): Promise<RuleInfo> {
364
- const invalid = validateRuleFile(input.file)
365
- if (invalid) throw new Error(invalid)
366
- const dir = dirOf(ctx, input.scope, input.workspacePath)
409
+ export async function rulesSave(ctx: Ctx, input: RuleSaveInput, dirs = userRuleDirs()): Promise<RuleInfo> {
410
+ const dir = dirOf(ctx, input, dirs)
367
411
  await mkdir(dir, { recursive: true })
368
412
  const absPath = join(dir, input.file)
369
413
  const content = String(input.content ?? '')
@@ -376,7 +420,7 @@ export async function rulesSave(ctx: Ctx, input: RuleSaveInput): Promise<RuleInf
376
420
  await writeFile(absPath, content, 'utf8')
377
421
  }
378
422
  const info = await stat(absPath).catch(() => null)
379
- return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
423
+ return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now(), input.origin)
380
424
  }
381
425
 
382
426
  /**
@@ -384,8 +428,8 @@ export async function rulesSave(ctx: Ctx, input: RuleSaveInput): Promise<RuleInf
384
428
  * @author ddj 2026年09月03号
385
429
  * @param ref 作用域 + 文件名(+ 项目工作区)
386
430
  */
387
- export async function rulesRemove(ctx: Ctx, ref: RuleRefInput): Promise<void> {
388
- const dir = dirOf(ctx, ref.scope, ref.workspacePath)
431
+ export async function rulesRemove(ctx: Ctx, ref: RuleRefInput, dirs = userRuleDirs()): Promise<void> {
432
+ const dir = dirOf(ctx, ref, dirs)
389
433
  await rm(join(dir, ref.file), { force: true })
390
434
  }
391
435
 
@@ -396,8 +440,8 @@ export async function rulesRemove(ctx: Ctx, ref: RuleRefInput): Promise<void> {
396
440
  * @param enabled 目标状态
397
441
  * @returns 更新后的规则元信息
398
442
  */
399
- export async function rulesToggle(ctx: Ctx, ref: RuleRefInput, enabled: boolean): Promise<RuleInfo> {
400
- const dir = dirOf(ctx, ref.scope, ref.workspacePath)
443
+ export async function rulesToggle(ctx: Ctx, ref: RuleRefInput, enabled: boolean, dirs = userRuleDirs()): Promise<RuleInfo> {
444
+ const dir = dirOf(ctx, ref, dirs)
401
445
  const absPath = join(dir, ref.file)
402
446
  const text = await readRuleText(ctx, ref, absPath)
403
447
  if (text === null) throw new Error('规则文件不存在:' + ref.file)
@@ -409,7 +453,10 @@ export async function rulesToggle(ctx: Ctx, ref: RuleRefInput, enabled: boolean)
409
453
 
410
454
  /** 按作用域读规则全文:project 与写盘同通道走 ctx fs,user 走 node fs;缺失返回 null。 */
411
455
  async function readRuleText(ctx: Ctx, ref: RuleRefInput, absPath: string): Promise<string | null> {
412
- if (ref.scope !== 'project') return readFile(absPath, 'utf8').catch(() => null)
456
+ if (ref.scope !== 'project') return readFile(absPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
457
+ if (error.code === 'ENOENT') return null
458
+ throw error
459
+ })
413
460
  const fs = ctx.get('fs')
414
461
  if (!fs) throw new Error('缺少 fs 服务')
415
462
  const target = await fs.resolve('.dsh/rules/' + ref.file, { cwd: requireWorkspace(ctx, ref.workspacePath).path })
@@ -435,7 +482,7 @@ async function rulesSaveContent(ctx: Ctx, ref: RuleRefInput, absPath: string, co
435
482
  /** 重建单条规则元信息(toggle 后回传 UI;stat 失败时用内容长度 + 当前时间兜底)。 */
436
483
  async function ruleInfoOf(ref: RuleRefInput, absPath: string, content: string): Promise<RuleInfo> {
437
484
  const info = await stat(absPath).catch(() => null)
438
- return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
485
+ return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now(), ref.origin)
439
486
  }
440
487
  // --endregion
441
488
 
@@ -449,20 +496,20 @@ interface InjCacheEntry {
449
496
  const injCache = new Map<string, InjCacheEntry>()
450
497
 
451
498
  /**
452
- * 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存,异常静默为空。
499
+ * 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存;缺失视为空。
453
500
  * @author ddj 2026年09月03号
454
501
  * @param dir 规则目录
455
502
  * @param scope 作用域
456
- * @param relHint 相对提示
503
+ * @param origin 用户规则来源
457
504
  * @returns 已加载规则列表
458
505
  */
459
- function readRulesSync(dir: string, scope: RuleScope): LoadedRule[] {
460
- if (!existsSync(dir)) return []
506
+ function readRulesSync(dir: string, scope: RuleScope, origin?: UserRuleOrigin): LoadedRule[] {
461
507
  let names: string[]
462
508
  try {
463
509
  names = readdirSync(dir)
464
- } catch {
465
- return []
510
+ } catch (error) {
511
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
512
+ throw error
466
513
  }
467
514
  const out: LoadedRule[] = []
468
515
  for (const file of names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)) {
@@ -470,8 +517,9 @@ function readRulesSync(dir: string, scope: RuleScope): LoadedRule[] {
470
517
  let info: { mtimeMs: number; size: number; isFile(): boolean }
471
518
  try {
472
519
  info = statSync(absPath)
473
- } catch {
474
- continue
520
+ } catch (error) {
521
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
522
+ throw error
475
523
  }
476
524
  if (!info.isFile()) continue
477
525
  const cached = injCache.get(absPath)
@@ -481,19 +529,27 @@ function readRulesSync(dir: string, scope: RuleScope): LoadedRule[] {
481
529
  } else {
482
530
  try {
483
531
  parsed = parseRuleMdc(readFileSync(absPath, 'utf8'))
484
- } catch {
485
- continue
532
+ } catch (error) {
533
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
534
+ throw error
486
535
  }
487
536
  injCache.set(absPath, { mtimeMs: info.mtimeMs, size: info.size, parsed })
488
537
  }
489
538
  out.push({
490
- info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs),
539
+ info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs, origin),
491
540
  body: parsed.body,
492
541
  })
493
542
  }
494
543
  return out
495
544
  }
496
545
 
546
+ /** 同步装配与列表同名遮蔽策略一致。 */
547
+ export function readUserRulesSync(dirs = userRuleDirs()): LoadedRule[] {
548
+ const current = readRulesSync(dirs.current, 'user', 'current')
549
+ const legacy = dirs.legacy ? readRulesSync(dirs.legacy, 'user', 'legacy') : []
550
+ return mergeUserRules(current, legacy, (rule) => rule.info.file)
551
+ }
552
+
497
553
  /** 从装配上下文解析会话工作区 cwd(agent.id → sessions → header.cwd;缺链返回 null)。 */
498
554
  function cwdFromAssemble(ctx: Ctx, asm: { agent?: { id?: unknown } } | undefined): string | null {
499
555
  const id = asm?.agent?.id
@@ -509,12 +565,12 @@ function cwdFromAssemble(ctx: Ctx, asm: { agent?: { id?: unknown } } | undefined
509
565
  * @param ctx DSH host 上下文
510
566
  * @returns 是否成功注册
511
567
  */
512
- export function installRulesSection(ctx: Ctx): boolean {
568
+ export function installRulesSection(ctx: Ctx, dirs = userRuleDirs()): boolean {
513
569
  const sp = ctx.get('systemPrompt')
514
570
  if (!sp || typeof sp.section !== 'function') return false
515
571
  const provider = (asm: { agent?: { id?: unknown } }): string => {
516
572
  try {
517
- const user = readRulesSync(userRulesDir(), 'user')
573
+ const user = readUserRulesSync(dirs)
518
574
  const cwd = cwdFromAssemble(ctx, asm)
519
575
  const project = cwd ? readRulesSync(projectRulesDir(cwd), 'project') : []
520
576
  return renderRulesSection(user, project, cwd ?? undefined)
@@ -5,6 +5,8 @@
5
5
  * `|` 连接多个候选(任一命中即触发),如 `Alt+ArrowLeft|Ctrl+Alt+-`。
6
6
  * 约束:本模块被 host 与 client 两半共用,**禁止 import client 侧模块**(React/浏览器 API)。
7
7
  * 与 client/ui/commandCatalog 的一致性由 tests/commands.test.ts 断言兜底。
8
+ * 官方机制(2026-10):SHORTCUT_PROFILES 描述各命令在官方 ctx.shortcuts 的 profile 默认键位
9
+ * (由 client 注册官方服务时使用);chordToBinding/bindingToChord 负责插件弦与官方绑定互转。
8
10
  * 作者 ddj 2026年08月26号 / 2026年09月10号
9
11
  */
10
12
 
@@ -66,3 +68,254 @@ export function normalizeKeybindings(raw: unknown): Record<string, string> {
66
68
  }
67
69
  return out
68
70
  }
71
+
72
+ //#region 官方 shortcuts 机制映射层(纯数据/纯函数,禁 import client 侧与官方包)
73
+
74
+ /** 官方 shortcuts 服务的修饰符标记('primary' = 桌面端按平台展开为 Ctrl/Cmd,web 同语义)。 */
75
+ export type OfficialModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta'
76
+
77
+ /** 官方绑定形状(物理 code + 修饰符;与 @deepseek-ai/dsh-client-shortcuts 绑定契约同构,鸭子类型)。 */
78
+ export interface OfficialBinding {
79
+ code: string
80
+ modifiers: OfficialModifier[]
81
+ secondCode?: string
82
+ }
83
+
84
+ /** 官方 profile 键(desktop|web × macos|windows|linux)。 */
85
+ export type ShortcutProfileKey =
86
+ | 'desktop:macos' | 'desktop:windows' | 'desktop:linux'
87
+ | 'web:macos' | 'web:windows' | 'web:linux'
88
+
89
+ /** 命令 id → 各 profile 官方默认键位(缺 profile = 该端不绑定)。 */
90
+ export type ShortcutProfiles = Partial<Record<ShortcutProfileKey, OfficialBinding>>
91
+
92
+ /** 显示主键 → 官方物理 code 对照(官方 code 白名单:Key[A-Z]|Digit[0-9]|F1-24|具名键)。 */
93
+ const DISPLAY_KEY_TO_CODE: Record<string, string> = {
94
+ '-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight',
95
+ ';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period',
96
+ '/': 'Slash', '\\': 'Backslash', '`': 'Backquote',
97
+ Enter: 'Enter', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete',
98
+ Escape: 'Escape', Space: 'Space',
99
+ ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
100
+ }
101
+
102
+ /** 官方具名 code(非 Key/Digit/F 前缀且可回显的部分)。 */
103
+ const NAMED_CODES = ['Enter', 'Tab', 'Backspace', 'Delete', 'Escape', 'Space',
104
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] as const
105
+
106
+ /** 官方 code → 显示主键(符号键反向对照)。 */
107
+ const CODE_TO_DISPLAY_KEY: Record<string, string> = {
108
+ Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']',
109
+ Semicolon: ';', Quote: "'", Comma: ',', Period: '.',
110
+ Slash: '/', Backslash: '\\', Backquote: '`',
111
+ }
112
+
113
+ /** 显示主键 → 官方 code(PageUp/PageDown/Home/End/Insert 等官方不支持 → null)。 */
114
+ function keyToCode(key: string): string | null {
115
+ if (Object.hasOwn(DISPLAY_KEY_TO_CODE, key)) return DISPLAY_KEY_TO_CODE[key]
116
+ if (key.length === 1 && key >= 'a' && key <= 'z') return 'Key' + key.toUpperCase()
117
+ if (key.length === 1 && key >= 'A' && key <= 'Z') return 'Key' + key
118
+ if (key.length === 1 && key >= '0' && key <= '9') return 'Digit' + key
119
+ if (/^F([1-9]|1[0-9]|2[0-4])$/u.test(key)) return key
120
+ return null
121
+ }
122
+
123
+ /** 官方 code → 显示主键(不可回显 → null)。 */
124
+ function codeToKey(code: string): string | null {
125
+ if (Object.hasOwn(CODE_TO_DISPLAY_KEY, code)) return CODE_TO_DISPLAY_KEY[code]
126
+ if (/^Key[A-Z]$/u.test(code)) return code.slice(3)
127
+ if (/^Digit[0-9]$/u.test(code)) return code.slice(5)
128
+ if (/^F([1-9]|1[0-9]|2[0-4])$/u.test(code)) return code
129
+ if ((NAMED_CODES as readonly string[]).includes(code)) return code
130
+ return null
131
+ }
132
+
133
+ /** 插件弦的修饰符 token → 官方修饰符(Ctrl 与 Cmd 同为 primary,与 matchEvent 的互认语义一致)。 */
134
+ const CHORD_MODIFIER_TOKENS: Record<string, OfficialModifier> = {
135
+ ctrl: 'primary', control: 'primary', cmd: 'primary', meta: 'primary',
136
+ shift: 'shift', alt: 'alt',
137
+ }
138
+
139
+ /**
140
+ * 插件键位弦(单候选)→ 官方绑定。
141
+ * 不可表示的主键(PageUp/PageDown/Home/End/Insert 等)返回 null,调用方跳过。
142
+ * @author ddj 2026年10月
143
+ * @param chord 键位弦(如 `Ctrl+Alt+-` / `F9` / `Alt+ArrowLeft`)
144
+ * @returns 官方绑定或 null
145
+ */
146
+ export function chordToBinding(chord: string): OfficialBinding | null {
147
+ const parts = String(chord ?? '').split('+').map((part) => part.trim()).filter(Boolean)
148
+ if (!parts.length) return null
149
+ const modifiers = new Set<OfficialModifier>()
150
+ let key = ''
151
+ for (const part of parts) {
152
+ const modifier = CHORD_MODIFIER_TOKENS[part.toLowerCase()]
153
+ if (modifier) {
154
+ modifiers.add(modifier)
155
+ continue
156
+ }
157
+ if (key) return null
158
+ key = part
159
+ }
160
+ if (!key) return null
161
+ if (key === ' ') key = 'Space'
162
+ else if (key.length === 1 && key >= 'a' && key <= 'z') key = key.toUpperCase()
163
+ const code = keyToCode(key)
164
+ return code === null ? null : { code, modifiers: [...modifiers] }
165
+ }
166
+
167
+ /**
168
+ * 官方绑定 → 插件显示键位弦(primary 显示为 Ctrl,与历史 tooltip 文案保持一致)。
169
+ * @author ddj 2026年10月
170
+ * @param binding 官方绑定
171
+ * @returns 插件弦(不可表示 → null)
172
+ */
173
+ export function bindingToChord(binding: OfficialBinding): string | null {
174
+ if (!binding || typeof binding !== 'object') return null
175
+ const key = codeToKey(binding.code)
176
+ if (key === null) return null
177
+ let suffix = ''
178
+ if (binding.secondCode !== undefined) {
179
+ const secondKey = codeToKey(binding.secondCode)
180
+ if (secondKey === null) return null
181
+ suffix = '+' + secondKey
182
+ }
183
+ const parts: string[] = []
184
+ if (binding.modifiers.includes('primary')) parts.push('Ctrl')
185
+ if (binding.modifiers.includes('shift')) parts.push('Shift')
186
+ if (binding.modifiers.includes('alt')) parts.push('Alt')
187
+ return [...parts, key + suffix].join('+')
188
+ }
189
+
190
+ /** 官方绑定字面量构造(表格可读性辅助)。 */
191
+ function bind(code: string, modifiers: OfficialModifier[]): OfficialBinding {
192
+ return { code, modifiers }
193
+ }
194
+
195
+ /**
196
+ * 命令 id → 官方 profile 默认键位(与 KEYBINDING_DEFAULTS 一一对应;client 注册官方服务的数据源)。
197
+ * 取值约束(对齐官方 bindingIssue/isWebBindingAllowed 校验,错报会在注册时抛错):
198
+ * - desktop:macos/windows:官方不做保留校验,用 VS Code 同款 chord;
199
+ * - desktop:linux / web:linux:Arrow*、裸 F 键、全 Shift、Ctrl+V/C/X/Z/Y/Q/H/A、Meta 均为保留键 → 不声明;
200
+ * - web:macos/windows:单修饰符组合不在白名单 → 用 primary+alt / primary+shift 变体(官方 ui-layout 同款);
201
+ * - Ctrl+PageDown/PageUp 候选废弃(官方 code 白名单无 PageUp/PageDown);
202
+ * - edrv.toggleSidebar 留空:Ctrl+B 与官方 sidebar.left.toggle(Mod+B)冲突,注册会抛错且冲突键互相阻断。
203
+ * @author ddj 2026年10月
204
+ */
205
+ export const SHORTCUT_PROFILES: Record<string, ShortcutProfiles> = {
206
+ 'edrv.toggleSidebar': {},
207
+ 'edrv.save': {
208
+ 'desktop:macos': bind('KeyS', ['primary']),
209
+ 'desktop:windows': bind('KeyS', ['primary']),
210
+ 'desktop:linux': bind('KeyS', ['primary']),
211
+ },
212
+ // 官方 workspace.files 已占用桌面 primary+P 和 Web primary+alt+P;插件 QuickOpen 使用非冲突组合。
213
+ 'edrv.quickOpen': {
214
+ 'desktop:macos': bind('KeyP', ['primary', 'alt']),
215
+ 'desktop:windows': bind('KeyP', ['primary', 'alt']),
216
+ 'desktop:linux': bind('KeyP', ['primary', 'alt']),
217
+ 'web:macos': bind('KeyP', ['primary', 'alt', 'shift']),
218
+ 'web:windows': bind('KeyP', ['primary', 'alt', 'shift']),
219
+ },
220
+ 'edrv.searchInFiles': {
221
+ 'desktop:macos': bind('KeyF', ['primary', 'shift']),
222
+ 'desktop:windows': bind('KeyF', ['primary', 'shift']),
223
+ 'desktop:linux': bind('KeyF', ['primary', 'shift']),
224
+ 'web:macos': bind('KeyF', ['primary', 'shift']),
225
+ 'web:windows': bind('KeyF', ['primary', 'shift']),
226
+ },
227
+ // Ctrl+Shift+V(primary+KeyV)在 linux 为保留键(官方 bindingIssue 不豁免 shift),desktop:linux 不声明
228
+ 'edrv.toggleMarkdownPreview': {
229
+ 'desktop:macos': bind('KeyV', ['primary', 'shift']),
230
+ 'desktop:windows': bind('KeyV', ['primary', 'shift']),
231
+ 'web:macos': bind('KeyV', ['primary', 'shift']),
232
+ 'web:windows': bind('KeyV', ['primary', 'shift']),
233
+ },
234
+ // 导航历史:主候选 Alt+Arrow 在 linux/web 为保留或白名单外;web 取第二候选(Ctrl+Alt+- / Ctrl+Shift+-)
235
+ 'edrv.navigateBack': {
236
+ 'desktop:macos': bind('ArrowLeft', ['alt']),
237
+ 'desktop:windows': bind('ArrowLeft', ['alt']),
238
+ 'web:macos': bind('Minus', ['primary', 'alt']),
239
+ 'web:windows': bind('Minus', ['primary', 'alt']),
240
+ },
241
+ 'edrv.navigateForward': {
242
+ 'desktop:macos': bind('ArrowRight', ['alt']),
243
+ 'desktop:windows': bind('ArrowRight', ['alt']),
244
+ 'web:macos': bind('Minus', ['primary', 'shift']),
245
+ 'web:windows': bind('Minus', ['primary', 'shift']),
246
+ },
247
+ // 页签循环:Ctrl+PageDown/PageUp 候选官方不可表示,仅保留 Ctrl+Alt+Arrow 主候选
248
+ 'edrv.nextTab': {
249
+ 'desktop:macos': bind('ArrowRight', ['primary', 'alt']),
250
+ 'desktop:windows': bind('ArrowRight', ['primary', 'alt']),
251
+ 'web:macos': bind('ArrowRight', ['primary', 'alt']),
252
+ 'web:windows': bind('ArrowRight', ['primary', 'alt']),
253
+ },
254
+ 'edrv.prevTab': {
255
+ 'desktop:macos': bind('ArrowLeft', ['primary', 'alt']),
256
+ 'desktop:windows': bind('ArrowLeft', ['primary', 'alt']),
257
+ 'web:macos': bind('ArrowLeft', ['primary', 'alt']),
258
+ 'web:windows': bind('ArrowLeft', ['primary', 'alt']),
259
+ },
260
+ 'edrv.goToLine': {
261
+ 'desktop:macos': bind('KeyG', ['primary']),
262
+ 'desktop:windows': bind('KeyG', ['primary']),
263
+ 'desktop:linux': bind('KeyG', ['primary']),
264
+ },
265
+ 'edrv.showCommands': {
266
+ 'desktop:macos': bind('KeyP', ['primary', 'shift']),
267
+ 'desktop:windows': bind('KeyP', ['primary', 'shift']),
268
+ 'desktop:linux': bind('KeyP', ['primary', 'shift']),
269
+ 'web:macos': bind('KeyP', ['primary', 'shift']),
270
+ 'web:windows': bind('KeyP', ['primary', 'shift']),
271
+ },
272
+ 'edrv.nextEditorRow': {
273
+ 'desktop:macos': bind('ArrowDown', ['primary', 'alt']),
274
+ 'desktop:windows': bind('ArrowDown', ['primary', 'alt']),
275
+ 'web:macos': bind('ArrowDown', ['primary', 'alt']),
276
+ 'web:windows': bind('ArrowDown', ['primary', 'alt']),
277
+ },
278
+ 'edrv.prevEditorRow': {
279
+ 'desktop:macos': bind('ArrowUp', ['primary', 'alt']),
280
+ 'desktop:windows': bind('ArrowUp', ['primary', 'alt']),
281
+ 'web:macos': bind('ArrowUp', ['primary', 'alt']),
282
+ 'web:windows': bind('ArrowUp', ['primary', 'alt']),
283
+ },
284
+ 'edrv.addSelectionRef': {
285
+ 'desktop:macos': bind('KeyU', ['primary']),
286
+ 'desktop:windows': bind('KeyU', ['primary']),
287
+ 'desktop:linux': bind('KeyU', ['primary']),
288
+ },
289
+ 'edrv.closeTab': {
290
+ 'desktop:macos': bind('F4', ['primary']),
291
+ 'desktop:windows': bind('F4', ['primary']),
292
+ 'desktop:linux': bind('F4', ['primary']),
293
+ },
294
+ // 调试 F 键:linux 判 modifier-required(裸 F/全 Shift),仅 desktop:macos/windows
295
+ 'edrv.debugToggleBreakpoint': {
296
+ 'desktop:macos': bind('F9', []),
297
+ 'desktop:windows': bind('F9', []),
298
+ },
299
+ 'edrv.debugStartContinue': {
300
+ 'desktop:macos': bind('F5', []),
301
+ 'desktop:windows': bind('F5', []),
302
+ },
303
+ 'edrv.debugStepOver': {
304
+ 'desktop:macos': bind('F10', []),
305
+ 'desktop:windows': bind('F10', []),
306
+ },
307
+ 'edrv.debugStepInto': {
308
+ 'desktop:macos': bind('F11', []),
309
+ 'desktop:windows': bind('F11', []),
310
+ },
311
+ 'edrv.debugStepOut': {
312
+ 'desktop:macos': bind('F11', ['shift']),
313
+ 'desktop:windows': bind('F11', ['shift']),
314
+ },
315
+ 'edrv.debugStop': {
316
+ 'desktop:macos': bind('F5', ['shift']),
317
+ 'desktop:windows': bind('F5', ['shift']),
318
+ },
319
+ }
320
+
321
+ //#endregion
@@ -4,7 +4,10 @@
4
4
  * 作者 ddj 2026年09月03号
5
5
  */
6
6
 
7
- /** 规则作用域:用户规则(~/.dsh/rules)或项目规则(<工作区>/.dsh/rules)。 */
7
+ /** 用户规则来源:当前 DSH_HOME 或系统用户目录的旧版 .dsh。 */
8
+ export type UserRuleOrigin = 'current' | 'legacy'
9
+
10
+ /** 规则作用域:用户规则(DSH_HOME/rules + ~/.dsh/rules)或项目规则(<工作区>/.dsh/rules)。 */
8
11
  export type RuleScope = 'user' | 'project'
9
12
 
10
13
  /** 规则生效类型(由 frontmatter 推导):总是 / 自动(globs 命中时)/ 手动(仅索引)。 */
@@ -13,6 +16,8 @@ export type RuleType = 'always' | 'auto' | 'manual'
13
16
  /** 一条 .mdc 规则的元信息(列表行展示 + 注入语义所需的最小集)。 */
14
17
  export interface RuleInfo {
15
18
  scope: RuleScope
19
+ /** 用户规则的实际来源;项目规则不设置。 */
20
+ origin?: UserRuleOrigin
16
21
  /** 文件名(含 .mdc 后缀,不含路径)。 */
17
22
  file: string
18
23
  /** 绝对路径(host 解析,供展示与按需读取)。 */
@@ -48,6 +53,8 @@ export interface RuleSaveInput {
48
53
  scope: RuleScope
49
54
  /** project 必填:目标工作区绝对路径(须为 DSH 已注册 workspace)。 */
50
55
  workspacePath?: string
56
+ /** 缺省 current;仅用户规则可指定 legacy。 */
57
+ origin?: UserRuleOrigin
51
58
  file: string
52
59
  content: string
53
60
  }
@@ -56,5 +63,6 @@ export interface RuleSaveInput {
56
63
  export interface RuleRefInput {
57
64
  scope: RuleScope
58
65
  workspacePath?: string
66
+ origin?: UserRuleOrigin
59
67
  file: string
60
68
  }