@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/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
- * Notify every mounted trash view that the Host index changed.
73
+ * Open the cross-window revision channel once, when the platform supports it.
62
74
  * @returns {void}
63
75
  */
64
- function bumpRevision() {
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 sendMutation(pathname, body) {
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.trashedAt ?? 0) - (left.trashedAt ?? 0))
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 = pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0)
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 fail(entry, message) {
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
- filtersActive
2087
- ? h(
2088
- 'span',
2089
- { className: 'dsh-trash__resultsCount' },
2090
- format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
2091
- )
2092
- : null,
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),