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/lib/index.js CHANGED
@@ -131,7 +131,8 @@ const KEYBINDING_DEFAULTS = {
131
131
  "edrv.prevTab": "Ctrl+Alt+ArrowLeft|Ctrl+PageUp",
132
132
  "edrv.showCommands": "Ctrl+Shift+P|F1",
133
133
  "edrv.nextEditorRow": "Ctrl+Alt+ArrowDown",
134
- "edrv.prevEditorRow": "Ctrl+Alt+ArrowUp"
134
+ "edrv.prevEditorRow": "Ctrl+Alt+ArrowUp",
135
+ "edrv.addSelectionRef": "Ctrl+U"
135
136
  };
136
137
  //#endregion
137
138
  //#region src/shared/integration.ts
@@ -3827,7 +3828,7 @@ async function restoreFile(ctx, session, rec) {
3827
3828
  /** 规则文件名白名单:字母数字开头,仅字母数字点横下划线,.mdc 后缀(天然拒绝路径分隔符)。 */
3828
3829
  const RULE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.mdc$/;
3829
3830
  /** Windows 保留设备名(防意外创建系统设备文件)。 */
3830
- const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
3831
+ const RESERVED_NAME_RE$1 = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
3831
3832
  /** 单目录最多加载的规则文件数(防失控目录拖垮装配)。 */
3832
3833
  const RULE_DIR_CAP = 200;
3833
3834
  /** 注入时单条规则正文字节上限(超出截断)。 */
@@ -3951,7 +3952,7 @@ function ruleTypeOf(parsed) {
3951
3952
  */
3952
3953
  function validateRuleFile(name) {
3953
3954
  if (!RULE_FILE_RE.test(name)) return "文件名不合法:仅允许字母数字开头,含字母/数字/点/横线/下划线,.mdc 后缀";
3954
- if (RESERVED_NAME_RE.test(name)) return "文件名是 Windows 保留设备名,不允许";
3955
+ if (RESERVED_NAME_RE$1.test(name)) return "文件名是 Windows 保留设备名,不允许";
3955
3956
  return null;
3956
3957
  }
3957
3958
  /**
@@ -4063,24 +4064,24 @@ async function listRulesDir(dir, scope) {
4063
4064
  return out;
4064
4065
  }
4065
4066
  /** 已注册 workspace 列表(项目 Tab 与写入校验共用)。 */
4066
- function workspaceList(ctx) {
4067
+ function workspaceList$1(ctx) {
4067
4068
  return (ctx.get("workspaceRegistry")?.list?.() ?? []).filter((item) => Boolean(item) && typeof item.path === "string" && item.path !== "");
4068
4069
  }
4069
4070
  /** 用户显式 GUI 写操作的策略:danger-full-access(照抄 mcpProject.fullPolicy)。 */
4070
- function fullPolicy$1(ctx) {
4071
+ function fullPolicy$2(ctx) {
4071
4072
  const svc = ctx.get("sandboxPolicy");
4072
4073
  if (!svc || typeof svc.resolve !== "function") return void 0;
4073
4074
  return svc.resolve({ mode: "danger-full-access" });
4074
4075
  }
4075
4076
  /** 校验项目作用域的 workspacePath 已注册为 DSH workspace(防 RPC 写任意目录)。 */
4076
- function requireWorkspace$1(ctx, workspacePath) {
4077
- const workspace = workspaceList(ctx).find((item) => item.path === workspacePath);
4077
+ function requireWorkspace$2(ctx, workspacePath) {
4078
+ const workspace = workspaceList$1(ctx).find((item) => item.path === workspacePath);
4078
4079
  if (!workspace) throw new Error("项目未注册为 DSH workspace,不能管理项目规则");
4079
4080
  return workspace;
4080
4081
  }
4081
4082
  /** 按作用域解析规则目录(project 先过 requireWorkspace)。 */
4082
- function dirOf(ctx, scope, workspacePath) {
4083
- if (scope === "project") return projectRulesDir(requireWorkspace$1(ctx, workspacePath).path);
4083
+ function dirOf$1(ctx, scope, workspacePath) {
4084
+ if (scope === "project") return projectRulesDir(requireWorkspace$2(ctx, workspacePath).path);
4084
4085
  return userRulesDir();
4085
4086
  }
4086
4087
  /**
@@ -4092,7 +4093,7 @@ function dirOf(ctx, scope, workspacePath) {
4092
4093
  async function rulesList(ctx) {
4093
4094
  const user = await listRulesDir(userRulesDir(), "user");
4094
4095
  const projects = [];
4095
- for (const workspace of workspaceList(ctx)) {
4096
+ for (const workspace of workspaceList$1(ctx)) {
4096
4097
  const dir = projectRulesDir(workspace.path);
4097
4098
  const exists = existsSync(dir);
4098
4099
  const rules = exists ? await listRulesDir(dir, "project") : [];
@@ -4116,7 +4117,7 @@ async function rulesList(ctx) {
4116
4117
  * @returns 文件全文
4117
4118
  */
4118
4119
  async function rulesRead(ctx, ref) {
4119
- const dir = dirOf(ctx, ref.scope, ref.workspacePath);
4120
+ const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
4120
4121
  return readFile(join(dir, ref.file), "utf8");
4121
4122
  }
4122
4123
  /**
@@ -4155,15 +4156,15 @@ function toRuleInfo(scope, file, absPath, parsed, size, mtime) {
4155
4156
  async function rulesSave(ctx, input) {
4156
4157
  const invalid = validateRuleFile(input.file);
4157
4158
  if (invalid) throw new Error(invalid);
4158
- const dir = dirOf(ctx, input.scope, input.workspacePath);
4159
+ const dir = dirOf$1(ctx, input.scope, input.workspacePath);
4159
4160
  await mkdir(dir, { recursive: true });
4160
4161
  const absPath = join(dir, input.file);
4161
4162
  const content = String(input.content ?? "");
4162
4163
  if (input.scope === "project") {
4163
4164
  const fs = ctx.get("fs");
4164
4165
  if (!fs) throw new Error("缺少 fs 服务");
4165
- const target = await fs.resolve(".dsh/rules/" + input.file, { cwd: requireWorkspace$1(ctx, input.workspacePath).path });
4166
- await fs.writeText(target, content, void 0, void 0, fullPolicy$1(ctx));
4166
+ const target = await fs.resolve(".dsh/rules/" + input.file, { cwd: requireWorkspace$2(ctx, input.workspacePath).path });
4167
+ await fs.writeText(target, content, void 0, void 0, fullPolicy$2(ctx));
4167
4168
  } else await writeFile(absPath, content, "utf8");
4168
4169
  const info = await stat(absPath).catch(() => null);
4169
4170
  return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now());
@@ -4174,7 +4175,7 @@ async function rulesSave(ctx, input) {
4174
4175
  * @param ref 作用域 + 文件名(+ 项目工作区)
4175
4176
  */
4176
4177
  async function rulesRemove(ctx, ref) {
4177
- const dir = dirOf(ctx, ref.scope, ref.workspacePath);
4178
+ const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
4178
4179
  await rm(join(dir, ref.file), { force: true });
4179
4180
  }
4180
4181
  /**
@@ -4185,7 +4186,7 @@ async function rulesRemove(ctx, ref) {
4185
4186
  * @returns 更新后的规则元信息
4186
4187
  */
4187
4188
  async function rulesToggle(ctx, ref, enabled) {
4188
- const dir = dirOf(ctx, ref.scope, ref.workspacePath);
4189
+ const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
4189
4190
  const absPath = join(dir, ref.file);
4190
4191
  const text = await readRuleText(ctx, ref, absPath);
4191
4192
  if (text === null) throw new Error("规则文件不存在:" + ref.file);
@@ -4199,7 +4200,7 @@ async function readRuleText(ctx, ref, absPath) {
4199
4200
  if (ref.scope !== "project") return readFile(absPath, "utf8").catch(() => null);
4200
4201
  const fs = ctx.get("fs");
4201
4202
  if (!fs) throw new Error("缺少 fs 服务");
4202
- const target = await fs.resolve(".dsh/rules/" + ref.file, { cwd: requireWorkspace$1(ctx, ref.workspacePath).path });
4203
+ const target = await fs.resolve(".dsh/rules/" + ref.file, { cwd: requireWorkspace$2(ctx, ref.workspacePath).path });
4203
4204
  try {
4204
4205
  return await fs.readText(target);
4205
4206
  } catch {
@@ -4214,8 +4215,8 @@ async function rulesSaveContent(ctx, ref, absPath, content) {
4214
4215
  }
4215
4216
  const fs = ctx.get("fs");
4216
4217
  if (!fs) throw new Error("缺少 fs 服务");
4217
- const target = await fs.resolve(".dsh/rules/" + ref.file, { cwd: requireWorkspace$1(ctx, ref.workspacePath).path });
4218
- await fs.writeText(target, content, void 0, void 0, fullPolicy$1(ctx));
4218
+ const target = await fs.resolve(".dsh/rules/" + ref.file, { cwd: requireWorkspace$2(ctx, ref.workspacePath).path });
4219
+ await fs.writeText(target, content, void 0, void 0, fullPolicy$2(ctx));
4219
4220
  }
4220
4221
  /** 重建单条规则元信息(toggle 后回传 UI;stat 失败时用内容长度 + 当前时间兜底)。 */
4221
4222
  async function ruleInfoOf(ref, absPath, content) {
@@ -4304,6 +4305,363 @@ function installRulesSection(ctx) {
4304
4305
  return true;
4305
4306
  }
4306
4307
  //#endregion
4308
+ //#region src/snippets.ts
4309
+ /**
4310
+ * dsh-vscode-mode host — 代码片段管理(VS Code 兼容 .code-snippets 文件)。
4311
+ * - 存储:全局片段 ~/.dsh/snippets/*.code-snippets;项目片段 <工作区>/.dsh/snippets/*.code-snippets
4312
+ * (随仓库共享)。文件名约定与 VS Code 一致:`<language>.code-snippets`(`global.code-snippets`
4313
+ * 表示全语言生效)。
4314
+ * - 格式:顶层对象 { "<片段名>": { prefix, body, description?, scope? } };body 支持字符串或
4315
+ * 字符串数组(数组按行拼接)。解析容错:单文件 JSON 损坏只影响该文件(error 字段提示)。
4316
+ * - 生效:client 侧 Monaco completion provider 按 model 语言拉取 snippetsEntries 后过滤展开;
4317
+ * 与 rules.ts 同一套 IO 通道(user 走 node fs;project 走 ctx fs + danger-full-access)。
4318
+ * --region 划分:常量与目录定位 / JSON 解析(纯)/ 条目展开(纯)/ 文件名与模板(纯)/ IO
4319
+ * 作者 ddj 2026年09月10号
4320
+ */
4321
+ /** 片段文件名白名单:字母数字开头,仅字母数字点横下划线,.code-snippets 后缀(天然拒绝路径分隔符)。 */
4322
+ const SNIPPET_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.code-snippets$/;
4323
+ /** Windows 保留设备名(防意外创建系统设备文件)。 */
4324
+ const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
4325
+ /** 单目录最多加载的片段文件数(防失控目录拖垮装配)。 */
4326
+ const SNIPPET_DIR_CAP = 200;
4327
+ /** 单条片段正文字节上限(超出截断,防超长片段撑爆补全载荷)。 */
4328
+ const SNIPPET_SINGLE_CAP = 16384;
4329
+ /**
4330
+ * 全局片段目录(~/.dsh/snippets)。
4331
+ * @author ddj 2026年09月10号
4332
+ * @param home DSH home(缺省自动解析)
4333
+ * @returns 绝对路径
4334
+ */
4335
+ function userSnippetsDir(home = dshHome()) {
4336
+ return join(home, "snippets");
4337
+ }
4338
+ /**
4339
+ * 项目片段目录(<工作区>/.dsh/snippets)。
4340
+ * @author ddj 2026年09月10号
4341
+ * @param workspacePath 工作区绝对路径
4342
+ * @returns 绝对路径
4343
+ */
4344
+ function projectSnippetsDir(workspacePath) {
4345
+ return join(workspacePath, ".dsh", "snippets");
4346
+ }
4347
+ /**
4348
+ * 判定绝对路径是否落在全局片段目录内(edrv.read / edrv.save 的片段分支依据)。
4349
+ * 归一化分隔符后按前缀比较;不在目录内返回 false。
4350
+ * @author ddj 2026年09月10号
4351
+ * @param path 待判定路径
4352
+ * @param home DSH home(缺省自动解析)
4353
+ * @returns 是否为全局片段文件
4354
+ */
4355
+ function isSnippetFilePath(path, home = dshHome()) {
4356
+ const raw = String(path ?? "");
4357
+ if (!raw) return false;
4358
+ return resolveSnippetPath(raw, userSnippetsDir(home)) !== null;
4359
+ }
4360
+ /**
4361
+ * 归一化并校验「路径是否位于 dir 直下」(拒绝 `..` 穿越与子目录)。
4362
+ * @author ddj 2026年09月10号
4363
+ * @param path 待判定路径
4364
+ * @param dir 目标目录
4365
+ * @returns 命中时返回归一化后的绝对路径(`/` 分隔);否则 null
4366
+ */
4367
+ function resolveSnippetPath(path, dir) {
4368
+ const norm = (text) => String(text).replace(/\\/g, "/").replace(/\/+$/, "");
4369
+ const target = norm(path);
4370
+ const base = norm(dir);
4371
+ if (!target.startsWith(base + "/")) return null;
4372
+ const rest = target.slice(base.length + 1);
4373
+ if (!rest || rest.includes("/")) return null;
4374
+ return SNIPPET_FILE_RE.test(rest) ? target : null;
4375
+ }
4376
+ /**
4377
+ * 归一化片段正文:字符串原样;字符串数组按行拼接(VS Code 语义)。
4378
+ * @author ddj 2026年09月10号
4379
+ * @param raw body 原始值
4380
+ * @returns 正文文本;非法类型返回空串
4381
+ */
4382
+ function normalizeBody(raw) {
4383
+ if (typeof raw === "string") return raw;
4384
+ if (Array.isArray(raw)) return raw.filter((line) => typeof line === "string").join("\n");
4385
+ return "";
4386
+ }
4387
+ /**
4388
+ * 归一化片段前缀:字符串去空白;字符串数组取首项;其余为空串。
4389
+ * @author ddj 2026年09月10号
4390
+ * @param raw prefix 原始值
4391
+ * @returns 前缀文本
4392
+ */
4393
+ function normalizePrefix(raw) {
4394
+ if (typeof raw === "string") return raw.trim();
4395
+ if (Array.isArray(raw)) {
4396
+ const first = raw.find((item) => typeof item === "string");
4397
+ return typeof first === "string" ? first.trim() : "";
4398
+ }
4399
+ return "";
4400
+ }
4401
+ /**
4402
+ * 归一化 scope:字符串或字符串数组,去空白并小写;其余为空数组(= 全语言)。
4403
+ * @author ddj 2026年09月10号
4404
+ * @param raw scope 原始值
4405
+ * @returns 语言 id 数组
4406
+ */
4407
+ function normalizeScope(raw) {
4408
+ return (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).filter((item) => typeof item === "string").map((item) => item.trim().toLowerCase()).filter(Boolean);
4409
+ }
4410
+ /**
4411
+ * 容错解析一个 .code-snippets 文件:顶层须为对象,逐条归一化;单条非法即跳过。
4412
+ * 解析永不抛错(失败以 error 文案返回)。
4413
+ * @author ddj 2026年09月10号
4414
+ * @param text 文件全文
4415
+ * @returns 解析结果(entries 为片段名 → 已解析条目)
4416
+ */
4417
+ function parseSnippetsJson(text) {
4418
+ const trimmed = String(text ?? "").replace(/^\uFEFF/, "").trim();
4419
+ if (!trimmed) return { entries: {} };
4420
+ let data;
4421
+ try {
4422
+ data = JSON.parse(trimmed);
4423
+ } catch (error) {
4424
+ return {
4425
+ entries: {},
4426
+ error: "JSON 解析失败:" + String(error)
4427
+ };
4428
+ }
4429
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {
4430
+ entries: {},
4431
+ error: "顶层必须是对象({ \"片段名\": { prefix, body } })"
4432
+ };
4433
+ const entries = {};
4434
+ for (const [key, value] of Object.entries(data)) {
4435
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
4436
+ const raw = value;
4437
+ const body = normalizeBody(raw.body);
4438
+ if (!body) continue;
4439
+ entries[key] = {
4440
+ prefix: normalizePrefix(raw.prefix),
4441
+ body: body.length > SNIPPET_SINGLE_CAP ? body.slice(0, SNIPPET_SINGLE_CAP) : body,
4442
+ description: typeof raw.description === "string" ? raw.description : "",
4443
+ scope: normalizeScope(raw.scope)
4444
+ };
4445
+ }
4446
+ return { entries };
4447
+ }
4448
+ /**
4449
+ * 由文件名推导语言 id:`<language>.code-snippets` → language;`global`/无法识别返回空串。
4450
+ * @author ddj 2026年09月10号
4451
+ * @param file 片段文件名(含后缀)
4452
+ * @returns 语言 id(空串 = 全语言)
4453
+ */
4454
+ function languageOfSnippetFile(file) {
4455
+ const base = String(file ?? "").replace(/\.code-snippets$/i, "");
4456
+ if (!base || base.toLowerCase() === "global") return "";
4457
+ return base.toLowerCase();
4458
+ }
4459
+ /**
4460
+ * 把「一个文件的解析结果」展开为补全条目:文件语言与条目 scope 取交集。
4461
+ * 条目 scope 为空 → 跟随文件语言;文件语言为空(global)→ 该条目对全语言生效。
4462
+ * @author ddj 2026年09月10号
4463
+ * @param info 文件元信息
4464
+ * @param parsed 解析结果
4465
+ * @returns 条目数组(含来源信息)
4466
+ */
4467
+ function entriesOfFile(info, parsed) {
4468
+ const fileLanguage = info.language;
4469
+ const out = [];
4470
+ for (const [key, entry] of Object.entries(parsed.entries)) {
4471
+ const languages = entry.scope.length ? entry.scope : [fileLanguage];
4472
+ for (const language of languages) out.push({
4473
+ key,
4474
+ prefix: entry.prefix,
4475
+ body: entry.body,
4476
+ description: entry.description,
4477
+ scope: info.scope,
4478
+ file: info.file,
4479
+ language
4480
+ });
4481
+ }
4482
+ return out;
4483
+ }
4484
+ /**
4485
+ * 展开全部文件的条目(补全 provider 载荷)。
4486
+ * @author ddj 2026年09月10号
4487
+ * @param loaded 已加载的文件(元信息 + 解析结果)
4488
+ * @returns 条目数组(项目文件靠后,同 key 时项目覆盖全局)
4489
+ */
4490
+ function flattenSnippetEntries(loaded) {
4491
+ const out = [];
4492
+ for (const item of loaded) out.push(...entriesOfFile(item.info, item.parsed));
4493
+ return out;
4494
+ }
4495
+ /**
4496
+ * 校验片段文件名(白名单 + Windows 保留名拒绝)。
4497
+ * @author ddj 2026年09月10号
4498
+ * @param name 文件名(含后缀)
4499
+ * @returns 错误文案;null=合法
4500
+ */
4501
+ function validateSnippetFile(name) {
4502
+ if (!SNIPPET_FILE_RE.test(name)) return "文件名不合法:仅允许字母数字开头,含字母/数字/点/横线/下划线,.code-snippets 后缀";
4503
+ if (RESERVED_NAME_RE.test(name)) return "文件名是 Windows 保留设备名,不允许";
4504
+ return null;
4505
+ }
4506
+ /**
4507
+ * 组装片段文件元信息视图(列表与补全载荷共用)。
4508
+ * @author ddj 2026年09月10号
4509
+ * @param scope 作用域
4510
+ * @param file 文件名
4511
+ * @param absPath 绝对路径
4512
+ * @param parsed 解析结果
4513
+ * @param size 字节数
4514
+ * @param mtime 修改时间毫秒
4515
+ * @returns 片段文件元信息
4516
+ */
4517
+ function toSnippetInfo(scope, file, absPath, parsed, size, mtime) {
4518
+ return {
4519
+ scope,
4520
+ file,
4521
+ absPath,
4522
+ relHint: scope === "project" ? ".dsh/snippets/" : "snippets/",
4523
+ language: languageOfSnippetFile(file),
4524
+ count: Object.keys(parsed.entries).length,
4525
+ size,
4526
+ mtime,
4527
+ error: parsed.error
4528
+ };
4529
+ }
4530
+ /** 单目录片段文件列表(仅 *.code-snippets,最多 SNIPPET_DIR_CAP 个;目录缺失返回空)。 */
4531
+ async function listSnippetsDir(dir, scope) {
4532
+ const files = (await readdir(dir).catch(() => [])).filter((name) => /\.code-snippets$/i.test(name)).sort().slice(0, SNIPPET_DIR_CAP);
4533
+ const out = [];
4534
+ for (const file of files) {
4535
+ const absPath = join(dir, file);
4536
+ const info = await stat(absPath).catch(() => null);
4537
+ if (!info || !info.isFile()) continue;
4538
+ const parsed = parseSnippetsJson(await readFile(absPath, "utf8").catch(() => ""));
4539
+ out.push(toSnippetInfo(scope, file, absPath, parsed, info.size, info.mtimeMs));
4540
+ }
4541
+ return out;
4542
+ }
4543
+ /** 已注册 workspace 列表(项目页与写入校验共用)。 */
4544
+ function workspaceList(ctx) {
4545
+ return (ctx.get("workspaceRegistry")?.list?.() ?? []).filter((item) => Boolean(item) && typeof item.path === "string" && item.path !== "");
4546
+ }
4547
+ /** 用户显式 GUI 写操作的策略:danger-full-access(照抄 rules.fullPolicy)。 */
4548
+ function fullPolicy$1(ctx) {
4549
+ const svc = ctx.get("sandboxPolicy");
4550
+ if (!svc || typeof svc.resolve !== "function") return void 0;
4551
+ return svc.resolve({ mode: "danger-full-access" });
4552
+ }
4553
+ /**
4554
+ * 校验项目作用域的 workspacePath 已注册为 DSH workspace(防 RPC 写任意目录)。
4555
+ * @author ddj 2026年09月10号
4556
+ * @param ctx DSH 上下文
4557
+ * @param workspacePath 目标项目路径
4558
+ * @returns 命中的工作区项
4559
+ */
4560
+ function requireWorkspace$1(ctx, workspacePath) {
4561
+ const workspace = workspaceList(ctx).find((item) => item.path === workspacePath);
4562
+ if (!workspace) throw new Error("项目未注册为 DSH workspace,不能管理项目片段");
4563
+ return workspace;
4564
+ }
4565
+ /** 按作用域解析片段目录(project 先过 requireWorkspace)。 */
4566
+ function dirOf(ctx, scope, workspacePath) {
4567
+ if (scope === "project") return projectSnippetsDir(requireWorkspace$1(ctx, workspacePath).path);
4568
+ return userSnippetsDir();
4569
+ }
4570
+ /**
4571
+ * 片段文件总列表:全局片段 + 各已注册工作区的项目片段。
4572
+ * @author ddj 2026年09月10号
4573
+ * @param ctx DSH 上下文
4574
+ * @returns snippets.list 载荷
4575
+ */
4576
+ async function snippetsList(ctx) {
4577
+ const user = await listSnippetsDir(userSnippetsDir(), "user");
4578
+ const projects = [];
4579
+ for (const workspace of workspaceList(ctx)) {
4580
+ const dir = projectSnippetsDir(workspace.path);
4581
+ const exists = existsSync(dir);
4582
+ const files = exists ? await listSnippetsDir(dir, "project") : [];
4583
+ projects.push({
4584
+ workspacePath: workspace.path,
4585
+ title: workspace.title ?? "",
4586
+ files,
4587
+ missingDir: exists ? void 0 : true
4588
+ });
4589
+ }
4590
+ return {
4591
+ user,
4592
+ projects
4593
+ };
4594
+ }
4595
+ /**
4596
+ * 读取一个片段文件全文(编辑界面打开用)。
4597
+ * @author ddj 2026年09月10号
4598
+ * @param ctx DSH 上下文
4599
+ * @param ref 作用域 + 文件名(+ 项目工作区)
4600
+ * @returns 文件全文
4601
+ */
4602
+ async function snippetsRead(ctx, ref) {
4603
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath);
4604
+ return readFile(join(dir, ref.file), "utf8");
4605
+ }
4606
+ /**
4607
+ * 保存(新建或覆盖)一个片段文件:project 作用域写盘走 ctx fs + danger-full-access(镜像 rules)。
4608
+ * @author ddj 2026年09月10号
4609
+ * @param ctx DSH 上下文
4610
+ * @param input 保存入参
4611
+ * @returns 保存后的片段文件元信息
4612
+ */
4613
+ async function snippetsSave(ctx, input) {
4614
+ const invalid = validateSnippetFile(input.file);
4615
+ if (invalid) throw new Error(invalid);
4616
+ const dir = dirOf(ctx, input.scope, input.workspacePath);
4617
+ await mkdir(dir, { recursive: true });
4618
+ const absPath = join(dir, input.file);
4619
+ const content = String(input.content ?? "");
4620
+ if (input.scope === "project") {
4621
+ const fs = ctx.get("fs");
4622
+ if (!fs) throw new Error("缺少 fs 服务");
4623
+ const target = await fs.resolve(".dsh/snippets/" + input.file, { cwd: requireWorkspace$1(ctx, input.workspacePath).path });
4624
+ await fs.writeText(target, content, void 0, void 0, fullPolicy$1(ctx));
4625
+ } else await writeFile(absPath, content, "utf8");
4626
+ const info = await stat(absPath).catch(() => null);
4627
+ return toSnippetInfo(input.scope, input.file, absPath, parseSnippetsJson(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now());
4628
+ }
4629
+ /**
4630
+ * 删除一个片段文件(project 作用域同样先过 workspace 注册校验)。
4631
+ * @author ddj 2026年09月10号
4632
+ * @param ctx DSH 上下文
4633
+ * @param ref 作用域 + 文件名(+ 项目工作区)
4634
+ */
4635
+ async function snippetsRemove(ctx, ref) {
4636
+ const dir = dirOf(ctx, ref.scope, ref.workspacePath);
4637
+ await rm(join(dir, ref.file), { force: true });
4638
+ }
4639
+ /**
4640
+ * 补全 provider 载荷:全局片段 + 指定工作区项目片段的全部条目。
4641
+ * 项目条目排在全局之后(同 key 时后出现的项目条目在 client 侧优先)。
4642
+ * @author ddj 2026年09月10号
4643
+ * @param ctx DSH 上下文
4644
+ * @param workspacePath 当前会话工作区(可选;未注册则仅全局片段)
4645
+ * @returns snippets.entries 载荷
4646
+ */
4647
+ async function snippetsEntries(ctx, workspacePath) {
4648
+ const loaded = [];
4649
+ const load = async (dir, scope) => {
4650
+ for (const info of await listSnippetsDir(dir, scope)) {
4651
+ const parsed = parseSnippetsJson(await readFile(info.absPath, "utf8").catch(() => ""));
4652
+ if (parsed.error) continue;
4653
+ loaded.push({
4654
+ info,
4655
+ parsed
4656
+ });
4657
+ }
4658
+ };
4659
+ await load(userSnippetsDir(), "user");
4660
+ const registered = workspaceList(ctx).some((item) => item.path === workspacePath);
4661
+ if (workspacePath && registered) await load(projectSnippetsDir(workspacePath), "project");
4662
+ return { entries: flattenSnippetEntries(loaded) };
4663
+ }
4664
+ //#endregion
4307
4665
  //#region src/mcpProject.ts
4308
4666
  /**
4309
4667
  * dsh-vscode-mode host — 项目级 MCP 管理。
@@ -6136,6 +6494,16 @@ async function requireSession(ctx, sessionId) {
6136
6494
  };
6137
6495
  }
6138
6496
  /**
6497
+ * 片段文件路径解析:命中全局片段目录(~/.dsh/snippets)返回归一化绝对路径,否则 null。
6498
+ * 全局片段位于工作区之外,edrv.read / edrv.save 需绕开 resolveTarget 的 cwd 语义直读直写。
6499
+ * @author ddj 2026年09月10号
6500
+ * @param path 客户端请求路径(绝对路径)
6501
+ * @returns 归一化后的绝对路径或 null
6502
+ */
6503
+ function snippetTargetOf(path) {
6504
+ return isSnippetFilePath(path) ? path.replace(/\\/g, "/") : null;
6505
+ }
6506
+ /**
6139
6507
  * 手动保存收尾(edrv.save / edrv.saveBinary 共用):目录树失效 + 该路径 pending
6140
6508
  * 文本差异记录标记 superseded 并归档(旧文本 diff 不得再应用到新内容上)。
6141
6509
  * @author ddj 2026年09月22号
@@ -6322,6 +6690,21 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
6322
6690
  };
6323
6691
  },
6324
6692
  "edrv.read": async (args) => {
6693
+ const snippetTarget = snippetTargetOf(args.path);
6694
+ if (snippetTarget && args.encoding !== "base64") try {
6695
+ const content = await readFile(snippetTarget, "utf8");
6696
+ return {
6697
+ ok: true,
6698
+ content,
6699
+ size: content.length
6700
+ };
6701
+ } catch (error) {
6702
+ return {
6703
+ ok: false,
6704
+ error: "读取片段文件失败:" + String(error),
6705
+ resolvedPath: snippetTarget
6706
+ };
6707
+ }
6325
6708
  const sc = await requireSession(ctx, args.sessionId);
6326
6709
  if ("err" in sc) return {
6327
6710
  ok: false,
@@ -6425,6 +6808,16 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
6425
6808
  }
6426
6809
  },
6427
6810
  "edrv.save": async (args) => {
6811
+ const snippetTarget = snippetTargetOf(args.path);
6812
+ if (snippetTarget) try {
6813
+ await writeFile(snippetTarget, args.content, "utf8");
6814
+ return { ok: true };
6815
+ } catch (error) {
6816
+ return {
6817
+ ok: false,
6818
+ error: "保存片段文件失败:" + String(error)
6819
+ };
6820
+ }
6428
6821
  const sc = await requireSession(ctx, args.sessionId);
6429
6822
  if ("err" in sc) return {
6430
6823
  ok: false,
@@ -7297,6 +7690,70 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7297
7690
  error: "切换规则失败:" + String(error)
7298
7691
  };
7299
7692
  }
7693
+ },
7694
+ "snippets.list": async () => {
7695
+ try {
7696
+ return {
7697
+ ok: true,
7698
+ ...await snippetsList(ctx)
7699
+ };
7700
+ } catch (error) {
7701
+ return {
7702
+ ok: false,
7703
+ error: "读取代码片段失败:" + String(error)
7704
+ };
7705
+ }
7706
+ },
7707
+ "snippets.read": async (args) => {
7708
+ try {
7709
+ return {
7710
+ ok: true,
7711
+ content: await snippetsRead(ctx, args)
7712
+ };
7713
+ } catch (error) {
7714
+ return {
7715
+ ok: false,
7716
+ error: "读取代码片段失败:" + String(error)
7717
+ };
7718
+ }
7719
+ },
7720
+ "snippets.save": async (args) => {
7721
+ try {
7722
+ return {
7723
+ ok: true,
7724
+ file: await snippetsSave(ctx, args)
7725
+ };
7726
+ } catch (error) {
7727
+ return {
7728
+ ok: false,
7729
+ error: "保存代码片段失败:" + String(error)
7730
+ };
7731
+ }
7732
+ },
7733
+ "snippets.remove": async (args) => {
7734
+ try {
7735
+ await snippetsRemove(ctx, args);
7736
+ return { ok: true };
7737
+ } catch (error) {
7738
+ return {
7739
+ ok: false,
7740
+ error: "删除代码片段失败:" + String(error)
7741
+ };
7742
+ }
7743
+ },
7744
+ "snippets.entries": async (args) => {
7745
+ try {
7746
+ const sc = await requireSession(ctx, args.sessionId);
7747
+ return {
7748
+ ok: true,
7749
+ ...await snippetsEntries(ctx, "err" in sc ? void 0 : sc.cwd)
7750
+ };
7751
+ } catch (error) {
7752
+ return {
7753
+ ok: false,
7754
+ error: "读取代码片段条目失败:" + String(error)
7755
+ };
7756
+ }
7300
7757
  }
7301
7758
  };
7302
7759
  }