dsh-recall-plugin 1.5.0 → 1.5.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,19 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [1.5.1] - 2026-08-18
6
+
7
+ ### 修复
8
+
9
+ - 设置页冷启动优化:`exclude-get` 首次遍历工作区、逐文件 shell 读改为并行 + 30s 结果缓存(保存后失效),二次打开设置页不再重复付出首访代价。
10
+ - 冷会话标题/消息文本补齐引入通用并发限制器(`runLimited`,同时最多 4 个):首次大量冷数据时 `readSession` 整日志解压不再全量并发压垮磁盘/CPU。
11
+ - 启动预热叠加 `sessionQuery.listSessions()` 冷元数据:冷启动时 `ctx.sessions.list()` 常为空(惰性载入),此前设置页首次打开仍要现场建 store,现开机即预热全部历史工作区。
12
+ - Client 侧 `usage`/`status` 补数据延后到 `list` 返回后异步执行:首屏先渲染树形内容,磁盘占用与错误日志随后补齐;`list` 失败时仍尝试补这两个数据,避免整卡全空。
13
+
14
+ ### 变更
15
+
16
+ - Client 侧 `titles` 请求的 sessionIds 去重(`Set` 去重 + 过滤空值)。
17
+
5
18
  ## [1.5.0] - 2026-08-18
6
19
 
7
20
  ### 新增
package/README.md CHANGED
@@ -27,9 +27,6 @@
27
27
 
28
28
  ![设置页](docs/screenshots/settings-exclude-2.png)
29
29
 
30
- - 设置页 · 快照管理(树形:工作区 → 会话 → 快照,叶子显示消息内容)
31
-
32
- > 树形快照管理界面截图待补充(当前版本已实现该功能,截图可后续更新)。
33
30
 
34
31
  ## 功能亮点
35
32
 
package/lib/client.js CHANGED
@@ -639,15 +639,28 @@ window.__ModuleLoader__.load({
639
639
  fetchTitles(res.items || [])
640
640
  fetchMessages(res.items || [])
641
641
  }
642
- }).catch(() => {})
643
- if (sessionId) {
644
- api('manage', { op: 'usage', sessionId }).then((res) => {
645
- if (res && res.ok) setUsage(res.bytes || 0)
642
+ // 列表返回后再补 usage/status:首次冷启动时磁盘占用和错误日志
643
+ // 都各要一条 shell,和 list 并发会抢资源拖慢首屏;延后到列表
644
+ // 渲染后,用户先看到树形内容,占用再异步补上。
645
+ if (sessionId) {
646
+ api('manage', { op: 'usage', sessionId }).then((res) => {
647
+ if (res && res.ok) setUsage(res.bytes || 0)
648
+ }).catch(() => {})
649
+ }
650
+ api('status', {}).then((res) => {
651
+ if (res && res.ok) setErrors(res.errors || [])
646
652
  }).catch(() => {})
647
- }
648
- api('status', {}).then((res) => {
649
- if (res && res.ok) setErrors(res.errors || [])
650
- }).catch(() => {})
653
+ }).catch(() => {
654
+ // list 失败时仍尝试补 usage/status,避免整卡全空
655
+ if (sessionId) {
656
+ api('manage', { op: 'usage', sessionId }).then((res) => {
657
+ if (res && res.ok) setUsage(res.bytes || 0)
658
+ }).catch(() => {})
659
+ }
660
+ api('status', {}).then((res) => {
661
+ if (res && res.ok) setErrors(res.errors || [])
662
+ }).catch(() => {})
663
+ })
651
664
  }
652
665
 
653
666
  React.useEffect(() => { refresh() }, [sessionId])
package/lib/index.js CHANGED
@@ -51,6 +51,9 @@ export function apply(ctx, config) {
51
51
  // 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
52
52
  // 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
53
53
  let listCache = { at: 0, payload: null }
54
+ // 排除配置枚举缓存(30s):exclude-get 首次要遍历工作区、逐文件 shell 读,
55
+ // 设置页反复打开时不该每次重算;exclude-set 成功写入后立即失效。
56
+ let excludeCache = { at: 0, payload: null }
54
57
 
55
58
  // 会话标题缓存(apply 级跨请求共享):冷会话标题要 readSession 整日志
56
59
  // 解压 + 重放校验(大日志 10 秒级),绝不能挡列表首屏——list 只查
@@ -138,7 +141,9 @@ export function apply(ctx, config) {
138
141
  if (cwd) roots.add(cwd)
139
142
  }
140
143
  const byFile = new Map()
141
- for (const root of roots) {
144
+ // 并行解析全部 root:冷启动时每个 root 首次 resolveStore 可能触发 shell
145
+ // 建目录/写 root.txt,串行会随工作区数量线性变慢;Promise.all 让它们并发。
146
+ await Promise.all(Array.from(roots).map(async (root) => {
142
147
  try {
143
148
  const store = await rt.resolveStore(root)
144
149
  if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
@@ -146,7 +151,7 @@ export function apply(ctx, config) {
146
151
  } catch (error) {
147
152
  /* 单个根解析失败只影响它自己,不拖垮整个列表 */
148
153
  }
149
- }
154
+ }))
150
155
  // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
151
156
  // 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑(哪怕从未
152
157
  // 写过、内容为空);容器不在 ⇒ 全新安装,让设置页显示引导文案。
@@ -278,6 +283,21 @@ export function apply(ctx, config) {
278
283
  return run
279
284
  }
280
285
 
286
+ // 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
287
+ // 首次大量冷数据时全量 Promise.all 会同时压垮磁盘/CPU。限制同时最多
288
+ // CONCURRENCY 个任务,剩余排队执行;这是纯内存调度,不依赖额外依赖。
289
+ async function runLimited(tasks, concurrency) {
290
+ const limit = concurrency > 0 ? concurrency : 4
291
+ let index = 0
292
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
293
+ while (index < tasks.length) {
294
+ const task = tasks[index++]
295
+ await task()
296
+ }
297
+ })
298
+ await Promise.all(workers)
299
+ }
300
+
281
301
  // 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/
282
302
  // 会话批量删除使用。返回 Map<消息ID, {id, root, sessionId, time}>;
283
303
  // 磁盘条目缺 root 时按 store 的 root.txt/内存映射补全(与 manage list
@@ -443,14 +463,19 @@ export function apply(ctx, config) {
443
463
  // 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
444
464
  // 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
445
465
  if (!supported) return { ok: false, unsupported: true }
466
+ // 30s 结果缓存:首次进入要并行 resolveStore + 逐文件 shell 读,
467
+ // 二次打开/切标签不应重复付出这份代价;exclude-set 写入后失效。
468
+ if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
446
469
  const byFile = await listExcludeFiles()
447
- const files = []
448
- for (const [path, info] of byFile) {
470
+ // 并行读取各 exclude 文件内容:每个文件一条 shell,串行会放大延迟
471
+ const files = await Promise.all(Array.from(byFile.entries()).map(async ([path, info]) => {
449
472
  let content = ''
450
473
  try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
451
- files.push({ path, home: Boolean(info.store.home), roots: info.roots, content })
452
- }
453
- return { ok: true, files }
474
+ return { path, home: Boolean(info.store.home), roots: info.roots, content }
475
+ }))
476
+ const payload = { ok: true, files }
477
+ excludeCache = { at: Date.now(), payload }
478
+ return payload
454
479
  },
455
480
 
456
481
  'exclude-set': async (args) => {
@@ -463,6 +488,8 @@ export function apply(ctx, config) {
463
488
  const info = byFile.get(path)
464
489
  if (!info) return { ok: false, code: 'UNKNOWN_PATH', message: '未知的排除文件路径' }
465
490
  await snaps.writeExclude(info.store, content)
491
+ // 写入后立即失效:设置页保存后刷新必须看到最新内容
492
+ excludeCache.payload = null
466
493
  return { ok: true }
467
494
  },
468
495
 
@@ -542,10 +569,14 @@ export function apply(ctx, config) {
542
569
  // 重放校验,大日志 10 秒级——独立于列表让首屏即时。并发交给
543
570
  // sessionQuery 自带的 inspect 并发闸,这里全量并行发车。
544
571
  if (!supported) return { ok: false, unsupported: true }
545
- const ids = Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String).slice(0, 100) : []
572
+ const ids = Array.from(new Set(
573
+ (Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String) : []).filter(Boolean)
574
+ )).slice(0, 100)
546
575
  const out = {}
547
- await Promise.all(ids.map(async (sid) => {
548
- if (!sid || out[sid] !== undefined) return
576
+ // 并发限 4:冷标题 readSession 是重 IO,全量并发会把首次设置页
577
+ // shell/日志解压都挤在一起;限制后列表本身不受影响,标题渐进补齐。
578
+ await runLimited(ids.map((sid) => async () => {
579
+ if (out[sid] !== undefined) return
549
580
  let title = liveTitleFast(sid)
550
581
  if (title === null) {
551
582
  const query = ctx.get('sessionQuery')
@@ -558,7 +589,7 @@ export function apply(ctx, config) {
558
589
  }
559
590
  sessionTitles.set(sid, title)
560
591
  out[sid] = title
561
- }))
592
+ }), 4)
562
593
  return { ok: true, titles: out }
563
594
  }
564
595
  if (op === 'messages') {
@@ -576,7 +607,7 @@ export function apply(ctx, config) {
576
607
  bySession.get(sid).push(mid)
577
608
  }
578
609
  const texts = {}
579
- await Promise.all(Array.from(bySession.entries()).map(async ([sid, mids]) => {
610
+ await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
580
611
  // 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
581
612
  const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
582
613
  let log = null
@@ -604,7 +635,7 @@ export function apply(ctx, config) {
604
635
  messageTexts.set(key, text)
605
636
  texts[mid] = text
606
637
  }
607
- }))
638
+ }), 4)
608
639
  return { ok: true, messageTexts: texts }
609
640
  }
610
641
  if (op === 'usage') {
@@ -730,16 +761,33 @@ export function apply(ctx, config) {
730
761
  // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
731
762
  // 并清理旧版项目内 blobs 目录(home 可用时)。
732
763
  // 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
733
- for (const session of ctx.sessions.list()) {
734
- const cwd = session && session.header && session.header.cwd
735
- if (!cwd) continue
736
- const sessionId = session.id
737
- Promise.resolve(rt.resolveStore(cwd))
738
- .then(() => rt.tryUpgradeToHome(cwd))
739
- .then((store) => rt.ensureGit(cwd, store))
740
- .then(() => snaps.loadIndex(cwd, sessionId))
741
- .then(() => snaps.rebuildOrphans(cwd, sessionId))
742
- .then(() => rt.cleanupLegacy(cwd))
743
- .catch(() => {})
744
- }
764
+ // 冷启动时 ctx.sessions.list() 常为空(惰性载入),必须叠加
765
+ // sessionQuery.listSessions() 冷元数据,否则设置页首次打开仍要现场建 store。
766
+ // apply 不是 async,这里用 IIFE 把冷元数据扫描包成异步任务。
767
+ ;(async () => {
768
+ const warmupRoots = new Map()
769
+ for (const session of ctx.sessions.list()) {
770
+ const cwd = session && session.header && session.header.cwd
771
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
772
+ }
773
+ const querySvc = ctx.get('sessionQuery')
774
+ if (querySvc && typeof querySvc.listSessions === 'function') {
775
+ try {
776
+ const records = await querySvc.listSessions()
777
+ for (const record of records || []) {
778
+ const cwd = record && record.header && record.header.cwd
779
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, record.id || null)
780
+ }
781
+ } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
782
+ }
783
+ for (const [cwd, sessionId] of warmupRoots) {
784
+ Promise.resolve(rt.resolveStore(cwd))
785
+ .then(() => rt.tryUpgradeToHome(cwd))
786
+ .then((store) => rt.ensureGit(cwd, store))
787
+ .then(() => snaps.loadIndex(cwd, sessionId))
788
+ .then(() => snaps.rebuildOrphans(cwd, sessionId))
789
+ .then(() => rt.cleanupLegacy(cwd))
790
+ .catch(() => {})
791
+ }
792
+ })()
745
793
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {