dsh-recall-plugin 1.6.0 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [1.6.1] - 2026-08-23
6
+
7
+ ### 新增
8
+
9
+ - 快照管理新增带二次确认的「全部删除」按钮:一次清理所有工作区的快照([PR #5](https://github.com/limbo947/dsh-recall-plugin/pull/5),@CangWeiohh 贡献)。
10
+
11
+ ### 修复
12
+
13
+ - 默认基础排除表补上 `dsh-recall-snapshots/`(home 存储目录名,无前导点):工作区 root 恰为 HOME 时(如容器内 root=/root),home 存储落入工作区且不被旧默认 `.dsh-recall-snapshots/` 匹配,`git add -A` 把影子仓库自己吞进去导致快照全部失败、撤回按钮永不出现([#6](https://github.com/limbo947/dsh-recall-plugin/issues/6))。排除表在下一次快照/回退时重同步并清理已误跟踪条目,存量坏索引自愈;曾在设置卡片改过基础排除表的用户需手动补一行 `dsh-recall-snapshots/`。
14
+ - 全部删除直接枚举每个影子仓库的真实 `snap-*` git tag,而非仅依赖可能丢失或过期的 `index.json`;tag 每 100 个分批删除并回读校验,确认成功后才清空索引。即使 `index.json` 为空、`root.txt` 缺失或列表未显示残留快照,仍可清理。
15
+ - 仅创建但未初始化 git 的空 store 视为无快照,不会阻断其他 store 的全部删除;任一 store 删除失败会保留其索引并在页面显示可重试错误。
16
+
5
17
  ## [1.6.0] - 2026-08-22
6
18
 
7
19
  ### 新增
package/lib/client.js CHANGED
@@ -765,6 +765,25 @@ window.__ModuleLoader__.load({
765
765
  }).catch((e) => setState({ busy: false, message: String(e), error: true }))
766
766
  }
767
767
 
768
+ // 全局删除独立于树节点:后端直接枚举每个 store 的真实 snap-* tag,
769
+ // 即使历史 index.json 已丢失也能清理。确认状态与节点删除共用,
770
+ // 但全局操作不需要构造 root/session 参数。
771
+ function renderDeleteAllConfirm() {
772
+ if (!confirming || confirming.kind !== 'all') return null
773
+ return React.createElement('div', { className: 'dsh-recall-tree-confirm' },
774
+ '确认删除所有工作区的全部快照?此操作不可恢复。',
775
+ React.createElement('button', {
776
+ type: 'button',
777
+ className: 'dsh-recall-ex-chip',
778
+ onClick: () => {
779
+ setConfirming(null)
780
+ run('deleteAll', {}, '已清空全部快照')
781
+ }
782
+ }, '确认全部删除'),
783
+ React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirming(null) }, '取消')
784
+ )
785
+ }
786
+
768
787
  // 树形管理:工作区 → 会话 → 快照三级。折叠状态用 Set<key> 记录,
769
788
  // 工作区 key 用 root 路径(可能含特殊字符,但 Set 按字符串精确匹配);
770
789
  // 会话 key 用 sessionId;叶子无子节点。删除走行内二次确认:
@@ -913,9 +932,17 @@ window.__ModuleLoader__.load({
913
932
  : '共 ' + (items ? items.length : '…') + ' 条快照,全部工作区快照存储占用 ' + sizeText(usage) + '。'
914
933
  ),
915
934
  treeNodes.length > 0 ? React.createElement('div', { className: 'dsh-recall-tree' }, ...treeNodes) : null,
935
+ renderDeleteAllConfirm(),
916
936
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
917
937
  state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
918
938
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy, onClick: refresh }, '刷新'),
939
+ React.createElement('button', {
940
+ type: 'button',
941
+ className: 'dsh-recall-btn',
942
+ disabled: state.busy,
943
+ title: '删除全部工作区的所有快照;会直接核对并删除 git tag(即使列表为空也可清理残留)',
944
+ onClick: () => setConfirming({ kind: 'all' })
945
+ }, '全部删除'),
919
946
  React.createElement('button', {
920
947
  type: 'button',
921
948
  className: 'dsh-recall-btn',
package/lib/config.js CHANGED
@@ -19,13 +19,17 @@ export const Config = Schema.object({
19
19
  gcSnaps: Schema.number().default(50).description('每积累多少条快照触发一次 git gc'),
20
20
  gcHours: Schema.number().default(24).description('距上次 gc 超过多少小时触发(与条数先到先触发)'),
21
21
  maxFileBytes: Schema.number().default(104857600).description('超过该字节数的文件不进快照、不被回退触碰'),
22
- baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
22
+ // 排除表必须同时覆盖两种存储目录名:降级存储是项目内 .dsh-recall-snapshots/,
23
+ // 而 home 存储目录名是 dsh-recall-snapshots/(无点)——工作区 root 恰为
24
+ // HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
25
+ // 把影子仓库自己吞进去、快照全部失败(issue #6)
26
+ baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
23
27
  refillDraft: Schema.boolean().default(true).description('撤回后把被撤回的消息文本回填到输入框'),
24
28
  })
25
29
 
26
30
  // schema 默认值的运行时镜像:settings 服务未组装时 createConfig 直接以
27
31
  // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)
28
- const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/']
32
+ const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
29
33
 
30
34
  export function createConfig(raw) {
31
35
  const cfg = raw && typeof raw === 'object' ? raw : {}
package/lib/index.js CHANGED
@@ -416,6 +416,83 @@ export function apply(ctx, config) {
416
416
  return deleted
417
417
  }
418
418
 
419
+ // 删除所有工作区的全部快照。树形管理的「工作区/会话」批量删除以
420
+ // index.json 中的记录为目标,适合保留其他节点;但「全部删除」必须把 git
421
+ // tag 当作真相源:index 可能因旧版/崩溃/手动修复而为空或过期,不能因为
422
+ // 索引里没有条目就漏删真实快照。磁盘枚举到的 store 即使 root.txt 丢失也
423
+ // 直接按目录操作,避免 resolveStore 新建同 root 的另一个空 store。
424
+ async function deleteAllSnapshots() {
425
+ return enqueue(async () => {
426
+ const stores = new Map()
427
+ for (const [root, store] of state.stores.entries()) {
428
+ if (store && store.dir) stores.set(store.dir, { store, root })
429
+ }
430
+ const dump = await dumpStores()
431
+ for (const [dir, info] of dump.entries()) {
432
+ const known = stores.get(dir)
433
+ if (known) {
434
+ if (!known.root && info.root) known.root = info.root
435
+ known.entries = info.entries || []
436
+ } else {
437
+ stores.set(dir, {
438
+ // 全局删除只动该目录下的 git/index;不必、也不能依赖可反解的 root。
439
+ store: rt.storeFromDir(dir, false),
440
+ root: info.root || null,
441
+ entries: info.entries || []
442
+ })
443
+ }
444
+ }
445
+
446
+ if (stores.size === 0) return { deleted: 0, stores: 0, failed: 0 }
447
+
448
+ const gitExe = await rt.resolveGit()
449
+ if (!gitExe) {
450
+ const message = '未检测到 git CLI,无法验证并删除快照 tag'
451
+ rt.recordError('recall delete all failed: ' + message)
452
+ return { deleted: 0, stores: 0, failed: stores.size || 1, message }
453
+ }
454
+
455
+ let deleted = 0
456
+ let clearedStores = 0
457
+ let failed = 0
458
+ for (const { store, root } of stores.values()) {
459
+ try {
460
+ // 先列出实际 tag;不要使用 entries 推导 tag,entries 是可丢失缓存。
461
+ const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
462
+ const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
463
+ for (let i = 0; i < tags.length; i += 100) {
464
+ await rt.runShell(rt.scripts.purgeTagsScript(store, gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
465
+ }
466
+ // purgeTagsScript 为幂等 best-effort,故必须回读校验,避免脚本吞掉
467
+ // 个别失败后仍错误地把 index.json 清空。
468
+ const remainedOutput = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
469
+ const remained = rt.scripts.stripBom(remainedOutput).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
470
+ if (remained.length) throw new Error('仍有 ' + remained.length + ' 个快照 tag 未删除')
471
+
472
+ // tag 清理被确认后才清空索引。直接写已枚举的 store,兼容 root.txt
473
+ // 缺失/错位的旧仓库;不能调用 saveIndex(root),后者会重新按 root 寻址。
474
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', '[]')
475
+ for (const tag of tags) state.snapshots.delete(tag.slice('snap-'.length))
476
+ if (root) {
477
+ for (const [id, snap] of state.snapshots.entries()) {
478
+ if (snap && snap.root === root) state.snapshots.delete(id)
479
+ }
480
+ state.indexLoaded.add(root)
481
+ }
482
+ deleted += tags.length
483
+ clearedStores += 1
484
+ } catch (error) {
485
+ failed += 1
486
+ rt.recordError('recall delete all failed for ' + store.dir + ': ' + String(error))
487
+ }
488
+ }
489
+ // list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
490
+ // 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
491
+ listCache.payload = null
492
+ return { deleted, stores: clearedStores, failed }
493
+ })
494
+ }
495
+
419
496
  const endpoints = {
420
497
  'init': async (args) => {
421
498
  if (!supported) {
@@ -812,6 +889,18 @@ export function apply(ctx, config) {
812
889
  })
813
890
  return { ok: true }
814
891
  }
892
+ if (op === 'deleteAll') {
893
+ const result = await deleteAllSnapshots()
894
+ if (result.failed > 0) {
895
+ return {
896
+ ok: false,
897
+ code: 'PARTIAL_DELETE',
898
+ deleted: result.deleted,
899
+ message: result.message || ('已删除 ' + result.deleted + ' 条快照,但有 ' + result.failed + ' 个存储未完成;请查看最近错误后重试')
900
+ }
901
+ }
902
+ return { ok: true, deleted: result.deleted, stores: result.stores }
903
+ }
815
904
  if (op === 'gc') {
816
905
  // 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
817
906
  // 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
@@ -71,7 +71,9 @@ function oversizeBlock(maxBytes) {
71
71
  // base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
72
72
  // 依赖外层已定义的 $git/$g。
73
73
  function excludeSyncBlock(excludeFile, base) {
74
- const baseList = Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/']
74
+ // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
75
+ // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
76
+ const baseList = Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
75
77
  const baseLines = baseList.join('\n') + '\n'
76
78
  return [
77
79
  'ex_file=' + psq(excludeFile),
@@ -258,6 +260,9 @@ export function listTagsScript(store, gitExe) {
258
260
  'set -e',
259
261
  'git=' + psq(gitExe),
260
262
  'g=' + psq(store.git),
263
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
264
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
265
+ '[ -d "$g" ] || exit 0',
261
266
  '"$git" --git-dir="$g" tag -l \'snap-*\''
262
267
  ].join('\n')
263
268
  }
@@ -73,7 +73,9 @@ function oversizeBlock(maxBytes) {
73
73
  // - 依赖外层已定义的 $git/$g;被 ensureGit/snapshot/diff/rollback 复用,
74
74
  // 因此 exclude.txt 的改动在下一次快照/diff/回退时即时生效,无需重启。
75
75
  function excludeSyncBlock(excludeFile, base) {
76
- const baseList = (Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/']).map(psq).join(',')
76
+ // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
77
+ // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
78
+ const baseList = (Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).map(psq).join(',')
77
79
  return [
78
80
  "$exFile = " + psq(excludeFile),
79
81
  '$userPats = @()',
@@ -288,6 +290,9 @@ export function listTagsScript(store, gitExe) {
288
290
  "$ErrorActionPreference = 'Stop'",
289
291
  '$git = ' + psq(gitExe),
290
292
  '$g = ' + psq(store.git),
293
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
294
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
295
+ 'if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }',
291
296
  '& $git --git-dir=$g tag -l "snap-*"'
292
297
  ].join('\n')
293
298
  }
package/lib/store.js CHANGED
@@ -247,6 +247,15 @@ export function createRuntime(ctx, config) {
247
247
  }
248
248
  }
249
249
 
250
+ // 将磁盘枚举出的 store 目录临时包装成 store 对象。全部删除必须覆盖
251
+ // `root.txt`/`index.json` 已失步的历史仓库:这时无法安全地用 root 调
252
+ // `resolveStore`(它可能新建另一个目录),所以直接以已枚举的 dir 为准。
253
+ // home 参数只影响 excludeFile;删除 tag/index 不依赖它,因而未知时用
254
+ // false 也安全。
255
+ function storeFromDir(dir, home) {
256
+ return makeStore(dir, Boolean(home))
257
+ }
258
+
250
259
  // store 级元数据 root.txt:内容为工作区绝对路径。store 目录名是 root 的
251
260
  // 单向 SHA256,反解不了——「快照管理」跨工作区展示时靠它把哈希目录映射
252
261
  // 回工作区名。best-effort(失败不阻断主流程),旧 store 在 resolveStore
@@ -371,5 +380,5 @@ export function createRuntime(ctx, config) {
371
380
  runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
372
381
  }
373
382
 
374
- return { state, isWin, scripts, recordError, runShell, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
383
+ return { state, isWin, scripts, recordError, runShell, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy }
375
384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {