@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 +12 -4
- package/README.md +12 -4
- package/client.js +258 -75
- package/package.json +3 -3
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
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 [
|
|
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)
|
|
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
|
|
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
|
-
|
|
1420
|
+
if (!beginWrite()) return
|
|
1421
|
+
markRowPending(entry.sessionId)
|
|
1269
1422
|
try {
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1462
|
+
if (!beginWrite()) return
|
|
1463
|
+
markRowPending(entry.sessionId)
|
|
1306
1464
|
try {
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1490
|
+
if (!beginWrite()) return
|
|
1491
|
+
markRowPending(entry.sessionId)
|
|
1332
1492
|
try {
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1517
|
+
if (!beginWrite()) return
|
|
1518
|
+
markRowPending(entry.sessionId)
|
|
1357
1519
|
try {
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
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
|
|
1393
|
-
if (!
|
|
1394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1418
|
-
if (!
|
|
1419
|
-
|
|
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
|
-
|
|
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
|
|
1450
|
-
if (!
|
|
1451
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1730
|
-
'aria-busy':
|
|
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:
|
|
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 =
|
|
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 ||
|
|
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 ||
|
|
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':
|
|
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.
|
|
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
|
|
47
|
-
"test:coverage": "node --experimental-test-coverage
|
|
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
|
},
|