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.
- package/README.md +20 -0
- package/lib/client.js +1737 -98
- package/lib/client.js.map +1 -1
- package/lib/index.js +482 -20
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/commandBridge.ts +85 -0
- package/src/client/commandGlobals.ts +13 -0
- package/src/client/commandPaletteStore.ts +212 -0
- package/src/client/commandRegistry.ts +150 -0
- package/src/client/commandSearch.ts +89 -0
- package/src/client/editorModelState.ts +30 -0
- package/src/client/index.ts +32 -0
- package/src/client/keybindings.ts +71 -17
- package/src/client/monaco/loader.ts +24 -0
- package/src/client/snippets/provider.ts +172 -0
- package/src/client/styles/editor.css +51 -0
- package/src/client/ui/CommandPalette.ts +194 -0
- package/src/client/ui/ConversationDiffDock.ts +17 -9
- package/src/client/ui/EditorView.ts +185 -4
- package/src/client/ui/KeybindingsPanel.ts +1 -1
- package/src/client/ui/QuickOpen.ts +11 -3
- package/src/client/ui/SnippetsPicker.ts +296 -0
- package/src/client/ui/commandCatalog.ts +204 -0
- package/src/rpc.ts +78 -0
- package/src/shared/keybindings.ts +9 -1
- package/src/shared/rpc.ts +11 -0
- package/src/shared/snippets.ts +156 -0
- package/src/snippets.ts +427 -0
package/src/snippets.ts
ADDED
|
@@ -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
|