@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.0

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 ADDED
@@ -0,0 +1,2231 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@wangxiaocuo/dsh-session-trash',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+
7
+ const NS = 'sessionTrash'
8
+ const API_BASE = '/plugins/dsh-session-trash/api'
9
+ const CSRF_HEADER = 'X-DSH-Session-Trash'
10
+ const MENU_ID = 'session-trash.move'
11
+ const SECTION_ID = 'session-trash'
12
+ const PREFLIGHT_TIMEOUT_MS = 800
13
+ const DAY_MS = 86_400_000
14
+ /** Rows per page in the trash list. */
15
+ const PAGE_SIZE = 50
16
+ /** A row counts as “expiring soon” from this many days before expiry. */
17
+ const EXPIRING_DAYS = 3
18
+ /** Filter identifiers in their sidebar order. */
19
+ const FILTERS = ['all', 'workspace-exists', 'workspace-missing', 'expiring']
20
+ /** Locale keys for each filter label. */
21
+ const FILTER_LABEL_KEYS = {
22
+ all: 'filterAll',
23
+ 'workspace-exists': 'filterWorkspaceExists',
24
+ 'workspace-missing': 'filterWorkspaceMissing',
25
+ expiring: 'filterExpiring',
26
+ }
27
+ /**
28
+ * Preflight refusals the user can act on themselves. Those keep a visible
29
+ * menu entry that explains the refusal instead of hiding it.
30
+ */
31
+ const MOVE_BLOCKED_KEYS = {
32
+ SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
33
+ SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
34
+ SESSION_HAS_DESCENDANTS: {
35
+ label: 'moveBlockedDescendants',
36
+ toast: 'blockedDescendantsToast',
37
+ },
38
+ }
39
+
40
+ /** Open trash views subscribe here so one successful mutation refreshes them all. */
41
+ const revisionListeners = new Set()
42
+
43
+ /**
44
+ * Notify every mounted trash view that the Host index changed.
45
+ * @returns {void}
46
+ */
47
+ function bumpRevision() {
48
+ for (const listener of [...revisionListeners]) {
49
+ try {
50
+ listener()
51
+ } catch (error) {
52
+ console.error('[dsh-session-trash] revision listener failed', error)
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {() => void} listener refresh callback.
59
+ * @returns {() => void} unsubscribe.
60
+ */
61
+ function subscribeRevision(listener) {
62
+ revisionListeners.add(listener)
63
+ return () => {
64
+ revisionListeners.delete(listener)
65
+ }
66
+ }
67
+
68
+ const zh = {
69
+ nav: '回收站',
70
+ title: '回收站',
71
+ subtitle: '已删除的会话会在这里保留 {days} 天,到期后自动永久删除。',
72
+ refresh: '刷新',
73
+ retry: '重试',
74
+ cleanupExpired: '清理已过期',
75
+ cleaning: '正在清理…',
76
+ statsCount: '{count} 个会话',
77
+ statsSize: '占用 {size}',
78
+ searchLabel: '搜索回收站',
79
+ searchPlaceholder: '搜索标题、Session ID 或原始工作区',
80
+ searchClear: '清除搜索',
81
+ resultsCount: '显示 {shown} / {total} 个会话',
82
+ filterLabel: '筛选回收站',
83
+ filterAll: '全部',
84
+ filterWorkspaceExists: '工作区仍存在',
85
+ filterWorkspaceMissing: '工作区缺失',
86
+ filterExpiring: '即将到期',
87
+ filterResults: '已筛选出 {count} 个会话',
88
+ noResultsTitle: '没有匹配的会话',
89
+ noResultsBody: '试试其他关键词,或清除搜索和筛选条件。',
90
+ clearFilters: '清除筛选',
91
+ paginationLabel: '回收站分页',
92
+ previousPage: '上一页',
93
+ nextPage: '下一页',
94
+ pageInfo: '第 {page} / {total} 页',
95
+ pageAnnouncement: '第 {page} 页,共 {total} 页',
96
+ nextSweep: '下次自动清理:{date}',
97
+ nextSweepUnknown: '下次自动清理:打开页面时检查',
98
+ autoCleanupOff: '自动清理已关闭',
99
+ autoCleanupBanner: '自动清理已关闭;过期会话不会自动删除,仍可手动永久删除。',
100
+ emptyTitle: '回收站为空',
101
+ emptyBody: '在侧边栏右键会话,选择「移入回收站」。',
102
+ loading: '正在加载…',
103
+ errorNetwork: '无法连接到回收站服务。',
104
+ errorUnauthorized: '连接已失效,请刷新页面。',
105
+ errorForbidden: '当前身份无权访问回收站。',
106
+ errorGeneric: '回收站加载失败。',
107
+ corruptBanner: '回收站索引损坏,已进入保护模式。现有会话不会被自动修改。',
108
+ recoveredBanner: '回收站索引已从备份恢复。',
109
+ unindexedBanner: '回收站目录中有 {count} 个未索引对象,插件不会自动删除它们。',
110
+ rebuild: '尝试重建索引',
111
+ rebuilding: '正在重建…',
112
+ rebuildDone: '索引已重建,共 {count} 个条目',
113
+ rebuildDoneUnindexed: '索引已重建,共 {count} 个条目;{unindexed} 个未索引对象已保留',
114
+ listLabel: '已删除的会话',
115
+ noWorkspace: '未分组',
116
+ unknownWorkspace: '未知工作区',
117
+ trashedAtLabel: '移入时间',
118
+ notScheduled: '不参与自动清理',
119
+ expiresOn: '将于 {date} 清理',
120
+ badgeMissing: '文件缺失',
121
+ badgeWorkspaceMissing: '工作区缺失',
122
+ badgeMigration: '需要迁移',
123
+ badgeExpiring: '即将到期',
124
+ badgeExpired: '已过期',
125
+ badgeSubagents: '含 {count} 个子会话',
126
+ stateTrashing: '正在移入…',
127
+ stateRestoring: '正在恢复…',
128
+ statePurging: '正在删除…',
129
+ actionRestore: '恢复',
130
+ actionRestoreFor: '恢复「{title}」',
131
+ actionMoreFor: '「{title}」的更多操作',
132
+ actionRestoreOrphan: '恢复为孤儿会话',
133
+ actionReveal: '在 Finder 中显示',
134
+ actionRevealDisabled: '原工作区已不存在',
135
+ actionCopyId: '复制 Session ID',
136
+ actionForget: '从索引移除',
137
+ actionPurge: '永久删除',
138
+ dialogCancel: '取消',
139
+ orphanTitle: '原工作区不存在',
140
+ orphanBody: '「{path}」已不存在。恢复为孤儿会话后,会话会出现在「未分组」,不会自动创建目录。',
141
+ orphanConfirm: '恢复为孤儿会话',
142
+ purgeTitle: '永久删除「{title}」的会话日志?',
143
+ purgeBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
144
+ purgeConfirm: '永久删除',
145
+ purgeAll: '永久删除全部',
146
+ purgingAll: '正在删除…',
147
+ purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
148
+ purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
149
+ purgeAllConfirm: '全部永久删除',
150
+ purgeAllDone: '已永久删除 {count} 个会话',
151
+ purgeAllNone: '回收站中没有可删除的会话',
152
+ purgeAllPartial: '已永久删除 {removed} 个会话,{failed} 个未能删除',
153
+ forgetTitle: '从回收站列表移除这条缺失记录?',
154
+ forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
155
+ forgetConfirm: '从索引移除',
156
+ cleanupDone: '已清理 {count} 个过期会话',
157
+ cleanupNone: '没有需要清理的过期会话',
158
+ cleanupFailedBanner: '上次自动清理有 {count} 个会话未能删除。',
159
+ cleanupErrorBanner: '上次自动清理失败:{message}',
160
+ cleanupRetryAt: '将在 {time} 自动重试。',
161
+ cleanupRetryNow: '立即重试',
162
+ copySuccess: '已复制 Session ID',
163
+ copyFailed: '无法复制 Session ID,请手动复制',
164
+ revealSuccess: '已在 Finder 中显示原工作区',
165
+ revealFailed: '无法打开原工作区',
166
+ lastErrorLabel: '上次错误:{message}',
167
+ footnote: '回收站目录:{path}',
168
+ move: '移入回收站',
169
+ moveBlockedLive: '移入回收站(会话已打开)',
170
+ moveBlockedActive: '移入回收站(有任务在运行)',
171
+ moveBlockedDescendants: '移入回收站(含可见子会话)',
172
+ undo: '撤销',
173
+ undoFailed: '撤销失败',
174
+ trashSuccess: '已将「{title}」移入回收站',
175
+ trashSuccessTree: '「{title}」及其 {count} 个子会话已移入回收站',
176
+ blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
177
+ blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
178
+ blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
179
+ restoreSuccess: '已恢复「{title}」',
180
+ restoreOrphanSuccess: '已恢复为孤儿会话「{title}」',
181
+ purgeSuccess: '已永久删除「{title}」',
182
+ forgetSuccess: '已从回收站列表移除',
183
+ errorLive: '这个会话正在使用中,无法移入回收站。',
184
+ errorActive: '这个会话还有正在运行的任务,无法移入回收站。',
185
+ errorSubagent: '子代理会话不能单独移入回收站。',
186
+ errorDescendants: '这个会话还有子会话,请改为归档整棵会话树。',
187
+ errorNotMaterialized: '这个会话还没有可移动的日志。',
188
+ errorStorage: '当前会话存储后端不支持回收站。',
189
+ errorAlreadyTrashed: '这个会话已经在回收站里了。',
190
+ errorNotFound: '会话不存在或已被移除。',
191
+ errorTrashNotFound: '回收站中未找到该会话。',
192
+ errorLocked: '另一个回收站操作正在进行,请稍后再试。',
193
+ errorTrashMissing: '回收站中的会话日志已缺失。',
194
+ errorRestoreTarget: '原位置已有同名目录,无法恢复。',
195
+ errorWorkspaceMissing: '原工作区目录已不存在,暂时无法普通恢复。',
196
+ errorRestoreVerify: '恢复后会话存储不可见,已回滚。',
197
+ errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
198
+ errorIndexOk: '回收站索引可正常读取,无需重建。',
199
+ errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
200
+ errorRevealUnavailable: '当前环境无法打开 Finder。',
201
+ errorRevealFailed: '无法打开原工作区。',
202
+ errorIo: '文件操作失败,请稍后重试。',
203
+ }
204
+
205
+ const en = {
206
+ nav: 'Trash',
207
+ title: 'Trash',
208
+ subtitle: 'Deleted sessions stay here for {days} days and are then permanently deleted.',
209
+ refresh: 'Refresh',
210
+ retry: 'Retry',
211
+ cleanupExpired: 'Delete expired',
212
+ cleaning: 'Cleaning…',
213
+ statsCount: '{count} sessions',
214
+ statsSize: '{size} used',
215
+ searchLabel: 'Search the trash',
216
+ searchPlaceholder: 'Search title, Session ID, or original workspace',
217
+ searchClear: 'Clear search',
218
+ resultsCount: 'Showing {shown} of {total} sessions',
219
+ filterLabel: 'Filter the trash',
220
+ filterAll: 'All',
221
+ filterWorkspaceExists: 'Workspace exists',
222
+ filterWorkspaceMissing: 'Workspace missing',
223
+ filterExpiring: 'Expiring soon',
224
+ filterResults: '{count} sessions shown',
225
+ noResultsTitle: 'No matching sessions',
226
+ noResultsBody: 'Try a different keyword, or clear the search and filters.',
227
+ clearFilters: 'Clear filters',
228
+ paginationLabel: 'Trash pagination',
229
+ previousPage: 'Previous page',
230
+ nextPage: 'Next page',
231
+ pageInfo: 'Page {page} of {total}',
232
+ pageAnnouncement: 'Page {page} of {total}',
233
+ nextSweep: 'Next automatic cleanup: {date}',
234
+ nextSweepUnknown: 'Next automatic cleanup: checked when this page opens',
235
+ autoCleanupOff: 'Automatic cleanup is off',
236
+ autoCleanupBanner: 'Automatic cleanup is off. Expired sessions are kept until you delete them.',
237
+ emptyTitle: 'Trash is empty',
238
+ emptyBody: 'Right-click a session in the sidebar and choose “Move to trash”.',
239
+ loading: 'Loading…',
240
+ errorNetwork: 'Could not reach the trash service.',
241
+ errorUnauthorized: 'Your connection expired. Reload the page.',
242
+ errorForbidden: 'You do not have access to the trash.',
243
+ errorGeneric: 'Could not load the trash.',
244
+ corruptBanner: 'The trash index is damaged, so protection mode is on. No session is changed automatically.',
245
+ recoveredBanner: 'The trash index was recovered from its backup.',
246
+ unindexedBanner: 'The trash directory holds {count} unindexed objects. The plugin will not delete them.',
247
+ rebuild: 'Try rebuilding the index',
248
+ rebuilding: 'Rebuilding…',
249
+ rebuildDone: 'Index rebuilt with {count} entries',
250
+ rebuildDoneUnindexed: 'Index rebuilt with {count} entries; {unindexed} unindexed objects were kept',
251
+ listLabel: 'Deleted sessions',
252
+ noWorkspace: 'Ungrouped',
253
+ unknownWorkspace: 'Unknown workspace',
254
+ trashedAtLabel: 'Trashed',
255
+ notScheduled: 'Not scheduled for cleanup',
256
+ expiresOn: 'Auto-deletes on {date}',
257
+ badgeMissing: 'Log missing',
258
+ badgeWorkspaceMissing: 'Workspace missing',
259
+ badgeMigration: 'Migration required',
260
+ badgeExpiring: 'Expiring soon',
261
+ badgeExpired: 'Expired',
262
+ badgeSubagents: '{count} sub-sessions',
263
+ stateTrashing: 'Moving…',
264
+ stateRestoring: 'Restoring…',
265
+ statePurging: 'Deleting…',
266
+ actionRestore: 'Restore',
267
+ actionRestoreFor: 'Restore “{title}”',
268
+ actionMoreFor: 'More actions for “{title}”',
269
+ actionRestoreOrphan: 'Restore as orphan',
270
+ actionReveal: 'Show in Finder',
271
+ actionRevealDisabled: 'The original workspace no longer exists',
272
+ actionCopyId: 'Copy Session ID',
273
+ actionForget: 'Remove from index',
274
+ actionPurge: 'Delete permanently',
275
+ dialogCancel: 'Cancel',
276
+ orphanTitle: 'Original workspace is missing',
277
+ orphanBody: '“{path}” no longer exists. An orphan restore puts the session back under “Ungrouped” and never recreates the directory.',
278
+ orphanConfirm: 'Restore as orphan',
279
+ purgeTitle: 'Permanently delete the session log for “{title}”?',
280
+ purgeBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
281
+ purgeConfirm: 'Delete permanently',
282
+ purgeAll: 'Delete all permanently',
283
+ purgingAll: 'Deleting…',
284
+ purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
285
+ purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
286
+ purgeAllConfirm: 'Delete all permanently',
287
+ purgeAllDone: 'Permanently deleted {count} sessions',
288
+ purgeAllNone: 'No sessions to delete',
289
+ purgeAllPartial: 'Permanently deleted {removed} sessions; {failed} could not be deleted',
290
+ forgetTitle: 'Remove this missing record from the trash list?',
291
+ forgetBody: 'No file is deleted; only this index record is removed.',
292
+ forgetConfirm: 'Remove from index',
293
+ cleanupDone: 'Deleted {count} expired sessions',
294
+ cleanupNone: 'No expired sessions to delete',
295
+ cleanupFailedBanner: 'The last automatic cleanup could not delete {count} session(s).',
296
+ cleanupErrorBanner: 'The last automatic cleanup failed: {message}',
297
+ cleanupRetryAt: 'Next retry: {time}.',
298
+ cleanupRetryNow: 'Retry now',
299
+ copySuccess: 'Session ID copied',
300
+ copyFailed: 'Could not copy the Session ID. Copy it manually.',
301
+ revealSuccess: 'Revealed the original workspace in Finder',
302
+ revealFailed: 'Could not open the original workspace',
303
+ lastErrorLabel: 'Last error: {message}',
304
+ footnote: 'Trash directory: {path}',
305
+ move: 'Move to trash',
306
+ moveBlockedLive: 'Move to trash (session is open)',
307
+ moveBlockedActive: 'Move to trash (work is running)',
308
+ moveBlockedDescendants: 'Move to trash (has visible sub-sessions)',
309
+ undo: 'Undo',
310
+ undoFailed: 'Could not undo',
311
+ trashSuccess: 'Moved “{title}” to the trash',
312
+ trashSuccessTree: '“{title}” and its {count} sub-sessions were moved to the trash',
313
+ blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
314
+ blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
315
+ blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
316
+ restoreSuccess: 'Restored “{title}”',
317
+ restoreOrphanSuccess: 'Restored “{title}” as an orphan session',
318
+ purgeSuccess: 'Permanently deleted “{title}”',
319
+ forgetSuccess: 'Removed from the trash list',
320
+ errorLive: 'This session is in use and cannot be moved to the trash.',
321
+ errorActive: 'This session still has running work.',
322
+ errorSubagent: 'A subagent session cannot be trashed on its own.',
323
+ errorDescendants: 'This session still has child sessions; archive the whole tree instead.',
324
+ errorNotMaterialized: 'This session has no stored log yet.',
325
+ errorStorage: 'The current session storage backend is not supported.',
326
+ errorAlreadyTrashed: 'This session is already in the trash.',
327
+ errorNotFound: 'This session no longer exists.',
328
+ errorTrashNotFound: 'That session is no longer in the trash.',
329
+ errorLocked: 'Another trash operation is in progress. Try again in a moment.',
330
+ errorTrashMissing: 'The stored session log is missing from the trash.',
331
+ errorRestoreTarget: 'A directory already exists at the original location.',
332
+ errorWorkspaceMissing: 'The original workspace directory no longer exists, so a normal restore is not possible.',
333
+ errorRestoreVerify: 'Session storage could not see the restored session; the move was rolled back.',
334
+ errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
335
+ errorIndexOk: 'The trash index is readable and does not need a rebuild.',
336
+ errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
337
+ errorRevealUnavailable: 'This environment cannot open Finder.',
338
+ errorRevealFailed: 'Could not open the original workspace.',
339
+ errorIo: 'A file operation failed. Try again.',
340
+ }
341
+
342
+ /**
343
+ * Replace `{name}` placeholders without relying on locale interpolation.
344
+ * @param {string} template localized text.
345
+ * @param {Record<string, unknown>} values replacement values.
346
+ * @returns {string} formatted text.
347
+ */
348
+ function format(template, values) {
349
+ if (typeof template !== 'string') return ''
350
+ return template.replace(/\{(\w+)\}/g, (match, key) =>
351
+ values !== undefined && values[key] !== undefined ? String(values[key]) : match,
352
+ )
353
+ }
354
+
355
+ /**
356
+ * @param {number} bytes raw byte count.
357
+ * @returns {string} a compact human-readable size.
358
+ */
359
+ function formatBytes(bytes) {
360
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
361
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
362
+ let value = bytes
363
+ let unit = 0
364
+ while (value >= 1024 && unit < units.length - 1) {
365
+ value /= 1024
366
+ unit += 1
367
+ }
368
+ const rounded = unit === 0 ? Math.round(value) : Math.round(value * 10) / 10
369
+ return `${rounded} ${units[unit]}`
370
+ }
371
+
372
+ /**
373
+ * @param {object | null | undefined} locale locale service snapshot source.
374
+ * @returns {string | undefined} the active BCP 47 locale tag.
375
+ */
376
+ function activeLocale(locale) {
377
+ try {
378
+ const snapshot = typeof locale?.getSnapshot === 'function' ? locale.getSnapshot() : undefined
379
+ return typeof snapshot?.active === 'string' ? snapshot.active : undefined
380
+ } catch {
381
+ return undefined
382
+ }
383
+ }
384
+
385
+ /**
386
+ * @param {number | null | undefined} timestamp epoch milliseconds.
387
+ * @param {string | undefined} locale active locale tag.
388
+ * @param {boolean} [withTime] include the time of day.
389
+ * @returns {string} a locale-aware date, or an em dash for a missing value.
390
+ */
391
+ function formatDate(timestamp, locale, withTime = false) {
392
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) return '—'
393
+ try {
394
+ return new Intl.DateTimeFormat(
395
+ locale,
396
+ withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' },
397
+ ).format(new Date(timestamp))
398
+ } catch {
399
+ return new Date(timestamp).toISOString().slice(0, 10)
400
+ }
401
+ }
402
+
403
+ /**
404
+ * @param {number} days day offset from now.
405
+ * @param {string | undefined} locale active locale tag.
406
+ * @returns {string} a relative day phrase.
407
+ */
408
+ function relativeDays(days, locale) {
409
+ try {
410
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day')
411
+ } catch {
412
+ return `${days}d`
413
+ }
414
+ }
415
+
416
+ /**
417
+ * @param {object} entry wire entry.
418
+ * @param {string | undefined} locale active locale tag.
419
+ * @param {number} now current timestamp.
420
+ * @returns {{ days: number | null, text: string }} remaining retention facts.
421
+ */
422
+ function remaining(entry, locale, now) {
423
+ if (entry.state !== 'trashed' || typeof entry.expiresAt !== 'number') {
424
+ return { days: null, text: '' }
425
+ }
426
+ const days = Math.ceil((entry.expiresAt - now) / DAY_MS)
427
+ return { days, text: days <= 0 ? '' : relativeDays(days, locale) }
428
+ }
429
+
430
+ /**
431
+ * Call one endpoint of this plugin's Host API.
432
+ * @param {string} pathname endpoint name below the API prefix.
433
+ * @param {{ method?: string, body?: unknown, signal?: AbortSignal }} [options] request options.
434
+ * @returns {Promise<{ status: number, ok: boolean, payload: any }>} the decoded response.
435
+ */
436
+ async function requestJson(pathname, options = {}) {
437
+ const { method = 'GET', body, signal } = options
438
+ const url = new URL(`${API_BASE}/${pathname}`, window.location.origin)
439
+ const headers = { [CSRF_HEADER]: '1' }
440
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
441
+ const response = await fetch(url, {
442
+ method,
443
+ credentials: 'same-origin',
444
+ cache: 'no-store',
445
+ headers,
446
+ body: body === undefined ? undefined : JSON.stringify(body),
447
+ signal,
448
+ })
449
+ let payload = null
450
+ try {
451
+ payload = await response.json()
452
+ } catch {
453
+ payload = null
454
+ }
455
+ return { status: response.status, ok: response.ok, payload }
456
+ }
457
+
458
+ /**
459
+ * @param {number} status HTTP status.
460
+ * @param {{ error?: { code?: string } } | null} payload decoded error body.
461
+ * @param {(key: string) => string} t bound translator.
462
+ * @returns {string} localized failure text.
463
+ */
464
+ function describeFailure(status, payload, t) {
465
+ if (status === 401) return t('errorUnauthorized')
466
+ if (status === 403) return t('errorForbidden')
467
+ const code = payload !== null && payload !== undefined && payload.error !== undefined ? payload.error.code : undefined
468
+ if (status === 500 && code === 'INDEX_CORRUPT') return t('corruptBanner')
469
+ return code === undefined ? t('errorGeneric') : `${t('errorGeneric')} (${code})`
470
+ }
471
+
472
+ /** Stable Host error codes mapped to owner-facing action messages. */
473
+ const ACTION_ERROR_KEYS = {
474
+ SESSION_NOT_FOUND: 'errorNotFound',
475
+ SESSION_LIVE: 'errorLive',
476
+ SESSION_ACTIVE: 'errorActive',
477
+ SESSION_SUBAGENT: 'errorSubagent',
478
+ SESSION_HAS_DESCENDANTS: 'errorDescendants',
479
+ SESSION_NOT_MATERIALIZED: 'errorNotMaterialized',
480
+ STORAGE_UNSUPPORTED: 'errorStorage',
481
+ ALREADY_TRASHED: 'errorAlreadyTrashed',
482
+ LOCKED: 'errorLocked',
483
+ TRASH_DIR_MISSING: 'errorTrashMissing',
484
+ TRASH_ENTRY_NOT_FOUND: 'errorTrashNotFound',
485
+ TRASH_STATE_INVALID: 'errorTrashNotFound',
486
+ RESTORE_TARGET_EXISTS: 'errorRestoreTarget',
487
+ WORKSPACE_MISSING: 'errorWorkspaceMissing',
488
+ RESTORE_VERIFY_FAILED: 'errorRestoreVerify',
489
+ MOVE_VERIFY_FAILED: 'errorRestoreVerify',
490
+ MOVE_CONFLICT: 'errorIo',
491
+ INDEX_CORRUPT: 'errorIndexCorrupt',
492
+ INDEX_NOT_CORRUPT: 'errorIndexOk',
493
+ INDEX_WRITE_FAILED: 'errorIo',
494
+ FORGET_NOT_ALLOWED: 'errorForgetNotAllowed',
495
+ REVEAL_UNAVAILABLE: 'errorRevealUnavailable',
496
+ REVEAL_FAILED: 'errorRevealFailed',
497
+ UNSAFE_SESSION_DIR: 'errorIo',
498
+ UNSAFE_RESTORE_PATH: 'errorIo',
499
+ IO_ERROR: 'errorIo',
500
+ }
501
+
502
+ /**
503
+ * Translate one failed mutation into a concise localized message.
504
+ *
505
+ * @param {number} status HTTP status.
506
+ * @param {{ error?: { code?: string } } | null} payload decoded error body.
507
+ * @param {(key: string) => string} t bound translator.
508
+ * @returns {string} localized failure text.
509
+ */
510
+ function describeActionFailure(status, payload, t) {
511
+ if (status === 401) return t('errorUnauthorized')
512
+ if (status === 403) return t('errorForbidden')
513
+ const code = payload !== null && payload !== undefined && payload.error !== undefined ? payload.error.code : undefined
514
+ const key = code === undefined ? undefined : ACTION_ERROR_KEYS[code]
515
+ return key === undefined ? t('errorIo') : t(key)
516
+ }
517
+
518
+ /**
519
+ * @param {string} sessionId session whose eligibility to ask for.
520
+ * @param {AbortSignal} [outerSignal] cancellation from the menu occurrence.
521
+ * @returns {Promise<{ eligible: boolean, code?: string, message?: string, details?: unknown } | null>} the verdict, or null when unknown.
522
+ */
523
+ async function preflight(sessionId, outerSignal) {
524
+ const controller = new AbortController()
525
+ const timer = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS)
526
+ const forward = () => controller.abort()
527
+ if (outerSignal !== undefined) outerSignal.addEventListener('abort', forward, { once: true })
528
+ try {
529
+ const { status, ok, payload } = await requestJson('preflight', {
530
+ method: 'POST',
531
+ body: { sessionId },
532
+ signal: controller.signal,
533
+ })
534
+ if (!ok || payload === null || typeof payload.eligible !== 'boolean') {
535
+ if (status === 401 || status === 403) return { eligible: false, code: 'FORBIDDEN' }
536
+ return null
537
+ }
538
+ return payload
539
+ } catch {
540
+ return null
541
+ } finally {
542
+ clearTimeout(timer)
543
+ if (outerSignal !== undefined) outerSignal.removeEventListener('abort', forward)
544
+ }
545
+ }
546
+
547
+ /**
548
+ * @param {string} text text to copy.
549
+ * @returns {Promise<boolean>} whether the clipboard accepted the text.
550
+ */
551
+ async function copyText(text) {
552
+ try {
553
+ if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
554
+ await navigator.clipboard.writeText(text)
555
+ return true
556
+ }
557
+ } catch {
558
+ // Fall back to a hidden textarea below.
559
+ }
560
+ try {
561
+ const area = document.createElement('textarea')
562
+ area.value = text
563
+ area.setAttribute('readonly', '')
564
+ area.style.position = 'fixed'
565
+ area.style.top = '-1000px'
566
+ area.style.opacity = '0'
567
+ document.body.appendChild(area)
568
+ area.select()
569
+ const copied = document.execCommand('copy')
570
+ area.remove()
571
+ return copied
572
+ } catch {
573
+ return false
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Trash artwork copied verbatim from the DeepSeek Harness product icon set
579
+ * (`trash-outline`, regular weight, MIT © 2026 DeepSeek), so the menu entry
580
+ * matches the shipped “Delete workspace” glyph instead of a look-alike.
581
+ */
582
+ const TRASH_PATHS = [
583
+ 'M1.28149 3.88831H14.7187',
584
+ 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833',
585
+ 'M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831',
586
+ 'M6.44946 6.98926V11.1238',
587
+ 'M9.55054 6.98926V11.1238',
588
+ ]
589
+
590
+ /**
591
+ * @param {{ size?: number, active?: boolean }} props icon presentation.
592
+ * @returns {import('react').ReactElement} the trash glyph.
593
+ */
594
+ function TrashGlyph(props) {
595
+ const size = typeof props?.size === 'number' ? props.size : 16
596
+ return h(
597
+ 'svg',
598
+ {
599
+ viewBox: '0 0 16 16',
600
+ width: size,
601
+ height: size,
602
+ fill: 'none',
603
+ stroke: 'currentColor',
604
+ strokeWidth: 1,
605
+ 'aria-hidden': true,
606
+ focusable: false,
607
+ style: { display: 'block', flex: 'none' },
608
+ },
609
+ TRASH_PATHS.map((d, index) => h('path', { key: index, d })),
610
+ )
611
+ }
612
+
613
+ /**
614
+ * @param {{ size?: number }} props icon presentation.
615
+ * @returns {import('react').ReactElement} a vertical ellipsis glyph.
616
+ */
617
+ function MoreGlyph(props) {
618
+ const size = typeof props?.size === 'number' ? props.size : 16
619
+ return h(
620
+ 'svg',
621
+ {
622
+ viewBox: '0 0 16 16',
623
+ width: size,
624
+ height: size,
625
+ fill: 'currentColor',
626
+ 'aria-hidden': true,
627
+ focusable: false,
628
+ style: { display: 'block', flex: 'none' },
629
+ },
630
+ [3, 8, 13].map((cy) => h('circle', { key: cy, cx: 8, cy, r: 1.15 })),
631
+ )
632
+ }
633
+
634
+ /**
635
+ * @param {{ size?: number }} props icon presentation.
636
+ * @returns {import('react').ReactElement} a magnifier glyph.
637
+ */
638
+ function SearchGlyph(props) {
639
+ const size = typeof props?.size === 'number' ? props.size : 16
640
+ return h(
641
+ 'svg',
642
+ {
643
+ viewBox: '0 0 16 16',
644
+ width: size,
645
+ height: size,
646
+ fill: 'none',
647
+ stroke: 'currentColor',
648
+ strokeWidth: 1.2,
649
+ strokeLinecap: 'round',
650
+ 'aria-hidden': true,
651
+ focusable: false,
652
+ style: { display: 'block', flex: 'none' },
653
+ },
654
+ [
655
+ h('circle', { key: 'circle', cx: 7, cy: 7, r: 4.25 }),
656
+ h('path', { key: 'handle', d: 'M10.25 10.25L13.5 13.5' }),
657
+ ],
658
+ )
659
+ }
660
+
661
+ /**
662
+ * @param {{ size?: number }} props icon presentation.
663
+ * @returns {import('react').ReactElement} a diagonal cross glyph.
664
+ */
665
+ function CloseGlyph(props) {
666
+ const size = typeof props?.size === 'number' ? props.size : 16
667
+ return h(
668
+ 'svg',
669
+ {
670
+ viewBox: '0 0 16 16',
671
+ width: size,
672
+ height: size,
673
+ fill: 'none',
674
+ stroke: 'currentColor',
675
+ strokeWidth: 1.4,
676
+ strokeLinecap: 'round',
677
+ 'aria-hidden': true,
678
+ focusable: false,
679
+ style: { display: 'block', flex: 'none' },
680
+ },
681
+ [h('path', { key: 'a', d: 'M4.5 4.5L11.5 11.5' }), h('path', { key: 'b', d: 'M11.5 4.5L4.5 11.5' })],
682
+ )
683
+ }
684
+
685
+ const STYLES = `
686
+ .dsh-trash { display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: 760px; margin: 0 auto; padding: 8px 0 48px; color: var(--dsw-alias-label-primary); }
687
+ .dsh-trash__header { display: flex; flex-direction: column; gap: 6px; }
688
+ .dsh-trash__title { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; }
689
+ .dsh-trash__subtitle { margin: 0; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
690
+ .dsh-trash__toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 12px; }
691
+ .dsh-trash__stats { font-size: 13px; color: var(--dsw-alias-label-secondary); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
692
+ .dsh-trash__dot { opacity: 0.5; }
693
+ .dsh-trash__toolbarActions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
694
+ .dsh-trash__controls { display: flex; flex-direction: column; gap: 10px; }
695
+ .dsh-trash__searchRow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
696
+ .dsh-trash__search { position: relative; display: flex; align-items: center; flex: 1 1 260px; min-width: 200px; }
697
+ .dsh-trash__searchGlyph { position: absolute; left: 10px; display: inline-flex; color: var(--dsw-alias-label-tertiary); pointer-events: none; }
698
+ .dsh-trash__searchInput { box-sizing: border-box; width: 100%; height: 34px; padding: 0 32px 0 30px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; line-height: 1.5; outline: none; }
699
+ .dsh-trash__searchInput::placeholder { color: var(--dsw-alias-label-dimmed); }
700
+ .dsh-trash__searchInput::-webkit-search-cancel-button { appearance: none; }
701
+ .dsh-trash__searchInput:focus { border-color: var(--dsw-alias-state-business-primary); }
702
+ .dsh-trash__searchInput:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
703
+ .dsh-trash__searchClear { position: absolute; right: 5px; width: 22px; height: 22px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 50%; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
704
+ .dsh-trash__searchClear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
705
+ .dsh-trash__searchClear:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
706
+ .dsh-trash__resultsCount { font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); }
707
+ .dsh-trash__filters { display: flex; flex-wrap: wrap; gap: 6px; }
708
+ .dsh-trash__filterOption { position: relative; display: inline-flex; }
709
+ .dsh-trash__filterInput { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; opacity: 0; pointer-events: none; }
710
+ .dsh-trash__filterLabel { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 999px; color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.4; white-space: nowrap; cursor: pointer; transition: background 120ms ease, color 120ms ease, border-color 120ms ease; }
711
+ .dsh-trash__filterLabel:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
712
+ .dsh-trash__filterInput:checked + .dsh-trash__filterLabel { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l3); background: var(--dsw-alias-interactive-bg-active); }
713
+ .dsh-trash__filterInput:focus-visible + .dsh-trash__filterLabel { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
714
+ .dsh-trash__button { appearance: none; border: 0.5px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: var(--dsw-radius-sm); padding: 6px 12px; font-size: 13px; line-height: 1.4; font-family: inherit; cursor: pointer; transition: background 120ms ease, border-color 120ms ease, color 120ms ease; }
715
+ .dsh-trash__button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
716
+ .dsh-trash__button:disabled { opacity: 0.5; cursor: default; }
717
+ .dsh-trash__button:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
718
+ .dsh-trash__button--danger { color: var(--dsw-alias-state-error-primary); border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent); }
719
+ .dsh-trash__button--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
720
+ .dsh-trash__panel { border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-1); }
721
+ .dsh-trash__empty { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; color: var(--dsw-alias-label-secondary); padding: 48px 20px; }
722
+ .dsh-trash__emptyGlyph { color: var(--dsw-alias-state-idle-primary); }
723
+ .dsh-trash__emptyTitle { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary); }
724
+ .dsh-trash__emptyBody { font-size: 13px; line-height: 1.6; max-width: 340px; margin: 0; }
725
+ .dsh-trash__banner { display: flex; align-items: flex-start; gap: 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-sm); padding: 10px 12px; font-size: 13px; line-height: 1.5; }
726
+ .dsh-trash__banner--error { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
727
+ .dsh-trash__banner--warn { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); }
728
+ .dsh-trash__banner--info { color: var(--dsw-alias-label-secondary); }
729
+ .dsh-trash__bannerText { flex: 1 1 auto; min-width: 0; }
730
+ .dsh-trash__skeletons { display: flex; flex-direction: column; gap: 8px; padding: 14px; }
731
+ .dsh-trash__skeleton { height: 64px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-skeleton); }
732
+ .dsh-trash__list { list-style: none; margin: 0; padding: 0; }
733
+ .dsh-trash__pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--dsw-alias-border-l1); }
734
+ .dsh-trash__pageInfo { font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
735
+ .dsh-trash__pageButtons { display: flex; align-items: center; gap: 6px; }
736
+ .dsh-trash__emptyAction { margin-top: 2px; }
737
+ .dsh-trash__row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
738
+ .dsh-trash__row:last-child { border-bottom: none; }
739
+ .dsh-trash__row:hover { background: var(--dsw-alias-interactive-bg-hover); }
740
+ .dsh-trash__rowMain { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1 1 auto; }
741
+ .dsh-trash__rowTitleLine { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
742
+ .dsh-trash__rowTitle { font-size: 14px; font-weight: 500; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
743
+ .dsh-trash__badge { font-size: 11px; line-height: 1.4; border-radius: 999px; padding: 1px 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); white-space: nowrap; }
744
+ .dsh-trash__badge--warn { color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); }
745
+ .dsh-trash__badge--danger { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
746
+ .dsh-trash__badge--info { color: var(--dsw-alias-state-business-primary); background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); }
747
+ .dsh-trash__rowId { font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11.5px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
748
+ .dsh-trash__rowMeta { display: flex; flex-wrap: wrap; gap: 2px 8px; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); min-width: 0; }
749
+ .dsh-trash__rowWorkspace { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; }
750
+ .dsh-trash__rowFacts { display: flex; flex-wrap: wrap; gap: 2px 8px; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); }
751
+ .dsh-trash__rowError { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; }
752
+ .dsh-trash__rowActions { flex: none; display: flex; align-items: center; gap: 6px; padding-top: 2px; }
753
+ .dsh-trash__menuWrap { position: relative; display: inline-flex; }
754
+ .dsh-trash__iconButton { width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
755
+ .dsh-trash__menu { position: fixed; z-index: 1100; min-width: 210px; max-width: 280px; max-height: min(320px, calc(100vh - 24px)); overflow-y: auto; padding: 4px; display: flex; flex-direction: column; background: var(--dsw-alias-bg-layer-2); border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md); box-shadow: var(--dsw-elevation-prominent); }
756
+ .dsh-trash__menuItem { appearance: none; display: block; width: 100%; text-align: left; background: none; border: none; color: var(--dsw-alias-label-primary); padding: 7px 10px; border-radius: var(--dsw-radius-sm); font-family: inherit; font-size: 13px; line-height: 1.4; cursor: pointer; }
757
+ .dsh-trash__menuItem:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
758
+ .dsh-trash__menuItem:disabled { opacity: 0.45; cursor: default; }
759
+ .dsh-trash__menuItem:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: -2px; }
760
+ .dsh-trash__menuItem--danger { color: var(--dsw-alias-state-error-primary); }
761
+ .dsh-trash__menuSeparator { height: 1px; margin: 4px 6px; background: var(--dsw-alias-border-l1); }
762
+ .dsh-trash__footnote { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); word-break: break-all; margin: 0; }
763
+ .dsh-trash__overlay { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--dsw-alias-bg-mask-1); backdrop-filter: var(--dsw-mask-blur, none); }
764
+ .dsh-trash__dialog { width: min(440px, 100%); background: var(--dsw-alias-bg-layer-2); border-radius: var(--dsw-radius-lg); box-shadow: var(--dsw-elevation-prominent); padding: 20px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 12px; }
765
+ .dsh-trash__dialogTitle { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.4; }
766
+ .dsh-trash__dialogBody { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
767
+ .dsh-trash__dialogActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
768
+ .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; }
769
+ @media (max-width: 560px) {
770
+ .dsh-trash__row { flex-direction: column; }
771
+ .dsh-trash__rowActions { align-self: flex-end; }
772
+ }
773
+ `
774
+
775
+ /**
776
+ * @param {object} locale locale service.
777
+ * @returns {number} the current locale revision, re-rendering on language changes.
778
+ */
779
+ function useLocaleRevision(locale) {
780
+ const subscribe = React.useCallback(
781
+ (listener) => {
782
+ if (typeof locale?.subscribe !== 'function') return () => {}
783
+ return locale.subscribe(listener)
784
+ },
785
+ [locale],
786
+ )
787
+ const snapshot = React.useCallback(() => {
788
+ try {
789
+ const value = typeof locale?.getSnapshot === 'function' ? locale.getSnapshot() : undefined
790
+ return value !== null && value !== undefined && typeof value.revision === 'number' ? value.revision : 0
791
+ } catch {
792
+ return 0
793
+ }
794
+ }, [locale])
795
+ return React.useSyncExternalStore(subscribe, snapshot, snapshot)
796
+ }
797
+
798
+ /**
799
+ * Narrow popup menu built from plain React so the settings page does not
800
+ * depend on internal DSH primitives.
801
+ *
802
+ * @param {{ label: string, items: object[], disabled?: boolean }} props menu presentation.
803
+ * @returns {import('react').ReactElement} the trigger and its open menu.
804
+ */
805
+ function ActionMenu({ label, items, disabled }) {
806
+ const [open, setOpen] = React.useState(false)
807
+ const [position, setPosition] = React.useState(null)
808
+ const wrapRef = React.useRef(null)
809
+ const buttonRef = React.useRef(null)
810
+ const menuRef = React.useRef(null)
811
+
812
+ React.useEffect(() => {
813
+ if (!open) return undefined
814
+ const onPointerDown = (event) => {
815
+ if (wrapRef.current !== null && !wrapRef.current.contains(event.target)) setOpen(false)
816
+ }
817
+ const onKeyDown = (event) => {
818
+ if (event.key !== 'Escape') return
819
+ event.preventDefault()
820
+ setOpen(false)
821
+ if (buttonRef.current !== null) buttonRef.current.focus()
822
+ }
823
+ const onViewportChange = () => setOpen(false)
824
+ const onScroll = (event) => {
825
+ if (event.target === menuRef.current) return
826
+ setOpen(false)
827
+ }
828
+ document.addEventListener('mousedown', onPointerDown, true)
829
+ document.addEventListener('keydown', onKeyDown, true)
830
+ window.addEventListener('resize', onViewportChange)
831
+ window.addEventListener('scroll', onScroll, true)
832
+ const first = menuRef.current === null ? null : menuRef.current.querySelector('[role="menuitem"]')
833
+ if (first !== null && typeof first.focus === 'function') first.focus()
834
+ return () => {
835
+ document.removeEventListener('mousedown', onPointerDown, true)
836
+ document.removeEventListener('keydown', onKeyDown, true)
837
+ window.removeEventListener('resize', onViewportChange)
838
+ window.removeEventListener('scroll', onScroll, true)
839
+ }
840
+ }, [open])
841
+
842
+ /**
843
+ * Open the menu below the trigger, or above it when the viewport is
844
+ * short. Fixed coordinates escape the settings pane's scroll clipping.
845
+ *
846
+ * @returns {void}
847
+ */
848
+ const toggleMenu = () => {
849
+ if (open) {
850
+ setOpen(false)
851
+ return
852
+ }
853
+ let next = { top: 8, right: 8 }
854
+ const button = buttonRef.current
855
+ if (button !== null && typeof button.getBoundingClientRect === 'function') {
856
+ const rect = button.getBoundingClientRect()
857
+ const estimatedHeight = Math.min(items.length, 6) * 34 + 16
858
+ const opensUp = rect.bottom + estimatedHeight + 8 > window.innerHeight
859
+ next = {
860
+ right: Math.max(8, window.innerWidth - rect.right),
861
+ ...(opensUp
862
+ ? { bottom: Math.max(8, window.innerHeight - rect.top + 4) }
863
+ : { top: rect.bottom + 4 }),
864
+ }
865
+ }
866
+ setPosition(next)
867
+ setOpen(true)
868
+ }
869
+
870
+ /**
871
+ * @param {import('react').KeyboardEvent} event menu key event.
872
+ * @returns {void}
873
+ */
874
+ const navigate = (event) => {
875
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
876
+ const nodes =
877
+ menuRef.current === null
878
+ ? []
879
+ : [...menuRef.current.querySelectorAll('[role="menuitem"]')].filter((node) => !node.disabled)
880
+ if (nodes.length === 0) return
881
+ event.preventDefault()
882
+ const index = nodes.indexOf(document.activeElement)
883
+ const next =
884
+ event.key === 'ArrowDown'
885
+ ? (index + 1 + nodes.length) % nodes.length
886
+ : (index <= 0 ? nodes.length : index) - 1
887
+ nodes[next].focus()
888
+ }
889
+
890
+ return h(
891
+ 'div',
892
+ { className: 'dsh-trash__menuWrap', ref: wrapRef },
893
+ h(
894
+ 'button',
895
+ {
896
+ type: 'button',
897
+ className: 'dsh-trash__button dsh-trash__iconButton',
898
+ 'aria-label': label,
899
+ 'aria-haspopup': 'menu',
900
+ 'aria-expanded': open ? 'true' : 'false',
901
+ disabled: disabled === true,
902
+ ref: buttonRef,
903
+ onClick: toggleMenu,
904
+ },
905
+ h(MoreGlyph),
906
+ ),
907
+ open
908
+ ? h(
909
+ 'div',
910
+ {
911
+ className: 'dsh-trash__menu',
912
+ role: 'menu',
913
+ 'aria-label': label,
914
+ ref: menuRef,
915
+ style: position ?? undefined,
916
+ onKeyDown: navigate,
917
+ },
918
+ items.map((item, index) =>
919
+ h(
920
+ 'div',
921
+ { key: item.key },
922
+ item.separatorBefore === true && index > 0 ? h('div', { className: 'dsh-trash__menuSeparator' }) : null,
923
+ h(
924
+ 'button',
925
+ {
926
+ type: 'button',
927
+ role: 'menuitem',
928
+ className: `dsh-trash__menuItem${item.danger === true ? ' dsh-trash__menuItem--danger' : ''}`,
929
+ disabled: item.disabled === true,
930
+ title: item.title,
931
+ 'aria-label': item.ariaLabel,
932
+ onClick: () => {
933
+ setOpen(false)
934
+ if (buttonRef.current !== null && typeof buttonRef.current.focus === 'function') {
935
+ buttonRef.current.focus()
936
+ }
937
+ item.onSelect()
938
+ },
939
+ },
940
+ item.label,
941
+ ),
942
+ ),
943
+ ),
944
+ )
945
+ : null,
946
+ )
947
+ }
948
+
949
+ /**
950
+ * Modal confirmation with a focus trap and focus restoration.
951
+ *
952
+ * @param {{
953
+ * kind: 'orphan' | 'purge' | 'forget' | 'purgeAll',
954
+ * entry?: object,
955
+ * count?: number,
956
+ * t: (key: string) => string,
957
+ * onCancel: () => void,
958
+ * onConfirm: () => void,
959
+ * }} props dialog content and handlers.
960
+ * @returns {import('react').ReactElement} the dialog overlay.
961
+ */
962
+ function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm }) {
963
+ const dialogRef = React.useRef(null)
964
+ const cancelRef = React.useRef(null)
965
+ const titleId = `dsh-trash-dialog-title-${kind}`
966
+ const bodyId = `dsh-trash-dialog-body-${kind}`
967
+
968
+ React.useEffect(() => {
969
+ const previous = document.activeElement
970
+ if (cancelRef.current !== null) cancelRef.current.focus()
971
+ const onKeyDown = (event) => {
972
+ if (event.key === 'Escape') {
973
+ event.preventDefault()
974
+ event.stopPropagation()
975
+ onCancel()
976
+ return
977
+ }
978
+ if (event.key !== 'Tab' || dialogRef.current === null) return
979
+ const nodes = [...dialogRef.current.querySelectorAll('button, [href], input, select, textarea')].filter(
980
+ (node) => !node.disabled,
981
+ )
982
+ if (nodes.length === 0) return
983
+ const first = nodes[0]
984
+ const last = nodes[nodes.length - 1]
985
+ if (event.shiftKey && document.activeElement === first) {
986
+ event.preventDefault()
987
+ last.focus()
988
+ } else if (!event.shiftKey && document.activeElement === last) {
989
+ event.preventDefault()
990
+ first.focus()
991
+ }
992
+ }
993
+ document.addEventListener('keydown', onKeyDown, true)
994
+ return () => {
995
+ document.removeEventListener('keydown', onKeyDown, true)
996
+ if (previous !== null && typeof previous.focus === 'function') previous.focus()
997
+ }
998
+ }, [onCancel])
999
+
1000
+ const title =
1001
+ entry !== undefined && entry.title !== undefined && entry.title !== '' ? entry.title : (entry?.sessionId ?? '')
1002
+ let heading = ''
1003
+ let body = ''
1004
+ let confirmLabel = ''
1005
+ if (kind === 'orphan') {
1006
+ heading = t('orphanTitle')
1007
+ body = format(t('orphanBody'), { path: entry.originalCwd ?? t('unknownWorkspace') })
1008
+ confirmLabel = t('orphanConfirm')
1009
+ } else if (kind === 'purge') {
1010
+ heading = format(t('purgeTitle'), { title })
1011
+ body = t('purgeBody')
1012
+ confirmLabel = t('purgeConfirm')
1013
+ } else if (kind === 'purgeAll') {
1014
+ heading = format(t('purgeAllTitle'), { count })
1015
+ body = t('purgeAllBody')
1016
+ confirmLabel = t('purgeAllConfirm')
1017
+ } else {
1018
+ heading = t('forgetTitle')
1019
+ body = t('forgetBody')
1020
+ confirmLabel = t('forgetConfirm')
1021
+ }
1022
+
1023
+ return h(
1024
+ 'div',
1025
+ {
1026
+ className: 'dsh-trash__overlay',
1027
+ onMouseDown: (event) => {
1028
+ if (event.target === event.currentTarget) onCancel()
1029
+ },
1030
+ },
1031
+ h(
1032
+ 'div',
1033
+ {
1034
+ className: 'dsh-trash__dialog',
1035
+ role: 'dialog',
1036
+ 'aria-modal': 'true',
1037
+ 'aria-labelledby': titleId,
1038
+ 'aria-describedby': bodyId,
1039
+ ref: dialogRef,
1040
+ },
1041
+ h('h3', { className: 'dsh-trash__dialogTitle', id: titleId }, heading),
1042
+ h('p', { className: 'dsh-trash__dialogBody', id: bodyId }, body),
1043
+ h(
1044
+ 'div',
1045
+ { className: 'dsh-trash__dialogActions' },
1046
+ h('button', { type: 'button', className: 'dsh-trash__button', onClick: onCancel, ref: cancelRef }, t('dialogCancel')),
1047
+ h(
1048
+ 'button',
1049
+ {
1050
+ type: 'button',
1051
+ className: `dsh-trash__button${
1052
+ kind === 'purge' || kind === 'purgeAll' ? ' dsh-trash__button--danger' : ''
1053
+ }`,
1054
+ onClick: onConfirm,
1055
+ },
1056
+ confirmLabel,
1057
+ ),
1058
+ ),
1059
+ ),
1060
+ )
1061
+ }
1062
+
1063
+ /**
1064
+ * @param {object} ctx plugin context.
1065
+ * @param {(key: string) => string} t bound translator.
1066
+ * @returns {import('react').FunctionComponent} the Trash settings section.
1067
+ */
1068
+ function createSection(ctx, t) {
1069
+ return function TrashSection() {
1070
+ useLocaleRevision(ctx.locale)
1071
+ const locale = activeLocale(ctx.locale)
1072
+ const [view, setView] = React.useState({ status: 'loading', data: null, error: null })
1073
+ const [busyId, setBusyId] = React.useState(null)
1074
+ const [rowErrors, setRowErrors] = React.useState({})
1075
+ const [dialog, setDialog] = React.useState(null)
1076
+ const [cleaning, setCleaning] = React.useState(false)
1077
+ const [rebuilding, setRebuilding] = React.useState(false)
1078
+ const [purgingAll, setPurgingAll] = React.useState(false)
1079
+ const [announcement, setAnnouncement] = React.useState('')
1080
+ const [query, setQuery] = React.useState('')
1081
+ const [filter, setFilter] = React.useState('all')
1082
+ const [page, setPage] = React.useState(1)
1083
+ const abortRef = React.useRef(null)
1084
+ const searchRef = React.useRef(null)
1085
+ const panelRef = React.useRef(null)
1086
+ const closeDialog = React.useCallback(() => setDialog(null), [])
1087
+
1088
+ const load = React.useCallback(() => {
1089
+ if (abortRef.current !== null) abortRef.current.abort()
1090
+ const controller = new AbortController()
1091
+ abortRef.current = controller
1092
+ setView((previous) => ({
1093
+ status: previous.data === null ? 'loading' : 'refreshing',
1094
+ data: previous.data,
1095
+ error: null,
1096
+ }))
1097
+ requestJson('state', { signal: controller.signal })
1098
+ .then(({ status, ok, payload }) => {
1099
+ if (controller.signal.aborted) return
1100
+ if (!ok || payload === null) {
1101
+ setView((previous) => ({
1102
+ status: 'error',
1103
+ data: previous.data,
1104
+ error: describeFailure(status, payload, t),
1105
+ }))
1106
+ return
1107
+ }
1108
+ setView({ status: 'ready', data: payload, error: null })
1109
+ setRowErrors({})
1110
+ })
1111
+ .catch(() => {
1112
+ if (controller.signal.aborted) return
1113
+ setView((previous) => ({ status: 'error', data: previous.data, error: t('errorNetwork') }))
1114
+ })
1115
+ }, [])
1116
+
1117
+ React.useEffect(() => {
1118
+ load()
1119
+ return () => {
1120
+ if (abortRef.current !== null) abortRef.current.abort()
1121
+ }
1122
+ }, [load])
1123
+
1124
+ React.useEffect(() => subscribeRevision(load), [load])
1125
+
1126
+ React.useEffect(() => {
1127
+ const refresh = () => load()
1128
+ const onVisibility = () => {
1129
+ if (document.visibilityState === 'visible') refresh()
1130
+ }
1131
+ window.addEventListener('focus', refresh)
1132
+ document.addEventListener('visibilitychange', onVisibility)
1133
+ return () => {
1134
+ window.removeEventListener('focus', refresh)
1135
+ document.removeEventListener('visibilitychange', onVisibility)
1136
+ }
1137
+ }, [load])
1138
+
1139
+ const data = view.data
1140
+ const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
1141
+ entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
1142
+ const retentionDays =
1143
+ data !== null && data.config !== undefined && typeof data.config.retentionDays === 'number'
1144
+ ? data.config.retentionDays
1145
+ : 30
1146
+ const autoCleanupEnabled =
1147
+ data === null || data.config === undefined || data.config.enableAutoCleanup !== false
1148
+ const totalBytes = data !== null && data.stats !== undefined ? data.stats.totalBytes : 0
1149
+ const nextSweepAt = data !== null && data.stats !== undefined ? data.stats.nextSweepAt : null
1150
+ const cleanupInfo =
1151
+ data !== null && data.stats !== undefined && data.stats.cleanup !== undefined && data.stats.cleanup !== null
1152
+ ? data.stats.cleanup
1153
+ : null
1154
+ const cleanupFailed =
1155
+ cleanupInfo !== null && cleanupInfo.lastRun !== null && cleanupInfo.lastRun !== undefined
1156
+ ? typeof cleanupInfo.lastRun.failed === 'number'
1157
+ ? cleanupInfo.lastRun.failed
1158
+ : 0
1159
+ : 0
1160
+ const cleanupRetryAt =
1161
+ cleanupInfo !== null && typeof cleanupInfo.retryAt === 'number' ? cleanupInfo.retryAt : null
1162
+ const cleanupError =
1163
+ cleanupInfo !== null &&
1164
+ cleanupInfo.lastRun !== null &&
1165
+ cleanupInfo.lastRun !== undefined &&
1166
+ typeof cleanupInfo.lastRun.error === 'string' &&
1167
+ cleanupInfo.lastRun.error !== ''
1168
+ ? cleanupInfo.lastRun.error
1169
+ : null
1170
+ const busy = view.status === 'loading' || view.status === 'refreshing'
1171
+ const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
1172
+ const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
1173
+ const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
1174
+ const now = Date.now()
1175
+ const normalizedQuery = query.trim().toLowerCase()
1176
+
1177
+ /**
1178
+ * @param {object} entry wire entry.
1179
+ * @returns {boolean} whether the entry matches the search text.
1180
+ */
1181
+ function matchesQuery(entry) {
1182
+ if (normalizedQuery === '') return true
1183
+ const candidates = [entry.title, entry.sessionId, entry.originalCwd]
1184
+ return candidates.some(
1185
+ (value) => typeof value === 'string' && value.toLowerCase().includes(normalizedQuery),
1186
+ )
1187
+ }
1188
+
1189
+ /**
1190
+ * @param {object} entry wire entry.
1191
+ * @param {string} [selected] filter to test; defaults to the active one.
1192
+ * @returns {boolean} whether the entry matches the selected filter.
1193
+ */
1194
+ function matchesFilter(entry, selected = filter) {
1195
+ if (selected === 'workspace-exists') return entry.originalCwd !== null && entry.workspaceNowExists === true
1196
+ if (selected === 'workspace-missing') return entry.originalCwd !== null && entry.workspaceNowExists === false
1197
+ if (selected === 'expiring') {
1198
+ const retention = remaining(entry, locale, now)
1199
+ return retention.days !== null && retention.days <= EXPIRING_DAYS
1200
+ }
1201
+ return true
1202
+ }
1203
+
1204
+ const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
1205
+ const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
1206
+ const currentPage = Math.min(Math.max(1, page), pageCount)
1207
+ const pageStart = (currentPage - 1) * PAGE_SIZE
1208
+ const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
1209
+ const filtersActive = normalizedQuery !== '' || filter !== 'all'
1210
+
1211
+ /**
1212
+ * @returns {void} focus the search field when it is mounted.
1213
+ */
1214
+ function focusSearch() {
1215
+ if (searchRef.current !== null && typeof searchRef.current.focus === 'function') {
1216
+ searchRef.current.focus()
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * @returns {void} reset both the search text and the filter.
1222
+ */
1223
+ function clearFilters() {
1224
+ setQuery('')
1225
+ setFilter('all')
1226
+ setPage(1)
1227
+ focusSearch()
1228
+ }
1229
+
1230
+ /**
1231
+ * @param {number} next desired page number.
1232
+ * @returns {void}
1233
+ */
1234
+ function goToPage(next) {
1235
+ const target = Math.min(Math.max(1, next), pageCount)
1236
+ if (target === currentPage) return
1237
+ setPage(target)
1238
+ announce(format(t('pageAnnouncement'), { page: target, total: pageCount }))
1239
+ if (panelRef.current !== null && typeof panelRef.current.scrollIntoView === 'function') {
1240
+ panelRef.current.scrollIntoView({ block: 'nearest' })
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * @param {object} entry wire entry.
1246
+ * @param {string} message localized failure text.
1247
+ * @returns {void}
1248
+ */
1249
+ function fail(entry, message) {
1250
+ setRowErrors((previous) => ({ ...previous, [entry.sessionId]: message }))
1251
+ ctx.contextMenus.notify(message, false)
1252
+ }
1253
+
1254
+ /**
1255
+ * @param {string} text localized announcement text.
1256
+ * @returns {void}
1257
+ */
1258
+ function announce(text) {
1259
+ setAnnouncement(text)
1260
+ }
1261
+
1262
+ /**
1263
+ * @param {object} entry wire entry.
1264
+ * @param {'normal' | 'orphan'} mode restore mode.
1265
+ * @returns {Promise<void>} completion of the operation.
1266
+ */
1267
+ async function performRestore(entry, mode) {
1268
+ setBusyId(entry.sessionId)
1269
+ 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
1276
+ if (mode === 'normal' && code === 'WORKSPACE_MISSING') {
1277
+ setDialog({ kind: 'orphan', entry })
1278
+ return
1279
+ }
1280
+ fail(entry, describeActionFailure(status, payload, t))
1281
+ return
1282
+ }
1283
+ const session = payload !== null && payload !== undefined ? payload.session : undefined
1284
+ const orphan = session !== null && session !== undefined ? session.orphan === true : mode === 'orphan'
1285
+ const title =
1286
+ session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
1287
+ ? session.title
1288
+ : entry.title
1289
+ const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
1290
+ bumpRevision()
1291
+ ctx.contextMenus.notify(message, true)
1292
+ announce(message)
1293
+ } catch {
1294
+ fail(entry, t('errorNetwork'))
1295
+ } finally {
1296
+ setBusyId(null)
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * @param {object} entry wire entry.
1302
+ * @returns {Promise<void>} completion of the operation.
1303
+ */
1304
+ async function performPurge(entry) {
1305
+ setBusyId(entry.sessionId)
1306
+ 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))
1313
+ return
1314
+ }
1315
+ const message = format(t('purgeSuccess'), { title: entry.title })
1316
+ bumpRevision()
1317
+ ctx.contextMenus.notify(message, true)
1318
+ announce(message)
1319
+ } catch {
1320
+ fail(entry, t('errorNetwork'))
1321
+ } finally {
1322
+ setBusyId(null)
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * @param {object} entry missing wire entry.
1328
+ * @returns {Promise<void>} completion of the operation.
1329
+ */
1330
+ async function performForget(entry) {
1331
+ setBusyId(entry.sessionId)
1332
+ 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))
1339
+ return
1340
+ }
1341
+ bumpRevision()
1342
+ ctx.contextMenus.notify(t('forgetSuccess'), true)
1343
+ announce(t('forgetSuccess'))
1344
+ } catch {
1345
+ fail(entry, t('errorNetwork'))
1346
+ } finally {
1347
+ setBusyId(null)
1348
+ }
1349
+ }
1350
+
1351
+ /**
1352
+ * @param {object} entry wire entry.
1353
+ * @returns {Promise<void>} completion of the operation.
1354
+ */
1355
+ async function performReveal(entry) {
1356
+ setBusyId(entry.sessionId)
1357
+ 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)
1365
+ return
1366
+ }
1367
+ ctx.contextMenus.notify(t('revealSuccess'), true)
1368
+ announce(t('revealSuccess'))
1369
+ } catch {
1370
+ fail(entry, t('errorNetwork'))
1371
+ } finally {
1372
+ setBusyId(null)
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * @param {object} entry wire entry.
1378
+ * @returns {Promise<void>} completion of the operation.
1379
+ */
1380
+ async function performCopy(entry) {
1381
+ const copied = await copyText(entry.sessionId)
1382
+ ctx.contextMenus.notify(copied ? t('copySuccess') : t('copyFailed'), copied)
1383
+ announce(copied ? t('copySuccess') : t('copyFailed'))
1384
+ }
1385
+
1386
+ /**
1387
+ * @returns {Promise<void>} completion of the operation.
1388
+ */
1389
+ async function runCleanup() {
1390
+ setCleaning(true)
1391
+ try {
1392
+ const { status, ok, payload } = await requestJson('cleanup', { method: 'POST' })
1393
+ if (!ok) {
1394
+ ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1395
+ return
1396
+ }
1397
+ const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1398
+ const message = purged > 0 ? format(t('cleanupDone'), { count: purged }) : t('cleanupNone')
1399
+ bumpRevision()
1400
+ ctx.contextMenus.notify(message, true)
1401
+ announce(message)
1402
+ } catch {
1403
+ ctx.contextMenus.notify(t('errorNetwork'), false)
1404
+ } finally {
1405
+ setCleaning(false)
1406
+ }
1407
+ }
1408
+
1409
+ /**
1410
+ * Permanently delete every entry in the trash.
1411
+ *
1412
+ * @returns {Promise<void>} completion of the operation.
1413
+ */
1414
+ async function performPurgeAll() {
1415
+ setPurgingAll(true)
1416
+ try {
1417
+ const { status, ok, payload } = await requestJson('purge-all', { method: 'POST' })
1418
+ if (!ok) {
1419
+ ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1420
+ return
1421
+ }
1422
+ const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
1423
+ const forgotten =
1424
+ payload !== null && payload !== undefined && typeof payload.forgotten === 'number' ? payload.forgotten : 0
1425
+ const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
1426
+ const removed = purged + forgotten
1427
+ const message =
1428
+ failed > 0
1429
+ ? format(t('purgeAllPartial'), { removed, failed })
1430
+ : removed > 0
1431
+ ? format(t('purgeAllDone'), { count: removed })
1432
+ : t('purgeAllNone')
1433
+ bumpRevision()
1434
+ ctx.contextMenus.notify(message, failed === 0)
1435
+ announce(message)
1436
+ } catch {
1437
+ ctx.contextMenus.notify(t('errorNetwork'), false)
1438
+ } finally {
1439
+ setPurgingAll(false)
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * @returns {Promise<void>} completion of the operation.
1445
+ */
1446
+ async function runRebuild() {
1447
+ setRebuilding(true)
1448
+ try {
1449
+ const { status, ok, payload } = await requestJson('rebuild', { method: 'POST' })
1450
+ if (!ok) {
1451
+ ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
1452
+ return
1453
+ }
1454
+ const count = payload !== null && payload !== undefined && typeof payload.entries === 'number' ? payload.entries : 0
1455
+ const unindexedCount =
1456
+ payload !== null && payload !== undefined && typeof payload.unindexed === 'number' ? payload.unindexed : 0
1457
+ const message =
1458
+ unindexedCount > 0
1459
+ ? format(t('rebuildDoneUnindexed'), { count, unindexed: unindexedCount })
1460
+ : format(t('rebuildDone'), { count })
1461
+ bumpRevision()
1462
+ ctx.contextMenus.notify(message, true)
1463
+ announce(message)
1464
+ } catch {
1465
+ ctx.contextMenus.notify(t('errorNetwork'), false)
1466
+ } finally {
1467
+ setRebuilding(false)
1468
+ }
1469
+ }
1470
+
1471
+ /**
1472
+ * @param {object} entry wire entry.
1473
+ * @param {string} key menu identity.
1474
+ * @returns {object} one menu item.
1475
+ */
1476
+ function menuItem(entry, key) {
1477
+ if (key === 'orphan') {
1478
+ return {
1479
+ key: 'orphan',
1480
+ label: t('actionRestoreOrphan'),
1481
+ onSelect: () => setDialog({ kind: 'orphan', entry }),
1482
+ }
1483
+ }
1484
+ if (key === 'reveal') {
1485
+ const available = entry.originalCwd !== null && entry.workspaceNowExists === true
1486
+ return {
1487
+ key: 'reveal',
1488
+ label: t('actionReveal'),
1489
+ disabled: !available,
1490
+ title: available ? entry.originalCwd : t('actionRevealDisabled'),
1491
+ ariaLabel: available ? undefined : `${t('actionReveal')} — ${t('actionRevealDisabled')}`,
1492
+ onSelect: () => {
1493
+ void performReveal(entry)
1494
+ },
1495
+ }
1496
+ }
1497
+ if (key === 'copy') {
1498
+ return {
1499
+ key: 'copy',
1500
+ label: t('actionCopyId'),
1501
+ onSelect: () => {
1502
+ void performCopy(entry)
1503
+ },
1504
+ }
1505
+ }
1506
+ if (key === 'forget') {
1507
+ return {
1508
+ key: 'forget',
1509
+ label: t('actionForget'),
1510
+ separatorBefore: true,
1511
+ onSelect: () => setDialog({ kind: 'forget', entry }),
1512
+ }
1513
+ }
1514
+ return {
1515
+ key: 'purge',
1516
+ label: t('actionPurge'),
1517
+ danger: true,
1518
+ separatorBefore: true,
1519
+ onSelect: () => setDialog({ kind: 'purge', entry }),
1520
+ }
1521
+ }
1522
+
1523
+ /**
1524
+ * @param {object} entry wire entry.
1525
+ * @returns {object[]} the row's menu items.
1526
+ */
1527
+ function menuItemsFor(entry) {
1528
+ const keys = []
1529
+ if (entry.canRestore === true) keys.push('orphan')
1530
+ keys.push('reveal', 'copy')
1531
+ if (entry.canForget === true) keys.push('forget')
1532
+ if (entry.canPurge === true) keys.push('purge')
1533
+ return keys.map((key) => menuItem(entry, key))
1534
+ }
1535
+
1536
+ /**
1537
+ * @param {object} entry wire entry.
1538
+ * @returns {object[]} status badges for one row.
1539
+ */
1540
+ function badgesFor(entry) {
1541
+ const badges = []
1542
+ if (entry.state === 'missing') {
1543
+ badges.push({ key: 'missing', label: t('badgeMissing'), tone: 'danger' })
1544
+ } else if (entry.state === 'trashing') {
1545
+ badges.push({ key: 'trashing', label: t('stateTrashing'), tone: 'info' })
1546
+ } else if (entry.state === 'restoring') {
1547
+ badges.push({ key: 'restoring', label: t('stateRestoring'), tone: 'info' })
1548
+ } else if (entry.state === 'purging') {
1549
+ badges.push({ key: 'purging', label: t('statePurging'), tone: 'info' })
1550
+ }
1551
+ if (entry.originalCwd !== null && entry.workspaceNowExists === false) {
1552
+ badges.push({ key: 'workspace', label: t('badgeWorkspaceMissing'), tone: 'warn' })
1553
+ }
1554
+ if (entry.formatStatus === 'migration-required') {
1555
+ badges.push({ key: 'migration', label: t('badgeMigration'), tone: 'warn' })
1556
+ }
1557
+ const subagentCount = entry.subagentCount ?? 0
1558
+ if (subagentCount > 0) {
1559
+ badges.push({
1560
+ key: 'subagents',
1561
+ label: format(t('badgeSubagents'), { count: subagentCount }),
1562
+ tone: 'info',
1563
+ })
1564
+ }
1565
+ const retention = remaining(entry, locale, now)
1566
+ if (retention.days !== null && retention.days <= 3) {
1567
+ badges.push({
1568
+ key: 'expiring',
1569
+ label: retention.days <= 0 ? t('badgeExpired') : t('badgeExpiring'),
1570
+ tone: 'warn',
1571
+ })
1572
+ }
1573
+ return badges
1574
+ }
1575
+
1576
+ /**
1577
+ * @param {object} entry wire entry.
1578
+ * @returns {string} the localized retention sentence for one row.
1579
+ */
1580
+ function retentionText(entry) {
1581
+ if (entry.state !== 'trashed' || typeof entry.expiresAt !== 'number') return t('notScheduled')
1582
+ const retention = remaining(entry, locale, now)
1583
+ if (retention.days === null || retention.days <= 0) return t('badgeExpired')
1584
+ return `${retention.text} · ${format(t('expiresOn'), { date: formatDate(entry.expiresAt, locale) })}`
1585
+ }
1586
+
1587
+ const header = h(
1588
+ 'header',
1589
+ { className: 'dsh-trash__header', key: 'header' },
1590
+ h('h2', { className: 'dsh-trash__title' }, t('title')),
1591
+ h('p', { className: 'dsh-trash__subtitle' }, format(t('subtitle'), { days: retentionDays })),
1592
+ )
1593
+
1594
+ const banners = []
1595
+ if (corrupt) {
1596
+ banners.push(
1597
+ h(
1598
+ 'div',
1599
+ { className: 'dsh-trash__banner dsh-trash__banner--warn', role: 'status', key: 'corrupt' },
1600
+ h(TrashGlyph, { size: 16 }),
1601
+ h('span', { className: 'dsh-trash__bannerText' }, t('corruptBanner')),
1602
+ h(
1603
+ 'button',
1604
+ {
1605
+ type: 'button',
1606
+ className: 'dsh-trash__button',
1607
+ onClick: () => {
1608
+ void runRebuild()
1609
+ },
1610
+ disabled: rebuilding,
1611
+ },
1612
+ rebuilding ? t('rebuilding') : t('rebuild'),
1613
+ ),
1614
+ ),
1615
+ )
1616
+ } else if (recovered) {
1617
+ banners.push(
1618
+ h(
1619
+ 'div',
1620
+ { className: 'dsh-trash__banner dsh-trash__banner--warn', role: 'status', key: 'recovered' },
1621
+ h('span', { className: 'dsh-trash__bannerText' }, t('recoveredBanner')),
1622
+ ),
1623
+ )
1624
+ }
1625
+ if (unindexed > 0) {
1626
+ banners.push(
1627
+ h(
1628
+ 'div',
1629
+ { className: 'dsh-trash__banner dsh-trash__banner--info', role: 'status', key: 'unindexed' },
1630
+ h('span', { className: 'dsh-trash__bannerText' }, format(t('unindexedBanner'), { count: unindexed })),
1631
+ ),
1632
+ )
1633
+ }
1634
+ if (!autoCleanupEnabled) {
1635
+ banners.push(
1636
+ h(
1637
+ 'div',
1638
+ { className: 'dsh-trash__banner dsh-trash__banner--info', role: 'status', key: 'auto-cleanup' },
1639
+ h('span', { className: 'dsh-trash__bannerText' }, t('autoCleanupBanner')),
1640
+ ),
1641
+ )
1642
+ }
1643
+ if (cleanupFailed > 0 || cleanupError !== null) {
1644
+ const retryText =
1645
+ cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
1646
+ const cleanupMessage =
1647
+ cleanupFailed > 0
1648
+ ? format(t('cleanupFailedBanner'), { count: cleanupFailed })
1649
+ : format(t('cleanupErrorBanner'), { message: cleanupError })
1650
+ banners.push(
1651
+ h(
1652
+ 'div',
1653
+ { className: 'dsh-trash__banner dsh-trash__banner--warn', role: 'status', key: 'cleanup-failed' },
1654
+ h(TrashGlyph, { size: 16 }),
1655
+ h(
1656
+ 'span',
1657
+ { className: 'dsh-trash__bannerText' },
1658
+ cleanupMessage,
1659
+ retryText === '' ? null : ` ${retryText}`,
1660
+ ),
1661
+ h(
1662
+ 'button',
1663
+ {
1664
+ type: 'button',
1665
+ className: 'dsh-trash__button',
1666
+ onClick: () => {
1667
+ void runCleanup()
1668
+ },
1669
+ disabled: busy || cleaning || corrupt,
1670
+ },
1671
+ cleaning ? t('cleaning') : t('cleanupRetryNow'),
1672
+ ),
1673
+ ),
1674
+ )
1675
+ }
1676
+ if (view.error !== null) {
1677
+ banners.push(
1678
+ h(
1679
+ 'div',
1680
+ { className: 'dsh-trash__banner dsh-trash__banner--error', role: 'alert', key: 'error' },
1681
+ h('span', { className: 'dsh-trash__bannerText' }, view.error),
1682
+ h('button', { type: 'button', className: 'dsh-trash__button', onClick: load }, t('retry')),
1683
+ ),
1684
+ )
1685
+ }
1686
+
1687
+ const statsText = autoCleanupEnabled
1688
+ ? nextSweepAt !== null && nextSweepAt !== undefined
1689
+ ? format(t('nextSweep'), { date: formatDate(nextSweepAt, locale, true) })
1690
+ : t('nextSweepUnknown')
1691
+ : t('autoCleanupOff')
1692
+
1693
+ const toolbar =
1694
+ data === null
1695
+ ? null
1696
+ : h(
1697
+ 'div',
1698
+ { className: 'dsh-trash__toolbar', key: 'toolbar' },
1699
+ h(
1700
+ 'span',
1701
+ { className: 'dsh-trash__stats' },
1702
+ format(t('statsCount'), { count: entries.length }),
1703
+ h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1704
+ format(t('statsSize'), { size: formatBytes(totalBytes) }),
1705
+ h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1706
+ statsText,
1707
+ ),
1708
+ h(
1709
+ 'div',
1710
+ { className: 'dsh-trash__toolbarActions' },
1711
+ h(
1712
+ 'button',
1713
+ {
1714
+ type: 'button',
1715
+ className: 'dsh-trash__button',
1716
+ onClick: () => {
1717
+ void runCleanup()
1718
+ },
1719
+ disabled: busy || cleaning || corrupt,
1720
+ },
1721
+ cleaning ? t('cleaning') : t('cleanupExpired'),
1722
+ ),
1723
+ h(
1724
+ 'button',
1725
+ {
1726
+ type: 'button',
1727
+ className: 'dsh-trash__button',
1728
+ onClick: load,
1729
+ disabled: busy,
1730
+ 'aria-busy': busy ? 'true' : 'false',
1731
+ },
1732
+ t('refresh'),
1733
+ ),
1734
+ h(
1735
+ 'button',
1736
+ {
1737
+ type: 'button',
1738
+ className: 'dsh-trash__button dsh-trash__button--danger',
1739
+ onClick: () => setDialog({ kind: 'purgeAll', count: entries.length }),
1740
+ disabled: busy || cleaning || purgingAll || corrupt || entries.length === 0,
1741
+ },
1742
+ purgingAll ? t('purgingAll') : t('purgeAll'),
1743
+ ),
1744
+ ),
1745
+ )
1746
+
1747
+ const controls =
1748
+ entries.length === 0
1749
+ ? null
1750
+ : h(
1751
+ 'div',
1752
+ { className: 'dsh-trash__controls', key: 'controls' },
1753
+ h(
1754
+ 'div',
1755
+ { className: 'dsh-trash__searchRow' },
1756
+ h(
1757
+ 'div',
1758
+ { className: 'dsh-trash__search' },
1759
+ h('span', { className: 'dsh-trash__searchGlyph' }, h(SearchGlyph, { size: 14 })),
1760
+ h('input', {
1761
+ type: 'search',
1762
+ className: 'dsh-trash__searchInput',
1763
+ value: query,
1764
+ placeholder: t('searchPlaceholder'),
1765
+ 'aria-label': t('searchLabel'),
1766
+ autoComplete: 'off',
1767
+ spellCheck: false,
1768
+ ref: searchRef,
1769
+ onChange: (event) => {
1770
+ setQuery(event.target.value)
1771
+ setPage(1)
1772
+ },
1773
+ onKeyDown: (event) => {
1774
+ if (event.key !== 'Escape' || query === '') return
1775
+ event.preventDefault()
1776
+ setQuery('')
1777
+ setPage(1)
1778
+ },
1779
+ }),
1780
+ query === ''
1781
+ ? null
1782
+ : h(
1783
+ 'button',
1784
+ {
1785
+ type: 'button',
1786
+ className: 'dsh-trash__searchClear',
1787
+ 'aria-label': t('searchClear'),
1788
+ title: t('searchClear'),
1789
+ onClick: () => {
1790
+ setQuery('')
1791
+ setPage(1)
1792
+ focusSearch()
1793
+ },
1794
+ },
1795
+ h(CloseGlyph, { size: 11 }),
1796
+ ),
1797
+ ),
1798
+ filtersActive
1799
+ ? h(
1800
+ 'span',
1801
+ { className: 'dsh-trash__resultsCount' },
1802
+ format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
1803
+ )
1804
+ : null,
1805
+ ),
1806
+ h(
1807
+ 'div',
1808
+ { className: 'dsh-trash__filters', role: 'radiogroup', 'aria-label': t('filterLabel') },
1809
+ FILTERS.map((value) =>
1810
+ h(
1811
+ 'label',
1812
+ { className: 'dsh-trash__filterOption', key: value },
1813
+ h('input', {
1814
+ type: 'radio',
1815
+ className: 'dsh-trash__filterInput',
1816
+ name: 'dsh-trash-filter',
1817
+ value,
1818
+ checked: filter === value,
1819
+ onChange: () => {
1820
+ setFilter(value)
1821
+ setPage(1)
1822
+ const matches = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry, value)).length
1823
+ announce(format(t('filterResults'), { count: matches }))
1824
+ },
1825
+ }),
1826
+ h('span', { className: 'dsh-trash__filterLabel' }, t(FILTER_LABEL_KEYS[value])),
1827
+ ),
1828
+ ),
1829
+ ),
1830
+ )
1831
+
1832
+ let panelBody
1833
+ if (view.status === 'loading') {
1834
+ panelBody = h(
1835
+ 'div',
1836
+ { className: 'dsh-trash__skeletons' },
1837
+ h('span', { className: 'dsh-trash__srOnly', role: 'status' }, t('loading')),
1838
+ [0, 1, 2, 3].map((index) => h('div', { className: 'dsh-trash__skeleton', key: index, 'aria-hidden': true })),
1839
+ )
1840
+ } else if (entries.length === 0) {
1841
+ panelBody = h(
1842
+ 'div',
1843
+ { className: 'dsh-trash__empty' },
1844
+ h('span', { className: 'dsh-trash__emptyGlyph' }, h(TrashGlyph, { size: 28 })),
1845
+ h('div', { className: 'dsh-trash__emptyTitle' }, t('emptyTitle')),
1846
+ h('div', { className: 'dsh-trash__emptyBody' }, t('emptyBody')),
1847
+ )
1848
+ } else if (filteredEntries.length === 0) {
1849
+ panelBody = h(
1850
+ 'div',
1851
+ { className: 'dsh-trash__empty' },
1852
+ h('span', { className: 'dsh-trash__emptyGlyph' }, h(SearchGlyph, { size: 28 })),
1853
+ h('div', { className: 'dsh-trash__emptyTitle' }, t('noResultsTitle')),
1854
+ h('div', { className: 'dsh-trash__emptyBody' }, t('noResultsBody')),
1855
+ h(
1856
+ 'button',
1857
+ { type: 'button', className: 'dsh-trash__button dsh-trash__emptyAction', onClick: clearFilters },
1858
+ t('clearFilters'),
1859
+ ),
1860
+ )
1861
+ } else {
1862
+ panelBody = h(
1863
+ 'ul',
1864
+ { className: 'dsh-trash__list', 'aria-label': t('listLabel') },
1865
+ visibleEntries.map((entry) => {
1866
+ const rowBusy = busyId === entry.sessionId
1867
+ const error = rowErrors[entry.sessionId] ?? entry.lastError
1868
+ const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
1869
+ return h(
1870
+ 'li',
1871
+ { className: 'dsh-trash__row', key: entry.sessionId, 'aria-busy': rowBusy ? 'true' : 'false' },
1872
+ h(
1873
+ 'div',
1874
+ { className: 'dsh-trash__rowMain' },
1875
+ h(
1876
+ 'div',
1877
+ { className: 'dsh-trash__rowTitleLine' },
1878
+ h('span', { className: 'dsh-trash__rowTitle', title }, title),
1879
+ badgesFor(entry).map((badge) =>
1880
+ h('span', { className: `dsh-trash__badge dsh-trash__badge--${badge.tone}`, key: badge.key }, badge.label),
1881
+ ),
1882
+ ),
1883
+ h(
1884
+ 'div',
1885
+ { className: 'dsh-trash__rowMeta' },
1886
+ h('code', { className: 'dsh-trash__rowId', title: entry.sessionId }, entry.sessionId),
1887
+ h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1888
+ h(
1889
+ 'span',
1890
+ {
1891
+ className: 'dsh-trash__rowWorkspace',
1892
+ title: entry.originalCwd ?? t('noWorkspace'),
1893
+ },
1894
+ entry.originalCwd ?? t('noWorkspace'),
1895
+ ),
1896
+ ),
1897
+ h(
1898
+ 'div',
1899
+ { className: 'dsh-trash__rowFacts' },
1900
+ h('span', null, `${t('trashedAtLabel')}: ${formatDate(entry.trashedAt, locale, true)}`),
1901
+ h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1902
+ h('span', null, retentionText(entry)),
1903
+ h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
1904
+ h('span', null, formatBytes(entry.sizeBytes)),
1905
+ ),
1906
+ error !== null && error !== undefined && error !== ''
1907
+ ? h('div', { className: 'dsh-trash__rowError', role: 'status' }, format(t('lastErrorLabel'), { message: error }))
1908
+ : null,
1909
+ ),
1910
+ h(
1911
+ 'div',
1912
+ { className: 'dsh-trash__rowActions' },
1913
+ entry.canRestore === true
1914
+ ? h(
1915
+ 'button',
1916
+ {
1917
+ type: 'button',
1918
+ className: 'dsh-trash__button',
1919
+ 'aria-label': format(t('actionRestoreFor'), { title }),
1920
+ disabled: rowBusy || busy || purgingAll,
1921
+ onClick: () => {
1922
+ if (entry.requiresOrphanRestore === true) setDialog({ kind: 'orphan', entry })
1923
+ else void performRestore(entry, 'normal')
1924
+ },
1925
+ },
1926
+ t('actionRestore'),
1927
+ )
1928
+ : null,
1929
+ h(ActionMenu, {
1930
+ label: format(t('actionMoreFor'), { title }),
1931
+ items: menuItemsFor(entry),
1932
+ disabled: rowBusy || busy || purgingAll,
1933
+ }),
1934
+ ),
1935
+ )
1936
+ }),
1937
+ )
1938
+ }
1939
+
1940
+ const pagination =
1941
+ pageCount > 1
1942
+ ? h(
1943
+ 'nav',
1944
+ { className: 'dsh-trash__pagination', 'aria-label': t('paginationLabel'), key: 'pagination' },
1945
+ h(
1946
+ 'span',
1947
+ { className: 'dsh-trash__pageInfo' },
1948
+ format(t('pageInfo'), { page: currentPage, total: pageCount }),
1949
+ ),
1950
+ h(
1951
+ 'div',
1952
+ { className: 'dsh-trash__pageButtons' },
1953
+ h(
1954
+ 'button',
1955
+ {
1956
+ type: 'button',
1957
+ className: 'dsh-trash__button',
1958
+ disabled: currentPage <= 1,
1959
+ onClick: () => goToPage(currentPage - 1),
1960
+ },
1961
+ t('previousPage'),
1962
+ ),
1963
+ h(
1964
+ 'button',
1965
+ {
1966
+ type: 'button',
1967
+ className: 'dsh-trash__button',
1968
+ disabled: currentPage >= pageCount,
1969
+ onClick: () => goToPage(currentPage + 1),
1970
+ },
1971
+ t('nextPage'),
1972
+ ),
1973
+ ),
1974
+ )
1975
+ : null
1976
+
1977
+ const panel =
1978
+ data === null && view.status === 'error'
1979
+ ? null
1980
+ : h(
1981
+ 'section',
1982
+ {
1983
+ className: 'dsh-trash__panel',
1984
+ key: 'panel',
1985
+ ref: panelRef,
1986
+ 'aria-busy': busy ? 'true' : 'false',
1987
+ },
1988
+ panelBody,
1989
+ pagination,
1990
+ )
1991
+
1992
+ const footnote =
1993
+ data !== null && typeof data.trashRootDisplay === 'string'
1994
+ ? h('p', { className: 'dsh-trash__footnote', key: 'footnote' }, format(t('footnote'), { path: data.trashRootDisplay }))
1995
+ : null
1996
+
1997
+ const dialogNode =
1998
+ dialog === null
1999
+ ? null
2000
+ : h(ConfirmDialog, {
2001
+ kind: dialog.kind,
2002
+ entry: dialog.entry,
2003
+ count: dialog.count,
2004
+ t,
2005
+ onCancel: closeDialog,
2006
+ onConfirm: () => {
2007
+ const current = dialog
2008
+ setDialog(null)
2009
+ if (current.kind === 'orphan') void performRestore(current.entry, 'orphan')
2010
+ else if (current.kind === 'purge') void performPurge(current.entry)
2011
+ else if (current.kind === 'purgeAll') void performPurgeAll()
2012
+ else void performForget(current.entry)
2013
+ },
2014
+ })
2015
+
2016
+ return h(
2017
+ 'div',
2018
+ { className: 'dsh-trash' },
2019
+ h('style', { key: 'styles' }, STYLES),
2020
+ header,
2021
+ banners,
2022
+ toolbar,
2023
+ controls,
2024
+ panel,
2025
+ footnote,
2026
+ h('div', { className: 'dsh-trash__srOnly', role: 'status', 'aria-live': 'polite' }, announcement),
2027
+ dialogNode,
2028
+ )
2029
+ }
2030
+ }
2031
+
2032
+ return {
2033
+ inject: ['slots', 'locale', 'contextMenus'],
2034
+ apply(ctx) {
2035
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-trash: locale')
2036
+ const t = ctx.locale.bind(NS)
2037
+ const TrashSection = createSection(ctx, t)
2038
+
2039
+ /**
2040
+ * Move one session into the trash and offer the shared Toast's single
2041
+ * undo action.
2042
+ *
2043
+ * @param {string} sessionId session to trash.
2044
+ * @param {string} [fallbackTitle] row title shown before the Host answers.
2045
+ * @returns {Promise<void>} completion of the click handler.
2046
+ */
2047
+ async function runTrash(sessionId, fallbackTitle) {
2048
+ try {
2049
+ const { status, ok, payload } = await requestJson('trash', {
2050
+ method: 'POST',
2051
+ body: { sessionId },
2052
+ })
2053
+ if (!ok) {
2054
+ ctx.contextMenus.notify(describeActionFailure(status, payload, t), false)
2055
+ return
2056
+ }
2057
+ bumpRevision()
2058
+ const responseEntry = payload !== null && payload !== undefined ? payload.entry : undefined
2059
+ const responseTitle = responseEntry !== null && responseEntry !== undefined ? responseEntry.title : undefined
2060
+ const title =
2061
+ typeof responseTitle === 'string' && responseTitle !== ''
2062
+ ? responseTitle
2063
+ : typeof fallbackTitle === 'string' && fallbackTitle !== ''
2064
+ ? fallbackTitle
2065
+ : sessionId
2066
+ const subagentCount =
2067
+ responseEntry !== null && responseEntry !== undefined && typeof responseEntry.subagentCount === 'number'
2068
+ ? responseEntry.subagentCount
2069
+ : 0
2070
+ const message =
2071
+ subagentCount > 0
2072
+ ? format(t('trashSuccessTree'), { title, count: subagentCount })
2073
+ : format(t('trashSuccess'), { title })
2074
+ ctx.contextMenus.notify(message, true, {
2075
+ label: t('undo'),
2076
+ run: () => undoTrash(sessionId, title),
2077
+ failureLabel: t('undoFailed'),
2078
+ })
2079
+ } catch {
2080
+ ctx.contextMenus.notify(t('errorNetwork'), false)
2081
+ }
2082
+ }
2083
+
2084
+ /**
2085
+ * Restore one session from the shared Toast. Normal restore is tried
2086
+ * first; a missing workspace silently retries once as an orphan restore,
2087
+ * so the Toast action never opens a dialog.
2088
+ *
2089
+ * @param {string} sessionId session to restore.
2090
+ * @param {string} [fallbackTitle] title captured when the Toast appeared.
2091
+ * @returns {Promise<void>} completion of the action.
2092
+ */
2093
+ async function undoTrash(sessionId, fallbackTitle) {
2094
+ try {
2095
+ let answer = await requestJson('restore', {
2096
+ method: 'POST',
2097
+ body: { sessionId, mode: 'normal' },
2098
+ })
2099
+ if (!answer.ok) {
2100
+ const code =
2101
+ answer.payload !== null && answer.payload !== undefined && answer.payload.error !== undefined
2102
+ ? answer.payload.error.code
2103
+ : undefined
2104
+ if (code === 'WORKSPACE_MISSING') {
2105
+ answer = await requestJson('restore', {
2106
+ method: 'POST',
2107
+ body: { sessionId, mode: 'orphan' },
2108
+ })
2109
+ }
2110
+ }
2111
+ if (!answer.ok) {
2112
+ ctx.contextMenus.notify(describeActionFailure(answer.status, answer.payload, t), false)
2113
+ return
2114
+ }
2115
+ bumpRevision()
2116
+ const session = answer.payload !== null && answer.payload !== undefined ? answer.payload.session : undefined
2117
+ const restoredTitle =
2118
+ session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
2119
+ ? session.title
2120
+ : typeof fallbackTitle === 'string' && fallbackTitle !== ''
2121
+ ? fallbackTitle
2122
+ : sessionId
2123
+ const orphan = session !== null && session !== undefined && session.orphan === true
2124
+ ctx.contextMenus.notify(format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title: restoredTitle }), true)
2125
+ } catch {
2126
+ ctx.contextMenus.notify(t('errorNetwork'), false)
2127
+ }
2128
+ }
2129
+
2130
+ ctx.effect(
2131
+ () =>
2132
+ ctx.slots.inject('settings.section', () =>
2133
+ ctx.slots.register(
2134
+ { name: 'settings.section', id: SECTION_ID, order: 25, label: () => t('nav'), locale: NS },
2135
+ TrashSection,
2136
+ ),
2137
+ ),
2138
+ 'session-trash: settings section',
2139
+ )
2140
+
2141
+ ctx.effect(
2142
+ () =>
2143
+ ctx.slots.inject('settings.section.icon', () =>
2144
+ ctx.slots.register({ name: 'settings.section.icon', id: SECTION_ID }, TrashGlyph),
2145
+ ),
2146
+ 'session-trash: settings section icon',
2147
+ )
2148
+
2149
+ ctx.effect(
2150
+ () =>
2151
+ ctx.slots.inject('contextMenu.icon', () =>
2152
+ ctx.slots.register({ name: 'contextMenu.icon', key: MENU_ID }, TrashGlyph),
2153
+ ),
2154
+ 'session-trash: menu icon',
2155
+ )
2156
+
2157
+ ctx.effect(
2158
+ () =>
2159
+ ctx.contextMenus.register({
2160
+ id: MENU_ID,
2161
+ target: 'session',
2162
+ order: 550,
2163
+ observe(target) {
2164
+ const sources = [ctx.locale]
2165
+ const remoteAccess = ctx.get('remoteAccess')
2166
+ if (
2167
+ remoteAccess !== undefined &&
2168
+ remoteAccess !== null &&
2169
+ typeof remoteAccess.source === 'function' &&
2170
+ target !== null &&
2171
+ typeof target === 'object' &&
2172
+ typeof target.sessionId === 'string'
2173
+ ) {
2174
+ try {
2175
+ const source = remoteAccess.source('session', target.sessionId, 'session/rename')
2176
+ if (source !== null && source !== undefined && typeof source.subscribe === 'function') {
2177
+ sources.push(source)
2178
+ }
2179
+ } catch {
2180
+ // Permission sources are optional; resolution still re-checks.
2181
+ }
2182
+ }
2183
+ return sources
2184
+ },
2185
+ async resolve(target, signal) {
2186
+ const sessionId = target !== null && typeof target === 'object' ? target.sessionId : undefined
2187
+ const displayTitle = target !== null && typeof target === 'object' ? target.displayTitle : undefined
2188
+ if (typeof sessionId !== 'string' || sessionId === '') return []
2189
+ const remoteAccess = ctx.get('remoteAccess')
2190
+ if (remoteAccess !== undefined && remoteAccess !== null && typeof remoteAccess.source === 'function') {
2191
+ try {
2192
+ const access = remoteAccess.source('session', sessionId, 'session/rename')
2193
+ if (access !== null && access !== undefined && typeof access.getSnapshot === 'function' && access.getSnapshot() !== true) {
2194
+ return []
2195
+ }
2196
+ } catch {
2197
+ // Access sources are advisory here; the Host API still enforces owner-only access.
2198
+ }
2199
+ }
2200
+ const verdict = await preflight(sessionId, signal)
2201
+ if (verdict === null) return []
2202
+ if (verdict.eligible !== true) {
2203
+ const blocked = typeof verdict.code === 'string' ? MOVE_BLOCKED_KEYS[verdict.code] : undefined
2204
+ if (blocked === undefined) return []
2205
+ return [
2206
+ {
2207
+ id: 'move-refused',
2208
+ label: t(blocked.label),
2209
+ icon: { key: MENU_ID },
2210
+ separatorBefore: true,
2211
+ run: () => ctx.contextMenus.notify(t(blocked.toast), false),
2212
+ },
2213
+ ]
2214
+ }
2215
+ return [
2216
+ {
2217
+ id: 'move',
2218
+ label: t('move'),
2219
+ icon: { key: MENU_ID },
2220
+ separatorBefore: true,
2221
+ run: () => runTrash(sessionId, typeof displayTitle === 'string' ? displayTitle : ''),
2222
+ },
2223
+ ]
2224
+ },
2225
+ }),
2226
+ 'session-trash: move menu',
2227
+ )
2228
+ },
2229
+ }
2230
+ },
2231
+ })