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/README.md +31 -10
- package/lib/client.js +1092 -475
- package/lib/client.js.map +1 -1
- package/lib/index.js +91 -41
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/activePathStore.ts +28 -0
- package/src/client/commandBridge.ts +9 -41
- package/src/client/index.ts +91 -14
- package/src/client/keybindings.ts +19 -73
- package/src/client/shortcutsOfficial.ts +451 -0
- package/src/client/sidebar/panels/RulesPanel.ts +15 -11
- package/src/client/ui/EditorView.ts +10 -83
- package/src/client/ui/McpSettings.ts +4 -5
- package/src/client/ui/QuickOpen.ts +10 -11
- package/src/client/ui/ShortcutSettings.ts +294 -0
- package/src/client/ui/commandCatalog.ts +36 -41
- package/src/fileOpenSettings.ts +3 -0
- package/src/rules.ts +100 -44
- package/src/shared/keybindings.ts +253 -0
- package/src/shared/rules.ts +9 -1
- package/src/client/ui/KeybindingsPanel.ts +0 -190
package/src/rules.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode host — 规则管理(Codebuddy/Cursor 式 .mdc 规则)。
|
|
3
|
-
* - 存储:用户规则 ~/.dsh/rules
|
|
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 {
|
|
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
|
-
*
|
|
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(() =>
|
|
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(() =>
|
|
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')
|
|
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
|
-
/**
|
|
301
|
-
function dirOf(ctx: Ctx,
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(() =>
|
|
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
|
|
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 =
|
|
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
|
package/src/shared/rules.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* 作者 ddj 2026年09月03号
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
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
|
}
|