@wangxiaocuo/dsh-session-trash 0.6.1 → 0.6.2

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.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  A session trash for DeepSeek Harness. Move cold sessions out of the sidebar without deleting their logs, restore them within the retention window, or delete the logs permanently. Built for DSH 0.2.2-rc.1 on Desktop and Web. Host and Client ship in one package, with no build step.
8
8
 
9
- Status: feature complete and covered by the regression suite. macOS has been verified on a real DSH Host. Windows and Linux path, lock, and retry rules have unit coverage but have not been run on those systems.
9
+ Status: feature complete. CI (GitHub Actions) runs the unit and Host smoke tests on Ubuntu (Node 20/22) and Windows (Node 22); macOS has run the end-to-end suite against a real DSH Host. The Windows and Linux DSH Hosts have not been verified manually yet, and Windows skips the cases that rely on POSIX permission bits.
10
10
 
11
11
  ## Features
12
12
 
@@ -167,7 +167,7 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
167
167
  - Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
168
168
  - `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
169
169
  - There is no cross-window live push. Another window's change appears after a refresh or when the window regains focus.
170
- - Windows and Linux have not been run on real hosts. On Windows, long paths over 260 characters and symlink copying depend on system configuration.
170
+ - The Windows and Linux DSH Hosts have not been verified manually; CI covers unit and Host smoke tests only. On Windows, cases that rely on POSIX permission bits are skipped, and long paths over 260 characters / symlink copying depend on system configuration (the related tests skip themselves when unsupported).
171
171
  - macOS path comparison is case-insensitive. On a case-sensitive APFS volume, paths that differ only by case are treated as the same location.
172
172
  - Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. A process paused for more than 10 minutes can lose the lock to another process.
173
173
 
@@ -180,9 +180,17 @@ pnpm test:smoke # Host smoke test (real Cordis, plain Node)
180
180
  npm run test:e2e # real dsh web end-to-end (needs the desktop app)
181
181
  ```
182
182
 
183
- The Host smoke test wires the plugin into a real Cordis context through the `@deepseek-ai/cordis` devDependency, creates its own temporary `DSH_HOME`, and removes it afterwards; it does not need the desktop app. Set `DSH_SMOKE_KEEP=1` to keep the temporary home for debugging. CI (GitHub Actions) runs the unit tests, the smoke test, and a package-contents check; the end-to-end suite needs a local DeepSeek Harness desktop install and is run manually.
183
+ The Host smoke test wires the plugin into a real Cordis context through the `@deepseek-ai/cordis` devDependency, creates its own temporary `DSH_HOME`, and removes it afterwards; it does not need the desktop app. Set `DSH_SMOKE_KEEP=1` to keep the temporary home for debugging. `pnpm test` enumerates the test files through `test/run-unit.mjs`, so it does not depend on shell glob expansion and behaves the same on Windows and POSIX shells.
184
184
 
185
- `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It uses `/Applications/DeepSeek Harness.app` by default; set `DSH_ELECTRON` to another Electron executable. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
185
+ What CI verifies:
186
+
187
+ | Check | Verified environment |
188
+ |---|---|
189
+ | Unit tests, Host smoke test | CI: Ubuntu Node 20/22, Windows Node 22 |
190
+ | Package contents (`npm pack`) | CI: Ubuntu |
191
+ | Real `dsh web` end to end | macOS + DSH 0.2.2-rc.1 (run manually) |
192
+
193
+ `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It discovers the desktop app in the platform's usual install locations, or uses `DSH_ELECTRON`; when no desktop app is found it prints a skip message and exits 0. The cleanup-failure scenario that relies on POSIX permission bits is skipped on Windows. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
186
194
 
187
195
  Project layout:
188
196
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
8
8
 
9
- 当前状态:功能已实现,回归测试与真实 Host 端到端测试通过。macOS 已在真实 DSH Host 上验证;Windows 和 Linux 的路径、锁、重试规则有单测覆盖,尚未在真实主机上运行。
9
+ 当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试与 Host 冒烟测试;macOS 已在真实 DSH Host 上跑通端到端测试。Windows 和 Linux 的真实 DSH Host 尚未手动验证;Windows 上依赖 POSIX 权限位的用例会显式跳过。
10
10
 
11
11
  ## 功能
12
12
 
@@ -166,7 +166,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
166
166
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
167
167
  - `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
168
168
  - 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
169
- - Windows 和 Linux 尚未在真实主机上验证。Windows 超过 260 字符的长路径和符号链接复制受系统设置影响。
169
+ - Windows 和 Linux 的真实 DSH Host 尚未手动验证;CI 只覆盖单元测试和 Host 冒烟测试。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
170
170
  - macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
171
171
  - 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
172
172
 
@@ -179,9 +179,17 @@ pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
179
179
  npm run test:e2e # 真实 dsh web 端到端(需要本机桌面版)
180
180
  ```
181
181
 
182
- Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。CI(GitHub Actions)会跑单元测试、冒烟测试和打包内容检查;e2e 依赖本机的 DeepSeek Harness 桌面版,需要手动执行。
182
+ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。`pnpm test` 通过 `test/run-unit.mjs` 枚举测试文件,不依赖 shell 展开 glob,Windows 和 POSIX shell 行为一致。
183
183
 
184
- `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认使用 `/Applications/DeepSeek Harness.app`,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
184
+ 验证范围:
185
+
186
+ | 检查 | 已验证环境 |
187
+ |---|---|
188
+ | 单元测试、Host 冒烟测试 | CI:Ubuntu Node 20/22、Windows Node 22 |
189
+ | 打包内容(`npm pack`) | CI:Ubuntu |
190
+ | 真实 `dsh web` 端到端 | macOS + DSH 0.2.2-rc.1(手动执行) |
191
+
192
+ `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。脚本会按平台在常见安装位置查找桌面版,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件;找不到桌面版时打印跳过信息并以 0 退出。依赖 POSIX 权限位的「清理失败」场景在 Windows 上会跳过。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
185
193
 
186
194
  项目结构:
187
195
 
package/client.js CHANGED
@@ -10,6 +10,10 @@ window.__ModuleLoader__.load({
10
10
  const MENU_ID = 'session-trash.move'
11
11
  const SECTION_ID = 'session-trash'
12
12
  const PREFLIGHT_TIMEOUT_MS = 800
13
+ /** Hard budget for one trash write request. */
14
+ const MUTATION_TIMEOUT_MS = 30_000
15
+ /** Hard budget for one `/state` read. */
16
+ const STATE_TIMEOUT_MS = 15_000
13
17
  const DAY_MS = 86_400_000
14
18
  /** Rows per page in the trash list. */
15
19
  const PAGE_SIZE = 50
@@ -101,6 +105,7 @@ window.__ModuleLoader__.load({
101
105
  emptyBody: '在侧边栏右键会话,选择「移入回收站」。',
102
106
  loading: '正在加载…',
103
107
  errorNetwork: '无法连接到回收站服务。',
108
+ errorTimeout: '请求超时,请重试。',
104
109
  errorUnauthorized: '连接已失效,请刷新页面。',
105
110
  errorForbidden: '当前身份无权访问回收站。',
106
111
  errorGeneric: '回收站加载失败。',
@@ -238,6 +243,7 @@ window.__ModuleLoader__.load({
238
243
  emptyBody: 'Right-click a session in the sidebar and choose “Move to trash”.',
239
244
  loading: 'Loading…',
240
245
  errorNetwork: 'Could not reach the trash service.',
246
+ errorTimeout: 'The request timed out. Try again.',
241
247
  errorUnauthorized: 'Your connection expired. Reload the page.',
242
248
  errorForbidden: 'You do not have access to the trash.',
243
249
  errorGeneric: 'Could not load the trash.',
@@ -809,6 +815,12 @@ window.__ModuleLoader__.load({
809
815
  const buttonRef = React.useRef(null)
810
816
  const menuRef = React.useRef(null)
811
817
 
818
+ // A pending write disables the trigger; closing the open menu prevents a
819
+ // stale item from starting a second mutation.
820
+ React.useEffect(() => {
821
+ if (disabled === true) setOpen(false)
822
+ }, [disabled])
823
+
812
824
  React.useEffect(() => {
813
825
  if (!open) return undefined
814
826
  const onPointerDown = (event) => {
@@ -846,6 +858,7 @@ window.__ModuleLoader__.load({
846
858
  * @returns {void}
847
859
  */
848
860
  const toggleMenu = () => {
861
+ if (disabled === true) return
849
862
  if (open) {
850
863
  setOpen(false)
851
864
  return
@@ -1070,7 +1083,7 @@ window.__ModuleLoader__.load({
1070
1083
  useLocaleRevision(ctx.locale)
1071
1084
  const locale = activeLocale(ctx.locale)
1072
1085
  const [view, setView] = React.useState({ status: 'loading', data: null, error: null })
1073
- const [busyId, setBusyId] = React.useState(null)
1086
+ const [pendingRows, setPendingRows] = React.useState(() => new Set())
1074
1087
  const [rowErrors, setRowErrors] = React.useState({})
1075
1088
  const [dialog, setDialog] = React.useState(null)
1076
1089
  const [cleaning, setCleaning] = React.useState(false)
@@ -1083,12 +1096,131 @@ window.__ModuleLoader__.load({
1083
1096
  const abortRef = React.useRef(null)
1084
1097
  const searchRef = React.useRef(null)
1085
1098
  const panelRef = React.useRef(null)
1099
+ /** False once the section unmounts, so late responses stay silent. */
1100
+ const aliveRef = React.useRef(true)
1101
+ /** Number of writes in flight; the section allows only one at a time. */
1102
+ const activeWritesRef = React.useRef(0)
1103
+ /** Abort scopes of in-flight writes, all cancelled on unmount. */
1104
+ const writeScopesRef = React.useRef(new Set())
1086
1105
  const closeDialog = React.useCallback(() => setDialog(null), [])
1087
1106
 
1107
+ /**
1108
+ * Claim the single write slot. A second click while one mutation is in
1109
+ * flight is ignored, so two rows can never race the Host lock.
1110
+ *
1111
+ * @returns {boolean} whether the write may proceed.
1112
+ */
1113
+ function beginWrite() {
1114
+ if (!aliveRef.current || activeWritesRef.current > 0) return false
1115
+ activeWritesRef.current += 1
1116
+ return true
1117
+ }
1118
+
1119
+ /**
1120
+ * @returns {void}
1121
+ */
1122
+ function endWrite() {
1123
+ activeWritesRef.current = Math.max(0, activeWritesRef.current - 1)
1124
+ }
1125
+
1126
+ /**
1127
+ * @param {string} sessionId row whose write is now pending.
1128
+ * @returns {void}
1129
+ */
1130
+ function markRowPending(sessionId) {
1131
+ setPendingRows((previous) => {
1132
+ if (previous.has(sessionId)) return previous
1133
+ const next = new Set(previous)
1134
+ next.add(sessionId)
1135
+ return next
1136
+ })
1137
+ }
1138
+
1139
+ /**
1140
+ * @param {string} sessionId row whose write settled.
1141
+ * @returns {void}
1142
+ */
1143
+ function clearRowPending(sessionId) {
1144
+ setPendingRows((previous) => {
1145
+ if (!previous.has(sessionId)) return previous
1146
+ const next = new Set(previous)
1147
+ next.delete(sessionId)
1148
+ return next
1149
+ })
1150
+ }
1151
+
1152
+ /**
1153
+ * Open an abort scope for one write. It is cancelled by a hard timeout
1154
+ * and by the unmount cleanup, so a stuck request can never wedge the
1155
+ * page or outlive the settings section.
1156
+ *
1157
+ * @returns {{ signal: AbortSignal, timedOut: () => boolean, aborted: () => boolean, cancel: () => void, close: () => void }} the scope.
1158
+ */
1159
+ function openWriteScope() {
1160
+ const controller = new AbortController()
1161
+ let timedOut = false
1162
+ const timer = setTimeout(() => {
1163
+ timedOut = true
1164
+ controller.abort()
1165
+ }, MUTATION_TIMEOUT_MS)
1166
+ const scope = {
1167
+ signal: controller.signal,
1168
+ timedOut: () => timedOut,
1169
+ aborted: () => controller.signal.aborted,
1170
+ cancel: () => controller.abort(),
1171
+ close: () => {
1172
+ clearTimeout(timer)
1173
+ writeScopesRef.current.delete(scope)
1174
+ },
1175
+ }
1176
+ writeScopesRef.current.add(scope)
1177
+ return scope
1178
+ }
1179
+
1180
+ /**
1181
+ * Send one write request, returning a discriminated transport outcome
1182
+ * instead of throwing, so every caller localizes failures the same way.
1183
+ *
1184
+ * @param {string} pathname endpoint below the API prefix.
1185
+ * @param {object} [body] JSON request body.
1186
+ * @returns {Promise<
1187
+ * | { kind: 'response', status: number, ok: boolean, payload: any }
1188
+ * | { kind: 'timeout' }
1189
+ * | { kind: 'cancelled' }
1190
+ * | { kind: 'network' }
1191
+ * >} the outcome.
1192
+ */
1193
+ async function sendMutation(pathname, body) {
1194
+ const scope = openWriteScope()
1195
+ try {
1196
+ const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
1197
+ return { kind: 'response', status, ok, payload }
1198
+ } catch {
1199
+ if (scope.timedOut()) return { kind: 'timeout' }
1200
+ if (scope.aborted()) return { kind: 'cancelled' }
1201
+ return { kind: 'network' }
1202
+ } finally {
1203
+ scope.close()
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * @param {'timeout' | 'network'} kind transport failure.
1209
+ * @returns {string} the localized message to show.
1210
+ */
1211
+ function transportMessage(kind) {
1212
+ return kind === 'timeout' ? t('errorTimeout') : t('errorNetwork')
1213
+ }
1214
+
1088
1215
  const load = React.useCallback(() => {
1089
1216
  if (abortRef.current !== null) abortRef.current.abort()
1090
1217
  const controller = new AbortController()
1091
1218
  abortRef.current = controller
1219
+ let timedOut = false
1220
+ const timer = setTimeout(() => {
1221
+ timedOut = true
1222
+ controller.abort()
1223
+ }, STATE_TIMEOUT_MS)
1092
1224
  setView((previous) => ({
1093
1225
  status: previous.data === null ? 'loading' : 'refreshing',
1094
1226
  data: previous.data,
@@ -1109,9 +1241,15 @@ window.__ModuleLoader__.load({
1109
1241
  setRowErrors({})
1110
1242
  })
1111
1243
  .catch(() => {
1112
- if (controller.signal.aborted) return
1244
+ if (controller.signal.aborted) {
1245
+ if (timedOut) {
1246
+ setView((previous) => ({ status: 'error', data: previous.data, error: t('errorTimeout') }))
1247
+ }
1248
+ return
1249
+ }
1113
1250
  setView((previous) => ({ status: 'error', data: previous.data, error: t('errorNetwork') }))
1114
1251
  })
1252
+ .finally(() => clearTimeout(timer))
1115
1253
  }, [])
1116
1254
 
1117
1255
  React.useEffect(() => {
@@ -1123,6 +1261,15 @@ window.__ModuleLoader__.load({
1123
1261
 
1124
1262
  React.useEffect(() => subscribeRevision(load), [load])
1125
1263
 
1264
+ React.useEffect(
1265
+ () => () => {
1266
+ aliveRef.current = false
1267
+ for (const scope of [...writeScopesRef.current]) scope.cancel()
1268
+ writeScopesRef.current.clear()
1269
+ },
1270
+ [],
1271
+ )
1272
+
1126
1273
  React.useEffect(() => {
1127
1274
  const refresh = () => load()
1128
1275
  const onVisibility = () => {
@@ -1167,7 +1314,12 @@ window.__ModuleLoader__.load({
1167
1314
  cleanupInfo.lastRun.error !== ''
1168
1315
  ? cleanupInfo.lastRun.error
1169
1316
  : null
1170
- const busy = view.status === 'loading' || view.status === 'refreshing'
1317
+ const loading = view.status === 'loading'
1318
+ const refreshing = view.status === 'refreshing'
1319
+ const readsBusy = loading || refreshing
1320
+ const pendingCount = pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0)
1321
+ /** Any write (row action or maintenance) is in flight. */
1322
+ const mutationBusy = pendingCount > 0
1171
1323
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
1172
1324
  const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
1173
1325
  const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
@@ -1265,22 +1417,28 @@ window.__ModuleLoader__.load({
1265
1417
  * @returns {Promise<void>} completion of the operation.
1266
1418
  */
1267
1419
  async function performRestore(entry, mode) {
1268
- setBusyId(entry.sessionId)
1420
+ if (!beginWrite()) return
1421
+ markRowPending(entry.sessionId)
1269
1422
  try {
1270
- const { status, ok, payload } = await requestJson('restore', {
1271
- method: 'POST',
1272
- body: { sessionId: entry.sessionId, mode },
1273
- })
1274
- if (!ok) {
1275
- const code = payload !== null && payload !== undefined && payload.error !== undefined ? payload.error.code : undefined
1423
+ const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode })
1424
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1425
+ if (outcome.kind !== 'response') {
1426
+ fail(entry, transportMessage(outcome.kind))
1427
+ return
1428
+ }
1429
+ if (!outcome.ok) {
1430
+ const code =
1431
+ outcome.payload !== null && outcome.payload !== undefined && outcome.payload.error !== undefined
1432
+ ? outcome.payload.error.code
1433
+ : undefined
1276
1434
  if (mode === 'normal' && code === 'WORKSPACE_MISSING') {
1277
1435
  setDialog({ kind: 'orphan', entry })
1278
1436
  return
1279
1437
  }
1280
- fail(entry, describeActionFailure(status, payload, t))
1438
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1281
1439
  return
1282
1440
  }
1283
- const session = payload !== null && payload !== undefined ? payload.session : undefined
1441
+ const session = outcome.payload !== null && outcome.payload !== undefined ? outcome.payload.session : undefined
1284
1442
  const orphan = session !== null && session !== undefined ? session.orphan === true : mode === 'orphan'
1285
1443
  const title =
1286
1444
  session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
@@ -1290,10 +1448,9 @@ window.__ModuleLoader__.load({
1290
1448
  bumpRevision()
1291
1449
  ctx.contextMenus.notify(message, true)
1292
1450
  announce(message)
1293
- } catch {
1294
- fail(entry, t('errorNetwork'))
1295
1451
  } finally {
1296
- setBusyId(null)
1452
+ endWrite()
1453
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1297
1454
  }
1298
1455
  }
1299
1456
 
@@ -1302,24 +1459,26 @@ window.__ModuleLoader__.load({
1302
1459
  * @returns {Promise<void>} completion of the operation.
1303
1460
  */
1304
1461
  async function performPurge(entry) {
1305
- setBusyId(entry.sessionId)
1462
+ if (!beginWrite()) return
1463
+ markRowPending(entry.sessionId)
1306
1464
  try {
1307
- const { status, ok, payload } = await requestJson('purge', {
1308
- method: 'POST',
1309
- body: { sessionId: entry.sessionId },
1310
- })
1311
- if (!ok) {
1312
- fail(entry, describeActionFailure(status, payload, t))
1465
+ const outcome = await sendMutation('purge', { sessionId: entry.sessionId })
1466
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1467
+ if (outcome.kind !== 'response') {
1468
+ fail(entry, transportMessage(outcome.kind))
1469
+ return
1470
+ }
1471
+ if (!outcome.ok) {
1472
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1313
1473
  return
1314
1474
  }
1315
1475
  const message = format(t('purgeSuccess'), { title: entry.title })
1316
1476
  bumpRevision()
1317
1477
  ctx.contextMenus.notify(message, true)
1318
1478
  announce(message)
1319
- } catch {
1320
- fail(entry, t('errorNetwork'))
1321
1479
  } finally {
1322
- setBusyId(null)
1480
+ endWrite()
1481
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1323
1482
  }
1324
1483
  }
1325
1484
 
@@ -1328,23 +1487,25 @@ window.__ModuleLoader__.load({
1328
1487
  * @returns {Promise<void>} completion of the operation.
1329
1488
  */
1330
1489
  async function performForget(entry) {
1331
- setBusyId(entry.sessionId)
1490
+ if (!beginWrite()) return
1491
+ markRowPending(entry.sessionId)
1332
1492
  try {
1333
- const { status, ok, payload } = await requestJson('forget', {
1334
- method: 'POST',
1335
- body: { sessionId: entry.sessionId },
1336
- })
1337
- if (!ok) {
1338
- fail(entry, describeActionFailure(status, payload, t))
1493
+ const outcome = await sendMutation('forget', { sessionId: entry.sessionId })
1494
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1495
+ if (outcome.kind !== 'response') {
1496
+ fail(entry, transportMessage(outcome.kind))
1497
+ return
1498
+ }
1499
+ if (!outcome.ok) {
1500
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1339
1501
  return
1340
1502
  }
1341
1503
  bumpRevision()
1342
1504
  ctx.contextMenus.notify(t('forgetSuccess'), true)
1343
1505
  announce(t('forgetSuccess'))
1344
- } catch {
1345
- fail(entry, t('errorNetwork'))
1346
1506
  } finally {
1347
- setBusyId(null)
1507
+ endWrite()
1508
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1348
1509
  }
1349
1510
  }
1350
1511
 
@@ -1353,23 +1514,24 @@ window.__ModuleLoader__.load({
1353
1514
  * @returns {Promise<void>} completion of the operation.
1354
1515
  */
1355
1516
  async function performReveal(entry) {
1356
- setBusyId(entry.sessionId)
1517
+ if (!beginWrite()) return
1518
+ markRowPending(entry.sessionId)
1357
1519
  try {
1358
- const { status, ok, payload } = await requestJson('reveal', {
1359
- method: 'POST',
1360
- body: { sessionId: entry.sessionId },
1361
- })
1362
- if (!ok) {
1363
- const message = describeActionFailure(status, payload, t) || t('revealFailed')
1364
- fail(entry, message)
1520
+ const outcome = await sendMutation('reveal', { sessionId: entry.sessionId })
1521
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1522
+ if (outcome.kind !== 'response') {
1523
+ fail(entry, transportMessage(outcome.kind))
1524
+ return
1525
+ }
1526
+ if (!outcome.ok) {
1527
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1365
1528
  return
1366
1529
  }
1367
1530
  ctx.contextMenus.notify(t('revealSuccess'), true)
1368
1531
  announce(t('revealSuccess'))
1369
- } catch {
1370
- fail(entry, t('errorNetwork'))
1371
1532
  } finally {
1372
- setBusyId(null)
1533
+ endWrite()
1534
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1373
1535
  }
1374
1536
  }
1375
1537
 
@@ -1379,6 +1541,7 @@ window.__ModuleLoader__.load({
1379
1541
  */
1380
1542
  async function performCopy(entry) {
1381
1543
  const copied = await copyText(entry.sessionId)
1544
+ if (!aliveRef.current) return
1382
1545
  ctx.contextMenus.notify(copied ? t('copySuccess') : t('copyFailed'), copied)
1383
1546
  announce(copied ? t('copySuccess') : t('copyFailed'))
1384
1547
  }
@@ -1387,22 +1550,30 @@ window.__ModuleLoader__.load({
1387
1550
  * @returns {Promise<void>} completion of the operation.
1388
1551
  */
1389
1552
  async function runCleanup() {
1553
+ if (!beginWrite()) return
1390
1554
  setCleaning(true)
1391
1555
  try {
1392
- const { status, ok, payload } = await requestJson('cleanup', { method: 'POST' })
1393
- if (!ok) {
1394
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1556
+ const outcome = await sendMutation('cleanup')
1557
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1558
+ if (outcome.kind !== 'response') {
1559
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1395
1560
  return
1396
1561
  }
1397
- const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1562
+ if (!outcome.ok) {
1563
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1564
+ return
1565
+ }
1566
+ const purged =
1567
+ outcome.payload !== null && outcome.payload !== undefined && typeof outcome.payload.purged === 'number'
1568
+ ? outcome.payload.purged
1569
+ : 0
1398
1570
  const message = purged > 0 ? format(t('cleanupDone'), { count: purged }) : t('cleanupNone')
1399
1571
  bumpRevision()
1400
1572
  ctx.contextMenus.notify(message, true)
1401
1573
  announce(message)
1402
- } catch {
1403
- ctx.contextMenus.notify(t('errorNetwork'), false)
1404
1574
  } finally {
1405
- setCleaning(false)
1575
+ endWrite()
1576
+ if (aliveRef.current) setCleaning(false)
1406
1577
  }
1407
1578
  }
1408
1579
 
@@ -1412,13 +1583,20 @@ window.__ModuleLoader__.load({
1412
1583
  * @returns {Promise<void>} completion of the operation.
1413
1584
  */
1414
1585
  async function performPurgeAll() {
1586
+ if (!beginWrite()) return
1415
1587
  setPurgingAll(true)
1416
1588
  try {
1417
- const { status, ok, payload } = await requestJson('purge-all', { method: 'POST' })
1418
- if (!ok) {
1419
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1589
+ const outcome = await sendMutation('purge-all')
1590
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1591
+ if (outcome.kind !== 'response') {
1592
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1593
+ return
1594
+ }
1595
+ if (!outcome.ok) {
1596
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1420
1597
  return
1421
1598
  }
1599
+ const payload = outcome.payload
1422
1600
  const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1423
1601
  const forgotten =
1424
1602
  payload !== null && payload !== undefined && typeof payload.forgotten === 'number' ? payload.forgotten : 0
@@ -1433,10 +1611,9 @@ window.__ModuleLoader__.load({
1433
1611
  bumpRevision()
1434
1612
  ctx.contextMenus.notify(message, failed === 0)
1435
1613
  announce(message)
1436
- } catch {
1437
- ctx.contextMenus.notify(t('errorNetwork'), false)
1438
1614
  } finally {
1439
- setPurgingAll(false)
1615
+ endWrite()
1616
+ if (aliveRef.current) setPurgingAll(false)
1440
1617
  }
1441
1618
  }
1442
1619
 
@@ -1444,13 +1621,20 @@ window.__ModuleLoader__.load({
1444
1621
  * @returns {Promise<void>} completion of the operation.
1445
1622
  */
1446
1623
  async function runRebuild() {
1624
+ if (!beginWrite()) return
1447
1625
  setRebuilding(true)
1448
1626
  try {
1449
- const { status, ok, payload } = await requestJson('rebuild', { method: 'POST' })
1450
- if (!ok) {
1451
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1627
+ const outcome = await sendMutation('rebuild')
1628
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1629
+ if (outcome.kind !== 'response') {
1630
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1631
+ return
1632
+ }
1633
+ if (!outcome.ok) {
1634
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1452
1635
  return
1453
1636
  }
1637
+ const payload = outcome.payload
1454
1638
  const count = payload !== null && payload !== undefined && typeof payload.entries === 'number' ? payload.entries : 0
1455
1639
  const unindexedCount =
1456
1640
  payload !== null && payload !== undefined && typeof payload.unindexed === 'number' ? payload.unindexed : 0
@@ -1461,10 +1645,9 @@ window.__ModuleLoader__.load({
1461
1645
  bumpRevision()
1462
1646
  ctx.contextMenus.notify(message, true)
1463
1647
  announce(message)
1464
- } catch {
1465
- ctx.contextMenus.notify(t('errorNetwork'), false)
1466
1648
  } finally {
1467
- setRebuilding(false)
1649
+ endWrite()
1650
+ if (aliveRef.current) setRebuilding(false)
1468
1651
  }
1469
1652
  }
1470
1653
 
@@ -1607,7 +1790,7 @@ window.__ModuleLoader__.load({
1607
1790
  onClick: () => {
1608
1791
  void runRebuild()
1609
1792
  },
1610
- disabled: rebuilding,
1793
+ disabled: loading || mutationBusy,
1611
1794
  },
1612
1795
  rebuilding ? t('rebuilding') : t('rebuild'),
1613
1796
  ),
@@ -1666,7 +1849,7 @@ window.__ModuleLoader__.load({
1666
1849
  onClick: () => {
1667
1850
  void runCleanup()
1668
1851
  },
1669
- disabled: busy || cleaning || corrupt,
1852
+ disabled: loading || mutationBusy || corrupt,
1670
1853
  },
1671
1854
  cleaning ? t('cleaning') : t('cleanupRetryNow'),
1672
1855
  ),
@@ -1716,7 +1899,7 @@ window.__ModuleLoader__.load({
1716
1899
  onClick: () => {
1717
1900
  void runCleanup()
1718
1901
  },
1719
- disabled: busy || cleaning || corrupt,
1902
+ disabled: loading || mutationBusy || corrupt,
1720
1903
  },
1721
1904
  cleaning ? t('cleaning') : t('cleanupExpired'),
1722
1905
  ),
@@ -1726,8 +1909,8 @@ window.__ModuleLoader__.load({
1726
1909
  type: 'button',
1727
1910
  className: 'dsh-trash__button',
1728
1911
  onClick: load,
1729
- disabled: busy,
1730
- 'aria-busy': busy ? 'true' : 'false',
1912
+ disabled: loading || refreshing,
1913
+ 'aria-busy': refreshing ? 'true' : 'false',
1731
1914
  },
1732
1915
  t('refresh'),
1733
1916
  ),
@@ -1737,7 +1920,7 @@ window.__ModuleLoader__.load({
1737
1920
  type: 'button',
1738
1921
  className: 'dsh-trash__button dsh-trash__button--danger',
1739
1922
  onClick: () => setDialog({ kind: 'purgeAll', count: entries.length }),
1740
- disabled: busy || cleaning || purgingAll || corrupt || entries.length === 0,
1923
+ disabled: loading || mutationBusy || corrupt || entries.length === 0,
1741
1924
  },
1742
1925
  purgingAll ? t('purgingAll') : t('purgeAll'),
1743
1926
  ),
@@ -1863,7 +2046,7 @@ window.__ModuleLoader__.load({
1863
2046
  'ul',
1864
2047
  { className: 'dsh-trash__list', 'aria-label': t('listLabel') },
1865
2048
  visibleEntries.map((entry) => {
1866
- const rowBusy = busyId === entry.sessionId
2049
+ const rowBusy = pendingRows.has(entry.sessionId)
1867
2050
  const error = rowErrors[entry.sessionId] ?? entry.lastError
1868
2051
  const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
1869
2052
  return h(
@@ -1917,7 +2100,7 @@ window.__ModuleLoader__.load({
1917
2100
  type: 'button',
1918
2101
  className: 'dsh-trash__button',
1919
2102
  'aria-label': format(t('actionRestoreFor'), { title }),
1920
- disabled: rowBusy || busy || purgingAll,
2103
+ disabled: rowBusy || mutationBusy || corrupt || loading,
1921
2104
  onClick: () => {
1922
2105
  if (entry.requiresOrphanRestore === true) setDialog({ kind: 'orphan', entry })
1923
2106
  else void performRestore(entry, 'normal')
@@ -1929,7 +2112,7 @@ window.__ModuleLoader__.load({
1929
2112
  h(ActionMenu, {
1930
2113
  label: format(t('actionMoreFor'), { title }),
1931
2114
  items: menuItemsFor(entry),
1932
- disabled: rowBusy || busy || purgingAll,
2115
+ disabled: rowBusy || mutationBusy || corrupt || loading,
1933
2116
  }),
1934
2117
  ),
1935
2118
  )
@@ -1983,7 +2166,7 @@ window.__ModuleLoader__.load({
1983
2166
  className: 'dsh-trash__panel',
1984
2167
  key: 'panel',
1985
2168
  ref: panelRef,
1986
- 'aria-busy': busy ? 'true' : 'false',
2169
+ 'aria-busy': readsBusy ? 'true' : 'false',
1987
2170
  },
1988
2171
  panelBody,
1989
2172
  pagination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
5
5
  "license": "MIT",
6
6
  "author": "wangxiaocuo",
@@ -43,8 +43,8 @@
43
43
  "README.en.md"
44
44
  ],
45
45
  "scripts": {
46
- "test": "node --test test/*.test.js",
47
- "test:coverage": "node --experimental-test-coverage --test test/*.test.js",
46
+ "test": "node test/run-unit.mjs",
47
+ "test:coverage": "node test/run-unit.mjs --experimental-test-coverage",
48
48
  "test:e2e": "node test/e2e-web.mjs",
49
49
  "test:smoke": "node test/smoke-host.mjs"
50
50
  },