dsh-vscode-mode 0.22.0 → 0.23.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 +5 -3
- package/lib/client.js +3370 -85
- package/lib/client.js.map +2 -2
- package/lib/index.js +1167 -266
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +9 -4
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/index.ts +2 -2
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +104 -75
- package/src/pending/persist.ts +6 -1
- package/src/pending/store.ts +10 -5
- package/src/pending/vcs.ts +2 -2
- package/src/pendingCompat.ts +3 -8
- package/src/pendingRpc.ts +140 -58
- package/src/quickMenu.ts +269 -0
- package/src/rpc.ts +37 -0
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +10 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode 快捷菜单共享纯函数 — 下拉选择器的状态与摘要文案。
|
|
3
|
+
*
|
|
4
|
+
* 纯函数模块:禁 node/react 导入(与 shared/quickMenu.ts 同约束)。
|
|
5
|
+
* 为什么抽:工作区/技能/规则三个下拉的「触发器上显示什么」「怎么切换一项」是同一类
|
|
6
|
+
* 纯计算,放在 shared 里可被 vitest 直接覆盖,避免只能靠肉眼在弹窗里验。
|
|
7
|
+
*
|
|
8
|
+
* 作者 ddj 2026年10月30号
|
|
9
|
+
*/
|
|
10
|
+
import { normalizePath } from './quickMenu.js'
|
|
11
|
+
|
|
12
|
+
/** 工作区限定态:全部 / 仅当前工作区 / 自定义集合。 */
|
|
13
|
+
export type WorkspacePickMode = 'all' | 'current' | 'some'
|
|
14
|
+
|
|
15
|
+
/** 工作区下拉触发器的摘要。 */
|
|
16
|
+
export interface WorkspacePickSummary {
|
|
17
|
+
mode: WorkspacePickMode
|
|
18
|
+
/** 触发器文案。 */
|
|
19
|
+
label: string
|
|
20
|
+
/** 归一化前的原始路径列表(空数组表示全部)。 */
|
|
21
|
+
paths: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 推导工作区下拉的摘要(null / 空数组都算「全部工作区」)。
|
|
26
|
+
* @author ddj 2026年10月30号
|
|
27
|
+
* @param paths 已选路径列表(null = 全部工作区)
|
|
28
|
+
* @param cwd 当前工作区(缺失时无法判定「当前工作区」态)
|
|
29
|
+
* @returns 摘要(mode + 文案 + 原始路径)
|
|
30
|
+
*/
|
|
31
|
+
export function workspaceLabel(paths: string[] | null, cwd?: string): WorkspacePickSummary {
|
|
32
|
+
const list = (paths ?? []).slice()
|
|
33
|
+
if (list.length === 0) return { mode: 'all', label: '全部工作区', paths: [] }
|
|
34
|
+
if (list.length === 1 && cwd !== undefined && normalizePath(cwd) !== '' && normalizePath(list[0]) === normalizePath(cwd)) {
|
|
35
|
+
return { mode: 'current', label: '当前工作区', paths: list }
|
|
36
|
+
}
|
|
37
|
+
return { mode: 'some', label: '已选 ' + list.length + ' 个工作区', paths: list }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 多选摘要文案(0 项、1~2 项全写、更多折叠成「A、B 等 N 项」)。
|
|
42
|
+
* @author ddj 2026年10月30号
|
|
43
|
+
* @param labels 已选项文案
|
|
44
|
+
* @param unit 折叠时的单位(如「项技能」「条规则」)
|
|
45
|
+
* @returns 触发器文案
|
|
46
|
+
*/
|
|
47
|
+
export function selectionLabel(labels: string[], unit: string): string {
|
|
48
|
+
if (labels.length === 0) return '未绑定'
|
|
49
|
+
if (labels.length <= 2) return labels.join('、')
|
|
50
|
+
return labels.slice(0, 2).join('、') + ' 等 ' + labels.length + ' ' + unit
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 工作区集合切换(归一化比较去重;清空即回到「全部工作区」的 null 语义)。
|
|
55
|
+
* @author ddj 2026年10月30号
|
|
56
|
+
* @param paths 当前列表(null = 全部)
|
|
57
|
+
* @param target 目标工作区路径
|
|
58
|
+
* @returns 新列表;空集合返回 null
|
|
59
|
+
*/
|
|
60
|
+
export function toggleWorkspace(paths: string[] | null, target: string): string[] | null {
|
|
61
|
+
const list = (paths ?? []).slice()
|
|
62
|
+
const key = normalizePath(target)
|
|
63
|
+
const index = list.findIndex((path) => normalizePath(path) === key)
|
|
64
|
+
if (index >= 0) list.splice(index, 1)
|
|
65
|
+
else list.push(target)
|
|
66
|
+
return list.length === 0 ? null : list
|
|
67
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -16,7 +16,9 @@ import type { McpSessionStatus, MpcConfig, MpcProject, MpcProjectSaveInput, MpcS
|
|
|
16
16
|
import type { CompatReport, DevFormInfo } from './compat.js'
|
|
17
17
|
import type { ShellIntegrationStatus, UnityListPayload, UnityProjectEntry } from './integration.js'
|
|
18
18
|
import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules.js'
|
|
19
|
+
import type { SkillCatalog } from './skillCatalog.js'
|
|
19
20
|
import type { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
|
|
21
|
+
import type { QuickMenuDoc } from './quickMenu.js'
|
|
20
22
|
import type { LspCompletionItem, LspCompletionList, LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSignatureHelp, LspSymbol } from './lsp.js'
|
|
21
23
|
import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
|
|
22
24
|
import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdatePoll, SvnUpdateResult, SvnUpdateState } from './svn.js'
|
|
@@ -491,11 +493,15 @@ export interface RpcRequestMap {
|
|
|
491
493
|
'rules.save': RuleSaveInput
|
|
492
494
|
'rules.remove': RuleRefInput
|
|
493
495
|
'rules.toggle': RuleRefInput & { enabled: boolean }
|
|
496
|
+
'skills.list': { cwd?: string }
|
|
494
497
|
'snippets.list': {}
|
|
495
498
|
'snippets.read': SnippetRefInput
|
|
496
499
|
'snippets.save': SnippetSaveInput
|
|
497
500
|
'snippets.remove': SnippetRefInput
|
|
498
501
|
'snippets.entries': { sessionId?: string }
|
|
502
|
+
'quickMenu.get': {}
|
|
503
|
+
'quickMenu.save': { doc: QuickMenuDoc; baseRevision: number }
|
|
504
|
+
'quickMenu.launcher': { x: number | null; y: number | null }
|
|
499
505
|
'svn.status': { sessionId?: string; force?: boolean }
|
|
500
506
|
'svn.changes': { sessionId?: string; force?: boolean }
|
|
501
507
|
'svn.explorerStatus': { sessionId?: string; force?: boolean }
|
|
@@ -672,11 +678,15 @@ export interface RpcOkMap {
|
|
|
672
678
|
'rules.save': { rule: RuleInfo }
|
|
673
679
|
'rules.remove': object
|
|
674
680
|
'rules.toggle': { rule: RuleInfo }
|
|
681
|
+
'skills.list': SkillCatalog
|
|
675
682
|
'snippets.list': { user: SnippetInfo[]; projects: SnippetProject[] }
|
|
676
683
|
'snippets.read': { content: string }
|
|
677
684
|
'snippets.save': { file: SnippetInfo }
|
|
678
685
|
'snippets.remove': object
|
|
679
686
|
'snippets.entries': { entries: SnippetEntry[] }
|
|
687
|
+
'quickMenu.get': { doc: QuickMenuDoc; warning?: string }
|
|
688
|
+
'quickMenu.save': { doc: QuickMenuDoc }
|
|
689
|
+
'quickMenu.launcher': { doc: QuickMenuDoc }
|
|
680
690
|
'svn.status': SvnStatusPayload
|
|
681
691
|
'svn.changes': { wcRoot: string; entries: SvnChangeEntry[]; truncated: boolean }
|
|
682
692
|
'svn.explorerStatus': { entries: SvnChangeEntry[]; truncated: boolean; prefix: string }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode 快捷菜单共享契约 — 技能候选(全局技能 + 当前工作区技能)。
|
|
3
|
+
*
|
|
4
|
+
* 纯类型模块:禁 node/react 导入(与 shared/rules.ts、shared/quickMenu.ts 同约束)。
|
|
5
|
+
* 为什么需要它:快捷菜单的「绑定技能」下拉要按来源分组展示候选,而会话可见技能名
|
|
6
|
+
* (remote.skills)只给名字、不给来源,且无活跃会话时不可用;这里定义 host 扫描
|
|
7
|
+
* 磁盘技能目录后的只读快照,供 UI 分组与摘要使用。
|
|
8
|
+
*
|
|
9
|
+
* 作者 ddj 2026年10月30号
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 技能来源:用户全局技能(DSH_HOME/skills)或当前工作区技能(<projectRoot>/.dsh/skills)。 */
|
|
13
|
+
export type SkillSource = 'user' | 'project'
|
|
14
|
+
|
|
15
|
+
/** 一条可绑定技能的元信息(下拉选项展示所需的最小集)。 */
|
|
16
|
+
export interface SkillInfo {
|
|
17
|
+
/** kebab-case 技能名(与提示词里 /技能名 token 一致)。 */
|
|
18
|
+
name: string
|
|
19
|
+
/** frontmatter description(必填字段的最小摘要,缺失时为空串)。 */
|
|
20
|
+
description: string
|
|
21
|
+
/** SKILL.md 或 <名>.md 的绝对路径(排障与 title 提示用)。 */
|
|
22
|
+
path: string
|
|
23
|
+
source: SkillSource
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 技能候选快照(skills.list 的返回,只读、不落缓存、不写盘)。 */
|
|
27
|
+
export interface SkillCatalog {
|
|
28
|
+
user: SkillInfo[]
|
|
29
|
+
/** 未识别到工作区根或工作区无技能目录时为空数组(非错误)。 */
|
|
30
|
+
project: SkillInfo[]
|
|
31
|
+
/** 解析出的工作区根目录(向上找到的含 .git 祖先);缺失表示按 cwd 兜底。 */
|
|
32
|
+
projectRoot?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 下拉面板的来源分组(按 key 顺序渲染)。 */
|
|
36
|
+
export interface SkillGroup {
|
|
37
|
+
key: SkillSource
|
|
38
|
+
label: string
|
|
39
|
+
skills: SkillInfo[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 来源分组标题文案。 */
|
|
43
|
+
export const SKILL_SOURCE_LABEL: Record<SkillSource, string> = {
|
|
44
|
+
user: '全局技能',
|
|
45
|
+
project: '当前工作区技能',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 按来源分组(空组不出现;面板全空时由 UI 统一渲染空态)。
|
|
50
|
+
* @author ddj 2026年10月30号
|
|
51
|
+
* @param input 技能候选快照
|
|
52
|
+
* @returns 非空分组(全局在前、工作区在后)
|
|
53
|
+
*/
|
|
54
|
+
export function groupSkills(input: SkillCatalog | null | undefined): SkillGroup[] {
|
|
55
|
+
if (!input) return []
|
|
56
|
+
const groups: SkillGroup[] = []
|
|
57
|
+
if (input.user.length > 0) groups.push({ key: 'user', label: SKILL_SOURCE_LABEL.user, skills: input.user })
|
|
58
|
+
if (input.project.length > 0) groups.push({ key: 'project', label: SKILL_SOURCE_LABEL.project, skills: input.project })
|
|
59
|
+
return groups
|
|
60
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 技能候选枚举(全局技能 + 当前工作区技能)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要另起一条通道:本插件原有的 `skills.ts` 只管「随包分发的技能组」
|
|
5
|
+
* (<包根>/skills,按 SKILL_PREFIXES 前缀白名单过滤);而快捷菜单「绑定技能」需要的是
|
|
6
|
+
* 用户能自由增删的两类技能目录,与 DSH 文件系统 skill provider 的两个根一致:
|
|
7
|
+
* - 全局技能:`$DSH_HOME/skills`(provider rank 400 / user-dsh)
|
|
8
|
+
* - 工作区技能:`<projectRoot>/.dsh/skills`(provider rank 100 / project-dsh)
|
|
9
|
+
* 只读、不写盘、不缓存:每次打开配置弹窗重扫一遍(目录数量级很小)。
|
|
10
|
+
*
|
|
11
|
+
* 作者 ddj 2026年10月30号
|
|
12
|
+
*/
|
|
13
|
+
import { statSync } from 'node:fs'
|
|
14
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { dshHome } from './paths.js'
|
|
17
|
+
import { isParsedSkill, parseSkillMd } from './skills.js'
|
|
18
|
+
import type { SkillCatalog, SkillInfo, SkillSource } from './shared/skillCatalog.js'
|
|
19
|
+
|
|
20
|
+
/** 单个根目录最多枚举的技能数(防失控目录拖垮弹窗)。 */
|
|
21
|
+
const SKILL_CATALOG_CAP = 200
|
|
22
|
+
/** 向上找项目根的层数上限(防异常路径下的长链遍历)。 */
|
|
23
|
+
const ROOT_DEPTH_CAP = 8
|
|
24
|
+
/** 目录式技能的清单文件名。 */
|
|
25
|
+
const SKILL_FILE = 'SKILL.md'
|
|
26
|
+
|
|
27
|
+
/** 技能根目录集合(绝对路径)。 */
|
|
28
|
+
export interface SkillRoots {
|
|
29
|
+
/** 全局技能根:<DSH_HOME>/skills。 */
|
|
30
|
+
user: string
|
|
31
|
+
/** 工作区技能根:<projectRoot>/.dsh/skills。 */
|
|
32
|
+
project: string
|
|
33
|
+
/** 解析出的工作区根(含 .git 的最近祖先;找不到时回落 cwd)。 */
|
|
34
|
+
projectRoot?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 从 cwd 向上找项目根(含 .git 的最近祖先;找不到回落 cwd)。
|
|
39
|
+
* @author ddj 2026年10月30号
|
|
40
|
+
* @param cwd 起始目录
|
|
41
|
+
* @returns 项目根绝对路径;cwd 缺失时 undefined
|
|
42
|
+
*/
|
|
43
|
+
export function projectRootOf(cwd?: string): string | undefined {
|
|
44
|
+
if (cwd === undefined || cwd === '') return undefined
|
|
45
|
+
let current = cwd
|
|
46
|
+
for (let depth = 0; depth < ROOT_DEPTH_CAP; depth += 1) {
|
|
47
|
+
const parent = join(current, '..')
|
|
48
|
+
// 用目录列举探测 .git:目录式与 worktree 的文件式 .git 都算命中
|
|
49
|
+
const entry = join(current, '.git')
|
|
50
|
+
if (entryExists(entry)) return current
|
|
51
|
+
if (parent === current) break
|
|
52
|
+
current = parent
|
|
53
|
+
}
|
|
54
|
+
return cwd
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 探测路径是否存在(同步存在性判断,避免为一次 up-walk 引入异步瀑布)。
|
|
59
|
+
* @author ddj 2026年10月30号
|
|
60
|
+
* @param path 绝对路径
|
|
61
|
+
* @returns 存在为 true
|
|
62
|
+
*/
|
|
63
|
+
function entryExists(path: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
// 只做存在性探测:statSync 对文件/目录/符号链接(跟随)都成立
|
|
66
|
+
statSync(path)
|
|
67
|
+
return true
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 技能根目录(全局 + 当前工作区)。
|
|
75
|
+
* @author ddj 2026年10月30号
|
|
76
|
+
* @param cwd 当前工作区
|
|
77
|
+
* @returns 两个根的绝对路径
|
|
78
|
+
*/
|
|
79
|
+
export function skillRootsOf(cwd?: string): SkillRoots {
|
|
80
|
+
const projectRoot = projectRootOf(cwd)
|
|
81
|
+
const project = projectRoot === undefined ? '' : join(projectRoot, '.dsh', 'skills')
|
|
82
|
+
return { user: join(dshHome(), 'skills'), project, projectRoot }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 扫描单个技能根(深度 1:<root>/<名>/SKILL.md 或 <root>/<名>.md)。
|
|
87
|
+
* 目录缺失/读失败/frontmatter 非法 → 跳过该条(不抛、不返回半成品)。
|
|
88
|
+
* @author ddj 2026年10月30号
|
|
89
|
+
* @param dir 技能根目录
|
|
90
|
+
* @param source 来源标记
|
|
91
|
+
* @returns 通过校验的技能(按名字排序,受 SKILL_CATALOG_CAP 约束)
|
|
92
|
+
*/
|
|
93
|
+
async function listRootSkills(dir: string, source: SkillSource): Promise<SkillInfo[]> {
|
|
94
|
+
if (dir === '') return []
|
|
95
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
96
|
+
const ordered = [...entries].sort((left, right) => left.name.localeCompare(right.name)).slice(0, SKILL_CATALOG_CAP)
|
|
97
|
+
const found: SkillInfo[] = []
|
|
98
|
+
const seen = new Set<string>()
|
|
99
|
+
for (const entry of ordered) {
|
|
100
|
+
// 扁平式(<名>.md,含符号链接)优先按后缀判定,其余一律当目录式技能
|
|
101
|
+
const file = entry.name.endsWith('.md') ? join(dir, entry.name) : join(dir, entry.name, SKILL_FILE)
|
|
102
|
+
const info = await readSkillInfo(file, source).catch(() => undefined)
|
|
103
|
+
if (info === undefined || seen.has(info.name)) continue
|
|
104
|
+
seen.add(info.name)
|
|
105
|
+
found.push(info)
|
|
106
|
+
}
|
|
107
|
+
return found.sort((left, right) => left.name.localeCompare(right.name))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 读取并解析单个技能文件。
|
|
112
|
+
* @author ddj 2026年10月30号
|
|
113
|
+
* @param file 技能文件绝对路径
|
|
114
|
+
* @param source 来源标记
|
|
115
|
+
* @returns 技能元信息;文件缺失/不可读/frontmatter 非法时 undefined
|
|
116
|
+
*/
|
|
117
|
+
async function readSkillInfo(file: string, source: SkillSource): Promise<SkillInfo | undefined> {
|
|
118
|
+
const text = await readFile(file, 'utf8').catch(() => undefined)
|
|
119
|
+
if (text === undefined) return undefined
|
|
120
|
+
const parsed = parseSkillMd(text)
|
|
121
|
+
if (!isParsedSkill(parsed)) return undefined
|
|
122
|
+
return { name: parsed.name, description: parsed.description, path: file, source }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 枚举技能候选(全局 + 当前工作区;只读,永不抛)。
|
|
127
|
+
* @author ddj 2026年10月30号
|
|
128
|
+
* @param cwd 当前工作区(缺失时只枚举全局根)
|
|
129
|
+
* @returns 技能候选快照
|
|
130
|
+
*/
|
|
131
|
+
export async function listSkillCatalog(cwd?: string): Promise<SkillCatalog> {
|
|
132
|
+
const roots = skillRootsOf(cwd)
|
|
133
|
+
const user = await listRootSkills(roots.user, 'user')
|
|
134
|
+
const project = await listRootSkills(roots.project, 'project')
|
|
135
|
+
return roots.projectRoot === undefined
|
|
136
|
+
? { user, project }
|
|
137
|
+
: { user, project, projectRoot: roots.projectRoot }
|
|
138
|
+
}
|