@wangxiaocuo/dsh-session-trash 0.6.1 → 0.6.3

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/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
@@ -28,6 +32,19 @@ window.__ModuleLoader__.load({
28
32
  * Preflight refusals the user can act on themselves. Those keep a visible
29
33
  * menu entry that explains the refusal instead of hiding it.
30
34
  */
35
+ /** Host `lastErrorCode` values mapped to owner-facing messages. */
36
+ const LAST_ERROR_KEYS = {
37
+ TRASH_COPY_MISSING: 'lastErrorTrashCopyMissing',
38
+ TRASH_COPY_MISSING_MEMBERS: 'lastErrorTrashCopyMissingMembers',
39
+ TRASH_OBJECT_MISSING: 'lastErrorTrashObjectMissing',
40
+ PURGE_FAILED: 'lastErrorPurgeFailed',
41
+ RESTORE_INVISIBLE: 'lastErrorRestoreInvisible',
42
+ MOVE_UNSETTLED: 'lastErrorMoveUnsettled',
43
+ SOURCE_CONFLICT: 'lastErrorSourceConflict',
44
+ INDEX_WRITE_FAILED: 'lastErrorIndexWriteFailed',
45
+ IO_ERROR: 'lastErrorIo',
46
+ }
47
+
31
48
  const MOVE_BLOCKED_KEYS = {
32
49
  SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
33
50
  SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
@@ -73,7 +90,8 @@ window.__ModuleLoader__.load({
73
90
  retry: '重试',
74
91
  cleanupExpired: '清理已过期',
75
92
  cleaning: '正在清理…',
76
- statsCount: '{count} 个会话',
93
+ statsCountOne: '{count} 个会话',
94
+ statsCountOther: '{count} 个会话',
77
95
  statsSize: '占用 {size}',
78
96
  searchLabel: '搜索回收站',
79
97
  searchPlaceholder: '搜索标题、Session ID 或原始工作区',
@@ -101,6 +119,7 @@ window.__ModuleLoader__.load({
101
119
  emptyBody: '在侧边栏右键会话,选择「移入回收站」。',
102
120
  loading: '正在加载…',
103
121
  errorNetwork: '无法连接到回收站服务。',
122
+ errorTimeout: '请求超时,请重试。',
104
123
  errorUnauthorized: '连接已失效,请刷新页面。',
105
124
  errorForbidden: '当前身份无权访问回收站。',
106
125
  errorGeneric: '回收站加载失败。',
@@ -122,7 +141,8 @@ window.__ModuleLoader__.load({
122
141
  badgeMigration: '需要迁移',
123
142
  badgeExpiring: '即将到期',
124
143
  badgeExpired: '已过期',
125
- badgeSubagents: '含 {count} 个子会话',
144
+ badgeSubagentsOne: '含 {count} 个子会话',
145
+ badgeSubagentsOther: '含 {count} 个子会话',
126
146
  stateTrashing: '正在移入…',
127
147
  stateRestoring: '正在恢复…',
128
148
  statePurging: '正在删除…',
@@ -130,7 +150,7 @@ window.__ModuleLoader__.load({
130
150
  actionRestoreFor: '恢复「{title}」',
131
151
  actionMoreFor: '「{title}」的更多操作',
132
152
  actionRestoreOrphan: '恢复为孤儿会话',
133
- actionReveal: '在 Finder 中显示',
153
+ actionReveal: '在文件管理器中显示',
134
154
  actionRevealDisabled: '原工作区已不存在',
135
155
  actionCopyId: '复制 Session ID',
136
156
  actionForget: '从索引移除',
@@ -147,23 +167,37 @@ window.__ModuleLoader__.load({
147
167
  purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
148
168
  purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
149
169
  purgeAllConfirm: '全部永久删除',
150
- purgeAllDone: '已永久删除 {count} 个会话',
170
+ purgeAllDoneOne: '已永久删除 {count} 个会话',
171
+ purgeAllDoneOther: '已永久删除 {count} 个会话',
151
172
  purgeAllNone: '回收站中没有可删除的会话',
152
- purgeAllPartial: '已永久删除 {removed} 个会话,{failed} 个未能删除',
173
+ purgeAllPartial: '批量删除完成:成功 {removed} 个,失败 {failed} 个。',
153
174
  forgetTitle: '从回收站列表移除这条缺失记录?',
154
175
  forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
155
176
  forgetConfirm: '从索引移除',
156
- cleanupDone: '已清理 {count} 个过期会话',
177
+ cleanupDoneOne: '已清理 {count} 个过期会话',
178
+ cleanupDoneOther: '已清理 {count} 个过期会话',
157
179
  cleanupNone: '没有需要清理的过期会话',
158
- cleanupFailedBanner: '上次自动清理有 {count} 个会话未能删除。',
159
- cleanupErrorBanner: '上次自动清理失败:{message}',
180
+ cleanupFailedBannerOne: '上次自动清理有 {count} 个会话未能删除。',
181
+ cleanupFailedBannerOther: '上次自动清理有 {count} 个会话未能删除。',
182
+ cleanupErrorBanner: '上次自动清理失败,请稍后在本页重试。',
183
+ cleanupFailed: '清理失败:还有 {failed} 个过期会话没有删除。',
184
+ cleanupPartial: '已清理 {purged} 个过期会话,{failed} 个没有删除。',
160
185
  cleanupRetryAt: '将在 {time} 自动重试。',
161
186
  cleanupRetryNow: '立即重试',
162
187
  copySuccess: '已复制 Session ID',
163
188
  copyFailed: '无法复制 Session ID,请手动复制',
164
- revealSuccess: '已在 Finder 中显示原工作区',
189
+ revealSuccess: '已在文件管理器中显示原工作区',
165
190
  revealFailed: '无法打开原工作区',
166
191
  lastErrorLabel: '上次错误:{message}',
192
+ lastErrorTrashCopyMissing: '回收站里的会话副本缺失。',
193
+ lastErrorTrashCopyMissingMembers: '回收站里的部分子会话副本缺失。',
194
+ lastErrorTrashObjectMissing: '回收站对象目录缺失。',
195
+ lastErrorPurgeFailed: '会话日志删除失败。',
196
+ lastErrorRestoreInvisible: '恢复后会话存储没有识别到该会话。',
197
+ lastErrorMoveUnsettled: '会话已移动,但会话存储仍报告它存在。',
198
+ lastErrorSourceConflict: '原会话目录与回收站副本同时存在,两者都已保留。',
199
+ lastErrorIndexWriteFailed: '回收站索引写入失败。',
200
+ lastErrorIo: '文件操作失败。',
167
201
  footnote: '回收站目录:{path}',
168
202
  move: '移入回收站',
169
203
  moveBlockedLive: '移入回收站(会话已打开)',
@@ -172,7 +206,8 @@ window.__ModuleLoader__.load({
172
206
  undo: '撤销',
173
207
  undoFailed: '撤销失败',
174
208
  trashSuccess: '已将「{title}」移入回收站',
175
- trashSuccessTree: '「{title}」及其 {count} 个子会话已移入回收站',
209
+ trashSuccessTreeOne: '「{title}」及其 {count} 个子会话已移入回收站',
210
+ trashSuccessTreeOther: '「{title}」及其 {count} 个子会话已移入回收站',
176
211
  blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
177
212
  blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
178
213
  blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
@@ -197,7 +232,7 @@ window.__ModuleLoader__.load({
197
232
  errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
198
233
  errorIndexOk: '回收站索引可正常读取,无需重建。',
199
234
  errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
200
- errorRevealUnavailable: '当前环境无法打开 Finder。',
235
+ errorRevealUnavailable: '当前环境无法打开文件管理器。',
201
236
  errorRevealFailed: '无法打开原工作区。',
202
237
  errorIo: '文件操作失败,请稍后重试。',
203
238
  }
@@ -210,7 +245,8 @@ window.__ModuleLoader__.load({
210
245
  retry: 'Retry',
211
246
  cleanupExpired: 'Delete expired',
212
247
  cleaning: 'Cleaning…',
213
- statsCount: '{count} sessions',
248
+ statsCountOne: '{count} session',
249
+ statsCountOther: '{count} sessions',
214
250
  statsSize: '{size} used',
215
251
  searchLabel: 'Search the trash',
216
252
  searchPlaceholder: 'Search title, Session ID, or original workspace',
@@ -238,6 +274,7 @@ window.__ModuleLoader__.load({
238
274
  emptyBody: 'Right-click a session in the sidebar and choose “Move to trash”.',
239
275
  loading: 'Loading…',
240
276
  errorNetwork: 'Could not reach the trash service.',
277
+ errorTimeout: 'The request timed out. Try again.',
241
278
  errorUnauthorized: 'Your connection expired. Reload the page.',
242
279
  errorForbidden: 'You do not have access to the trash.',
243
280
  errorGeneric: 'Could not load the trash.',
@@ -259,7 +296,8 @@ window.__ModuleLoader__.load({
259
296
  badgeMigration: 'Migration required',
260
297
  badgeExpiring: 'Expiring soon',
261
298
  badgeExpired: 'Expired',
262
- badgeSubagents: '{count} sub-sessions',
299
+ badgeSubagentsOne: '{count} sub-session',
300
+ badgeSubagentsOther: '{count} sub-sessions',
263
301
  stateTrashing: 'Moving…',
264
302
  stateRestoring: 'Restoring…',
265
303
  statePurging: 'Deleting…',
@@ -267,7 +305,7 @@ window.__ModuleLoader__.load({
267
305
  actionRestoreFor: 'Restore “{title}”',
268
306
  actionMoreFor: 'More actions for “{title}”',
269
307
  actionRestoreOrphan: 'Restore as orphan',
270
- actionReveal: 'Show in Finder',
308
+ actionReveal: 'Show in file manager',
271
309
  actionRevealDisabled: 'The original workspace no longer exists',
272
310
  actionCopyId: 'Copy Session ID',
273
311
  actionForget: 'Remove from index',
@@ -284,23 +322,37 @@ window.__ModuleLoader__.load({
284
322
  purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
285
323
  purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
286
324
  purgeAllConfirm: 'Delete all permanently',
287
- purgeAllDone: 'Permanently deleted {count} sessions',
325
+ purgeAllDoneOne: 'Permanently deleted {count} session',
326
+ purgeAllDoneOther: 'Permanently deleted {count} sessions',
288
327
  purgeAllNone: 'No sessions to delete',
289
- purgeAllPartial: 'Permanently deleted {removed} sessions; {failed} could not be deleted',
328
+ purgeAllPartial: 'Bulk delete finished: {removed} removed, {failed} failed.',
290
329
  forgetTitle: 'Remove this missing record from the trash list?',
291
330
  forgetBody: 'No file is deleted; only this index record is removed.',
292
331
  forgetConfirm: 'Remove from index',
293
- cleanupDone: 'Deleted {count} expired sessions',
332
+ cleanupDoneOne: 'Deleted {count} expired session',
333
+ cleanupDoneOther: 'Deleted {count} expired sessions',
294
334
  cleanupNone: 'No expired sessions to delete',
295
- cleanupFailedBanner: 'The last automatic cleanup could not delete {count} session(s).',
296
- cleanupErrorBanner: 'The last automatic cleanup failed: {message}',
335
+ cleanupFailedBannerOne: 'The last automatic cleanup could not delete {count} expired session.',
336
+ cleanupFailedBannerOther: 'The last automatic cleanup could not delete {count} expired sessions.',
337
+ cleanupErrorBanner: 'The last automatic cleanup failed. Retry from this page later.',
338
+ cleanupFailed: 'Cleanup could not delete {failed} of the expired sessions.',
339
+ cleanupPartial: 'Cleanup deleted {purged}; {failed} could not be deleted.',
297
340
  cleanupRetryAt: 'Next retry: {time}.',
298
341
  cleanupRetryNow: 'Retry now',
299
342
  copySuccess: 'Session ID copied',
300
343
  copyFailed: 'Could not copy the Session ID. Copy it manually.',
301
- revealSuccess: 'Revealed the original workspace in Finder',
344
+ revealSuccess: 'Revealed the original workspace in the file manager',
302
345
  revealFailed: 'Could not open the original workspace',
303
346
  lastErrorLabel: 'Last error: {message}',
347
+ lastErrorTrashCopyMissing: 'The stored copy of this session is missing from the trash.',
348
+ lastErrorTrashCopyMissingMembers: 'Some sub-session copies are missing from the trash.',
349
+ lastErrorTrashObjectMissing: 'The trash object directory is missing.',
350
+ lastErrorPurgeFailed: 'The session log could not be deleted.',
351
+ lastErrorRestoreInvisible: 'Session storage did not report the restored session.',
352
+ lastErrorMoveUnsettled: 'The session was moved, but session storage still reports it.',
353
+ lastErrorSourceConflict: 'The original session directory and the trash copy both exist; both were kept.',
354
+ lastErrorIndexWriteFailed: 'The trash index could not be written.',
355
+ lastErrorIo: 'A file operation failed.',
304
356
  footnote: 'Trash directory: {path}',
305
357
  move: 'Move to trash',
306
358
  moveBlockedLive: 'Move to trash (session is open)',
@@ -309,7 +361,8 @@ window.__ModuleLoader__.load({
309
361
  undo: 'Undo',
310
362
  undoFailed: 'Could not undo',
311
363
  trashSuccess: 'Moved “{title}” to the trash',
312
- trashSuccessTree: '“{title}” and its {count} sub-sessions were moved to the trash',
364
+ trashSuccessTreeOne: '“{title}” and its single sub-session were moved to the trash',
365
+ trashSuccessTreeOther: '“{title}” and its {count} sub-sessions were moved to the trash',
313
366
  blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
314
367
  blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
315
368
  blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
@@ -334,7 +387,7 @@ window.__ModuleLoader__.load({
334
387
  errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
335
388
  errorIndexOk: 'The trash index is readable and does not need a rebuild.',
336
389
  errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
337
- errorRevealUnavailable: 'This environment cannot open Finder.',
390
+ errorRevealUnavailable: 'This environment cannot open a file manager.',
338
391
  errorRevealFailed: 'Could not open the original workspace.',
339
392
  errorIo: 'A file operation failed. Try again.',
340
393
  }
@@ -353,11 +406,26 @@ window.__ModuleLoader__.load({
353
406
  }
354
407
 
355
408
  /**
356
- * @param {number} bytes raw byte count.
357
- * @returns {string} a compact human-readable size.
409
+ * Pick the grammatical singular/plural locale form for a count.
410
+ *
411
+ * @param {(key: string) => string} t bound translator.
412
+ * @param {string} key base locale key (without the One/Other suffix).
413
+ * @param {number} count item count.
414
+ * @param {Record<string, unknown>} [values] extra placeholders.
415
+ * @returns {string} the formatted text.
416
+ */
417
+ function countText(t, key, count, values = {}) {
418
+ const template = t(`${key}${count === 1 ? 'One' : 'Other'}`)
419
+ return format(template, { ...values, count })
420
+ }
421
+
422
+ /**
423
+ * @param {number | null | undefined} bytes raw byte count.
424
+ * @returns {string} a compact human-readable size, or an em dash when unknown.
358
425
  */
359
426
  function formatBytes(bytes) {
360
- if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
427
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '—'
428
+ if (bytes <= 0) return '0 B'
361
429
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
362
430
  let value = bytes
363
431
  let unit = 0
@@ -809,6 +877,12 @@ window.__ModuleLoader__.load({
809
877
  const buttonRef = React.useRef(null)
810
878
  const menuRef = React.useRef(null)
811
879
 
880
+ // A pending write disables the trigger; closing the open menu prevents a
881
+ // stale item from starting a second mutation.
882
+ React.useEffect(() => {
883
+ if (disabled === true) setOpen(false)
884
+ }, [disabled])
885
+
812
886
  React.useEffect(() => {
813
887
  if (!open) return undefined
814
888
  const onPointerDown = (event) => {
@@ -846,6 +920,7 @@ window.__ModuleLoader__.load({
846
920
  * @returns {void}
847
921
  */
848
922
  const toggleMenu = () => {
923
+ if (disabled === true) return
849
924
  if (open) {
850
925
  setOpen(false)
851
926
  return
@@ -1070,25 +1145,158 @@ window.__ModuleLoader__.load({
1070
1145
  useLocaleRevision(ctx.locale)
1071
1146
  const locale = activeLocale(ctx.locale)
1072
1147
  const [view, setView] = React.useState({ status: 'loading', data: null, error: null })
1073
- const [busyId, setBusyId] = React.useState(null)
1148
+ const [pendingRows, setPendingRows] = React.useState(() => new Set())
1074
1149
  const [rowErrors, setRowErrors] = React.useState({})
1075
1150
  const [dialog, setDialog] = React.useState(null)
1076
1151
  const [cleaning, setCleaning] = React.useState(false)
1077
1152
  const [rebuilding, setRebuilding] = React.useState(false)
1078
1153
  const [purgingAll, setPurgingAll] = React.useState(false)
1079
1154
  const [announcement, setAnnouncement] = React.useState('')
1155
+ const [clock, setClock] = React.useState(() => Date.now())
1080
1156
  const [query, setQuery] = React.useState('')
1081
1157
  const [filter, setFilter] = React.useState('all')
1082
1158
  const [page, setPage] = React.useState(1)
1083
1159
  const abortRef = React.useRef(null)
1084
1160
  const searchRef = React.useRef(null)
1085
1161
  const panelRef = React.useRef(null)
1162
+ /** False once the section unmounts, so late responses stay silent. */
1163
+ const aliveRef = React.useRef(true)
1164
+ /** Number of writes in flight; the section allows only one at a time. */
1165
+ const activeWritesRef = React.useRef(0)
1166
+ /** Abort scopes of in-flight writes, all cancelled on unmount. */
1167
+ const writeScopesRef = React.useRef(new Set())
1086
1168
  const closeDialog = React.useCallback(() => setDialog(null), [])
1087
1169
 
1170
+ /**
1171
+ * Claim the single write slot. A second click while one mutation is in
1172
+ * flight is ignored, so two rows can never race the Host lock.
1173
+ *
1174
+ * @returns {boolean} whether the write may proceed.
1175
+ */
1176
+ function beginWrite() {
1177
+ if (!aliveRef.current || activeWritesRef.current > 0) return false
1178
+ activeWritesRef.current += 1
1179
+ return true
1180
+ }
1181
+
1182
+ /**
1183
+ * @returns {void}
1184
+ */
1185
+ function endWrite() {
1186
+ activeWritesRef.current = Math.max(0, activeWritesRef.current - 1)
1187
+ }
1188
+
1189
+ /**
1190
+ * @param {string} sessionId row whose write is now pending.
1191
+ * @returns {void}
1192
+ */
1193
+ function markRowPending(sessionId) {
1194
+ setPendingRows((previous) => {
1195
+ if (previous.has(sessionId)) return previous
1196
+ const next = new Set(previous)
1197
+ next.add(sessionId)
1198
+ return next
1199
+ })
1200
+ }
1201
+
1202
+ /**
1203
+ * @param {string} sessionId row whose write settled.
1204
+ * @returns {void}
1205
+ */
1206
+ function clearRowPending(sessionId) {
1207
+ setPendingRows((previous) => {
1208
+ if (!previous.has(sessionId)) return previous
1209
+ const next = new Set(previous)
1210
+ next.delete(sessionId)
1211
+ return next
1212
+ })
1213
+ }
1214
+
1215
+ /**
1216
+ * @param {string} sessionId row whose owner-facing error is resolved.
1217
+ * @returns {void}
1218
+ */
1219
+ function clearRowError(sessionId) {
1220
+ setRowErrors((previous) => {
1221
+ if (!(sessionId in previous)) return previous
1222
+ const next = { ...previous }
1223
+ delete next[sessionId]
1224
+ return next
1225
+ })
1226
+ }
1227
+
1228
+ /**
1229
+ * Open an abort scope for one write. It is cancelled by a hard timeout
1230
+ * and by the unmount cleanup, so a stuck request can never wedge the
1231
+ * page or outlive the settings section.
1232
+ *
1233
+ * @returns {{ signal: AbortSignal, timedOut: () => boolean, aborted: () => boolean, cancel: () => void, close: () => void }} the scope.
1234
+ */
1235
+ function openWriteScope() {
1236
+ const controller = new AbortController()
1237
+ let timedOut = false
1238
+ const timer = setTimeout(() => {
1239
+ timedOut = true
1240
+ controller.abort()
1241
+ }, MUTATION_TIMEOUT_MS)
1242
+ const scope = {
1243
+ signal: controller.signal,
1244
+ timedOut: () => timedOut,
1245
+ aborted: () => controller.signal.aborted,
1246
+ cancel: () => controller.abort(),
1247
+ close: () => {
1248
+ clearTimeout(timer)
1249
+ writeScopesRef.current.delete(scope)
1250
+ },
1251
+ }
1252
+ writeScopesRef.current.add(scope)
1253
+ return scope
1254
+ }
1255
+
1256
+ /**
1257
+ * Send one write request, returning a discriminated transport outcome
1258
+ * instead of throwing, so every caller localizes failures the same way.
1259
+ *
1260
+ * @param {string} pathname endpoint below the API prefix.
1261
+ * @param {object} [body] JSON request body.
1262
+ * @returns {Promise<
1263
+ * | { kind: 'response', status: number, ok: boolean, payload: any }
1264
+ * | { kind: 'timeout' }
1265
+ * | { kind: 'cancelled' }
1266
+ * | { kind: 'network' }
1267
+ * >} the outcome.
1268
+ */
1269
+ async function sendMutation(pathname, body) {
1270
+ const scope = openWriteScope()
1271
+ try {
1272
+ const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
1273
+ return { kind: 'response', status, ok, payload }
1274
+ } catch {
1275
+ if (scope.timedOut()) return { kind: 'timeout' }
1276
+ if (scope.aborted()) return { kind: 'cancelled' }
1277
+ return { kind: 'network' }
1278
+ } finally {
1279
+ scope.close()
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * @param {'timeout' | 'network'} kind transport failure.
1285
+ * @returns {string} the localized message to show.
1286
+ */
1287
+ function transportMessage(kind) {
1288
+ return kind === 'timeout' ? t('errorTimeout') : t('errorNetwork')
1289
+ }
1290
+
1088
1291
  const load = React.useCallback(() => {
1089
1292
  if (abortRef.current !== null) abortRef.current.abort()
1090
1293
  const controller = new AbortController()
1091
1294
  abortRef.current = controller
1295
+ let timedOut = false
1296
+ const timer = setTimeout(() => {
1297
+ timedOut = true
1298
+ controller.abort()
1299
+ }, STATE_TIMEOUT_MS)
1092
1300
  setView((previous) => ({
1093
1301
  status: previous.data === null ? 'loading' : 'refreshing',
1094
1302
  data: previous.data,
@@ -1109,9 +1317,15 @@ window.__ModuleLoader__.load({
1109
1317
  setRowErrors({})
1110
1318
  })
1111
1319
  .catch(() => {
1112
- if (controller.signal.aborted) return
1320
+ if (controller.signal.aborted) {
1321
+ if (timedOut) {
1322
+ setView((previous) => ({ status: 'error', data: previous.data, error: t('errorTimeout') }))
1323
+ }
1324
+ return
1325
+ }
1113
1326
  setView((previous) => ({ status: 'error', data: previous.data, error: t('errorNetwork') }))
1114
1327
  })
1328
+ .finally(() => clearTimeout(timer))
1115
1329
  }, [])
1116
1330
 
1117
1331
  React.useEffect(() => {
@@ -1123,6 +1337,15 @@ window.__ModuleLoader__.load({
1123
1337
 
1124
1338
  React.useEffect(() => subscribeRevision(load), [load])
1125
1339
 
1340
+ React.useEffect(
1341
+ () => () => {
1342
+ aliveRef.current = false
1343
+ for (const scope of [...writeScopesRef.current]) scope.cancel()
1344
+ writeScopesRef.current.clear()
1345
+ },
1346
+ [],
1347
+ )
1348
+
1126
1349
  React.useEffect(() => {
1127
1350
  const refresh = () => load()
1128
1351
  const onVisibility = () => {
@@ -1136,6 +1359,15 @@ window.__ModuleLoader__.load({
1136
1359
  }
1137
1360
  }, [load])
1138
1361
 
1362
+ // Keep relative expiry text honest on a page left open for a long time.
1363
+ React.useEffect(() => {
1364
+ if (typeof window === 'undefined' || typeof window.setInterval !== 'function') return undefined
1365
+ const handle = window.setInterval(() => setClock(Date.now()), 60_000)
1366
+ return () => {
1367
+ if (typeof window.clearInterval === 'function') window.clearInterval(handle)
1368
+ }
1369
+ }, [])
1370
+
1139
1371
  const data = view.data
1140
1372
  const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
1141
1373
  entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
@@ -1167,11 +1399,16 @@ window.__ModuleLoader__.load({
1167
1399
  cleanupInfo.lastRun.error !== ''
1168
1400
  ? cleanupInfo.lastRun.error
1169
1401
  : null
1170
- const busy = view.status === 'loading' || view.status === 'refreshing'
1402
+ const loading = view.status === 'loading'
1403
+ const refreshing = view.status === 'refreshing'
1404
+ const readsBusy = loading || refreshing
1405
+ const pendingCount = pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0)
1406
+ /** Any write (row action or maintenance) is in flight. */
1407
+ const mutationBusy = pendingCount > 0
1171
1408
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
1172
1409
  const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
1173
1410
  const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
1174
- const now = Date.now()
1411
+ const now = clock
1175
1412
  const normalizedQuery = query.trim().toLowerCase()
1176
1413
 
1177
1414
  /**
@@ -1204,6 +1441,9 @@ window.__ModuleLoader__.load({
1204
1441
  const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
1205
1442
  const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
1206
1443
  const currentPage = Math.min(Math.max(1, page), pageCount)
1444
+ React.useEffect(() => {
1445
+ if (page > pageCount) setPage(pageCount)
1446
+ }, [page, pageCount])
1207
1447
  const pageStart = (currentPage - 1) * PAGE_SIZE
1208
1448
  const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
1209
1449
  const filtersActive = normalizedQuery !== '' || filter !== 'all'
@@ -1265,35 +1505,41 @@ window.__ModuleLoader__.load({
1265
1505
  * @returns {Promise<void>} completion of the operation.
1266
1506
  */
1267
1507
  async function performRestore(entry, mode) {
1268
- setBusyId(entry.sessionId)
1508
+ if (!beginWrite()) return
1509
+ markRowPending(entry.sessionId)
1269
1510
  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
1511
+ const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode })
1512
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1513
+ if (outcome.kind !== 'response') {
1514
+ fail(entry, transportMessage(outcome.kind))
1515
+ return
1516
+ }
1517
+ if (!outcome.ok) {
1518
+ const code =
1519
+ outcome.payload !== null && outcome.payload !== undefined && outcome.payload.error !== undefined
1520
+ ? outcome.payload.error.code
1521
+ : undefined
1276
1522
  if (mode === 'normal' && code === 'WORKSPACE_MISSING') {
1277
1523
  setDialog({ kind: 'orphan', entry })
1278
1524
  return
1279
1525
  }
1280
- fail(entry, describeActionFailure(status, payload, t))
1526
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1281
1527
  return
1282
1528
  }
1283
- const session = payload !== null && payload !== undefined ? payload.session : undefined
1529
+ const session = outcome.payload !== null && outcome.payload !== undefined ? outcome.payload.session : undefined
1284
1530
  const orphan = session !== null && session !== undefined ? session.orphan === true : mode === 'orphan'
1285
1531
  const title =
1286
1532
  session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
1287
1533
  ? session.title
1288
1534
  : entry.title
1535
+ clearRowError(entry.sessionId)
1289
1536
  const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
1290
1537
  bumpRevision()
1291
1538
  ctx.contextMenus.notify(message, true)
1292
1539
  announce(message)
1293
- } catch {
1294
- fail(entry, t('errorNetwork'))
1295
1540
  } finally {
1296
- setBusyId(null)
1541
+ endWrite()
1542
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1297
1543
  }
1298
1544
  }
1299
1545
 
@@ -1302,24 +1548,27 @@ window.__ModuleLoader__.load({
1302
1548
  * @returns {Promise<void>} completion of the operation.
1303
1549
  */
1304
1550
  async function performPurge(entry) {
1305
- setBusyId(entry.sessionId)
1551
+ if (!beginWrite()) return
1552
+ markRowPending(entry.sessionId)
1306
1553
  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))
1554
+ const outcome = await sendMutation('purge', { sessionId: entry.sessionId })
1555
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1556
+ if (outcome.kind !== 'response') {
1557
+ fail(entry, transportMessage(outcome.kind))
1558
+ return
1559
+ }
1560
+ if (!outcome.ok) {
1561
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1313
1562
  return
1314
1563
  }
1564
+ clearRowError(entry.sessionId)
1315
1565
  const message = format(t('purgeSuccess'), { title: entry.title })
1316
1566
  bumpRevision()
1317
1567
  ctx.contextMenus.notify(message, true)
1318
1568
  announce(message)
1319
- } catch {
1320
- fail(entry, t('errorNetwork'))
1321
1569
  } finally {
1322
- setBusyId(null)
1570
+ endWrite()
1571
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1323
1572
  }
1324
1573
  }
1325
1574
 
@@ -1328,23 +1577,26 @@ window.__ModuleLoader__.load({
1328
1577
  * @returns {Promise<void>} completion of the operation.
1329
1578
  */
1330
1579
  async function performForget(entry) {
1331
- setBusyId(entry.sessionId)
1580
+ if (!beginWrite()) return
1581
+ markRowPending(entry.sessionId)
1332
1582
  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))
1583
+ const outcome = await sendMutation('forget', { sessionId: entry.sessionId })
1584
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1585
+ if (outcome.kind !== 'response') {
1586
+ fail(entry, transportMessage(outcome.kind))
1339
1587
  return
1340
1588
  }
1589
+ if (!outcome.ok) {
1590
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1591
+ return
1592
+ }
1593
+ clearRowError(entry.sessionId)
1341
1594
  bumpRevision()
1342
1595
  ctx.contextMenus.notify(t('forgetSuccess'), true)
1343
1596
  announce(t('forgetSuccess'))
1344
- } catch {
1345
- fail(entry, t('errorNetwork'))
1346
1597
  } finally {
1347
- setBusyId(null)
1598
+ endWrite()
1599
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1348
1600
  }
1349
1601
  }
1350
1602
 
@@ -1353,23 +1605,25 @@ window.__ModuleLoader__.load({
1353
1605
  * @returns {Promise<void>} completion of the operation.
1354
1606
  */
1355
1607
  async function performReveal(entry) {
1356
- setBusyId(entry.sessionId)
1608
+ if (!beginWrite()) return
1609
+ markRowPending(entry.sessionId)
1357
1610
  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)
1611
+ const outcome = await sendMutation('reveal', { sessionId: entry.sessionId })
1612
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1613
+ if (outcome.kind !== 'response') {
1614
+ fail(entry, transportMessage(outcome.kind))
1615
+ return
1616
+ }
1617
+ if (!outcome.ok) {
1618
+ fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
1365
1619
  return
1366
1620
  }
1621
+ clearRowError(entry.sessionId)
1367
1622
  ctx.contextMenus.notify(t('revealSuccess'), true)
1368
1623
  announce(t('revealSuccess'))
1369
- } catch {
1370
- fail(entry, t('errorNetwork'))
1371
1624
  } finally {
1372
- setBusyId(null)
1625
+ endWrite()
1626
+ if (aliveRef.current) clearRowPending(entry.sessionId)
1373
1627
  }
1374
1628
  }
1375
1629
 
@@ -1379,6 +1633,7 @@ window.__ModuleLoader__.load({
1379
1633
  */
1380
1634
  async function performCopy(entry) {
1381
1635
  const copied = await copyText(entry.sessionId)
1636
+ if (!aliveRef.current) return
1382
1637
  ctx.contextMenus.notify(copied ? t('copySuccess') : t('copyFailed'), copied)
1383
1638
  announce(copied ? t('copySuccess') : t('copyFailed'))
1384
1639
  }
@@ -1387,22 +1642,32 @@ window.__ModuleLoader__.load({
1387
1642
  * @returns {Promise<void>} completion of the operation.
1388
1643
  */
1389
1644
  async function runCleanup() {
1645
+ if (!beginWrite()) return
1390
1646
  setCleaning(true)
1391
1647
  try {
1392
- const { status, ok, payload } = await requestJson('cleanup', { method: 'POST' })
1393
- if (!ok) {
1394
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1648
+ const outcome = await sendMutation('cleanup')
1649
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1650
+ if (outcome.kind !== 'response') {
1651
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1395
1652
  return
1396
1653
  }
1654
+ if (!outcome.ok) {
1655
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1656
+ return
1657
+ }
1658
+ const payload = outcome.payload
1397
1659
  const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1398
- const message = purged > 0 ? format(t('cleanupDone'), { count: purged }) : t('cleanupNone')
1660
+ const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
1661
+ let message
1662
+ if (failed > 0 && purged > 0) message = format(t('cleanupPartial'), { purged, failed })
1663
+ else if (failed > 0) message = format(t('cleanupFailed'), { failed })
1664
+ else message = purged > 0 ? countText(t, 'cleanupDone', purged) : t('cleanupNone')
1399
1665
  bumpRevision()
1400
- ctx.contextMenus.notify(message, true)
1666
+ ctx.contextMenus.notify(message, failed === 0)
1401
1667
  announce(message)
1402
- } catch {
1403
- ctx.contextMenus.notify(t('errorNetwork'), false)
1404
1668
  } finally {
1405
- setCleaning(false)
1669
+ endWrite()
1670
+ if (aliveRef.current) setCleaning(false)
1406
1671
  }
1407
1672
  }
1408
1673
 
@@ -1412,13 +1677,20 @@ window.__ModuleLoader__.load({
1412
1677
  * @returns {Promise<void>} completion of the operation.
1413
1678
  */
1414
1679
  async function performPurgeAll() {
1680
+ if (!beginWrite()) return
1415
1681
  setPurgingAll(true)
1416
1682
  try {
1417
- const { status, ok, payload } = await requestJson('purge-all', { method: 'POST' })
1418
- if (!ok) {
1419
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1683
+ const outcome = await sendMutation('purge-all')
1684
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1685
+ if (outcome.kind !== 'response') {
1686
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1687
+ return
1688
+ }
1689
+ if (!outcome.ok) {
1690
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1420
1691
  return
1421
1692
  }
1693
+ const payload = outcome.payload
1422
1694
  const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1423
1695
  const forgotten =
1424
1696
  payload !== null && payload !== undefined && typeof payload.forgotten === 'number' ? payload.forgotten : 0
@@ -1428,15 +1700,14 @@ window.__ModuleLoader__.load({
1428
1700
  failed > 0
1429
1701
  ? format(t('purgeAllPartial'), { removed, failed })
1430
1702
  : removed > 0
1431
- ? format(t('purgeAllDone'), { count: removed })
1703
+ ? countText(t, 'purgeAllDone', removed)
1432
1704
  : t('purgeAllNone')
1433
1705
  bumpRevision()
1434
1706
  ctx.contextMenus.notify(message, failed === 0)
1435
1707
  announce(message)
1436
- } catch {
1437
- ctx.contextMenus.notify(t('errorNetwork'), false)
1438
1708
  } finally {
1439
- setPurgingAll(false)
1709
+ endWrite()
1710
+ if (aliveRef.current) setPurgingAll(false)
1440
1711
  }
1441
1712
  }
1442
1713
 
@@ -1444,13 +1715,20 @@ window.__ModuleLoader__.load({
1444
1715
  * @returns {Promise<void>} completion of the operation.
1445
1716
  */
1446
1717
  async function runRebuild() {
1718
+ if (!beginWrite()) return
1447
1719
  setRebuilding(true)
1448
1720
  try {
1449
- const { status, ok, payload } = await requestJson('rebuild', { method: 'POST' })
1450
- if (!ok) {
1451
- ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1721
+ const outcome = await sendMutation('rebuild')
1722
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1723
+ if (outcome.kind !== 'response') {
1724
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1725
+ return
1726
+ }
1727
+ if (!outcome.ok) {
1728
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1452
1729
  return
1453
1730
  }
1731
+ const payload = outcome.payload
1454
1732
  const count = payload !== null && payload !== undefined && typeof payload.entries === 'number' ? payload.entries : 0
1455
1733
  const unindexedCount =
1456
1734
  payload !== null && payload !== undefined && typeof payload.unindexed === 'number' ? payload.unindexed : 0
@@ -1461,10 +1739,9 @@ window.__ModuleLoader__.load({
1461
1739
  bumpRevision()
1462
1740
  ctx.contextMenus.notify(message, true)
1463
1741
  announce(message)
1464
- } catch {
1465
- ctx.contextMenus.notify(t('errorNetwork'), false)
1466
1742
  } finally {
1467
- setRebuilding(false)
1743
+ endWrite()
1744
+ if (aliveRef.current) setRebuilding(false)
1468
1745
  }
1469
1746
  }
1470
1747
 
@@ -1558,7 +1835,7 @@ window.__ModuleLoader__.load({
1558
1835
  if (subagentCount > 0) {
1559
1836
  badges.push({
1560
1837
  key: 'subagents',
1561
- label: format(t('badgeSubagents'), { count: subagentCount }),
1838
+ label: countText(t, 'badgeSubagents', subagentCount),
1562
1839
  tone: 'info',
1563
1840
  })
1564
1841
  }
@@ -1607,7 +1884,7 @@ window.__ModuleLoader__.load({
1607
1884
  onClick: () => {
1608
1885
  void runRebuild()
1609
1886
  },
1610
- disabled: rebuilding,
1887
+ disabled: loading || mutationBusy,
1611
1888
  },
1612
1889
  rebuilding ? t('rebuilding') : t('rebuild'),
1613
1890
  ),
@@ -1644,9 +1921,7 @@ window.__ModuleLoader__.load({
1644
1921
  const retryText =
1645
1922
  cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
1646
1923
  const cleanupMessage =
1647
- cleanupFailed > 0
1648
- ? format(t('cleanupFailedBanner'), { count: cleanupFailed })
1649
- : format(t('cleanupErrorBanner'), { message: cleanupError })
1924
+ cleanupFailed > 0 ? countText(t, 'cleanupFailedBanner', cleanupFailed) : t('cleanupErrorBanner')
1650
1925
  banners.push(
1651
1926
  h(
1652
1927
  'div',
@@ -1654,7 +1929,7 @@ window.__ModuleLoader__.load({
1654
1929
  h(TrashGlyph, { size: 16 }),
1655
1930
  h(
1656
1931
  'span',
1657
- { className: 'dsh-trash__bannerText' },
1932
+ { className: 'dsh-trash__bannerText', title: cleanupError ?? undefined },
1658
1933
  cleanupMessage,
1659
1934
  retryText === '' ? null : ` ${retryText}`,
1660
1935
  ),
@@ -1666,7 +1941,7 @@ window.__ModuleLoader__.load({
1666
1941
  onClick: () => {
1667
1942
  void runCleanup()
1668
1943
  },
1669
- disabled: busy || cleaning || corrupt,
1944
+ disabled: loading || mutationBusy || corrupt,
1670
1945
  },
1671
1946
  cleaning ? t('cleaning') : t('cleanupRetryNow'),
1672
1947
  ),
@@ -1699,7 +1974,7 @@ window.__ModuleLoader__.load({
1699
1974
  h(
1700
1975
  'span',
1701
1976
  { className: 'dsh-trash__stats' },
1702
- format(t('statsCount'), { count: entries.length }),
1977
+ countText(t, 'statsCount', entries.length),
1703
1978
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1704
1979
  format(t('statsSize'), { size: formatBytes(totalBytes) }),
1705
1980
  h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
@@ -1716,7 +1991,7 @@ window.__ModuleLoader__.load({
1716
1991
  onClick: () => {
1717
1992
  void runCleanup()
1718
1993
  },
1719
- disabled: busy || cleaning || corrupt,
1994
+ disabled: loading || mutationBusy || corrupt,
1720
1995
  },
1721
1996
  cleaning ? t('cleaning') : t('cleanupExpired'),
1722
1997
  ),
@@ -1726,8 +2001,8 @@ window.__ModuleLoader__.load({
1726
2001
  type: 'button',
1727
2002
  className: 'dsh-trash__button',
1728
2003
  onClick: load,
1729
- disabled: busy,
1730
- 'aria-busy': busy ? 'true' : 'false',
2004
+ disabled: loading || refreshing,
2005
+ 'aria-busy': refreshing ? 'true' : 'false',
1731
2006
  },
1732
2007
  t('refresh'),
1733
2008
  ),
@@ -1737,7 +2012,7 @@ window.__ModuleLoader__.load({
1737
2012
  type: 'button',
1738
2013
  className: 'dsh-trash__button dsh-trash__button--danger',
1739
2014
  onClick: () => setDialog({ kind: 'purgeAll', count: entries.length }),
1740
- disabled: busy || cleaning || purgingAll || corrupt || entries.length === 0,
2015
+ disabled: loading || mutationBusy || corrupt || entries.length === 0,
1741
2016
  },
1742
2017
  purgingAll ? t('purgingAll') : t('purgeAll'),
1743
2018
  ),
@@ -1863,8 +2138,15 @@ window.__ModuleLoader__.load({
1863
2138
  'ul',
1864
2139
  { className: 'dsh-trash__list', 'aria-label': t('listLabel') },
1865
2140
  visibleEntries.map((entry) => {
1866
- const rowBusy = busyId === entry.sessionId
1867
- const error = rowErrors[entry.sessionId] ?? entry.lastError
2141
+ const rowBusy = pendingRows.has(entry.sessionId)
2142
+ const rowError = rowErrors[entry.sessionId]
2143
+ const lastErrorCode = typeof entry.lastErrorCode === 'string' ? entry.lastErrorCode : null
2144
+ const lastErrorText =
2145
+ typeof entry.lastError === 'string' && entry.lastError !== ''
2146
+ ? t(LAST_ERROR_KEYS[lastErrorCode] ?? 'lastErrorIo')
2147
+ : null
2148
+ const error = rowError ?? lastErrorText
2149
+ const errorTitle = rowError === undefined && typeof entry.lastError === 'string' ? entry.lastError : undefined
1868
2150
  const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
1869
2151
  return h(
1870
2152
  'li',
@@ -1904,7 +2186,11 @@ window.__ModuleLoader__.load({
1904
2186
  h('span', null, formatBytes(entry.sizeBytes)),
1905
2187
  ),
1906
2188
  error !== null && error !== undefined && error !== ''
1907
- ? h('div', { className: 'dsh-trash__rowError', role: 'status' }, format(t('lastErrorLabel'), { message: error }))
2189
+ ? h(
2190
+ 'div',
2191
+ { className: 'dsh-trash__rowError', role: 'status', title: errorTitle },
2192
+ format(t('lastErrorLabel'), { message: error }),
2193
+ )
1908
2194
  : null,
1909
2195
  ),
1910
2196
  h(
@@ -1917,7 +2203,7 @@ window.__ModuleLoader__.load({
1917
2203
  type: 'button',
1918
2204
  className: 'dsh-trash__button',
1919
2205
  'aria-label': format(t('actionRestoreFor'), { title }),
1920
- disabled: rowBusy || busy || purgingAll,
2206
+ disabled: rowBusy || mutationBusy || corrupt || loading,
1921
2207
  onClick: () => {
1922
2208
  if (entry.requiresOrphanRestore === true) setDialog({ kind: 'orphan', entry })
1923
2209
  else void performRestore(entry, 'normal')
@@ -1929,7 +2215,7 @@ window.__ModuleLoader__.load({
1929
2215
  h(ActionMenu, {
1930
2216
  label: format(t('actionMoreFor'), { title }),
1931
2217
  items: menuItemsFor(entry),
1932
- disabled: rowBusy || busy || purgingAll,
2218
+ disabled: rowBusy || mutationBusy || corrupt || loading,
1933
2219
  }),
1934
2220
  ),
1935
2221
  )
@@ -1983,7 +2269,7 @@ window.__ModuleLoader__.load({
1983
2269
  className: 'dsh-trash__panel',
1984
2270
  key: 'panel',
1985
2271
  ref: panelRef,
1986
- 'aria-busy': busy ? 'true' : 'false',
2272
+ 'aria-busy': readsBusy ? 'true' : 'false',
1987
2273
  },
1988
2274
  panelBody,
1989
2275
  pagination,
@@ -2069,7 +2355,7 @@ window.__ModuleLoader__.load({
2069
2355
  : 0
2070
2356
  const message =
2071
2357
  subagentCount > 0
2072
- ? format(t('trashSuccessTree'), { title, count: subagentCount })
2358
+ ? countText(t, 'trashSuccessTree', subagentCount, { title })
2073
2359
  : format(t('trashSuccess'), { title })
2074
2360
  ctx.contextMenus.notify(message, true, {
2075
2361
  label: t('undo'),