@wangxiaocuo/dsh-session-trash 0.6.0 → 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/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,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Small concurrency helpers shared by the trash services.
3
+ *
4
+ * @module dsh-session-trash/concurrency
5
+ */
6
+
7
+ /**
8
+ * Run `worker` over every item with a bounded number of in-flight operations.
9
+ * Results keep the input order; the first rejection stops the remaining work and
10
+ * rejects the returned promise.
11
+ *
12
+ * @param {readonly T[]} items input items.
13
+ * @param {number} limit maximum in-flight operations.
14
+ * @param {(item: T, index: number) => Promise<R>} worker per-item operation.
15
+ * @returns {Promise<R[]>} results in input order.
16
+ * @template T, R
17
+ */
18
+ export async function mapLimit(items, limit, worker) {
19
+ const results = new Array(items.length)
20
+ let next = 0
21
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
22
+ while (next < items.length) {
23
+ const index = next
24
+ next += 1
25
+ results[index] = await worker(items[index], index)
26
+ }
27
+ })
28
+ await Promise.all(runners)
29
+ return results
30
+ }