dsh-vscode-mode 0.1.63 → 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.
@@ -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