@wangxiaocuo/dsh-session-trash 0.6.4 → 0.6.6
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/CHANGELOG.md +32 -0
- package/README.en.md +22 -4
- package/README.md +22 -5
- package/client.js +465 -13
- package/lib/cleanup.js +15 -8
- package/lib/errors.js +13 -10
- package/lib/fsops.js +8 -6
- package/lib/http.js +27 -9
- package/lib/inventory.js +24 -0
- package/lib/members.js +70 -28
- package/lib/state.js +53 -11
- package/lib/store.js +27 -1
- package/lib/trash-service.js +44 -7
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -21,6 +21,13 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
const EXPIRING_DAYS = 3
|
|
22
22
|
/** Filter identifiers in their sidebar order. */
|
|
23
23
|
const FILTERS = ['all', 'workspace-exists', 'workspace-missing', 'expiring']
|
|
24
|
+
/** Sort options in their menu order. */
|
|
25
|
+
const SORT_OPTIONS = [
|
|
26
|
+
{ value: 'trashed', key: 'sortTrashed' },
|
|
27
|
+
{ value: 'title', key: 'sortTitle' },
|
|
28
|
+
{ value: 'size', key: 'sortSize' },
|
|
29
|
+
{ value: 'expires', key: 'sortExpires' },
|
|
30
|
+
]
|
|
24
31
|
/** Locale keys for each filter label. */
|
|
25
32
|
const FILTER_LABEL_KEYS = {
|
|
26
33
|
all: 'filterAll',
|
|
@@ -56,12 +63,38 @@ window.__ModuleLoader__.load({
|
|
|
56
63
|
|
|
57
64
|
/** Open trash views subscribe here so one successful mutation refreshes them all. */
|
|
58
65
|
const revisionListeners = new Set()
|
|
66
|
+
/** Cross-window channel name; same-origin tabs refresh each other. */
|
|
67
|
+
const REVISION_CHANNEL_NAME = 'dsh-session-trash/revision'
|
|
68
|
+
/** Lazily created BroadcastChannel shared by every view in this tab. */
|
|
69
|
+
let revisionChannel = null
|
|
70
|
+
let revisionChannelTried = false
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
|
-
*
|
|
73
|
+
* Open the cross-window revision channel once, when the platform supports it.
|
|
62
74
|
* @returns {void}
|
|
63
75
|
*/
|
|
64
|
-
function
|
|
76
|
+
function ensureRevisionChannel() {
|
|
77
|
+
if (revisionChannelTried) return
|
|
78
|
+
revisionChannelTried = true
|
|
79
|
+
const Ctor = typeof window === 'undefined' ? undefined : window.BroadcastChannel
|
|
80
|
+
if (typeof Ctor !== 'function') return
|
|
81
|
+
try {
|
|
82
|
+
revisionChannel = new Ctor(REVISION_CHANNEL_NAME)
|
|
83
|
+
revisionChannel.addEventListener('message', (event) => {
|
|
84
|
+
if (event === null || event === undefined) return
|
|
85
|
+
const data = event.data
|
|
86
|
+
if (data === null || typeof data !== 'object') return
|
|
87
|
+
if (data.type === 'revision') notifyRevisionListeners()
|
|
88
|
+
})
|
|
89
|
+
} catch {
|
|
90
|
+
revisionChannel = null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function notifyRevisionListeners() {
|
|
65
98
|
for (const listener of [...revisionListeners]) {
|
|
66
99
|
try {
|
|
67
100
|
listener()
|
|
@@ -71,11 +104,27 @@ window.__ModuleLoader__.load({
|
|
|
71
104
|
}
|
|
72
105
|
}
|
|
73
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Notify every mounted trash view in this tab and across same-origin tabs.
|
|
109
|
+
* @returns {void}
|
|
110
|
+
*/
|
|
111
|
+
function bumpRevision() {
|
|
112
|
+
ensureRevisionChannel()
|
|
113
|
+
notifyRevisionListeners()
|
|
114
|
+
if (revisionChannel === null) return
|
|
115
|
+
try {
|
|
116
|
+
revisionChannel.postMessage({ type: 'revision' })
|
|
117
|
+
} catch {
|
|
118
|
+
// A closed channel must never break the mutation that triggered it.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
74
122
|
/**
|
|
75
123
|
* @param {() => void} listener refresh callback.
|
|
76
124
|
* @returns {() => void} unsubscribe.
|
|
77
125
|
*/
|
|
78
126
|
function subscribeRevision(listener) {
|
|
127
|
+
ensureRevisionChannel()
|
|
79
128
|
revisionListeners.add(listener)
|
|
80
129
|
return () => {
|
|
81
130
|
revisionListeners.delete(listener)
|
|
@@ -97,6 +146,24 @@ window.__ModuleLoader__.load({
|
|
|
97
146
|
searchPlaceholder: '搜索标题、Session ID 或原始工作区',
|
|
98
147
|
searchClear: '清除搜索',
|
|
99
148
|
resultsCount: '显示 {shown} / {total} 个会话',
|
|
149
|
+
selectRow: '选择「{title}」',
|
|
150
|
+
selectAll: '全选本页可选会话',
|
|
151
|
+
selectedCount: '已选择 {count} 个会话',
|
|
152
|
+
clearSelection: '取消选择',
|
|
153
|
+
batchRestore: '恢复选中',
|
|
154
|
+
batchRestoring: '正在恢复…',
|
|
155
|
+
batchRestoreDoneOne: '已恢复 {count} 个会话',
|
|
156
|
+
batchRestoreDoneOther: '已恢复 {count} 个会话',
|
|
157
|
+
batchRestorePartial: '已恢复 {restored} 个会话,{failed} 个失败',
|
|
158
|
+
sortLabel: '排序方式',
|
|
159
|
+
sortTrashed: '按移入时间(最新在前)',
|
|
160
|
+
sortTitle: '按标题',
|
|
161
|
+
sortSize: '按大小(最大在前)',
|
|
162
|
+
sortExpires: '按到期时间(最早在前)',
|
|
163
|
+
expandMembers: '展开子会话',
|
|
164
|
+
collapseMembers: '收起子会话',
|
|
165
|
+
memberParent: '父会话',
|
|
166
|
+
memberSubagent: '子会话',
|
|
100
167
|
filterLabel: '筛选回收站',
|
|
101
168
|
filterAll: '全部',
|
|
102
169
|
filterWorkspaceExists: '工作区仍存在',
|
|
@@ -126,6 +193,13 @@ window.__ModuleLoader__.load({
|
|
|
126
193
|
corruptBanner: '回收站索引损坏,已进入保护模式。现有会话不会被自动修改。',
|
|
127
194
|
recoveredBanner: '回收站索引已从备份恢复。',
|
|
128
195
|
unindexedBanner: '回收站目录中有 {count} 个未索引对象,插件不会自动删除它们。',
|
|
196
|
+
orphansClean: '清理空目录',
|
|
197
|
+
orphansTitle: '清理空的无主对象?',
|
|
198
|
+
orphansBody: '只删除回收站中不含会话日志的残留目录;可能含日志的无主对象会保留。',
|
|
199
|
+
orphansConfirm: '清理空目录',
|
|
200
|
+
orphansDoneOne: '已清理 {count} 个空的无主对象',
|
|
201
|
+
orphansDoneOther: '已清理 {count} 个空的无主对象',
|
|
202
|
+
orphansNone: '没有可安全清理的空目录',
|
|
129
203
|
rebuild: '尝试重建索引',
|
|
130
204
|
rebuilding: '正在重建…',
|
|
131
205
|
rebuildDone: '索引已重建,共 {count} 个条目',
|
|
@@ -252,6 +326,24 @@ window.__ModuleLoader__.load({
|
|
|
252
326
|
searchPlaceholder: 'Search title, Session ID, or original workspace',
|
|
253
327
|
searchClear: 'Clear search',
|
|
254
328
|
resultsCount: 'Showing {shown} of {total} sessions',
|
|
329
|
+
selectRow: 'Select “{title}”',
|
|
330
|
+
selectAll: 'Select all selectable sessions on this page',
|
|
331
|
+
selectedCount: 'Selected: {count} sessions',
|
|
332
|
+
clearSelection: 'Clear selection',
|
|
333
|
+
batchRestore: 'Restore selected',
|
|
334
|
+
batchRestoring: 'Restoring…',
|
|
335
|
+
batchRestoreDoneOne: 'Restored {count} session',
|
|
336
|
+
batchRestoreDoneOther: 'Restored {count} sessions',
|
|
337
|
+
batchRestorePartial: 'Restored {restored}; {failed} failed',
|
|
338
|
+
sortLabel: 'Sort by',
|
|
339
|
+
sortTrashed: 'Newest trashed first',
|
|
340
|
+
sortTitle: 'Title',
|
|
341
|
+
sortSize: 'Largest first',
|
|
342
|
+
sortExpires: 'Expiring soonest first',
|
|
343
|
+
expandMembers: 'Show sub-sessions',
|
|
344
|
+
collapseMembers: 'Hide sub-sessions',
|
|
345
|
+
memberParent: 'Parent',
|
|
346
|
+
memberSubagent: 'Sub-session',
|
|
255
347
|
filterLabel: 'Filter the trash',
|
|
256
348
|
filterAll: 'All',
|
|
257
349
|
filterWorkspaceExists: 'Workspace exists',
|
|
@@ -281,6 +373,13 @@ window.__ModuleLoader__.load({
|
|
|
281
373
|
corruptBanner: 'The trash index is damaged, so protection mode is on. No session is changed automatically.',
|
|
282
374
|
recoveredBanner: 'The trash index was recovered from its backup.',
|
|
283
375
|
unindexedBanner: 'The trash directory holds {count} unindexed objects. The plugin will not delete them.',
|
|
376
|
+
orphansClean: 'Clean empty leftovers',
|
|
377
|
+
orphansTitle: 'Remove empty unindexed objects?',
|
|
378
|
+
orphansBody: 'Only leftover directories without session logs are removed; unindexed objects that may contain logs are kept.',
|
|
379
|
+
orphansConfirm: 'Clean empty objects',
|
|
380
|
+
orphansDoneOne: 'Removed {count} empty unindexed object',
|
|
381
|
+
orphansDoneOther: 'Removed {count} empty unindexed objects',
|
|
382
|
+
orphansNone: 'No empty leftovers to clean',
|
|
284
383
|
rebuild: 'Try rebuilding the index',
|
|
285
384
|
rebuilding: 'Rebuilding…',
|
|
286
385
|
rebuildDone: 'Index rebuilt with {count} entries',
|
|
@@ -419,6 +518,27 @@ window.__ModuleLoader__.load({
|
|
|
419
518
|
return format(template, { ...values, count })
|
|
420
519
|
}
|
|
421
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Compare two trash entries for the selected sort order.
|
|
523
|
+
*
|
|
524
|
+
* @param {any} left first entry.
|
|
525
|
+
* @param {any} right second entry.
|
|
526
|
+
* @param {string} sortKey active sort identifier.
|
|
527
|
+
* @param {string | undefined} locale active locale.
|
|
528
|
+
* @returns {number} comparator result.
|
|
529
|
+
*/
|
|
530
|
+
function compareEntries(left, right, sortKey, locale) {
|
|
531
|
+
const titleOf = (entry) => (typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.sessionId)
|
|
532
|
+
if (sortKey === 'title') return String(titleOf(left)).localeCompare(String(titleOf(right)), locale)
|
|
533
|
+
if (sortKey === 'size') return (Number(right.sizeBytes) || 0) - (Number(left.sizeBytes) || 0)
|
|
534
|
+
if (sortKey === 'expires') {
|
|
535
|
+
const leftAt = typeof left.expiresAt === 'number' ? left.expiresAt : Number.POSITIVE_INFINITY
|
|
536
|
+
const rightAt = typeof right.expiresAt === 'number' ? right.expiresAt : Number.POSITIVE_INFINITY
|
|
537
|
+
return leftAt - rightAt
|
|
538
|
+
}
|
|
539
|
+
return (right.trashedAt ?? 0) - (left.trashedAt ?? 0)
|
|
540
|
+
}
|
|
541
|
+
|
|
422
542
|
/**
|
|
423
543
|
* @param {number | null | undefined} bytes raw byte count.
|
|
424
544
|
* @returns {string} a compact human-readable size, or an em dash when unknown.
|
|
@@ -834,6 +954,26 @@ window.__ModuleLoader__.load({
|
|
|
834
954
|
.dsh-trash__dialogBody { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
835
955
|
.dsh-trash__dialogActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
836
956
|
.dsh-trash__srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
957
|
+
.dsh-trash__controlsRight { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin-left: auto; }
|
|
958
|
+
.dsh-trash__selectAll { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); cursor: pointer; white-space: nowrap; }
|
|
959
|
+
.dsh-trash__selectAllInput { width: 14px; height: 14px; margin: 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
|
|
960
|
+
.dsh-trash__sort { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
961
|
+
.dsh-trash__sortLabel { white-space: nowrap; }
|
|
962
|
+
.dsh-trash__sortSelect { height: 28px; padding: 0 8px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
|
|
963
|
+
.dsh-trash__sortSelect:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
|
|
964
|
+
.dsh-trash__rowCheck { width: 15px; height: 15px; margin: 3px 0 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
|
|
965
|
+
.dsh-trash__batch { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); }
|
|
966
|
+
.dsh-trash__batchCount { margin-right: auto; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
967
|
+
.dsh-trash__membersWrap { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
|
968
|
+
.dsh-trash__membersToggle { appearance: none; align-self: flex-start; border: none; background: none; padding: 0; color: var(--dsw-alias-state-business-primary); font: inherit; font-size: 12.5px; line-height: 1.5; cursor: pointer; }
|
|
969
|
+
.dsh-trash__membersToggle:hover { text-decoration: underline; }
|
|
970
|
+
.dsh-trash__membersToggle:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
971
|
+
.dsh-trash__members { list-style: none; margin: 0; padding: 0 0 0 10px; display: flex; flex-direction: column; gap: 4px; border-left: 2px solid var(--dsw-alias-border-l1); }
|
|
972
|
+
.dsh-trash__member { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
973
|
+
.dsh-trash__memberKind { color: var(--dsw-alias-label-tertiary); }
|
|
974
|
+
.dsh-trash__memberId { font-family: var(--dsw-font-mono, monospace); word-break: break-all; }
|
|
975
|
+
.dsh-trash__memberSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); }
|
|
976
|
+
|
|
837
977
|
@media (max-width: 560px) {
|
|
838
978
|
.dsh-trash__row { flex-direction: column; }
|
|
839
979
|
.dsh-trash__rowActions { align-self: flex-end; }
|
|
@@ -1102,6 +1242,10 @@ window.__ModuleLoader__.load({
|
|
|
1102
1242
|
heading = format(t('purgeAllTitle'), { count })
|
|
1103
1243
|
body = t('purgeAllBody')
|
|
1104
1244
|
confirmLabel = t('purgeAllConfirm')
|
|
1245
|
+
} else if (kind === 'orphans') {
|
|
1246
|
+
heading = t('orphansTitle')
|
|
1247
|
+
body = t('orphansBody')
|
|
1248
|
+
confirmLabel = t('orphansConfirm')
|
|
1105
1249
|
} else {
|
|
1106
1250
|
heading = t('forgetTitle')
|
|
1107
1251
|
body = t('forgetBody')
|
|
@@ -1164,11 +1308,16 @@ window.__ModuleLoader__.load({
|
|
|
1164
1308
|
const [cleaning, setCleaning] = React.useState(false)
|
|
1165
1309
|
const [rebuilding, setRebuilding] = React.useState(false)
|
|
1166
1310
|
const [purgingAll, setPurgingAll] = React.useState(false)
|
|
1311
|
+
const [forgettingOrphans, setForgettingOrphans] = React.useState(false)
|
|
1167
1312
|
const [announcement, setAnnouncement] = React.useState('')
|
|
1168
1313
|
const [clock, setClock] = React.useState(() => Date.now())
|
|
1169
1314
|
const [query, setQuery] = React.useState('')
|
|
1170
1315
|
const [filter, setFilter] = React.useState('all')
|
|
1171
1316
|
const [page, setPage] = React.useState(1)
|
|
1317
|
+
const [selectedIds, setSelectedIds] = React.useState(() => new Set())
|
|
1318
|
+
const [sortKey, setSortKey] = React.useState('trashed')
|
|
1319
|
+
const [expandedRows, setExpandedRows] = React.useState(() => new Set())
|
|
1320
|
+
const [batchRestoring, setBatchRestoring] = React.useState(false)
|
|
1172
1321
|
const abortRef = React.useRef(null)
|
|
1173
1322
|
const searchRef = React.useRef(null)
|
|
1174
1323
|
const panelRef = React.useRef(null)
|
|
@@ -1178,6 +1327,8 @@ window.__ModuleLoader__.load({
|
|
|
1178
1327
|
const activeWritesRef = React.useRef(0)
|
|
1179
1328
|
/** Abort scopes of in-flight writes, all cancelled on unmount. */
|
|
1180
1329
|
const writeScopesRef = React.useRef(new Set())
|
|
1330
|
+
/** The page-open sweep is requested once per mounted section. */
|
|
1331
|
+
const pageSweepRef = React.useRef(false)
|
|
1181
1332
|
const closeDialog = React.useCallback(() => setDialog(null), [])
|
|
1182
1333
|
|
|
1183
1334
|
/**
|
|
@@ -1279,7 +1430,7 @@ window.__ModuleLoader__.load({
|
|
|
1279
1430
|
* | { kind: 'network' }
|
|
1280
1431
|
* >} the outcome.
|
|
1281
1432
|
*/
|
|
1282
|
-
async function
|
|
1433
|
+
async function sendMutationOnce(pathname, body) {
|
|
1283
1434
|
const scope = openWriteScope()
|
|
1284
1435
|
try {
|
|
1285
1436
|
const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
|
|
@@ -1293,6 +1444,30 @@ window.__ModuleLoader__.load({
|
|
|
1293
1444
|
}
|
|
1294
1445
|
}
|
|
1295
1446
|
|
|
1447
|
+
/**
|
|
1448
|
+
* Send one write request. A LOCKED answer means the Host rejected the
|
|
1449
|
+
* call before touching anything, so it is safe to retry once after a
|
|
1450
|
+
* short jitter instead of surfacing a spurious failure.
|
|
1451
|
+
*
|
|
1452
|
+
* @param {string} pathname endpoint below the API prefix.
|
|
1453
|
+
* @param {object} [body] JSON request body.
|
|
1454
|
+
* @returns {Promise<any>} the outcome, see {@link sendMutationOnce}.
|
|
1455
|
+
*/
|
|
1456
|
+
async function sendMutation(pathname, body) {
|
|
1457
|
+
let outcome = await sendMutationOnce(pathname, body)
|
|
1458
|
+
if (outcome.kind === 'response' && outcome.ok === false) {
|
|
1459
|
+
const code =
|
|
1460
|
+
outcome.payload !== null && outcome.payload !== undefined && outcome.payload.error !== undefined
|
|
1461
|
+
? outcome.payload.error.code
|
|
1462
|
+
: undefined
|
|
1463
|
+
if (code === 'LOCKED') {
|
|
1464
|
+
await new Promise((resolve) => setTimeout(resolve, 350 + Math.floor(Math.random() * 350)))
|
|
1465
|
+
outcome = await sendMutationOnce(pathname, body)
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return outcome
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1296
1471
|
/**
|
|
1297
1472
|
* @param {'timeout' | 'network'} kind transport failure.
|
|
1298
1473
|
* @returns {string} the localized message to show.
|
|
@@ -1328,6 +1503,12 @@ window.__ModuleLoader__.load({
|
|
|
1328
1503
|
}
|
|
1329
1504
|
setView({ status: 'ready', data: payload, error: null })
|
|
1330
1505
|
setRowErrors({})
|
|
1506
|
+
// Read-only /state never deletes; ask the Host for the throttled
|
|
1507
|
+
// maintenance sweep explicitly, once per mounted page.
|
|
1508
|
+
if (!pageSweepRef.current) {
|
|
1509
|
+
pageSweepRef.current = true
|
|
1510
|
+
requestJson('sweep', { method: 'POST' }).catch(() => {})
|
|
1511
|
+
}
|
|
1331
1512
|
})
|
|
1332
1513
|
.catch(() => {
|
|
1333
1514
|
if (controller.signal.aborted) {
|
|
@@ -1383,7 +1564,7 @@ window.__ModuleLoader__.load({
|
|
|
1383
1564
|
|
|
1384
1565
|
const data = view.data
|
|
1385
1566
|
const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
|
|
1386
|
-
entries.sort((left, right) => (right
|
|
1567
|
+
entries.sort((left, right) => compareEntries(left, right, sortKey, locale))
|
|
1387
1568
|
const retentionDays =
|
|
1388
1569
|
data !== null && data.config !== undefined && typeof data.config.retentionDays === 'number'
|
|
1389
1570
|
? data.config.retentionDays
|
|
@@ -1415,7 +1596,13 @@ window.__ModuleLoader__.load({
|
|
|
1415
1596
|
const loading = view.status === 'loading'
|
|
1416
1597
|
const refreshing = view.status === 'refreshing'
|
|
1417
1598
|
const readsBusy = loading || refreshing
|
|
1418
|
-
const pendingCount =
|
|
1599
|
+
const pendingCount =
|
|
1600
|
+
pendingRows.size +
|
|
1601
|
+
(cleaning ? 1 : 0) +
|
|
1602
|
+
(purgingAll ? 1 : 0) +
|
|
1603
|
+
(rebuilding ? 1 : 0) +
|
|
1604
|
+
(forgettingOrphans ? 1 : 0) +
|
|
1605
|
+
(batchRestoring ? 1 : 0)
|
|
1419
1606
|
/** Any write (row action or maintenance) is in flight. */
|
|
1420
1607
|
const mutationBusy = pendingCount > 0
|
|
1421
1608
|
const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
|
|
@@ -1460,6 +1647,10 @@ window.__ModuleLoader__.load({
|
|
|
1460
1647
|
const pageStart = (currentPage - 1) * PAGE_SIZE
|
|
1461
1648
|
const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
|
|
1462
1649
|
const filtersActive = normalizedQuery !== '' || filter !== 'all'
|
|
1650
|
+
const pageSelectable = visibleEntries.filter((entry) => isSelectable(entry))
|
|
1651
|
+
const selectedEntries = entries.filter((entry) => selectedIds.has(entry.sessionId) && isSelectable(entry))
|
|
1652
|
+
const allPageSelected =
|
|
1653
|
+
pageSelectable.length > 0 && pageSelectable.every((entry) => selectedIds.has(entry.sessionId))
|
|
1463
1654
|
|
|
1464
1655
|
/**
|
|
1465
1656
|
* @returns {void} focus the search field when it is mounted.
|
|
@@ -1494,13 +1685,118 @@ window.__ModuleLoader__.load({
|
|
|
1494
1685
|
}
|
|
1495
1686
|
}
|
|
1496
1687
|
|
|
1688
|
+
/**
|
|
1689
|
+
* @param {object} entry wire entry.
|
|
1690
|
+
* @returns {boolean} whether this row can join a batch restore.
|
|
1691
|
+
*/
|
|
1692
|
+
function isSelectable(entry) {
|
|
1693
|
+
return entry.canRestore === true && entry.requiresOrphanRestore !== true
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* @param {string} sessionId selected row identity.
|
|
1698
|
+
* @returns {void}
|
|
1699
|
+
*/
|
|
1700
|
+
function toggleSelected(sessionId) {
|
|
1701
|
+
setSelectedIds((previous) => {
|
|
1702
|
+
const next = new Set(previous)
|
|
1703
|
+
if (next.has(sessionId)) next.delete(sessionId)
|
|
1704
|
+
else next.add(sessionId)
|
|
1705
|
+
return next
|
|
1706
|
+
})
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* @param {boolean} checked whether the page should be selected.
|
|
1711
|
+
* @returns {void}
|
|
1712
|
+
*/
|
|
1713
|
+
function togglePageSelection(checked) {
|
|
1714
|
+
setSelectedIds((previous) => {
|
|
1715
|
+
const next = new Set(previous)
|
|
1716
|
+
for (const entry of pageSelectable) {
|
|
1717
|
+
if (checked) next.add(entry.sessionId)
|
|
1718
|
+
else next.delete(entry.sessionId)
|
|
1719
|
+
}
|
|
1720
|
+
return next
|
|
1721
|
+
})
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* @returns {void}
|
|
1726
|
+
*/
|
|
1727
|
+
function clearSelection() {
|
|
1728
|
+
setSelectedIds(new Set())
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* @param {string} sessionId row whose member list is toggled.
|
|
1733
|
+
* @returns {void}
|
|
1734
|
+
*/
|
|
1735
|
+
function toggleExpanded(sessionId) {
|
|
1736
|
+
setExpandedRows((previous) => {
|
|
1737
|
+
const next = new Set(previous)
|
|
1738
|
+
if (next.has(sessionId)) next.delete(sessionId)
|
|
1739
|
+
else next.add(sessionId)
|
|
1740
|
+
return next
|
|
1741
|
+
})
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Restore every selected row, one Host request at a time. Failures are
|
|
1746
|
+
* collected per row and summarized in one Toast.
|
|
1747
|
+
*
|
|
1748
|
+
* @returns {Promise<void>} completion of the operation.
|
|
1749
|
+
*/
|
|
1750
|
+
async function performBatchRestore() {
|
|
1751
|
+
if (!beginWrite()) return
|
|
1752
|
+
const targets = selectedEntries.slice()
|
|
1753
|
+
setBatchRestoring(true)
|
|
1754
|
+
let restored = 0
|
|
1755
|
+
let failed = 0
|
|
1756
|
+
try {
|
|
1757
|
+
for (const entry of targets) {
|
|
1758
|
+
if (!aliveRef.current) return
|
|
1759
|
+
const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode: 'normal' })
|
|
1760
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1761
|
+
if (outcome.kind !== 'response') {
|
|
1762
|
+
failed += 1
|
|
1763
|
+
recordRowError(entry, transportMessage(outcome.kind))
|
|
1764
|
+
continue
|
|
1765
|
+
}
|
|
1766
|
+
if (!outcome.ok) {
|
|
1767
|
+
failed += 1
|
|
1768
|
+
recordRowError(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1769
|
+
continue
|
|
1770
|
+
}
|
|
1771
|
+
restored += 1
|
|
1772
|
+
clearRowError(entry.sessionId)
|
|
1773
|
+
}
|
|
1774
|
+
if (!aliveRef.current) return
|
|
1775
|
+
bumpRevision()
|
|
1776
|
+
clearSelection()
|
|
1777
|
+
const message =
|
|
1778
|
+
failed === 0
|
|
1779
|
+
? countText(t, 'batchRestoreDone', restored)
|
|
1780
|
+
: format(t('batchRestorePartial'), { restored, failed })
|
|
1781
|
+
ctx.contextMenus.notify(message, failed === 0)
|
|
1782
|
+
announce(message)
|
|
1783
|
+
} finally {
|
|
1784
|
+
endWrite()
|
|
1785
|
+
if (aliveRef.current) setBatchRestoring(false)
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1497
1789
|
/**
|
|
1498
1790
|
* @param {object} entry wire entry.
|
|
1499
1791
|
* @param {string} message localized failure text.
|
|
1500
1792
|
* @returns {void}
|
|
1501
1793
|
*/
|
|
1502
|
-
function
|
|
1794
|
+
function recordRowError(entry, message) {
|
|
1503
1795
|
setRowErrors((previous) => ({ ...previous, [entry.sessionId]: message }))
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function fail(entry, message) {
|
|
1799
|
+
recordRowError(entry, message)
|
|
1504
1800
|
ctx.contextMenus.notify(message, false)
|
|
1505
1801
|
}
|
|
1506
1802
|
|
|
@@ -1758,6 +2054,39 @@ window.__ModuleLoader__.load({
|
|
|
1758
2054
|
}
|
|
1759
2055
|
}
|
|
1760
2056
|
|
|
2057
|
+
/**
|
|
2058
|
+
* Remove empty unindexed object directories. They contain no session
|
|
2059
|
+
* log, only leftover plugin metadata, so this is safe to offer.
|
|
2060
|
+
*
|
|
2061
|
+
* @returns {Promise<void>} completion of the operation.
|
|
2062
|
+
*/
|
|
2063
|
+
async function performPurgeOrphans() {
|
|
2064
|
+
if (!beginWrite()) return
|
|
2065
|
+
setForgettingOrphans(true)
|
|
2066
|
+
try {
|
|
2067
|
+
const outcome = await sendMutation('orphans/forget')
|
|
2068
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
2069
|
+
if (outcome.kind !== 'response') {
|
|
2070
|
+
ctx.contextMenus.notify(transportMessage(outcome.kind), false)
|
|
2071
|
+
return
|
|
2072
|
+
}
|
|
2073
|
+
if (!outcome.ok) {
|
|
2074
|
+
ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
|
|
2075
|
+
return
|
|
2076
|
+
}
|
|
2077
|
+
const payload = outcome.payload
|
|
2078
|
+
const removed =
|
|
2079
|
+
payload !== null && payload !== undefined && typeof payload.removed === 'number' ? payload.removed : 0
|
|
2080
|
+
const message = removed > 0 ? countText(t, 'orphansDone', removed) : t('orphansNone')
|
|
2081
|
+
bumpRevision()
|
|
2082
|
+
ctx.contextMenus.notify(message, removed > 0)
|
|
2083
|
+
announce(message)
|
|
2084
|
+
} finally {
|
|
2085
|
+
endWrite()
|
|
2086
|
+
if (aliveRef.current) setForgettingOrphans(false)
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1761
2090
|
/**
|
|
1762
2091
|
* @param {object} entry wire entry.
|
|
1763
2092
|
* @param {string} key menu identity.
|
|
@@ -1918,6 +2247,16 @@ window.__ModuleLoader__.load({
|
|
|
1918
2247
|
'div',
|
|
1919
2248
|
{ className: 'dsh-trash__banner dsh-trash__banner--info', role: 'status', key: 'unindexed' },
|
|
1920
2249
|
h('span', { className: 'dsh-trash__bannerText' }, format(t('unindexedBanner'), { count: unindexed })),
|
|
2250
|
+
h(
|
|
2251
|
+
'button',
|
|
2252
|
+
{
|
|
2253
|
+
type: 'button',
|
|
2254
|
+
className: 'dsh-trash__button',
|
|
2255
|
+
onClick: () => setDialog({ kind: 'orphans' }),
|
|
2256
|
+
disabled: loading || mutationBusy,
|
|
2257
|
+
},
|
|
2258
|
+
t('orphansClean'),
|
|
2259
|
+
),
|
|
1921
2260
|
),
|
|
1922
2261
|
)
|
|
1923
2262
|
}
|
|
@@ -2083,13 +2422,48 @@ window.__ModuleLoader__.load({
|
|
|
2083
2422
|
h(CloseGlyph, { size: 11 }),
|
|
2084
2423
|
),
|
|
2085
2424
|
),
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2425
|
+
h(
|
|
2426
|
+
'div',
|
|
2427
|
+
{ className: 'dsh-trash__controlsRight' },
|
|
2428
|
+
filtersActive
|
|
2429
|
+
? h(
|
|
2430
|
+
'span',
|
|
2431
|
+
{ className: 'dsh-trash__resultsCount' },
|
|
2432
|
+
format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
|
|
2433
|
+
)
|
|
2434
|
+
: null,
|
|
2435
|
+
h(
|
|
2436
|
+
'label',
|
|
2437
|
+
{ className: 'dsh-trash__selectAll' },
|
|
2438
|
+
h('input', {
|
|
2439
|
+
type: 'checkbox',
|
|
2440
|
+
className: 'dsh-trash__selectAllInput',
|
|
2441
|
+
checked: allPageSelected,
|
|
2442
|
+
'aria-label': t('selectAll'),
|
|
2443
|
+
disabled: pageSelectable.length === 0 || loading || mutationBusy || corrupt,
|
|
2444
|
+
onChange: (event) => togglePageSelection(event.target.checked),
|
|
2445
|
+
}),
|
|
2446
|
+
h('span', null, t('selectAll')),
|
|
2447
|
+
),
|
|
2448
|
+
h(
|
|
2449
|
+
'label',
|
|
2450
|
+
{ className: 'dsh-trash__sort' },
|
|
2451
|
+
h('span', { className: 'dsh-trash__sortLabel' }, t('sortLabel')),
|
|
2452
|
+
h(
|
|
2453
|
+
'select',
|
|
2454
|
+
{
|
|
2455
|
+
className: 'dsh-trash__sortSelect',
|
|
2456
|
+
value: sortKey,
|
|
2457
|
+
'aria-label': t('sortLabel'),
|
|
2458
|
+
onChange: (event) => {
|
|
2459
|
+
setSortKey(event.target.value)
|
|
2460
|
+
setPage(1)
|
|
2461
|
+
},
|
|
2462
|
+
},
|
|
2463
|
+
SORT_OPTIONS.map((option) => h('option', { value: option.value, key: option.value }, t(option.key))),
|
|
2464
|
+
),
|
|
2465
|
+
),
|
|
2466
|
+
),
|
|
2093
2467
|
),
|
|
2094
2468
|
h(
|
|
2095
2469
|
'div',
|
|
@@ -2117,6 +2491,37 @@ window.__ModuleLoader__.load({
|
|
|
2117
2491
|
),
|
|
2118
2492
|
)
|
|
2119
2493
|
|
|
2494
|
+
const batchBar =
|
|
2495
|
+
selectedEntries.length === 0
|
|
2496
|
+
? null
|
|
2497
|
+
: h(
|
|
2498
|
+
'div',
|
|
2499
|
+
{ className: 'dsh-trash__batch', key: 'batch', role: 'region', 'aria-label': format(t('selectedCount'), { count: selectedEntries.length }) },
|
|
2500
|
+
h('span', { className: 'dsh-trash__batchCount' }, format(t('selectedCount'), { count: selectedEntries.length })),
|
|
2501
|
+
h(
|
|
2502
|
+
'button',
|
|
2503
|
+
{
|
|
2504
|
+
type: 'button',
|
|
2505
|
+
className: 'dsh-trash__button',
|
|
2506
|
+
onClick: () => {
|
|
2507
|
+
void performBatchRestore()
|
|
2508
|
+
},
|
|
2509
|
+
disabled: loading || mutationBusy || corrupt,
|
|
2510
|
+
},
|
|
2511
|
+
batchRestoring ? t('batchRestoring') : t('batchRestore'),
|
|
2512
|
+
),
|
|
2513
|
+
h(
|
|
2514
|
+
'button',
|
|
2515
|
+
{
|
|
2516
|
+
type: 'button',
|
|
2517
|
+
className: 'dsh-trash__button',
|
|
2518
|
+
onClick: clearSelection,
|
|
2519
|
+
disabled: batchRestoring,
|
|
2520
|
+
},
|
|
2521
|
+
t('clearSelection'),
|
|
2522
|
+
),
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2120
2525
|
let panelBody
|
|
2121
2526
|
if (view.status === 'loading') {
|
|
2122
2527
|
panelBody = h(
|
|
@@ -2164,6 +2569,16 @@ window.__ModuleLoader__.load({
|
|
|
2164
2569
|
return h(
|
|
2165
2570
|
'li',
|
|
2166
2571
|
{ className: 'dsh-trash__row', key: entry.sessionId, 'aria-busy': rowBusy ? 'true' : 'false' },
|
|
2572
|
+
isSelectable(entry)
|
|
2573
|
+
? h('input', {
|
|
2574
|
+
type: 'checkbox',
|
|
2575
|
+
className: 'dsh-trash__rowCheck',
|
|
2576
|
+
checked: selectedIds.has(entry.sessionId),
|
|
2577
|
+
'aria-label': format(t('selectRow'), { title }),
|
|
2578
|
+
disabled: rowBusy || mutationBusy || corrupt || loading,
|
|
2579
|
+
onChange: () => toggleSelected(entry.sessionId),
|
|
2580
|
+
})
|
|
2581
|
+
: null,
|
|
2167
2582
|
h(
|
|
2168
2583
|
'div',
|
|
2169
2584
|
{ className: 'dsh-trash__rowMain' },
|
|
@@ -2198,6 +2613,41 @@ window.__ModuleLoader__.load({
|
|
|
2198
2613
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
2199
2614
|
h('span', null, formatBytes(entry.sizeBytes)),
|
|
2200
2615
|
),
|
|
2616
|
+
entry.subagentCount > 0
|
|
2617
|
+
? h(
|
|
2618
|
+
'div',
|
|
2619
|
+
{ className: 'dsh-trash__membersWrap' },
|
|
2620
|
+
h(
|
|
2621
|
+
'button',
|
|
2622
|
+
{
|
|
2623
|
+
type: 'button',
|
|
2624
|
+
className: 'dsh-trash__membersToggle',
|
|
2625
|
+
'aria-expanded': expandedRows.has(entry.sessionId) ? 'true' : 'false',
|
|
2626
|
+
onClick: () => toggleExpanded(entry.sessionId),
|
|
2627
|
+
},
|
|
2628
|
+
expandedRows.has(entry.sessionId) ? t('collapseMembers') : t('expandMembers'),
|
|
2629
|
+
),
|
|
2630
|
+
expandedRows.has(entry.sessionId)
|
|
2631
|
+
? h(
|
|
2632
|
+
'ul',
|
|
2633
|
+
{ className: 'dsh-trash__members' },
|
|
2634
|
+
(Array.isArray(entry.members) ? entry.members : []).map((member) =>
|
|
2635
|
+
h(
|
|
2636
|
+
'li',
|
|
2637
|
+
{ className: 'dsh-trash__member', key: member.sessionId },
|
|
2638
|
+
h(
|
|
2639
|
+
'span',
|
|
2640
|
+
{ className: 'dsh-trash__memberKind' },
|
|
2641
|
+
member.origin === 'subagent' ? t('memberSubagent') : t('memberParent'),
|
|
2642
|
+
),
|
|
2643
|
+
h('code', { className: 'dsh-trash__memberId' }, member.sessionId),
|
|
2644
|
+
h('span', { className: 'dsh-trash__memberSize' }, formatBytes(member.sizeBytes)),
|
|
2645
|
+
),
|
|
2646
|
+
),
|
|
2647
|
+
)
|
|
2648
|
+
: null,
|
|
2649
|
+
)
|
|
2650
|
+
: null,
|
|
2201
2651
|
error !== null && error !== undefined && error !== ''
|
|
2202
2652
|
? h(
|
|
2203
2653
|
'div',
|
|
@@ -2310,6 +2760,7 @@ window.__ModuleLoader__.load({
|
|
|
2310
2760
|
if (current.kind === 'orphan') void performRestore(current.entry, 'orphan')
|
|
2311
2761
|
else if (current.kind === 'purge') void performPurge(current.entry)
|
|
2312
2762
|
else if (current.kind === 'purgeAll') void performPurgeAll()
|
|
2763
|
+
else if (current.kind === 'orphans') void performPurgeOrphans()
|
|
2313
2764
|
else void performForget(current.entry)
|
|
2314
2765
|
},
|
|
2315
2766
|
})
|
|
@@ -2322,6 +2773,7 @@ window.__ModuleLoader__.load({
|
|
|
2322
2773
|
banners,
|
|
2323
2774
|
toolbar,
|
|
2324
2775
|
controls,
|
|
2776
|
+
batchBar,
|
|
2325
2777
|
panel,
|
|
2326
2778
|
footnote,
|
|
2327
2779
|
h('div', { className: 'dsh-trash__srOnly', role: 'status', 'aria-live': 'polite' }, announcement),
|