dsh-vscode-mode 0.2.0 → 0.3.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/shared/rpc.ts CHANGED
@@ -16,6 +16,7 @@ import type { MpcConfig, MpcProject, MpcProjectSaveInput, MpcServer } from './mc
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 { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
19
20
  import type { LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSymbol } from './lsp.js'
20
21
  import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
21
22
 
@@ -236,6 +237,11 @@ export interface RpcRequestMap {
236
237
  'rules.save': RuleSaveInput
237
238
  'rules.remove': RuleRefInput
238
239
  'rules.toggle': RuleRefInput & { enabled: boolean }
240
+ 'snippets.list': {}
241
+ 'snippets.read': SnippetRefInput
242
+ 'snippets.save': SnippetSaveInput
243
+ 'snippets.remove': SnippetRefInput
244
+ 'snippets.entries': { sessionId?: string }
239
245
  }
240
246
 
241
247
  export type RpcMethod = keyof RpcRequestMap
@@ -324,6 +330,11 @@ export interface RpcOkMap {
324
330
  'rules.save': { rule: RuleInfo }
325
331
  'rules.remove': object
326
332
  'rules.toggle': { rule: RuleInfo }
333
+ 'snippets.list': { user: SnippetInfo[]; projects: SnippetProject[] }
334
+ 'snippets.read': { content: string }
335
+ 'snippets.save': { file: SnippetInfo }
336
+ 'snippets.remove': object
337
+ 'snippets.entries': { entries: SnippetEntry[] }
327
338
  }
328
339
 
329
340
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
@@ -0,0 +1,156 @@
1
+ /**
2
+ * dsh-vscode-mode 代码片段共享数据契约(VS Code 兼容 .code-snippets 文件)。
3
+ * 纯类型模块:禁 node/react 导入(与 shared/rules.ts 同约束)。
4
+ * 文件格式与 VS Code 一致:顶层对象 { "<片段名>": { prefix, body, description?, scope? } },
5
+ * body 可为字符串或字符串数组(数组按行拼接);scope 为语言 id 或语言 id 数组。
6
+ * 作者 ddj 2026年09月10号
7
+ */
8
+
9
+ /** 片段作用域:全局片段(~/.dsh/snippets)或项目片段(<工作区>/.dsh/snippets)。 */
10
+ export type SnippetScope = 'user' | 'project'
11
+
12
+ /** 一个 .code-snippets 文件的元信息(列表行展示 + 补全索引所需的最小集)。 */
13
+ export interface SnippetInfo {
14
+ scope: SnippetScope
15
+ /** 文件名(含 .code-snippets 后缀,不含路径)。 */
16
+ file: string
17
+ /** 绝对路径(host 解析,供展示与在编辑界面打开)。 */
18
+ absPath: string
19
+ /** 相对提示(用户层 snippets/ 或项目层 .dsh/snippets/)。 */
20
+ relHint: string
21
+ /** 由文件名推导的语言 id(`global`/无法识别为空串 = 全语言生效)。 */
22
+ language: string
23
+ /** 文件内片段条目数(解析失败为 0)。 */
24
+ count: number
25
+ /** 文件字节数(列表排序/超大提示用)。 */
26
+ size: number
27
+ /** 修改时间毫秒。 */
28
+ mtime: number
29
+ /** JSON 解析失败文案(仅 UI 提示,不影响其他文件)。 */
30
+ error?: string
31
+ }
32
+
33
+ /** 一个工作区的项目片段聚合(snippets.list 的 projects 项)。 */
34
+ export interface SnippetProject {
35
+ workspacePath: string
36
+ title: string
37
+ files: SnippetInfo[]
38
+ /** 工作区目录不存在或无片段目录时为 true(UI 显示空态而非报错)。 */
39
+ missingDir?: boolean
40
+ }
41
+
42
+ /** 片段文件读取/删除入参公共字段。 */
43
+ export interface SnippetRefInput {
44
+ scope: SnippetScope
45
+ /** project 必填:目标工作区绝对路径(须为 DSH 已注册 workspace)。 */
46
+ workspacePath?: string
47
+ file: string
48
+ }
49
+
50
+ /** 片段文件保存入参:content 为完整 JSON 文本(host 原样写盘)。 */
51
+ export interface SnippetSaveInput {
52
+ scope: SnippetScope
53
+ workspacePath?: string
54
+ file: string
55
+ content: string
56
+ }
57
+
58
+ /** 一条已展开的片段条目(补全 provider 与插入命令共用)。 */
59
+ export interface SnippetEntry {
60
+ /** 片段名(对象键,补全候选的 label)。 */
61
+ key: string
62
+ /** 触发前缀(VS Code prefix;空串表示仅靠描述/手动插入)。 */
63
+ prefix: string
64
+ /** 展开正文(已按数组形式拼接为含换行的字符串)。 */
65
+ body: string
66
+ /** 片段描述(补全候选的说明,缺省为空串)。 */
67
+ description: string
68
+ scope: SnippetScope
69
+ /** 来源文件名(补全候选 detail 展示)。 */
70
+ file: string
71
+ /** 生效语言 id(空串 = 全语言)。 */
72
+ language: string
73
+ }
74
+
75
+ // --region 文件名与模板(纯函数:host 与 client 共用,避免两处漂移)
76
+ /** 全语言生效的文件名(无语言前缀)。 */
77
+ export const SNIPPET_GLOBAL_FILE = 'global.code-snippets'
78
+ /** 片段文件后缀。 */
79
+ export const SNIPPET_EXT = '.code-snippets'
80
+
81
+ /**
82
+ * 语言 id → 默认片段文件名(空语言回退 global)。
83
+ * @author ddj 2026年09月10号
84
+ * @param language 语言 id
85
+ * @returns 文件名(如 `lua.code-snippets`)
86
+ */
87
+ export function snippetFileNameFor(language: string): string {
88
+ const lang = String(language ?? '').trim().toLowerCase()
89
+ return (lang || 'global') + SNIPPET_EXT
90
+ }
91
+
92
+ /**
93
+ * 归一化新建文件名:用户输入可为裸语言名(补后缀)或完整文件名;空输入为空串。
94
+ * @author ddj 2026年09月10号
95
+ * @param raw 用户输入
96
+ * @returns 归一化后的文件名
97
+ */
98
+ export function normalizeSnippetFileName(raw: string): string {
99
+ const text = String(raw ?? '').trim()
100
+ if (!text) return ''
101
+ return text.toLowerCase().endsWith(SNIPPET_EXT) ? text : text + SNIPPET_EXT
102
+ }
103
+
104
+ /**
105
+ * 新建片段文件模板(一条示例,改 body 即可用)。
106
+ * @author ddj 2026年09月10号
107
+ * @param language 目标语言 id(空串 = 全局)
108
+ * @returns JSON 文本(带尾换行)
109
+ */
110
+ export function snippetFileTemplate(language: string): string {
111
+ const lang = String(language ?? '').trim().toLowerCase()
112
+ const sample = (lang || 'global') + ' 示例片段'
113
+ return JSON.stringify({
114
+ [sample]: {
115
+ prefix: 'hello',
116
+ body: ['// ' + (lang || 'global') + ' 片段示例', '$1'],
117
+ description: '示例:输入 hello 后 Tab 展开',
118
+ },
119
+ }, null, 2) + '\n'
120
+ }
121
+
122
+ /**
123
+ * 可绑定语言目录(新建片段时的语言下拉来源;与 client/monaco/loader 的 LANG_BY_EXT
124
+ * 取值集合保持一致)。此处冗余一份纯数据,使本模块不依赖浏览器端 Monaco
125
+ * (shared 禁 import client 侧模块);一致性由 tests/snippetLanguage.test.ts 断言兜底。
126
+ */
127
+ export const SNIPPET_LANGUAGES: readonly string[] = [
128
+ 'c', 'cpp', 'csharp', 'css', 'dart', 'dockerfile', 'go', 'html', 'ini', 'java',
129
+ 'javascript', 'json', 'jsonc', 'kotlin', 'less', 'lua', 'markdown', 'mdx', 'php',
130
+ 'plaintext', 'powershell', 'python', 'ruby', 'rust', 'scss', 'shell', 'sql',
131
+ 'swift', 'typescript', 'xml', 'yaml',
132
+ ]
133
+
134
+ /** 语言 id → 展示名(仅收录写法与 id 明显不同的;未收录的原样返回)。 */
135
+ const LANGUAGE_LABELS: Record<string, string> = {
136
+ cpp: 'C++',
137
+ csharp: 'C#',
138
+ javascript: 'JavaScript',
139
+ typescript: 'TypeScript',
140
+ jsonc: 'JSON with Comments',
141
+ powershell: 'PowerShell',
142
+ plaintext: '纯文本',
143
+ }
144
+
145
+ /**
146
+ * 语言 id 的人类可读名(用于下拉与「新建 xx 代码片段文件」文案)。
147
+ * @author ddj 2026年09月10号
148
+ * @param id 语言 id(空串表示全语言)
149
+ * @returns 展示名
150
+ */
151
+ export function languageLabelOf(id: string): string {
152
+ const key = String(id ?? '').trim().toLowerCase()
153
+ if (!key) return '全语言'
154
+ return LANGUAGE_LABELS[key] ?? key
155
+ }
156
+ // --endregion
@@ -0,0 +1,427 @@
1
+ /**
2
+ * dsh-vscode-mode host — 代码片段管理(VS Code 兼容 .code-snippets 文件)。
3
+ * - 存储:全局片段 ~/.dsh/snippets/*.code-snippets;项目片段 <工作区>/.dsh/snippets/*.code-snippets
4
+ * (随仓库共享)。文件名约定与 VS Code 一致:`<language>.code-snippets`(`global.code-snippets`
5
+ * 表示全语言生效)。
6
+ * - 格式:顶层对象 { "<片段名>": { prefix, body, description?, scope? } };body 支持字符串或
7
+ * 字符串数组(数组按行拼接)。解析容错:单文件 JSON 损坏只影响该文件(error 字段提示)。
8
+ * - 生效:client 侧 Monaco completion provider 按 model 语言拉取 snippetsEntries 后过滤展开;
9
+ * 与 rules.ts 同一套 IO 通道(user 走 node fs;project 走 ctx fs + danger-full-access)。
10
+ * --region 划分:常量与目录定位 / JSON 解析(纯)/ 条目展开(纯)/ 文件名与模板(纯)/ IO
11
+ * 作者 ddj 2026年09月10号
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
15
+ import { Buffer } from 'node:buffer'
16
+ import { join } from 'node:path'
17
+ import { dshHome } from './paths.js'
18
+ import type {
19
+ SnippetEntry,
20
+ SnippetInfo,
21
+ SnippetProject,
22
+ SnippetRefInput,
23
+ SnippetSaveInput,
24
+ SnippetScope,
25
+ } from './shared/snippets.js'
26
+ import type { Ctx } from './store.js'
27
+
28
+ /** 文件名与模板的纯函数定义在 shared(host 与 client 共用,避免两处漂移),此处透传便于 host 调用方单点导入。 */
29
+ export { normalizeSnippetFileName, snippetFileTemplate } from './shared/snippets.js'
30
+
31
+ // --region 常量与目录定位
32
+ /** 片段文件名白名单:字母数字开头,仅字母数字点横下划线,.code-snippets 后缀(天然拒绝路径分隔符)。 */
33
+ export const SNIPPET_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.code-snippets$/
34
+ /** Windows 保留设备名(防意外创建系统设备文件)。 */
35
+ const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
36
+ /** 单目录最多加载的片段文件数(防失控目录拖垮装配)。 */
37
+ const SNIPPET_DIR_CAP = 200
38
+ /** 单条片段正文字节上限(超出截断,防超长片段撑爆补全载荷)。 */
39
+ const SNIPPET_SINGLE_CAP = 16 * 1024
40
+
41
+ /**
42
+ * 全局片段目录(~/.dsh/snippets)。
43
+ * @author ddj 2026年09月10号
44
+ * @param home DSH home(缺省自动解析)
45
+ * @returns 绝对路径
46
+ */
47
+ export function userSnippetsDir(home = dshHome()): string {
48
+ return join(home, 'snippets')
49
+ }
50
+
51
+ /**
52
+ * 项目片段目录(<工作区>/.dsh/snippets)。
53
+ * @author ddj 2026年09月10号
54
+ * @param workspacePath 工作区绝对路径
55
+ * @returns 绝对路径
56
+ */
57
+ export function projectSnippetsDir(workspacePath: string): string {
58
+ return join(workspacePath, '.dsh', 'snippets')
59
+ }
60
+
61
+ /**
62
+ * 判定绝对路径是否落在全局片段目录内(edrv.read / edrv.save 的片段分支依据)。
63
+ * 归一化分隔符后按前缀比较;不在目录内返回 false。
64
+ * @author ddj 2026年09月10号
65
+ * @param path 待判定路径
66
+ * @param home DSH home(缺省自动解析)
67
+ * @returns 是否为全局片段文件
68
+ */
69
+ export function isSnippetFilePath(path: string, home = dshHome()): boolean {
70
+ const raw = String(path ?? '')
71
+ if (!raw) return false
72
+ return resolveSnippetPath(raw, userSnippetsDir(home)) !== null
73
+ }
74
+
75
+ /**
76
+ * 归一化并校验「路径是否位于 dir 直下」(拒绝 `..` 穿越与子目录)。
77
+ * @author ddj 2026年09月10号
78
+ * @param path 待判定路径
79
+ * @param dir 目标目录
80
+ * @returns 命中时返回归一化后的绝对路径(`/` 分隔);否则 null
81
+ */
82
+ function resolveSnippetPath(path: string, dir: string): string | null {
83
+ const norm = (text: string): string => String(text).replace(/\\/g, '/').replace(/\/+$/, '')
84
+ const target = norm(path)
85
+ const base = norm(dir)
86
+ if (!target.startsWith(base + '/')) return null
87
+ const rest = target.slice(base.length + 1)
88
+ // 仅允许目录直下的单层文件名(防子目录与穿越)
89
+ if (!rest || rest.includes('/')) return null
90
+ return SNIPPET_FILE_RE.test(rest) ? target : null
91
+ }
92
+ // --endregion
93
+
94
+ // --region JSON 解析(纯函数)
95
+ /** 一条片段的原始 JSON 声明(宽容:字段可能缺失或类型不符)。 */
96
+ interface RawSnippet {
97
+ prefix?: unknown
98
+ body?: unknown
99
+ description?: unknown
100
+ scope?: unknown
101
+ }
102
+
103
+ /** parseSnippetsJson 的产出:条目映射 + 可选解析错误。 */
104
+ export interface ParsedSnippets {
105
+ entries: Record<string, ParsedEntry>
106
+ error?: string
107
+ }
108
+
109
+ /** 已解析的单条片段(正文已归一为字符串)。 */
110
+ export interface ParsedEntry {
111
+ prefix: string
112
+ body: string
113
+ description: string
114
+ scope: string[]
115
+ }
116
+
117
+ /**
118
+ * 归一化片段正文:字符串原样;字符串数组按行拼接(VS Code 语义)。
119
+ * @author ddj 2026年09月10号
120
+ * @param raw body 原始值
121
+ * @returns 正文文本;非法类型返回空串
122
+ */
123
+ export function normalizeBody(raw: unknown): string {
124
+ if (typeof raw === 'string') return raw
125
+ if (Array.isArray(raw)) return raw.filter((line) => typeof line === 'string').join('\n')
126
+ return ''
127
+ }
128
+
129
+ /**
130
+ * 归一化片段前缀:字符串去空白;字符串数组取首项;其余为空串。
131
+ * @author ddj 2026年09月10号
132
+ * @param raw prefix 原始值
133
+ * @returns 前缀文本
134
+ */
135
+ export function normalizePrefix(raw: unknown): string {
136
+ if (typeof raw === 'string') return raw.trim()
137
+ if (Array.isArray(raw)) {
138
+ const first = raw.find((item) => typeof item === 'string')
139
+ return typeof first === 'string' ? first.trim() : ''
140
+ }
141
+ return ''
142
+ }
143
+
144
+ /**
145
+ * 归一化 scope:字符串或字符串数组,去空白并小写;其余为空数组(= 全语言)。
146
+ * @author ddj 2026年09月10号
147
+ * @param raw scope 原始值
148
+ * @returns 语言 id 数组
149
+ */
150
+ export function normalizeScope(raw: unknown): string[] {
151
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []
152
+ return list
153
+ .filter((item): item is string => typeof item === 'string')
154
+ .map((item) => item.trim().toLowerCase())
155
+ .filter(Boolean)
156
+ }
157
+
158
+ /**
159
+ * 容错解析一个 .code-snippets 文件:顶层须为对象,逐条归一化;单条非法即跳过。
160
+ * 解析永不抛错(失败以 error 文案返回)。
161
+ * @author ddj 2026年09月10号
162
+ * @param text 文件全文
163
+ * @returns 解析结果(entries 为片段名 → 已解析条目)
164
+ */
165
+ export function parseSnippetsJson(text: string): ParsedSnippets {
166
+ const trimmed = String(text ?? '').replace(/^\uFEFF/, '').trim()
167
+ if (!trimmed) return { entries: {} }
168
+ let data: unknown
169
+ try {
170
+ data = JSON.parse(trimmed)
171
+ } catch (error) {
172
+ return { entries: {}, error: 'JSON 解析失败:' + String(error) }
173
+ }
174
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
175
+ return { entries: {}, error: '顶层必须是对象({ "片段名": { prefix, body } })' }
176
+ }
177
+ const entries: Record<string, ParsedEntry> = {}
178
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
179
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue
180
+ const raw = value as RawSnippet
181
+ const body = normalizeBody(raw.body)
182
+ if (!body) continue // body 缺失/非法:该条静默跳过(不污染整体)
183
+ entries[key] = {
184
+ prefix: normalizePrefix(raw.prefix),
185
+ body: body.length > SNIPPET_SINGLE_CAP ? body.slice(0, SNIPPET_SINGLE_CAP) : body,
186
+ description: typeof raw.description === 'string' ? raw.description : '',
187
+ scope: normalizeScope(raw.scope),
188
+ }
189
+ }
190
+ return { entries }
191
+ }
192
+ // --endregion
193
+
194
+ // --region 条目展开(纯函数)
195
+ /**
196
+ * 由文件名推导语言 id:`<language>.code-snippets` → language;`global`/无法识别返回空串。
197
+ * @author ddj 2026年09月10号
198
+ * @param file 片段文件名(含后缀)
199
+ * @returns 语言 id(空串 = 全语言)
200
+ */
201
+ export function languageOfSnippetFile(file: string): string {
202
+ const base = String(file ?? '').replace(/\.code-snippets$/i, '')
203
+ if (!base || base.toLowerCase() === 'global') return ''
204
+ return base.toLowerCase()
205
+ }
206
+
207
+ /**
208
+ * 把「一个文件的解析结果」展开为补全条目:文件语言与条目 scope 取交集。
209
+ * 条目 scope 为空 → 跟随文件语言;文件语言为空(global)→ 该条目对全语言生效。
210
+ * @author ddj 2026年09月10号
211
+ * @param info 文件元信息
212
+ * @param parsed 解析结果
213
+ * @returns 条目数组(含来源信息)
214
+ */
215
+ export function entriesOfFile(info: SnippetInfo, parsed: ParsedSnippets): SnippetEntry[] {
216
+ const fileLanguage = info.language
217
+ const out: SnippetEntry[] = []
218
+ for (const [key, entry] of Object.entries(parsed.entries)) {
219
+ // 条目 scope 覆盖文件语言:有 scope 时按 scope,无 scope 时继承文件语言(空=全语言)
220
+ const languages = entry.scope.length ? entry.scope : [fileLanguage]
221
+ for (const language of languages) {
222
+ out.push({
223
+ key,
224
+ prefix: entry.prefix,
225
+ body: entry.body,
226
+ description: entry.description,
227
+ scope: info.scope,
228
+ file: info.file,
229
+ language,
230
+ })
231
+ }
232
+ }
233
+ return out
234
+ }
235
+
236
+ /**
237
+ * 展开全部文件的条目(补全 provider 载荷)。
238
+ * @author ddj 2026年09月10号
239
+ * @param loaded 已加载的文件(元信息 + 解析结果)
240
+ * @returns 条目数组(项目文件靠后,同 key 时项目覆盖全局)
241
+ */
242
+ export function flattenSnippetEntries(loaded: Array<{ info: SnippetInfo; parsed: ParsedSnippets }>): SnippetEntry[] {
243
+ const out: SnippetEntry[] = []
244
+ for (const item of loaded) out.push(...entriesOfFile(item.info, item.parsed))
245
+ return out
246
+ }
247
+ // --endregion
248
+
249
+ // --region 文件名与模板(纯函数)
250
+ /**
251
+ * 校验片段文件名(白名单 + Windows 保留名拒绝)。
252
+ * @author ddj 2026年09月10号
253
+ * @param name 文件名(含后缀)
254
+ * @returns 错误文案;null=合法
255
+ */
256
+ export function validateSnippetFile(name: string): string | null {
257
+ if (!SNIPPET_FILE_RE.test(name)) return '文件名不合法:仅允许字母数字开头,含字母/数字/点/横线/下划线,.code-snippets 后缀'
258
+ if (RESERVED_NAME_RE.test(name)) return '文件名是 Windows 保留设备名,不允许'
259
+ return null
260
+ }
261
+ // --endregion
262
+
263
+ // --region IO(列表 / 读 / 存 / 删 / 条目)
264
+ /**
265
+ * 组装片段文件元信息视图(列表与补全载荷共用)。
266
+ * @author ddj 2026年09月10号
267
+ * @param scope 作用域
268
+ * @param file 文件名
269
+ * @param absPath 绝对路径
270
+ * @param parsed 解析结果
271
+ * @param size 字节数
272
+ * @param mtime 修改时间毫秒
273
+ * @returns 片段文件元信息
274
+ */
275
+ function toSnippetInfo(scope: SnippetScope, file: string, absPath: string, parsed: ParsedSnippets, size: number, mtime: number): SnippetInfo {
276
+ return {
277
+ scope,
278
+ file,
279
+ absPath,
280
+ relHint: scope === 'project' ? '.dsh/snippets/' : 'snippets/',
281
+ language: languageOfSnippetFile(file),
282
+ count: Object.keys(parsed.entries).length,
283
+ size,
284
+ mtime,
285
+ error: parsed.error,
286
+ }
287
+ }
288
+
289
+ /** 单目录片段文件列表(仅 *.code-snippets,最多 SNIPPET_DIR_CAP 个;目录缺失返回空)。 */
290
+ async function listSnippetsDir(dir: string, scope: SnippetScope): Promise<SnippetInfo[]> {
291
+ const names = await readdir(dir).catch(() => [] as string[])
292
+ const files = names.filter((name) => /\.code-snippets$/i.test(name)).sort().slice(0, SNIPPET_DIR_CAP)
293
+ const out: SnippetInfo[] = []
294
+ for (const file of files) {
295
+ const absPath = join(dir, file)
296
+ const info = await stat(absPath).catch(() => null)
297
+ if (!info || !info.isFile()) continue
298
+ const parsed = parseSnippetsJson(await readFile(absPath, 'utf8').catch(() => ''))
299
+ out.push(toSnippetInfo(scope, file, absPath, parsed, info.size, info.mtimeMs))
300
+ }
301
+ return out
302
+ }
303
+
304
+ /** 已注册 workspace 列表(项目页与写入校验共用)。 */
305
+ function workspaceList(ctx: Ctx): Array<{ path: string; title?: string }> {
306
+ const list = ctx.get('workspaceRegistry')?.list?.() ?? []
307
+ return (list as Array<{ path?: string; title?: string }>).filter(
308
+ (item): item is { path: string; title?: string } => Boolean(item) && typeof item.path === 'string' && item.path !== '',
309
+ )
310
+ }
311
+
312
+ /** 用户显式 GUI 写操作的策略:danger-full-access(照抄 rules.fullPolicy)。 */
313
+ function fullPolicy(ctx: Ctx): unknown {
314
+ const svc = ctx.get('sandboxPolicy')
315
+ if (!svc || typeof svc.resolve !== 'function') return undefined
316
+ return svc.resolve({ mode: 'danger-full-access' })
317
+ }
318
+
319
+ /**
320
+ * 校验项目作用域的 workspacePath 已注册为 DSH workspace(防 RPC 写任意目录)。
321
+ * @author ddj 2026年09月10号
322
+ * @param ctx DSH 上下文
323
+ * @param workspacePath 目标项目路径
324
+ * @returns 命中的工作区项
325
+ */
326
+ function requireWorkspace(ctx: Ctx, workspacePath: string | undefined): { path: string; title?: string } {
327
+ const workspace = workspaceList(ctx).find((item) => item.path === workspacePath)
328
+ if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目片段')
329
+ return workspace as { path: string; title?: string }
330
+ }
331
+
332
+ /** 按作用域解析片段目录(project 先过 requireWorkspace)。 */
333
+ function dirOf(ctx: Ctx, scope: SnippetScope, workspacePath?: string): string {
334
+ if (scope === 'project') return projectSnippetsDir(requireWorkspace(ctx, workspacePath).path)
335
+ return userSnippetsDir()
336
+ }
337
+
338
+ /**
339
+ * 片段文件总列表:全局片段 + 各已注册工作区的项目片段。
340
+ * @author ddj 2026年09月10号
341
+ * @param ctx DSH 上下文
342
+ * @returns snippets.list 载荷
343
+ */
344
+ export async function snippetsList(ctx: Ctx): Promise<{ user: SnippetInfo[]; projects: SnippetProject[] }> {
345
+ const user = await listSnippetsDir(userSnippetsDir(), 'user')
346
+ const projects: SnippetProject[] = []
347
+ for (const workspace of workspaceList(ctx)) {
348
+ const dir = projectSnippetsDir(workspace.path)
349
+ const exists = existsSync(dir)
350
+ const files = exists ? await listSnippetsDir(dir, 'project') : []
351
+ projects.push({ workspacePath: workspace.path, title: workspace.title ?? '', files, missingDir: exists ? undefined : true })
352
+ }
353
+ return { user, projects }
354
+ }
355
+
356
+ /**
357
+ * 读取一个片段文件全文(编辑界面打开用)。
358
+ * @author ddj 2026年09月10号
359
+ * @param ctx DSH 上下文
360
+ * @param ref 作用域 + 文件名(+ 项目工作区)
361
+ * @returns 文件全文
362
+ */
363
+ export async function snippetsRead(ctx: Ctx, ref: SnippetRefInput): Promise<string> {
364
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath)
365
+ return readFile(join(dir, ref.file), 'utf8')
366
+ }
367
+
368
+ /**
369
+ * 保存(新建或覆盖)一个片段文件:project 作用域写盘走 ctx fs + danger-full-access(镜像 rules)。
370
+ * @author ddj 2026年09月10号
371
+ * @param ctx DSH 上下文
372
+ * @param input 保存入参
373
+ * @returns 保存后的片段文件元信息
374
+ */
375
+ export async function snippetsSave(ctx: Ctx, input: SnippetSaveInput): Promise<SnippetInfo> {
376
+ const invalid = validateSnippetFile(input.file)
377
+ if (invalid) throw new Error(invalid)
378
+ const dir = dirOf(ctx, input.scope, input.workspacePath)
379
+ await mkdir(dir, { recursive: true })
380
+ const absPath = join(dir, input.file)
381
+ const content = String(input.content ?? '')
382
+ if (input.scope === 'project') {
383
+ const fs = ctx.get('fs')
384
+ if (!fs) throw new Error('缺少 fs 服务')
385
+ const target = await fs.resolve('.dsh/snippets/' + input.file, { cwd: requireWorkspace(ctx, input.workspacePath).path })
386
+ await fs.writeText(target, content, void 0, void 0, fullPolicy(ctx))
387
+ } else {
388
+ await writeFile(absPath, content, 'utf8')
389
+ }
390
+ const info = await stat(absPath).catch(() => null)
391
+ return toSnippetInfo(input.scope, input.file, absPath, parseSnippetsJson(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
392
+ }
393
+
394
+ /**
395
+ * 删除一个片段文件(project 作用域同样先过 workspace 注册校验)。
396
+ * @author ddj 2026年09月10号
397
+ * @param ctx DSH 上下文
398
+ * @param ref 作用域 + 文件名(+ 项目工作区)
399
+ */
400
+ export async function snippetsRemove(ctx: Ctx, ref: SnippetRefInput): Promise<void> {
401
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath)
402
+ await rm(join(dir, ref.file), { force: true })
403
+ }
404
+
405
+ /**
406
+ * 补全 provider 载荷:全局片段 + 指定工作区项目片段的全部条目。
407
+ * 项目条目排在全局之后(同 key 时后出现的项目条目在 client 侧优先)。
408
+ * @author ddj 2026年09月10号
409
+ * @param ctx DSH 上下文
410
+ * @param workspacePath 当前会话工作区(可选;未注册则仅全局片段)
411
+ * @returns snippets.entries 载荷
412
+ */
413
+ export async function snippetsEntries(ctx: Ctx, workspacePath?: string): Promise<{ entries: SnippetEntry[] }> {
414
+ const loaded: Array<{ info: SnippetInfo; parsed: ParsedSnippets }> = []
415
+ const load = async (dir: string, scope: SnippetScope): Promise<void> => {
416
+ for (const info of await listSnippetsDir(dir, scope)) {
417
+ const parsed = parseSnippetsJson(await readFile(info.absPath, 'utf8').catch(() => ''))
418
+ if (parsed.error) continue // 损坏文件不影响其他片段
419
+ loaded.push({ info, parsed })
420
+ }
421
+ }
422
+ await load(userSnippetsDir(), 'user')
423
+ const registered = workspaceList(ctx).some((item) => item.path === workspacePath)
424
+ if (workspacePath && registered) await load(projectSnippetsDir(workspacePath), 'project')
425
+ return { entries: flattenSnippetEntries(loaded) }
426
+ }
427
+ // --endregion