dsh-prompt-snippets 1.3.0 → 1.3.1

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.
Files changed (4) hide show
  1. package/README.md +3 -1
  2. package/client.js +110 -14
  3. package/index.js +89 -44
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -108,7 +108,9 @@ and reload the page.
108
108
  面板里点 🕘 展开该片段的历史,可回看历次正文;`/snippet show` 也会显示 revision 号。
109
109
  - **命令面板(全局快捷唤起)**:任意时刻按 `Ctrl+Shift+K`(或 `Ctrl+Shift+P`)弹出片段搜索
110
110
  浮层,输入关键字即时过滤(名称/分类/标签/正文),`↑` `↓` 选择、`Enter` 插入、`Esc` 关闭;
111
- 收藏片段自动置顶,无需先点开 dock 面板。
111
+ 收藏片段自动置顶,无需先点开 dock 面板。选中后按 `Ctrl+Enter`(Mac `Cmd+Enter`)则是
112
+ **插入并立即发送**;带必填占位符的片段仍需先填表单,无法直接发送。`↑` `↓` 切换选中项时
113
+ 列表自动滚动跟随,始终把选中项保持在可视区内。
112
114
 
113
115
  ## 使用
114
116
 
package/client.js CHANGED
@@ -334,6 +334,12 @@ window.__ModuleLoader__.load({
334
334
  maxHeight: "120px",
335
335
  overflow: "auto",
336
336
  },
337
+ historyLink: {
338
+ color: "var(--dsw-alias-brand-primary)",
339
+ cursor: "pointer",
340
+ textDecoration: "underline",
341
+ textUnderlineOffset: "2px",
342
+ },
337
343
  // ── v1.3 命令面板(全局快捷唤起 overlay) ──
338
344
  paletteOverlay: {
339
345
  position: "fixed",
@@ -366,7 +372,7 @@ window.__ModuleLoader__.load({
366
372
  outline: "none",
367
373
  fontFamily: "inherit",
368
374
  },
369
- paletteList: { overflowY: "auto", padding: "6px", flex: 1 },
375
+ paletteList: { overflowY: "auto", padding: "6px", flex: 1, minHeight: 0 },
370
376
  paletteRow: {
371
377
  display: "flex",
372
378
  alignItems: "center",
@@ -750,12 +756,12 @@ window.__ModuleLoader__.load({
750
756
  return [...groups.entries()];
751
757
  }
752
758
 
753
- /** 大小写不敏感、命中名称/分类/内容任一处。 */
759
+ /** 大小写不敏感、命中名称/分类/标签/内容任一处。 */
754
760
  function filterSnippets(snippets, query) {
755
761
  const q = query.trim().toLowerCase();
756
762
  if (!q) return snippets;
757
763
  return snippets.filter((s) => {
758
- const hay = `${s.name}\n${s.category || ""}\n${s.content}`.toLowerCase();
764
+ const hay = `${s.name}\n${s.category || ""}\n${(s.tags || []).join(" ")}\n${s.content}`.toLowerCase();
759
765
  return hay.includes(q);
760
766
  });
761
767
  }
@@ -984,6 +990,7 @@ window.__ModuleLoader__.load({
984
990
  const [paletteQuery, setPaletteQuery] = useState("");
985
991
  const [paletteSelected, setPaletteSelected] = useState(-1);
986
992
  const paletteInputRef = useRef(null);
993
+ const paletteListRef = useRef(null);
987
994
  const searchRef = useRef(null);
988
995
  const nameRef = useRef(null);
989
996
  // 面板 DOM 引用:拖拽/缩放期间直接改 style.left/top/width/height,
@@ -1034,11 +1041,13 @@ window.__ModuleLoader__.load({
1034
1041
 
1035
1042
  useEffect(() => {
1036
1043
  const onKey = (e) => {
1037
- if (e.key === "k" && e.ctrlKey && e.shiftKey) {
1044
+ // 用 e.code(物理键位)而非 e.key:Shift+K 会让 e.key 变成大写 "K",
1045
+ // 导致 Ctrl+Shift+K 永远匹配不到小写 "k"。
1046
+ if (e.code === "KeyK" && (e.ctrlKey || e.metaKey) && e.shiftKey) {
1038
1047
  e.preventDefault();
1039
1048
  setPaletteOpen(true);
1040
1049
  setOpen(false);
1041
- } else if (e.key === "p" && e.ctrlKey && e.shiftKey) {
1050
+ } else if (e.code === "KeyP" && (e.ctrlKey || e.metaKey) && e.shiftKey) {
1042
1051
  e.preventDefault();
1043
1052
  setPaletteOpen(true);
1044
1053
  setOpen(false);
@@ -1061,6 +1070,22 @@ window.__ModuleLoader__.load({
1061
1070
  return sorted.slice(0, 30);
1062
1071
  }, [snippets, paletteQuery]);
1063
1072
 
1073
+ // 选中项变化时,把对应的列表行滚进可视区(否则上下键切换超过可视高度后不跟随)。
1074
+ useEffect(() => {
1075
+ if (!paletteOpen || paletteSelected < 0 || !paletteListRef.current) return;
1076
+ const el = paletteListRef.current;
1077
+ const row = el.querySelector(`[data-ps-index="${paletteSelected}"]`);
1078
+ if (!row) return;
1079
+ const elTop = el.getBoundingClientRect().top;
1080
+ const rowTop = row.getBoundingClientRect().top;
1081
+ const rowBottom = row.getBoundingClientRect().bottom;
1082
+ if (rowTop < elTop) {
1083
+ el.scrollTop += rowTop - elTop;
1084
+ } else if (rowBottom > el.getBoundingClientRect().bottom) {
1085
+ el.scrollTop += rowBottom - el.getBoundingClientRect().bottom;
1086
+ }
1087
+ }, [paletteOpen, paletteSelected]);
1088
+
1064
1089
  const openPalette = () => {
1065
1090
  setPaletteQuery("");
1066
1091
  setPaletteSelected(-1);
@@ -1074,9 +1099,9 @@ window.__ModuleLoader__.load({
1074
1099
  setPaletteSelected(-1);
1075
1100
  };
1076
1101
 
1077
- const paletteChoose = (snippet) => {
1102
+ const paletteChoose = (snippet, sendAfter = false) => {
1078
1103
  closePalette();
1079
- insert(snippet);
1104
+ insert(snippet, sendAfter);
1080
1105
  };
1081
1106
 
1082
1107
  // §3.1:启动时问一次模型可用性。拿不到就不降级为「假装可用」——
@@ -1295,12 +1320,32 @@ window.__ModuleLoader__.load({
1295
1320
  Promise.resolve(rpc.call(CHANNEL, "touch", { ids: [snippetId] })).catch(() => {});
1296
1321
  };
1297
1322
 
1323
+ // 发送当前草稿(Ctrl+Enter 插入并发送时调用;失败不打断,交给用户手动补救)。
1324
+ const submitDraft = () => {
1325
+ try {
1326
+ inputActions.submit();
1327
+ } catch (_) {
1328
+ setError("发送失败:请确认输入框里有内容再试。");
1329
+ }
1330
+ };
1331
+
1298
1332
  // ────────── 插入:直插 / 按默认值插 / 弹表单 ──────────
1299
- const insert = (snippet) => {
1333
+ // sendAfter:插入成功后立即发送草稿。带必填占位符的片段仍需先填表单,
1334
+ // 无法自动发送,此时降级为「弹表单、填完手动发送」。
1335
+ const insert = (snippet, sendAfter = false) => {
1300
1336
  const plan = fillPlan(snippet);
1301
1337
  if (plan.kind === "form") {
1302
1338
  setFilling({ snippet, values: {} });
1303
1339
  setError(null);
1340
+ // 从命令面板(palette)触发时主面板是关闭的,表单渲染在 open 面板内部,
1341
+ // 必须同时打开主面板并定位,否则「按回车后表单被吞」。
1342
+ if (!open) {
1343
+ if (!panelPos) {
1344
+ const saved = loadSavedPos();
1345
+ setPanelPos(saved ? clampPos(saved.left, saved.top, panelSize) : clampPos(GAP, GAP, panelSize));
1346
+ }
1347
+ setOpen(true);
1348
+ }
1304
1349
  return;
1305
1350
  }
1306
1351
  const { tokens } = scanPlaceholders(snippet.content);
@@ -1312,7 +1357,11 @@ window.__ModuleLoader__.load({
1312
1357
  touch(snippet.id);
1313
1358
  setError(null);
1314
1359
  setOpen(false);
1315
- if (plan.kind === "defaults") setNotice("已用默认值填充,可在 ✏️ 里改");
1360
+ if (sendAfter) {
1361
+ submitDraft();
1362
+ } else if (plan.kind === "defaults") {
1363
+ setNotice("已用默认值填充,可在 ✏️ 里改");
1364
+ }
1316
1365
  };
1317
1366
 
1318
1367
  const setFillValue = (name, value) => {
@@ -1988,6 +2037,10 @@ window.__ModuleLoader__.load({
1988
2037
  const opts = Array.isArray(d.options) ? d.options : [];
1989
2038
  const label = (typeof d.label === "string" && d.label ? d.label : nm) + (required ? " · 必填" : "");
1990
2039
  const val = typeof cur.values[nm] === "string" ? cur.values[nm] : "";
2040
+ // 长文本变量(变量名带「代码/原文/记录/要点/说明/内容/text/body」或已含换行)用 textarea,
2041
+ // 单行 input 会吃掉换行,粘贴多行代码/会议记录没法看。
2042
+ const multiline =
2043
+ /代码|code|原文|记录|要点|说明|内容|text|body/i.test(nm) || (typeof val === "string" && val.includes("\n"));
1991
2044
  return h(
1992
2045
  "label",
1993
2046
  { key: nm, style: style.field },
@@ -2005,6 +2058,14 @@ window.__ModuleLoader__.load({
2005
2058
  !required ? h("option", { value: "" }, "(不填)") : null,
2006
2059
  opts.map((o) => h("option", { key: o, value: o }, o))
2007
2060
  )
2061
+ : multiline
2062
+ ? h("textarea", {
2063
+ className: "ps-textarea",
2064
+ style: { ...style.textarea, minHeight: "54px" },
2065
+ value: val,
2066
+ placeholder: typeof d.default === "string" ? d.default : "",
2067
+ onChange: (ev) => setFillValue(nm, ev.target.value),
2068
+ })
2008
2069
  : h("input", {
2009
2070
  type: "text",
2010
2071
  className: "ps-input",
@@ -2251,7 +2312,7 @@ window.__ModuleLoader__.load({
2251
2312
  {
2252
2313
  type: "button",
2253
2314
  className: "ps-icon",
2254
- style: { ...style.icon, ...style.iconBusy },
2315
+ style: history && history.id === s.id ? { ...style.icon, ...style.itemEditing } : style.icon,
2255
2316
  onClick: () => showHistory(s),
2256
2317
  title: "查看历史版本",
2257
2318
  "aria-label": `历史「${s.name}」`,
@@ -2357,7 +2418,8 @@ window.__ModuleLoader__.load({
2357
2418
  } else if (e.key === "Enter") {
2358
2419
  e.preventDefault();
2359
2420
  const pick = paletteSelected >= 0 ? paletteResults[paletteSelected] : paletteResults[0];
2360
- if (pick) paletteChoose(pick);
2421
+ // Enter 插入;Ctrl+Enter / Meta+Enter 插入并立即发送。
2422
+ if (pick) paletteChoose(pick, e.ctrlKey || e.metaKey);
2361
2423
  } else if (e.key === "Escape") {
2362
2424
  e.preventDefault();
2363
2425
  closePalette();
@@ -2366,7 +2428,7 @@ window.__ModuleLoader__.load({
2366
2428
  }),
2367
2429
  h(
2368
2430
  "div",
2369
- { style: style.paletteList },
2431
+ { style: style.paletteList, ref: paletteListRef },
2370
2432
  paletteResults.length === 0
2371
2433
  ? h("div", { style: style.empty }, snippets.length === 0 ? "还没有片段,点 dock 的「+ 新建」保存一个。" : `没有匹配「${paletteQuery}」的片段。`)
2372
2434
  : paletteResults.map((s, i) =>
@@ -2376,6 +2438,7 @@ window.__ModuleLoader__.load({
2376
2438
  key: s.id,
2377
2439
  type: "button",
2378
2440
  className: "ps-item",
2441
+ "data-ps-index": i,
2379
2442
  style: i === paletteSelected ? style.paletteRowActive : style.paletteRow,
2380
2443
  onMouseEnter: () => setPaletteSelected(i),
2381
2444
  onClick: () => paletteChoose(s),
@@ -2393,7 +2456,7 @@ window.__ModuleLoader__.load({
2393
2456
  h(
2394
2457
  "div",
2395
2458
  { style: style.paletteHint },
2396
- "↑↓ 选择 · Enter 插入 · Esc 关闭 · Ctrl+Shift+K 唤起"
2459
+ "↑↓ 选择 · Enter 插入 · Ctrl+Enter 插入并发送 · Esc 关闭 · Ctrl+Shift+K 唤起"
2397
2460
  )
2398
2461
  )
2399
2462
  );
@@ -2421,7 +2484,40 @@ window.__ModuleLoader__.load({
2421
2484
  h(
2422
2485
  "div",
2423
2486
  { style: style.historyContent },
2424
- h("div", { style: { color: C.textDim, fontSize: "10px" } }, `保存于 ${fmtTime(v.at)} · 插入旧版`),
2487
+ h(
2488
+ "div",
2489
+ { style: { color: C.textDim, fontSize: "10px" } },
2490
+ `保存于 ${fmtTime(v.at)} · `,
2491
+ h(
2492
+ "span",
2493
+ {
2494
+ style: style.historyLink,
2495
+ role: "button",
2496
+ tabIndex: 0,
2497
+ onClick: () => {
2498
+ if (!insertText(v.content)) {
2499
+ setError("插入失败:请先把光标放进输入框再试。");
2500
+ return;
2501
+ }
2502
+ setError(null);
2503
+ setNotice("已插入旧版内容");
2504
+ },
2505
+ onKeyDown: (e) => {
2506
+ if (e.key === "Enter" || e.key === " ") {
2507
+ e.preventDefault();
2508
+ if (!insertText(v.content)) {
2509
+ setError("插入失败:请先把光标放进输入框再试。");
2510
+ return;
2511
+ }
2512
+ setError(null);
2513
+ setNotice("已插入旧版内容");
2514
+ }
2515
+ },
2516
+ title: "把这一版的内容插入输入框",
2517
+ },
2518
+ "插入旧版"
2519
+ )
2520
+ ),
2425
2521
  v.content
2426
2522
  )
2427
2523
  )
package/index.js CHANGED
@@ -34,48 +34,78 @@ const TEMPLATE_PACK = [
34
34
  category: "开发",
35
35
  tags: ["代码", "审查"],
36
36
  content: "请审查下面这段代码,重点关注 {{关注点}},指出潜在 bug、性能问题与可读性改进,并给出修改建议。\n\n```\n{{代码}}\n```",
37
+ variables: {
38
+ 关注点: {
39
+ label: "关注点",
40
+ options: ["正确性、边界与命名", "性能与内存", "安全漏洞", "可读性与重构"],
41
+ default: "正确性、边界与命名",
42
+ },
43
+ 代码: { label: "待审查代码", required: true },
44
+ },
37
45
  },
38
46
  {
39
47
  name: "Git 提交信息",
40
48
  category: "开发",
41
49
  tags: ["git", "提交"],
42
50
  content: "请根据以下变更,生成一条规范的 git commit message(约定式提交风格,一行标题 + 空行 + 要点式正文):\n\n{{变更说明}}",
51
+ variables: { 变更说明: { label: "变更说明", required: true } },
43
52
  },
44
53
  {
45
54
  name: "Bug 排查",
46
55
  category: "开发",
47
56
  tags: ["bug", "排查"],
48
57
  content: "我遇到一个问题:{{现象}}。相关环境:{{环境}}。已尝试:{{已尝试}}。请按「现象 → 根因假设 → 验证方法 → 修复方案」逐步分析。",
58
+ variables: {
59
+ 现象: { label: "问题现象", required: true },
60
+ 环境: { label: "相关环境", default: "操作系统 / 版本 / 依赖" },
61
+ 已尝试: { label: "已尝试的方法" },
62
+ },
49
63
  },
50
64
  {
51
65
  name: "周报",
52
66
  category: "写作",
53
67
  tags: ["周报", "工作"],
54
68
  content: "请根据以下要点,帮我整理成一份结构清晰的周报(本周完成 / 进行中 / 下周计划 / 风险与求助):\n\n{{要点}}",
69
+ variables: { 要点: { label: "工作要点", required: true } },
55
70
  },
56
71
  {
57
72
  name: "翻译润色",
58
73
  category: "写作",
59
74
  tags: ["翻译", "润色"],
60
75
  content: "请把下面的内容翻译成 {{目标语言}},并润色为自然流畅的表达,保留原意与格式:\n\n{{原文}}",
76
+ variables: {
77
+ 目标语言: {
78
+ label: "目标语言",
79
+ options: ["英文", "简体中文", "繁体中文", "日文", "韩文", "法文", "德文"],
80
+ default: "英文",
81
+ },
82
+ 原文: { label: "待翻译内容", required: true },
83
+ },
61
84
  },
62
85
  {
63
86
  name: "日报",
64
87
  category: "写作",
65
88
  tags: ["日报", "工作"],
66
89
  content: "今天是 {{date}},请根据以下要点帮我生成一份工作日报(今日完成 / 明日计划 / 需要协调):\n\n{{要点}}",
90
+ variables: { 要点: { label: "工作要点", required: true } },
67
91
  },
68
92
  {
69
93
  name: "会议纪要",
70
94
  category: "写作",
71
95
  tags: ["会议", "纪要"],
72
96
  content: "请把下面的会议记录整理成纪要(结论 / 待办与负责人 / 时间点):\n\n{{记录}}",
97
+ variables: { 记录: { label: "会议记录", required: true } },
73
98
  },
74
99
  {
75
100
  name: "学习计划",
76
101
  category: "学习",
77
102
  tags: ["学习", "计划"],
78
103
  content: "我想在 {{时间}} 内掌握 {{主题}},目前基础是 {{当前水平}}。请制定一个可执行的学习计划,分阶段列出目标、内容与检验方式。",
104
+ variables: {
105
+ 时间: { label: "学习周期", default: "3 个月" },
106
+ 主题: { label: "学习主题", required: true },
107
+ 当前水平: { label: "当前基础", default: "零基础" },
108
+ },
79
109
  },
80
110
  ];
81
111
 
@@ -292,7 +322,7 @@ function seedTemplates() {
292
322
  const now = Date.now();
293
323
  const snippets = TEMPLATE_PACK.map((t, i) => {
294
324
  const { names } = scanPlaceholders(t.content);
295
- const { variables } = normalizeVariables(names, {});
325
+ const { variables } = normalizeVariables(names, t.variables || {});
296
326
  return {
297
327
  id: randomUUID(),
298
328
  name: t.name,
@@ -957,17 +987,23 @@ async function saveSnippet(payload) {
957
987
  if (other) {
958
988
  return conflictError("name", `已有另一个片段叫「${name}」。`, other);
959
989
  }
960
- // 记历史:被覆盖的这一版(name/content/variables 快照)
961
- historyId = existing.id;
962
- historySnapshot = {
963
- name: existing.name,
964
- content: existing.content,
965
- variables: existing.variables,
966
- category: existing.category,
967
- tags: existing.tags,
968
- at: existing.updatedAt,
969
- revision: existing.revision,
970
- };
990
+ // 记历史:被覆盖的这一版(name/content/variables 快照)。
991
+ // 只改分类/标签/置顶这类元数据时内容与变量都没动,不该产生一条历史版本,
992
+ // 否则「换了个标签」就多出一次可回退的旧版,历史列表会迅速被噪声塞满。
993
+ const contentChanged = existing.content !== content;
994
+ const variablesChanged = JSON.stringify(existing.variables) !== JSON.stringify(variables);
995
+ if (contentChanged || variablesChanged) {
996
+ historyId = existing.id;
997
+ historySnapshot = {
998
+ name: existing.name,
999
+ content: existing.content,
1000
+ variables: existing.variables,
1001
+ category: existing.category,
1002
+ tags: existing.tags,
1003
+ at: existing.updatedAt,
1004
+ revision: existing.revision,
1005
+ };
1006
+ }
971
1007
  existing.name = name;
972
1008
  existing.content = content;
973
1009
  existing.variables = variables;
@@ -1570,22 +1606,7 @@ async function dispatch(ctx, endpoint, payload, signal) {
1570
1606
  // 不重新落盘、不重新生成内容——否则会覆盖掉用户手工改过的导出文件。
1571
1607
  if (typeof payload?.revealPath === "string" && payload.revealPath.trim() !== "") {
1572
1608
  const p = payload.revealPath.trim();
1573
- let revealed = false;
1574
- let revealError = null;
1575
- if (process.platform === "win32") {
1576
- try {
1577
- const { spawn } = await import("node:child_process");
1578
- await new Promise((resolve) => {
1579
- const child = spawn("explorer.exe", [`/select,${p}`], { stdio: "ignore", detached: true });
1580
- child.on("error", () => resolve(false));
1581
- child.on("spawn", () => resolve(true));
1582
- setTimeout(() => resolve(true), 500);
1583
- });
1584
- revealed = true;
1585
- } catch (error) {
1586
- revealError = error instanceof Error ? error.message : String(error);
1587
- }
1588
- }
1609
+ const { revealed, revealError } = await revealInFileManager(p);
1589
1610
  return { ok: true, value: { path: p, revealed, revealError: revealError || undefined } };
1590
1611
  }
1591
1612
  // payload.write = 文件名(字符串)时,落盘到数据目录的 exports/ 并回传路径。
@@ -1600,24 +1621,12 @@ async function dispatch(ctx, endpoint, payload, signal) {
1600
1621
  } catch (error) {
1601
1622
  return fail("internal", `写入导出文件失败:${error instanceof Error ? error.message : String(error)}`);
1602
1623
  }
1603
- // 请求方要求打开导出文件所在目录时,用系统默认方式 reveal(Windows: explorer /select)。
1604
- // 打开失败只降级不报错——用户仍可凭回传的绝对路径手动定位。
1624
+ // 请求方要求打开导出文件所在目录时,用系统默认方式 reveal(Windows: explorer /select;
1625
+ // macOS: open -R;Linux: xdg-open 目录)。打开失败只降级不报错——用户仍可凭回传的绝对路径手动定位。
1605
1626
  let revealed = false;
1606
1627
  let revealError = null;
1607
- if (payload.reveal === true && process.platform === "win32") {
1608
- try {
1609
- const { spawn } = await import("node:child_process");
1610
- await new Promise((resolve) => {
1611
- const p = spawn("explorer.exe", [`/select,${target}`], { stdio: "ignore", detached: true });
1612
- p.on("error", () => resolve(false));
1613
- p.on("spawn", () => resolve(true));
1614
- // explorer 可能已经存在则立即退出,也可能挂起;500ms 兜底,不阻塞导出结果
1615
- setTimeout(() => resolve(true), 500);
1616
- });
1617
- revealed = true;
1618
- } catch (error) {
1619
- revealError = error instanceof Error ? error.message : String(error);
1620
- }
1628
+ if (payload.reveal === true) {
1629
+ ({ revealed, revealError } = await revealInFileManager(target));
1621
1630
  }
1622
1631
  return {
1623
1632
  ok: true,
@@ -1686,6 +1695,42 @@ const ADD_USAGE = "/snippet add <名称> <内容> [--cat=<分类>] [--tag=<标
1686
1695
  const USAGE = `${ADD_USAGE} | list | show <名称> | rm <名称|#id> | undo <名称|#id> | mv <名称> <新名称> | pin <名称|#id> | unpin <名称|#id> | tag <名称|#id> <标签…> | optimize <名称> | export [文件名] | import <文件路径> [--replace] [--dry]`;
1687
1696
 
1688
1697
  /** 导出文件名不能带路径分隔符,也不能是 `.`/`..`,避免写出数据目录。 */
1698
+ /**
1699
+ * 用系统默认方式在文件管理器里定位一个已存在的文件。
1700
+ * win32 → explorer /select,;darwin → open -R;linux 等 → xdg-open 所在目录。
1701
+ * 打开失败只降级(revealed=false + revealError),不抛异常。
1702
+ */
1703
+ async function revealInFileManager(filePath) {
1704
+ let revealed = false;
1705
+ let revealError = null;
1706
+ try {
1707
+ const { spawn } = await import("node:child_process");
1708
+ let cmd;
1709
+ let args;
1710
+ if (process.platform === "win32") {
1711
+ cmd = "explorer.exe";
1712
+ args = [`/select,${filePath}`];
1713
+ } else if (process.platform === "darwin") {
1714
+ cmd = "open";
1715
+ args = ["-R", filePath];
1716
+ } else {
1717
+ cmd = "xdg-open";
1718
+ args = [dirname(filePath)];
1719
+ }
1720
+ await new Promise((resolve) => {
1721
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1722
+ child.on("error", () => resolve(false));
1723
+ child.on("spawn", () => resolve(true));
1724
+ // 文件管理器可能立即退出也可能挂起;500ms 兜底,不阻塞导出结果
1725
+ setTimeout(() => resolve(true), 500);
1726
+ });
1727
+ revealed = true;
1728
+ } catch (error) {
1729
+ revealError = error instanceof Error ? error.message : String(error);
1730
+ }
1731
+ return { revealed, revealError };
1732
+ }
1733
+
1689
1734
  function safeExportName(raw) {
1690
1735
  const text = typeof raw === "string" ? raw.trim() : "";
1691
1736
  const base = text === "" ? "snippets" : text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prompt-snippets",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "Save reusable prompt snippets with categories, {{placeholder}} fill forms & dropdown enums, version history, pinned/tag search, and crash-safe atomic JSON storage — insert them into the composer from a draggable dock.",
6
6
  "keywords": [