dsh-plugin-delete-session 0.0.0-stage → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.en.md +132 -0
- package/README.md +134 -2
- package/client.js +841 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +15 -0
- package/index.js +652 -0
- package/lib/paths.js +156 -0
- package/lib/trash.js +539 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
package/client.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the session-trash bundle.
|
|
3
|
+
*
|
|
4
|
+
* Registers three additive surfaces:
|
|
5
|
+
* - a destructive row in one Session's "..." menu;
|
|
6
|
+
* - a small recycle-bin button beside Settings;
|
|
7
|
+
* - the `shell.overlay` dialogs (delete confirmation, recycle bin) and the
|
|
8
|
+
* post-delete notice with an immediate undo.
|
|
9
|
+
*
|
|
10
|
+
* All durable work runs in the Host half over the `/session-trash-api` HTTP
|
|
11
|
+
* route, which enforces the same Host/Origin and browser-credential checks as
|
|
12
|
+
* every other `/api` request; this module only renders and calls it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-plugin-delete-session',
|
|
17
|
+
factory(require) {
|
|
18
|
+
const React = require('react')
|
|
19
|
+
const h = React.createElement
|
|
20
|
+
let Toast
|
|
21
|
+
let IconWarningOutlineRegular
|
|
22
|
+
try {
|
|
23
|
+
// The archive UI uses the same primitive. Keep the delete action usable
|
|
24
|
+
// (with a plain fallback notice) if a future DSH build moves this module.
|
|
25
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
26
|
+
Toast = typeof primitives.Toast === 'function' ? primitives.Toast : undefined
|
|
27
|
+
IconWarningOutlineRegular = typeof primitives.IconWarningOutlineRegular === 'function'
|
|
28
|
+
? primitives.IconWarningOutlineRegular
|
|
29
|
+
: undefined
|
|
30
|
+
} catch {
|
|
31
|
+
Toast = undefined
|
|
32
|
+
IconWarningOutlineRegular = undefined
|
|
33
|
+
}
|
|
34
|
+
const NS = 'session-trash'
|
|
35
|
+
|
|
36
|
+
const zh = {
|
|
37
|
+
'menu.delete': '删除对话',
|
|
38
|
+
'dialog.title': '删除对话',
|
|
39
|
+
'dialog.desc': '确定要删除“{title}”吗?删除后会立即从列表移除,并在回收站保留 {days} 天。期间可以恢复,逾期将永久删除。',
|
|
40
|
+
'dialog.cancel': '取消',
|
|
41
|
+
'dialog.confirm': '删除',
|
|
42
|
+
'dialog.deleting': '正在删除…',
|
|
43
|
+
'toast.deleted': '会话已删除,可',
|
|
44
|
+
'toast.deletedEmpty': '空会话已删除',
|
|
45
|
+
'toast.undo': '撤销',
|
|
46
|
+
'toast.restored': '会话已恢复',
|
|
47
|
+
'trash.title': '已删除的对话',
|
|
48
|
+
'trash.subtitle': '保留最近 {days} 天,期间可以随时恢复。',
|
|
49
|
+
'trash.empty': '回收站是空的',
|
|
50
|
+
'trash.loading': '正在加载…',
|
|
51
|
+
'trash.meta': '删除于 {time} · 剩余 {days} 天 · {size}',
|
|
52
|
+
'trash.restore': '恢复',
|
|
53
|
+
'trash.restoring': '恢复中…',
|
|
54
|
+
'trash.purge': '彻底删除',
|
|
55
|
+
'trash.purgeConfirm': '确认永久删除这条对话?此操作无法撤销。',
|
|
56
|
+
'trash.purgeYes': '永久删除',
|
|
57
|
+
'trash.cancel': '取消',
|
|
58
|
+
'trash.close': '关闭',
|
|
59
|
+
'trash.failed': '操作失败:{message}',
|
|
60
|
+
'footer.label': '已删除的对话',
|
|
61
|
+
'untitled': '未命名对话',
|
|
62
|
+
'error.bad-id': '无效的对话 ID',
|
|
63
|
+
'error.not-found': '找不到这个对话,它可能已经被删除了',
|
|
64
|
+
'error.no-entry': '回收站里没有这个对话,可能已经恢复或已过期',
|
|
65
|
+
'error.conflict': '原位置已存在同名对话数据,已取消恢复',
|
|
66
|
+
'error.lost-data': '回收站中的对话数据已丢失,无法恢复',
|
|
67
|
+
'error.connection': '无法连接到 DSH 服务',
|
|
68
|
+
'error.busy': '这个对话仍在使用中,请稍候重试',
|
|
69
|
+
'error.running': '这个对话仍在运行中,请先停止任务再删除',
|
|
70
|
+
'error.archive-failed': '无法归档这个对话,删除已取消',
|
|
71
|
+
'error.detach-failed': '无法从工作区移除这个对话,删除已取消',
|
|
72
|
+
'error.flush-failed': '无法写入会话日志,删除已取消',
|
|
73
|
+
'error.request': '请求失败',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const en = {
|
|
77
|
+
'menu.delete': 'Delete session',
|
|
78
|
+
'dialog.title': 'Delete session',
|
|
79
|
+
'dialog.desc': 'Delete “{title}”? It will disappear from the list immediately and stay in the recycle bin for {days} days. You can restore it during that window; after it expires the copy is removed permanently.',
|
|
80
|
+
'dialog.cancel': 'Cancel',
|
|
81
|
+
'dialog.confirm': 'Delete',
|
|
82
|
+
'dialog.deleting': 'Deleting…',
|
|
83
|
+
'toast.deleted': 'Session deleted. You can ',
|
|
84
|
+
'toast.deletedEmpty': 'Blank session deleted',
|
|
85
|
+
'toast.undo': 'undo',
|
|
86
|
+
'toast.restored': 'Session restored',
|
|
87
|
+
'trash.title': 'Deleted sessions',
|
|
88
|
+
'trash.subtitle': 'Kept for the last {days} days; restore any of them before it expires.',
|
|
89
|
+
'trash.empty': 'The recycle bin is empty',
|
|
90
|
+
'trash.loading': 'Loading…',
|
|
91
|
+
'trash.meta': 'Deleted {time} · {days} days left · {size}',
|
|
92
|
+
'trash.restore': 'Restore',
|
|
93
|
+
'trash.restoring': 'Restoring…',
|
|
94
|
+
'trash.purge': 'Delete permanently',
|
|
95
|
+
'trash.purgeConfirm': 'Delete this session permanently? This cannot be undone.',
|
|
96
|
+
'trash.purgeYes': 'Delete permanently',
|
|
97
|
+
'trash.cancel': 'Cancel',
|
|
98
|
+
'trash.close': 'Close',
|
|
99
|
+
'trash.failed': 'Action failed: {message}',
|
|
100
|
+
'footer.label': 'Deleted sessions',
|
|
101
|
+
'untitled': 'Untitled session',
|
|
102
|
+
'error.bad-id': 'Invalid session ID',
|
|
103
|
+
'error.not-found': 'This session no longer exists',
|
|
104
|
+
'error.no-entry': 'This session is not in the recycle bin; it may already be restored or expired',
|
|
105
|
+
'error.conflict': 'Session data already exists at the original location; restore cancelled',
|
|
106
|
+
'error.lost-data': 'The deleted copy is missing; it cannot be restored',
|
|
107
|
+
'error.connection': 'Cannot reach the DSH service',
|
|
108
|
+
'error.busy': 'This session is still in use; try again in a moment',
|
|
109
|
+
'error.running': 'This session is still running; stop its task before deleting it',
|
|
110
|
+
'error.archive-failed': 'Could not archive this session; deletion was cancelled',
|
|
111
|
+
'error.detach-failed': 'Could not remove this session from its workspace; deletion was cancelled',
|
|
112
|
+
'error.flush-failed': 'Could not write the session log; deletion was cancelled',
|
|
113
|
+
'error.request': 'Request failed',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createStore(initial) {
|
|
117
|
+
let value = initial
|
|
118
|
+
const listeners = new Set()
|
|
119
|
+
return {
|
|
120
|
+
get: () => value,
|
|
121
|
+
set: (next) => {
|
|
122
|
+
if (Object.is(next, value)) return
|
|
123
|
+
value = next
|
|
124
|
+
for (const listener of [...listeners]) listener()
|
|
125
|
+
},
|
|
126
|
+
subscribe: (listener) => {
|
|
127
|
+
listeners.add(listener)
|
|
128
|
+
return () => {
|
|
129
|
+
listeners.delete(listener)
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function useStore(store) {
|
|
136
|
+
return React.useSyncExternalStore(store.subscribe, store.get, store.get)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function TrashIcon({ size = 14 }) {
|
|
140
|
+
return h('svg', {
|
|
141
|
+
viewBox: '0 0 16 16',
|
|
142
|
+
width: size,
|
|
143
|
+
height: size,
|
|
144
|
+
fill: 'none',
|
|
145
|
+
stroke: 'currentColor',
|
|
146
|
+
strokeWidth: 1.4,
|
|
147
|
+
strokeLinecap: 'round',
|
|
148
|
+
strokeLinejoin: 'round',
|
|
149
|
+
'aria-hidden': true,
|
|
150
|
+
focusable: 'false',
|
|
151
|
+
style: { flex: 'none', display: 'block' },
|
|
152
|
+
},
|
|
153
|
+
h('path', { d: 'M2.4 4.4h11.2' }),
|
|
154
|
+
h('path', { d: 'M6.1 4.4V3.1c0-.5.4-.9.9-.9h1.9c.5 0 .9.4.9.9v1.3' }),
|
|
155
|
+
h('path', { d: 'M4.1 4.4l.6 8.4c0 .6.5 1 1 1h4.6c.5 0 1-.4 1-1l.6-8.4' }),
|
|
156
|
+
h('path', { d: 'M6.6 6.9v4.2M9.4 6.9v4.2' })
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function RestoreIcon({ size = 14 }) {
|
|
161
|
+
return h('svg', {
|
|
162
|
+
viewBox: '0 0 16 16',
|
|
163
|
+
width: size,
|
|
164
|
+
height: size,
|
|
165
|
+
fill: 'none',
|
|
166
|
+
stroke: 'currentColor',
|
|
167
|
+
strokeWidth: 1.4,
|
|
168
|
+
strokeLinecap: 'round',
|
|
169
|
+
strokeLinejoin: 'round',
|
|
170
|
+
'aria-hidden': true,
|
|
171
|
+
focusable: 'false',
|
|
172
|
+
style: { flex: 'none', display: 'block' },
|
|
173
|
+
},
|
|
174
|
+
h('path', { d: 'M3.2 6.4a5 5 0 1 1 .4 4.1' }),
|
|
175
|
+
h('path', { d: 'M3 3.4v3.1h3.1' })
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const MENU_CSS = `
|
|
180
|
+
.dsh-st-menu-item{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:18px;text-align:start;cursor:pointer}
|
|
181
|
+
.dsh-st-menu-item:hover,.dsh-st-menu-item:focus-visible{background:color-mix(in oklab,var(--dsw-alias-state-error-primary) 10%,transparent);outline:none}
|
|
182
|
+
.dsh-st-menu-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
183
|
+
`
|
|
184
|
+
|
|
185
|
+
const FOOTER_CSS = `
|
|
186
|
+
.dsh-st-footer-button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
187
|
+
.dsh-st-footer-button:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent);color:var(--dsw-alias-label-primary)}
|
|
188
|
+
.dsh-st-footer-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
189
|
+
`
|
|
190
|
+
|
|
191
|
+
const OVERLAY_CSS = `
|
|
192
|
+
.dsh-st-backdrop{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(15,23,42,.42);pointer-events:auto}
|
|
193
|
+
.dsh-st-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:14px;width:min(440px,100%);max-height:min(82vh,640px);padding:20px;border-radius:14px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 24px 64px rgba(0,0,0,.32);color:var(--dsw-alias-label-primary);font:inherit}
|
|
194
|
+
.dsh-st-head{display:flex;align-items:flex-start;gap:12px}
|
|
195
|
+
.dsh-st-head-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
|
196
|
+
.dsh-st-title{margin:0;font-size:15px;line-height:22px;font-weight:600}
|
|
197
|
+
.dsh-st-desc{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
|
|
198
|
+
.dsh-st-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px;flex:none}
|
|
199
|
+
.dsh-st-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;padding:7px 14px;cursor:pointer}
|
|
200
|
+
.dsh-st-btn:hover:not(:disabled){background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent)}
|
|
201
|
+
.dsh-st-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
202
|
+
.dsh-st-btn:disabled{opacity:.55;cursor:default}
|
|
203
|
+
.dsh-st-btn-danger{border-color:color-mix(in oklab,var(--dsw-alias-state-error-primary) 45%,transparent);color:var(--dsw-alias-state-error-primary)}
|
|
204
|
+
.dsh-st-btn-danger:hover:not(:disabled){background:color-mix(in oklab,var(--dsw-alias-state-error-primary) 12%,transparent)}
|
|
205
|
+
.dsh-st-small{padding:4px 9px;border-radius:7px;font-size:12px;line-height:16px}
|
|
206
|
+
.dsh-st-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:16px;line-height:1;cursor:pointer;flex:none}
|
|
207
|
+
.dsh-st-icon-btn:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent);color:var(--dsw-alias-label-primary)}
|
|
208
|
+
.dsh-st-icon-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
209
|
+
.dsh-st-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}
|
|
210
|
+
.dsh-st-list{display:flex;flex-direction:column;gap:2px;overflow:auto;margin:0 -6px;padding:0 6px;min-height:48px}
|
|
211
|
+
.dsh-st-placeholder{padding:18px 8px;text-align:center;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
212
|
+
.dsh-st-row{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px}
|
|
213
|
+
.dsh-st-row:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 6%,transparent)}
|
|
214
|
+
.dsh-st-row-main{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
|
|
215
|
+
.dsh-st-row-title{font-size:13px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
216
|
+
.dsh-st-row-meta{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
217
|
+
.dsh-st-row-actions{display:flex;align-items:center;gap:4px;flex:none}
|
|
218
|
+
`
|
|
219
|
+
|
|
220
|
+
function DeleteMenuItem(props) {
|
|
221
|
+
const { sessionId, displayTitle, requestDelete } = props
|
|
222
|
+
const tr = textOf(props.t, zh)
|
|
223
|
+
const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
|
|
224
|
+
const setMenuOpen = Array.isArray(menu) ? menu[1] : undefined
|
|
225
|
+
return h(React.Fragment, null,
|
|
226
|
+
h('style', { key: 'style' }, MENU_CSS),
|
|
227
|
+
h('button', {
|
|
228
|
+
key: 'button',
|
|
229
|
+
type: 'button',
|
|
230
|
+
role: 'menuitem',
|
|
231
|
+
className: 'dsh-st-menu-item',
|
|
232
|
+
onClick: (event) => {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
event.stopPropagation()
|
|
235
|
+
if (typeof setMenuOpen === 'function') setMenuOpen(false)
|
|
236
|
+
requestDelete(sessionId, displayTitle)
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
h(TrashIcon, { size: 14 }),
|
|
240
|
+
h('span', { className: 'dsh-st-menu-label' }, tr('menu.delete'))
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function TrashFooterButton(props) {
|
|
246
|
+
const tr = textOf(props.t, zh)
|
|
247
|
+
const label = tr('footer.label')
|
|
248
|
+
return h(React.Fragment, null,
|
|
249
|
+
h('style', { key: 'style' }, FOOTER_CSS),
|
|
250
|
+
h('button', {
|
|
251
|
+
key: 'button',
|
|
252
|
+
type: 'button',
|
|
253
|
+
className: 'dsh-st-footer-button',
|
|
254
|
+
title: label,
|
|
255
|
+
'aria-label': label,
|
|
256
|
+
onClick: () => props.openTrash(),
|
|
257
|
+
}, h(TrashIcon, { size: 16 }))
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function TrashOverlay(props) {
|
|
262
|
+
const face = props.face
|
|
263
|
+
const tr = textOf(props.t, zh)
|
|
264
|
+
const stores = face.stores
|
|
265
|
+
const actions = face.actions
|
|
266
|
+
const showNotice = face.showNotice
|
|
267
|
+
const request = useStore(stores.deleteRequest)
|
|
268
|
+
const open = useStore(stores.trashOpen)
|
|
269
|
+
const notice = useStore(stores.notice)
|
|
270
|
+
const listState = useStore(stores.entries)
|
|
271
|
+
const retentionDays = useStore(face.retentionDays)
|
|
272
|
+
|
|
273
|
+
const [busy, setBusy] = React.useState(false)
|
|
274
|
+
const [error, setError] = React.useState(null)
|
|
275
|
+
const [rowBusy, setRowBusy] = React.useState(null)
|
|
276
|
+
const [confirming, setConfirming] = React.useState(null)
|
|
277
|
+
const cancelRef = React.useRef(null)
|
|
278
|
+
const closeRef = React.useRef(null)
|
|
279
|
+
|
|
280
|
+
React.useEffect(() => {
|
|
281
|
+
if (!open) return
|
|
282
|
+
setError(null)
|
|
283
|
+
setConfirming(null)
|
|
284
|
+
void actions.loadTrash()
|
|
285
|
+
}, [open, actions])
|
|
286
|
+
|
|
287
|
+
React.useEffect(() => {
|
|
288
|
+
if (request) setError(null)
|
|
289
|
+
}, [request])
|
|
290
|
+
|
|
291
|
+
React.useEffect(() => {
|
|
292
|
+
const frame = requestAnimationFrame(() => {
|
|
293
|
+
if (request) {
|
|
294
|
+
cancelRef.current?.focus?.()
|
|
295
|
+
} else if (open) {
|
|
296
|
+
closeRef.current?.focus?.()
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
return () => cancelAnimationFrame(frame)
|
|
300
|
+
}, [request, open])
|
|
301
|
+
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (!request && !open) return undefined
|
|
304
|
+
const onKeyDown = (event) => {
|
|
305
|
+
if (event.key !== 'Escape' || busy) return
|
|
306
|
+
if (request) {
|
|
307
|
+
actions.cancelDelete()
|
|
308
|
+
setError(null)
|
|
309
|
+
} else if (open) {
|
|
310
|
+
actions.closeTrash()
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
document.addEventListener('keydown', onKeyDown)
|
|
314
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
315
|
+
}, [request, open, busy, actions])
|
|
316
|
+
|
|
317
|
+
React.useEffect(() => {
|
|
318
|
+
if (Toast !== undefined || !notice) return undefined
|
|
319
|
+
const timer = setTimeout(() => actions.dismissNotice(), 7000)
|
|
320
|
+
return () => clearTimeout(timer)
|
|
321
|
+
}, [notice, actions])
|
|
322
|
+
|
|
323
|
+
const deleteTitle = request && (request.title || tr('untitled'))
|
|
324
|
+
const confirmDelete = async () => {
|
|
325
|
+
if (busy) return
|
|
326
|
+
setBusy(true)
|
|
327
|
+
setError(null)
|
|
328
|
+
try {
|
|
329
|
+
await actions.confirmDelete()
|
|
330
|
+
} catch (reason) {
|
|
331
|
+
setError(errorText(reason, tr))
|
|
332
|
+
} finally {
|
|
333
|
+
setBusy(false)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const restoreRow = async (sessionId) => {
|
|
337
|
+
if (rowBusy) return
|
|
338
|
+
setRowBusy(sessionId)
|
|
339
|
+
setError(null)
|
|
340
|
+
try {
|
|
341
|
+
await actions.restore(sessionId)
|
|
342
|
+
} catch (reason) {
|
|
343
|
+
setError(errorText(reason, tr))
|
|
344
|
+
} finally {
|
|
345
|
+
setRowBusy(null)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const purgeRow = async (sessionId) => {
|
|
349
|
+
if (rowBusy) return
|
|
350
|
+
setRowBusy(sessionId)
|
|
351
|
+
setError(null)
|
|
352
|
+
try {
|
|
353
|
+
await actions.purge(sessionId)
|
|
354
|
+
setConfirming(null)
|
|
355
|
+
} catch (reason) {
|
|
356
|
+
setError(errorText(reason, tr))
|
|
357
|
+
} finally {
|
|
358
|
+
setRowBusy(null)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const undoDelete = async () => {
|
|
362
|
+
try {
|
|
363
|
+
await actions.undoDelete()
|
|
364
|
+
} catch (reason) {
|
|
365
|
+
showNotice({ kind: 'error', message: errorText(reason, tr) })
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const rows = listState.items.map((entry) => {
|
|
370
|
+
const days = Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 86_400_000))
|
|
371
|
+
const title = entry.title || tr('untitled')
|
|
372
|
+
const rowIsBusy = rowBusy === entry.sessionId
|
|
373
|
+
const asking = confirming === entry.sessionId
|
|
374
|
+
return h('div', { key: entry.sessionId, className: 'dsh-st-row' },
|
|
375
|
+
h(RestoreIcon, { size: 14 }),
|
|
376
|
+
h('div', { className: 'dsh-st-row-main' },
|
|
377
|
+
h('div', { className: 'dsh-st-row-title', title }, title),
|
|
378
|
+
h('div', { className: 'dsh-st-row-meta' }, tr('trash.meta', {
|
|
379
|
+
time: new Date(entry.deletedAt).toLocaleString(),
|
|
380
|
+
days: String(days),
|
|
381
|
+
size: formatBytes(entry.bytes),
|
|
382
|
+
}))
|
|
383
|
+
),
|
|
384
|
+
h('div', { className: 'dsh-st-row-actions' },
|
|
385
|
+
asking
|
|
386
|
+
? h(React.Fragment, null,
|
|
387
|
+
h('button', {
|
|
388
|
+
type: 'button',
|
|
389
|
+
className: 'dsh-st-btn dsh-st-small dsh-st-btn-danger',
|
|
390
|
+
disabled: rowIsBusy,
|
|
391
|
+
title: tr('trash.purgeConfirm'),
|
|
392
|
+
'aria-label': tr('trash.purgeConfirm'),
|
|
393
|
+
onClick: () => { void purgeRow(entry.sessionId) },
|
|
394
|
+
}, tr('trash.purgeYes')),
|
|
395
|
+
h('button', {
|
|
396
|
+
type: 'button',
|
|
397
|
+
className: 'dsh-st-btn dsh-st-small',
|
|
398
|
+
disabled: rowIsBusy,
|
|
399
|
+
onClick: () => setConfirming(null),
|
|
400
|
+
}, tr('trash.cancel'))
|
|
401
|
+
)
|
|
402
|
+
: h(React.Fragment, null,
|
|
403
|
+
h('button', {
|
|
404
|
+
type: 'button',
|
|
405
|
+
className: 'dsh-st-btn dsh-st-small',
|
|
406
|
+
disabled: rowIsBusy,
|
|
407
|
+
onClick: () => { void restoreRow(entry.sessionId) },
|
|
408
|
+
}, rowIsBusy ? tr('trash.restoring') : tr('trash.restore')),
|
|
409
|
+
h('button', {
|
|
410
|
+
type: 'button',
|
|
411
|
+
className: 'dsh-st-btn dsh-st-small',
|
|
412
|
+
disabled: rowIsBusy,
|
|
413
|
+
onClick: () => setConfirming(entry.sessionId),
|
|
414
|
+
}, tr('trash.purge'))
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
let listBody
|
|
421
|
+
if (listState.phase === 'loading' && listState.items.length === 0) {
|
|
422
|
+
listBody = h('div', { className: 'dsh-st-placeholder', role: 'status' }, tr('trash.loading'))
|
|
423
|
+
} else if (listState.items.length === 0) {
|
|
424
|
+
listBody = h('div', { className: 'dsh-st-placeholder' }, tr('trash.empty'))
|
|
425
|
+
} else {
|
|
426
|
+
listBody = h('div', { className: 'dsh-st-list' }, rows)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const toastText = notice === null
|
|
430
|
+
? ''
|
|
431
|
+
: notice.kind === 'deleted'
|
|
432
|
+
? (notice.empty === true ? tr('toast.deletedEmpty') : tr('toast.deleted'))
|
|
433
|
+
: notice.kind === 'restored'
|
|
434
|
+
? tr('toast.restored')
|
|
435
|
+
: tr('trash.failed', { message: notice.message ?? '' })
|
|
436
|
+
const toastActions = notice !== null && notice.kind === 'deleted' && notice.empty !== true
|
|
437
|
+
? [{ label: tr('toast.undo'), onClick: () => { void undoDelete() } }]
|
|
438
|
+
: []
|
|
439
|
+
|
|
440
|
+
return h(React.Fragment, null,
|
|
441
|
+
h('style', { key: 'style' }, OVERLAY_CSS),
|
|
442
|
+
request && h('div', {
|
|
443
|
+
key: 'delete-backdrop',
|
|
444
|
+
className: 'dsh-st-backdrop',
|
|
445
|
+
onMouseDown: (event) => {
|
|
446
|
+
if (event.target === event.currentTarget && !busy) {
|
|
447
|
+
actions.cancelDelete()
|
|
448
|
+
setError(null)
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
h('div', {
|
|
453
|
+
className: 'dsh-st-dialog',
|
|
454
|
+
role: 'dialog',
|
|
455
|
+
'aria-modal': 'true',
|
|
456
|
+
'aria-labelledby': 'dsh-st-delete-title',
|
|
457
|
+
},
|
|
458
|
+
h('h2', { className: 'dsh-st-title', id: 'dsh-st-delete-title' }, tr('dialog.title')),
|
|
459
|
+
h('p', { className: 'dsh-st-desc' }, tr('dialog.desc', {
|
|
460
|
+
title: deleteTitle,
|
|
461
|
+
days: String(retentionDays),
|
|
462
|
+
})),
|
|
463
|
+
error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: error })),
|
|
464
|
+
h('div', { className: 'dsh-st-actions' },
|
|
465
|
+
h('button', {
|
|
466
|
+
type: 'button',
|
|
467
|
+
className: 'dsh-st-btn',
|
|
468
|
+
ref: cancelRef,
|
|
469
|
+
disabled: busy,
|
|
470
|
+
onClick: () => {
|
|
471
|
+
if (busy) return
|
|
472
|
+
actions.cancelDelete()
|
|
473
|
+
setError(null)
|
|
474
|
+
},
|
|
475
|
+
}, tr('dialog.cancel')),
|
|
476
|
+
h('button', {
|
|
477
|
+
type: 'button',
|
|
478
|
+
className: 'dsh-st-btn dsh-st-btn-danger',
|
|
479
|
+
disabled: busy,
|
|
480
|
+
onClick: () => { void confirmDelete() },
|
|
481
|
+
}, busy ? tr('dialog.deleting') : tr('dialog.confirm'))
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
),
|
|
485
|
+
open && h('div', {
|
|
486
|
+
key: 'trash-backdrop',
|
|
487
|
+
className: 'dsh-st-backdrop',
|
|
488
|
+
onMouseDown: (event) => {
|
|
489
|
+
if (event.target === event.currentTarget && !busy) actions.closeTrash()
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
h('div', {
|
|
493
|
+
className: 'dsh-st-dialog',
|
|
494
|
+
role: 'dialog',
|
|
495
|
+
'aria-modal': 'true',
|
|
496
|
+
'aria-labelledby': 'dsh-st-trash-title',
|
|
497
|
+
},
|
|
498
|
+
h('div', { className: 'dsh-st-head' },
|
|
499
|
+
h('div', { className: 'dsh-st-head-main' },
|
|
500
|
+
h('h2', { className: 'dsh-st-title', id: 'dsh-st-trash-title' }, tr('trash.title')),
|
|
501
|
+
h('p', { className: 'dsh-st-desc' }, tr('trash.subtitle', { days: String(retentionDays) }))
|
|
502
|
+
),
|
|
503
|
+
h('button', {
|
|
504
|
+
type: 'button',
|
|
505
|
+
className: 'dsh-st-icon-btn',
|
|
506
|
+
ref: closeRef,
|
|
507
|
+
'aria-label': tr('trash.close'),
|
|
508
|
+
title: tr('trash.close'),
|
|
509
|
+
onClick: () => actions.closeTrash(),
|
|
510
|
+
}, '×')
|
|
511
|
+
),
|
|
512
|
+
error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: error })),
|
|
513
|
+
listState.error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: listState.error })),
|
|
514
|
+
listBody,
|
|
515
|
+
h('div', { className: 'dsh-st-actions' },
|
|
516
|
+
h('button', {
|
|
517
|
+
type: 'button',
|
|
518
|
+
className: 'dsh-st-btn',
|
|
519
|
+
onClick: () => actions.closeTrash(),
|
|
520
|
+
}, tr('trash.close'))
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
),
|
|
524
|
+
notice && (Toast !== undefined
|
|
525
|
+
? h(Toast, {
|
|
526
|
+
key: `dsh-st-notice-${notice.seq ?? 0}`,
|
|
527
|
+
text: toastText,
|
|
528
|
+
onDone: () => actions.dismissNotice(),
|
|
529
|
+
holdMs: 6000,
|
|
530
|
+
...(notice.kind === 'error'
|
|
531
|
+
? (IconWarningOutlineRegular !== undefined
|
|
532
|
+
? { icon: h(IconWarningOutlineRegular, {}) }
|
|
533
|
+
: {})
|
|
534
|
+
: { tone: 'success' }),
|
|
535
|
+
...(toastActions.length > 0 ? { actions: toastActions } : {}),
|
|
536
|
+
})
|
|
537
|
+
: h('div', {
|
|
538
|
+
key: `dsh-st-notice-${notice.seq ?? 0}`,
|
|
539
|
+
role: 'alert',
|
|
540
|
+
style: {
|
|
541
|
+
position: 'fixed',
|
|
542
|
+
top: '40px',
|
|
543
|
+
left: '50%',
|
|
544
|
+
transform: 'translateX(-50%)',
|
|
545
|
+
zIndex: 2147483001,
|
|
546
|
+
display: 'flex',
|
|
547
|
+
alignItems: 'center',
|
|
548
|
+
gap: '8px',
|
|
549
|
+
maxWidth: 'min(640px, calc(100vw - 48px))',
|
|
550
|
+
padding: '12px 16px',
|
|
551
|
+
borderRadius: '12px',
|
|
552
|
+
background: 'var(--dsw-alias-toast-bg, #1f2329)',
|
|
553
|
+
color: 'var(--dsw-alias-toast-label, #fff)',
|
|
554
|
+
boxShadow: 'var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.24))',
|
|
555
|
+
font: 'inherit',
|
|
556
|
+
fontSize: '14px',
|
|
557
|
+
lineHeight: '22px',
|
|
558
|
+
pointerEvents: 'auto',
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
h('span', { style: { minWidth: 0 } }, toastText),
|
|
562
|
+
...toastActions.map((action) => h('button', {
|
|
563
|
+
key: action.label,
|
|
564
|
+
type: 'button',
|
|
565
|
+
onClick: action.onClick,
|
|
566
|
+
style: {
|
|
567
|
+
border: 0,
|
|
568
|
+
background: 'transparent',
|
|
569
|
+
color: 'var(--dsw-static-deepseek-400, #8ab4ff)',
|
|
570
|
+
font: 'inherit',
|
|
571
|
+
cursor: 'pointer',
|
|
572
|
+
marginInline: '3px',
|
|
573
|
+
pointerEvents: 'auto',
|
|
574
|
+
},
|
|
575
|
+
}, action.label))
|
|
576
|
+
))
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
inject: ['slots'],
|
|
582
|
+
apply(ctx) {
|
|
583
|
+
const locale = ctx.get('locale')
|
|
584
|
+
const retentionDays = createStore(7)
|
|
585
|
+
|
|
586
|
+
ctx.effect(() => {
|
|
587
|
+
const dispose = locale?.register?.(NS, { zh, en })
|
|
588
|
+
return () => {
|
|
589
|
+
if (typeof dispose === 'function') dispose()
|
|
590
|
+
}
|
|
591
|
+
}, 'session-trash: dictionaries')
|
|
592
|
+
|
|
593
|
+
const deleteRequest = createStore(null)
|
|
594
|
+
const trashOpen = createStore(false)
|
|
595
|
+
const notice = createStore(null)
|
|
596
|
+
const entries = createStore({ phase: 'idle', items: [], error: null })
|
|
597
|
+
let noticeSeq = 0
|
|
598
|
+
const showNotice = (value) => {
|
|
599
|
+
notice.set(value === null ? null : { ...value, seq: ++noticeSeq })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const call = async (method, payload) => {
|
|
603
|
+
let response
|
|
604
|
+
try {
|
|
605
|
+
response = await fetch('/session-trash-api', {
|
|
606
|
+
method: 'POST',
|
|
607
|
+
credentials: 'same-origin',
|
|
608
|
+
headers: { 'content-type': 'application/json' },
|
|
609
|
+
body: JSON.stringify({ method, payload: payload ?? {} }),
|
|
610
|
+
})
|
|
611
|
+
} catch (reason) {
|
|
612
|
+
const error = new Error(messageOf(reason))
|
|
613
|
+
error.code = 'connection'
|
|
614
|
+
throw error
|
|
615
|
+
}
|
|
616
|
+
if (!response.ok) {
|
|
617
|
+
const error = new Error(`HTTP ${response.status}`)
|
|
618
|
+
error.code = response.status === 401 || response.status === 403 ? 'connection' : 'request'
|
|
619
|
+
throw error
|
|
620
|
+
}
|
|
621
|
+
let result
|
|
622
|
+
try {
|
|
623
|
+
result = await response.json()
|
|
624
|
+
} catch (reason) {
|
|
625
|
+
const error = new Error(messageOf(reason))
|
|
626
|
+
error.code = 'request'
|
|
627
|
+
throw error
|
|
628
|
+
}
|
|
629
|
+
if (!result || result.ok !== true) {
|
|
630
|
+
const error = new Error(result?.error?.message ?? zh['error.request'])
|
|
631
|
+
error.code = String(result?.error?.code ?? 'request').replace(/^session-trash\//, '')
|
|
632
|
+
throw error
|
|
633
|
+
}
|
|
634
|
+
return result.value
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const refreshSessions = () => {
|
|
638
|
+
try {
|
|
639
|
+
const sessions = ctx.get('sessions')
|
|
640
|
+
const pending = sessions?.refresh?.()
|
|
641
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
642
|
+
} catch {
|
|
643
|
+
// A refresh failure only delays the list reconciliation; the Host
|
|
644
|
+
// state is already changed.
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const loadTrash = async () => {
|
|
649
|
+
const current = entries.get()
|
|
650
|
+
entries.set({ phase: 'loading', items: current.items, error: null })
|
|
651
|
+
try {
|
|
652
|
+
const value = await call('list')
|
|
653
|
+
entries.set({
|
|
654
|
+
phase: 'ready',
|
|
655
|
+
items: Array.isArray(value?.entries) ? value.entries : [],
|
|
656
|
+
error: null,
|
|
657
|
+
})
|
|
658
|
+
} catch (reason) {
|
|
659
|
+
entries.set({ phase: 'error', items: [], error: messageOf(reason) })
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const retireSession = (sessionId) => {
|
|
664
|
+
try {
|
|
665
|
+
const sessions = ctx.get('sessions')
|
|
666
|
+
if (!sessions || typeof sessions.retireScope !== 'function') return false
|
|
667
|
+
const record = sessions.scopes?.get?.(sessionId)
|
|
668
|
+
if (record === undefined) return false
|
|
669
|
+
// Drop this page's resident scope so the Host can close the agent
|
|
670
|
+
// and the row actually leaves the list instead of falling into the
|
|
671
|
+
// ungrouped bucket as a still-live session.
|
|
672
|
+
sessions.retireScope(sessionId, record)
|
|
673
|
+
refreshSessions()
|
|
674
|
+
return true
|
|
675
|
+
} catch {
|
|
676
|
+
return false
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const sessionIsMain = (sessionId) => {
|
|
680
|
+
try {
|
|
681
|
+
const row = ctx.get('sessions')?.list?.getSnapshot?.().byId?.[sessionId]
|
|
682
|
+
return (row?.retainedBy?.mainView ?? 0) > 0
|
|
683
|
+
} catch {
|
|
684
|
+
return true
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const actions = {
|
|
689
|
+
requestDelete(sessionId, title) {
|
|
690
|
+
deleteRequest.set({
|
|
691
|
+
sessionId,
|
|
692
|
+
title: typeof title === 'string' ? title.slice(0, 200) : '',
|
|
693
|
+
})
|
|
694
|
+
},
|
|
695
|
+
cancelDelete() {
|
|
696
|
+
deleteRequest.set(null)
|
|
697
|
+
},
|
|
698
|
+
async confirmDelete() {
|
|
699
|
+
const request = deleteRequest.get()
|
|
700
|
+
if (request === null) return
|
|
701
|
+
const { sessionId, title } = request
|
|
702
|
+
// The current conversation is cleared by the archive update itself;
|
|
703
|
+
// every other resident scope is retired here so the Host sees the
|
|
704
|
+
// session as closed before it starts copying files.
|
|
705
|
+
if (!sessionIsMain(sessionId)) retireSession(sessionId)
|
|
706
|
+
let value
|
|
707
|
+
try {
|
|
708
|
+
value = await call('delete', { sessionId, title })
|
|
709
|
+
} catch (reason) {
|
|
710
|
+
refreshSessions()
|
|
711
|
+
throw reason
|
|
712
|
+
}
|
|
713
|
+
retireSession(sessionId)
|
|
714
|
+
deleteRequest.set(null)
|
|
715
|
+
refreshSessions()
|
|
716
|
+
showNotice({
|
|
717
|
+
kind: 'deleted',
|
|
718
|
+
sessionId,
|
|
719
|
+
empty: value?.empty === true,
|
|
720
|
+
})
|
|
721
|
+
},
|
|
722
|
+
async undoDelete() {
|
|
723
|
+
const current = notice.get()
|
|
724
|
+
if (!current || current.kind !== 'deleted') return
|
|
725
|
+
showNotice(null)
|
|
726
|
+
await call('restore', { sessionId: current.sessionId })
|
|
727
|
+
refreshSessions()
|
|
728
|
+
void loadTrash()
|
|
729
|
+
showNotice({ kind: 'restored' })
|
|
730
|
+
},
|
|
731
|
+
openTrash() {
|
|
732
|
+
trashOpen.set(true)
|
|
733
|
+
},
|
|
734
|
+
closeTrash() {
|
|
735
|
+
trashOpen.set(false)
|
|
736
|
+
},
|
|
737
|
+
loadTrash,
|
|
738
|
+
async restore(sessionId) {
|
|
739
|
+
await call('restore', { sessionId })
|
|
740
|
+
await loadTrash()
|
|
741
|
+
refreshSessions()
|
|
742
|
+
showNotice({ kind: 'restored' })
|
|
743
|
+
},
|
|
744
|
+
async purge(sessionId) {
|
|
745
|
+
retireSession(sessionId)
|
|
746
|
+
await call('purge', { sessionId })
|
|
747
|
+
refreshSessions()
|
|
748
|
+
await loadTrash()
|
|
749
|
+
},
|
|
750
|
+
dismissNotice() {
|
|
751
|
+
showNotice(null)
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const face = {
|
|
756
|
+
retentionDays,
|
|
757
|
+
stores: { deleteRequest, trashOpen, notice, entries },
|
|
758
|
+
actions,
|
|
759
|
+
showNotice,
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
void call('info').then((value) => {
|
|
763
|
+
if (value && Number.isFinite(value.retentionDays) && value.retentionDays > 0) {
|
|
764
|
+
retentionDays.set(value.retentionDays)
|
|
765
|
+
}
|
|
766
|
+
}).catch(() => {})
|
|
767
|
+
|
|
768
|
+
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
|
|
769
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
770
|
+
id: 'dsh-plugin-delete-session',
|
|
771
|
+
order: 500,
|
|
772
|
+
locale: NS,
|
|
773
|
+
inject: () => ({ requestDelete: actions.requestDelete }),
|
|
774
|
+
}, DeleteMenuItem))
|
|
775
|
+
|
|
776
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
777
|
+
name: 'sidebar.footer.action',
|
|
778
|
+
id: 'session-trash',
|
|
779
|
+
order: 100,
|
|
780
|
+
locale: NS,
|
|
781
|
+
inject: () => ({ openTrash: actions.openTrash }),
|
|
782
|
+
}, TrashFooterButton))
|
|
783
|
+
|
|
784
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
785
|
+
name: 'shell.overlay',
|
|
786
|
+
id: 'session-trash-overlay',
|
|
787
|
+
locale: NS,
|
|
788
|
+
inject: () => ({ face }),
|
|
789
|
+
}, TrashOverlay))
|
|
790
|
+
},
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
function textOf(translate, fallback) {
|
|
796
|
+
return (key, params) => {
|
|
797
|
+
let text
|
|
798
|
+
if (typeof translate === 'function') {
|
|
799
|
+
try {
|
|
800
|
+
text = translate(key, params)
|
|
801
|
+
} catch {
|
|
802
|
+
text = undefined
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (typeof text !== 'string' || text === key || text.endsWith(`:${key}`)) {
|
|
806
|
+
text = fallback?.[key] ?? key
|
|
807
|
+
}
|
|
808
|
+
if (params === undefined) return text
|
|
809
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (
|
|
810
|
+
Object.hasOwn(params, name) ? String(params[name]) : match
|
|
811
|
+
))
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function messageOf(reason) {
|
|
816
|
+
if (reason instanceof Error) return reason.message
|
|
817
|
+
return String(reason)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function errorText(reason, tr) {
|
|
821
|
+
const code = reason && typeof reason === 'object' ? reason.code : undefined
|
|
822
|
+
if (typeof code === 'string' && code !== '' && code !== 'request' && code !== 'session-trash/failed') {
|
|
823
|
+
const key = `error.${code}`
|
|
824
|
+
const text = tr(key)
|
|
825
|
+
if (text !== key) return text
|
|
826
|
+
}
|
|
827
|
+
return messageOf(reason)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function formatBytes(bytes) {
|
|
831
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 KB'
|
|
832
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
833
|
+
let value = bytes
|
|
834
|
+
let index = 0
|
|
835
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
836
|
+
value /= 1024
|
|
837
|
+
index += 1
|
|
838
|
+
}
|
|
839
|
+
const digits = value >= 10 || index === 0 ? 0 : 1
|
|
840
|
+
return `${value.toFixed(digits)} ${units[index]}`
|
|
841
|
+
}
|