@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/README.en.md +14 -11
- package/README.md +14 -11
- package/client.js +258 -75
- package/lib/concurrency.js +30 -0
- package/lib/inventory.js +58 -25
- package/lib/state.js +1 -21
- package/lib/trash-service.js +241 -88
- package/package.json +8 -3
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,
|
|
@@ -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
|
+
}
|