@wangxiaocuo/dsh-session-trash 0.6.1 → 0.6.3
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 +85 -0
- package/README.en.md +14 -6
- package/README.md +14 -6
- package/client.js +398 -112
- package/lib/errors.js +61 -1
- package/lib/fsops.js +8 -1
- package/lib/state.js +47 -4
- package/package.json +5 -4
package/client.js
CHANGED
|
@@ -10,6 +10,10 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
const MENU_ID = 'session-trash.move'
|
|
11
11
|
const SECTION_ID = 'session-trash'
|
|
12
12
|
const PREFLIGHT_TIMEOUT_MS = 800
|
|
13
|
+
/** Hard budget for one trash write request. */
|
|
14
|
+
const MUTATION_TIMEOUT_MS = 30_000
|
|
15
|
+
/** Hard budget for one `/state` read. */
|
|
16
|
+
const STATE_TIMEOUT_MS = 15_000
|
|
13
17
|
const DAY_MS = 86_400_000
|
|
14
18
|
/** Rows per page in the trash list. */
|
|
15
19
|
const PAGE_SIZE = 50
|
|
@@ -28,6 +32,19 @@ window.__ModuleLoader__.load({
|
|
|
28
32
|
* Preflight refusals the user can act on themselves. Those keep a visible
|
|
29
33
|
* menu entry that explains the refusal instead of hiding it.
|
|
30
34
|
*/
|
|
35
|
+
/** Host `lastErrorCode` values mapped to owner-facing messages. */
|
|
36
|
+
const LAST_ERROR_KEYS = {
|
|
37
|
+
TRASH_COPY_MISSING: 'lastErrorTrashCopyMissing',
|
|
38
|
+
TRASH_COPY_MISSING_MEMBERS: 'lastErrorTrashCopyMissingMembers',
|
|
39
|
+
TRASH_OBJECT_MISSING: 'lastErrorTrashObjectMissing',
|
|
40
|
+
PURGE_FAILED: 'lastErrorPurgeFailed',
|
|
41
|
+
RESTORE_INVISIBLE: 'lastErrorRestoreInvisible',
|
|
42
|
+
MOVE_UNSETTLED: 'lastErrorMoveUnsettled',
|
|
43
|
+
SOURCE_CONFLICT: 'lastErrorSourceConflict',
|
|
44
|
+
INDEX_WRITE_FAILED: 'lastErrorIndexWriteFailed',
|
|
45
|
+
IO_ERROR: 'lastErrorIo',
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
const MOVE_BLOCKED_KEYS = {
|
|
32
49
|
SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
|
|
33
50
|
SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
|
|
@@ -73,7 +90,8 @@ window.__ModuleLoader__.load({
|
|
|
73
90
|
retry: '重试',
|
|
74
91
|
cleanupExpired: '清理已过期',
|
|
75
92
|
cleaning: '正在清理…',
|
|
76
|
-
|
|
93
|
+
statsCountOne: '{count} 个会话',
|
|
94
|
+
statsCountOther: '{count} 个会话',
|
|
77
95
|
statsSize: '占用 {size}',
|
|
78
96
|
searchLabel: '搜索回收站',
|
|
79
97
|
searchPlaceholder: '搜索标题、Session ID 或原始工作区',
|
|
@@ -101,6 +119,7 @@ window.__ModuleLoader__.load({
|
|
|
101
119
|
emptyBody: '在侧边栏右键会话,选择「移入回收站」。',
|
|
102
120
|
loading: '正在加载…',
|
|
103
121
|
errorNetwork: '无法连接到回收站服务。',
|
|
122
|
+
errorTimeout: '请求超时,请重试。',
|
|
104
123
|
errorUnauthorized: '连接已失效,请刷新页面。',
|
|
105
124
|
errorForbidden: '当前身份无权访问回收站。',
|
|
106
125
|
errorGeneric: '回收站加载失败。',
|
|
@@ -122,7 +141,8 @@ window.__ModuleLoader__.load({
|
|
|
122
141
|
badgeMigration: '需要迁移',
|
|
123
142
|
badgeExpiring: '即将到期',
|
|
124
143
|
badgeExpired: '已过期',
|
|
125
|
-
|
|
144
|
+
badgeSubagentsOne: '含 {count} 个子会话',
|
|
145
|
+
badgeSubagentsOther: '含 {count} 个子会话',
|
|
126
146
|
stateTrashing: '正在移入…',
|
|
127
147
|
stateRestoring: '正在恢复…',
|
|
128
148
|
statePurging: '正在删除…',
|
|
@@ -130,7 +150,7 @@ window.__ModuleLoader__.load({
|
|
|
130
150
|
actionRestoreFor: '恢复「{title}」',
|
|
131
151
|
actionMoreFor: '「{title}」的更多操作',
|
|
132
152
|
actionRestoreOrphan: '恢复为孤儿会话',
|
|
133
|
-
actionReveal: '
|
|
153
|
+
actionReveal: '在文件管理器中显示',
|
|
134
154
|
actionRevealDisabled: '原工作区已不存在',
|
|
135
155
|
actionCopyId: '复制 Session ID',
|
|
136
156
|
actionForget: '从索引移除',
|
|
@@ -147,23 +167,37 @@ window.__ModuleLoader__.load({
|
|
|
147
167
|
purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
|
|
148
168
|
purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
|
|
149
169
|
purgeAllConfirm: '全部永久删除',
|
|
150
|
-
|
|
170
|
+
purgeAllDoneOne: '已永久删除 {count} 个会话',
|
|
171
|
+
purgeAllDoneOther: '已永久删除 {count} 个会话',
|
|
151
172
|
purgeAllNone: '回收站中没有可删除的会话',
|
|
152
|
-
purgeAllPartial: '
|
|
173
|
+
purgeAllPartial: '批量删除完成:成功 {removed} 个,失败 {failed} 个。',
|
|
153
174
|
forgetTitle: '从回收站列表移除这条缺失记录?',
|
|
154
175
|
forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
|
|
155
176
|
forgetConfirm: '从索引移除',
|
|
156
|
-
|
|
177
|
+
cleanupDoneOne: '已清理 {count} 个过期会话',
|
|
178
|
+
cleanupDoneOther: '已清理 {count} 个过期会话',
|
|
157
179
|
cleanupNone: '没有需要清理的过期会话',
|
|
158
|
-
|
|
159
|
-
|
|
180
|
+
cleanupFailedBannerOne: '上次自动清理有 {count} 个会话未能删除。',
|
|
181
|
+
cleanupFailedBannerOther: '上次自动清理有 {count} 个会话未能删除。',
|
|
182
|
+
cleanupErrorBanner: '上次自动清理失败,请稍后在本页重试。',
|
|
183
|
+
cleanupFailed: '清理失败:还有 {failed} 个过期会话没有删除。',
|
|
184
|
+
cleanupPartial: '已清理 {purged} 个过期会话,{failed} 个没有删除。',
|
|
160
185
|
cleanupRetryAt: '将在 {time} 自动重试。',
|
|
161
186
|
cleanupRetryNow: '立即重试',
|
|
162
187
|
copySuccess: '已复制 Session ID',
|
|
163
188
|
copyFailed: '无法复制 Session ID,请手动复制',
|
|
164
|
-
revealSuccess: '
|
|
189
|
+
revealSuccess: '已在文件管理器中显示原工作区',
|
|
165
190
|
revealFailed: '无法打开原工作区',
|
|
166
191
|
lastErrorLabel: '上次错误:{message}',
|
|
192
|
+
lastErrorTrashCopyMissing: '回收站里的会话副本缺失。',
|
|
193
|
+
lastErrorTrashCopyMissingMembers: '回收站里的部分子会话副本缺失。',
|
|
194
|
+
lastErrorTrashObjectMissing: '回收站对象目录缺失。',
|
|
195
|
+
lastErrorPurgeFailed: '会话日志删除失败。',
|
|
196
|
+
lastErrorRestoreInvisible: '恢复后会话存储没有识别到该会话。',
|
|
197
|
+
lastErrorMoveUnsettled: '会话已移动,但会话存储仍报告它存在。',
|
|
198
|
+
lastErrorSourceConflict: '原会话目录与回收站副本同时存在,两者都已保留。',
|
|
199
|
+
lastErrorIndexWriteFailed: '回收站索引写入失败。',
|
|
200
|
+
lastErrorIo: '文件操作失败。',
|
|
167
201
|
footnote: '回收站目录:{path}',
|
|
168
202
|
move: '移入回收站',
|
|
169
203
|
moveBlockedLive: '移入回收站(会话已打开)',
|
|
@@ -172,7 +206,8 @@ window.__ModuleLoader__.load({
|
|
|
172
206
|
undo: '撤销',
|
|
173
207
|
undoFailed: '撤销失败',
|
|
174
208
|
trashSuccess: '已将「{title}」移入回收站',
|
|
175
|
-
|
|
209
|
+
trashSuccessTreeOne: '「{title}」及其 {count} 个子会话已移入回收站',
|
|
210
|
+
trashSuccessTreeOther: '「{title}」及其 {count} 个子会话已移入回收站',
|
|
176
211
|
blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
|
|
177
212
|
blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
|
|
178
213
|
blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
|
|
@@ -197,7 +232,7 @@ window.__ModuleLoader__.load({
|
|
|
197
232
|
errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
|
|
198
233
|
errorIndexOk: '回收站索引可正常读取,无需重建。',
|
|
199
234
|
errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
|
|
200
|
-
errorRevealUnavailable: '
|
|
235
|
+
errorRevealUnavailable: '当前环境无法打开文件管理器。',
|
|
201
236
|
errorRevealFailed: '无法打开原工作区。',
|
|
202
237
|
errorIo: '文件操作失败,请稍后重试。',
|
|
203
238
|
}
|
|
@@ -210,7 +245,8 @@ window.__ModuleLoader__.load({
|
|
|
210
245
|
retry: 'Retry',
|
|
211
246
|
cleanupExpired: 'Delete expired',
|
|
212
247
|
cleaning: 'Cleaning…',
|
|
213
|
-
|
|
248
|
+
statsCountOne: '{count} session',
|
|
249
|
+
statsCountOther: '{count} sessions',
|
|
214
250
|
statsSize: '{size} used',
|
|
215
251
|
searchLabel: 'Search the trash',
|
|
216
252
|
searchPlaceholder: 'Search title, Session ID, or original workspace',
|
|
@@ -238,6 +274,7 @@ window.__ModuleLoader__.load({
|
|
|
238
274
|
emptyBody: 'Right-click a session in the sidebar and choose “Move to trash”.',
|
|
239
275
|
loading: 'Loading…',
|
|
240
276
|
errorNetwork: 'Could not reach the trash service.',
|
|
277
|
+
errorTimeout: 'The request timed out. Try again.',
|
|
241
278
|
errorUnauthorized: 'Your connection expired. Reload the page.',
|
|
242
279
|
errorForbidden: 'You do not have access to the trash.',
|
|
243
280
|
errorGeneric: 'Could not load the trash.',
|
|
@@ -259,7 +296,8 @@ window.__ModuleLoader__.load({
|
|
|
259
296
|
badgeMigration: 'Migration required',
|
|
260
297
|
badgeExpiring: 'Expiring soon',
|
|
261
298
|
badgeExpired: 'Expired',
|
|
262
|
-
|
|
299
|
+
badgeSubagentsOne: '{count} sub-session',
|
|
300
|
+
badgeSubagentsOther: '{count} sub-sessions',
|
|
263
301
|
stateTrashing: 'Moving…',
|
|
264
302
|
stateRestoring: 'Restoring…',
|
|
265
303
|
statePurging: 'Deleting…',
|
|
@@ -267,7 +305,7 @@ window.__ModuleLoader__.load({
|
|
|
267
305
|
actionRestoreFor: 'Restore “{title}”',
|
|
268
306
|
actionMoreFor: 'More actions for “{title}”',
|
|
269
307
|
actionRestoreOrphan: 'Restore as orphan',
|
|
270
|
-
actionReveal: 'Show in
|
|
308
|
+
actionReveal: 'Show in file manager',
|
|
271
309
|
actionRevealDisabled: 'The original workspace no longer exists',
|
|
272
310
|
actionCopyId: 'Copy Session ID',
|
|
273
311
|
actionForget: 'Remove from index',
|
|
@@ -284,23 +322,37 @@ window.__ModuleLoader__.load({
|
|
|
284
322
|
purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
|
|
285
323
|
purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
|
|
286
324
|
purgeAllConfirm: 'Delete all permanently',
|
|
287
|
-
|
|
325
|
+
purgeAllDoneOne: 'Permanently deleted {count} session',
|
|
326
|
+
purgeAllDoneOther: 'Permanently deleted {count} sessions',
|
|
288
327
|
purgeAllNone: 'No sessions to delete',
|
|
289
|
-
purgeAllPartial: '
|
|
328
|
+
purgeAllPartial: 'Bulk delete finished: {removed} removed, {failed} failed.',
|
|
290
329
|
forgetTitle: 'Remove this missing record from the trash list?',
|
|
291
330
|
forgetBody: 'No file is deleted; only this index record is removed.',
|
|
292
331
|
forgetConfirm: 'Remove from index',
|
|
293
|
-
|
|
332
|
+
cleanupDoneOne: 'Deleted {count} expired session',
|
|
333
|
+
cleanupDoneOther: 'Deleted {count} expired sessions',
|
|
294
334
|
cleanupNone: 'No expired sessions to delete',
|
|
295
|
-
|
|
296
|
-
|
|
335
|
+
cleanupFailedBannerOne: 'The last automatic cleanup could not delete {count} expired session.',
|
|
336
|
+
cleanupFailedBannerOther: 'The last automatic cleanup could not delete {count} expired sessions.',
|
|
337
|
+
cleanupErrorBanner: 'The last automatic cleanup failed. Retry from this page later.',
|
|
338
|
+
cleanupFailed: 'Cleanup could not delete {failed} of the expired sessions.',
|
|
339
|
+
cleanupPartial: 'Cleanup deleted {purged}; {failed} could not be deleted.',
|
|
297
340
|
cleanupRetryAt: 'Next retry: {time}.',
|
|
298
341
|
cleanupRetryNow: 'Retry now',
|
|
299
342
|
copySuccess: 'Session ID copied',
|
|
300
343
|
copyFailed: 'Could not copy the Session ID. Copy it manually.',
|
|
301
|
-
revealSuccess: 'Revealed the original workspace in
|
|
344
|
+
revealSuccess: 'Revealed the original workspace in the file manager',
|
|
302
345
|
revealFailed: 'Could not open the original workspace',
|
|
303
346
|
lastErrorLabel: 'Last error: {message}',
|
|
347
|
+
lastErrorTrashCopyMissing: 'The stored copy of this session is missing from the trash.',
|
|
348
|
+
lastErrorTrashCopyMissingMembers: 'Some sub-session copies are missing from the trash.',
|
|
349
|
+
lastErrorTrashObjectMissing: 'The trash object directory is missing.',
|
|
350
|
+
lastErrorPurgeFailed: 'The session log could not be deleted.',
|
|
351
|
+
lastErrorRestoreInvisible: 'Session storage did not report the restored session.',
|
|
352
|
+
lastErrorMoveUnsettled: 'The session was moved, but session storage still reports it.',
|
|
353
|
+
lastErrorSourceConflict: 'The original session directory and the trash copy both exist; both were kept.',
|
|
354
|
+
lastErrorIndexWriteFailed: 'The trash index could not be written.',
|
|
355
|
+
lastErrorIo: 'A file operation failed.',
|
|
304
356
|
footnote: 'Trash directory: {path}',
|
|
305
357
|
move: 'Move to trash',
|
|
306
358
|
moveBlockedLive: 'Move to trash (session is open)',
|
|
@@ -309,7 +361,8 @@ window.__ModuleLoader__.load({
|
|
|
309
361
|
undo: 'Undo',
|
|
310
362
|
undoFailed: 'Could not undo',
|
|
311
363
|
trashSuccess: 'Moved “{title}” to the trash',
|
|
312
|
-
|
|
364
|
+
trashSuccessTreeOne: '“{title}” and its single sub-session were moved to the trash',
|
|
365
|
+
trashSuccessTreeOther: '“{title}” and its {count} sub-sessions were moved to the trash',
|
|
313
366
|
blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
|
|
314
367
|
blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
|
|
315
368
|
blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
|
|
@@ -334,7 +387,7 @@ window.__ModuleLoader__.load({
|
|
|
334
387
|
errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
|
|
335
388
|
errorIndexOk: 'The trash index is readable and does not need a rebuild.',
|
|
336
389
|
errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
|
|
337
|
-
errorRevealUnavailable: 'This environment cannot open
|
|
390
|
+
errorRevealUnavailable: 'This environment cannot open a file manager.',
|
|
338
391
|
errorRevealFailed: 'Could not open the original workspace.',
|
|
339
392
|
errorIo: 'A file operation failed. Try again.',
|
|
340
393
|
}
|
|
@@ -353,11 +406,26 @@ window.__ModuleLoader__.load({
|
|
|
353
406
|
}
|
|
354
407
|
|
|
355
408
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
409
|
+
* Pick the grammatical singular/plural locale form for a count.
|
|
410
|
+
*
|
|
411
|
+
* @param {(key: string) => string} t bound translator.
|
|
412
|
+
* @param {string} key base locale key (without the One/Other suffix).
|
|
413
|
+
* @param {number} count item count.
|
|
414
|
+
* @param {Record<string, unknown>} [values] extra placeholders.
|
|
415
|
+
* @returns {string} the formatted text.
|
|
416
|
+
*/
|
|
417
|
+
function countText(t, key, count, values = {}) {
|
|
418
|
+
const template = t(`${key}${count === 1 ? 'One' : 'Other'}`)
|
|
419
|
+
return format(template, { ...values, count })
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {number | null | undefined} bytes raw byte count.
|
|
424
|
+
* @returns {string} a compact human-readable size, or an em dash when unknown.
|
|
358
425
|
*/
|
|
359
426
|
function formatBytes(bytes) {
|
|
360
|
-
if (typeof bytes !== 'number' || !Number.isFinite(bytes)
|
|
427
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '—'
|
|
428
|
+
if (bytes <= 0) return '0 B'
|
|
361
429
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
362
430
|
let value = bytes
|
|
363
431
|
let unit = 0
|
|
@@ -809,6 +877,12 @@ window.__ModuleLoader__.load({
|
|
|
809
877
|
const buttonRef = React.useRef(null)
|
|
810
878
|
const menuRef = React.useRef(null)
|
|
811
879
|
|
|
880
|
+
// A pending write disables the trigger; closing the open menu prevents a
|
|
881
|
+
// stale item from starting a second mutation.
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
if (disabled === true) setOpen(false)
|
|
884
|
+
}, [disabled])
|
|
885
|
+
|
|
812
886
|
React.useEffect(() => {
|
|
813
887
|
if (!open) return undefined
|
|
814
888
|
const onPointerDown = (event) => {
|
|
@@ -846,6 +920,7 @@ window.__ModuleLoader__.load({
|
|
|
846
920
|
* @returns {void}
|
|
847
921
|
*/
|
|
848
922
|
const toggleMenu = () => {
|
|
923
|
+
if (disabled === true) return
|
|
849
924
|
if (open) {
|
|
850
925
|
setOpen(false)
|
|
851
926
|
return
|
|
@@ -1070,25 +1145,158 @@ window.__ModuleLoader__.load({
|
|
|
1070
1145
|
useLocaleRevision(ctx.locale)
|
|
1071
1146
|
const locale = activeLocale(ctx.locale)
|
|
1072
1147
|
const [view, setView] = React.useState({ status: 'loading', data: null, error: null })
|
|
1073
|
-
const [
|
|
1148
|
+
const [pendingRows, setPendingRows] = React.useState(() => new Set())
|
|
1074
1149
|
const [rowErrors, setRowErrors] = React.useState({})
|
|
1075
1150
|
const [dialog, setDialog] = React.useState(null)
|
|
1076
1151
|
const [cleaning, setCleaning] = React.useState(false)
|
|
1077
1152
|
const [rebuilding, setRebuilding] = React.useState(false)
|
|
1078
1153
|
const [purgingAll, setPurgingAll] = React.useState(false)
|
|
1079
1154
|
const [announcement, setAnnouncement] = React.useState('')
|
|
1155
|
+
const [clock, setClock] = React.useState(() => Date.now())
|
|
1080
1156
|
const [query, setQuery] = React.useState('')
|
|
1081
1157
|
const [filter, setFilter] = React.useState('all')
|
|
1082
1158
|
const [page, setPage] = React.useState(1)
|
|
1083
1159
|
const abortRef = React.useRef(null)
|
|
1084
1160
|
const searchRef = React.useRef(null)
|
|
1085
1161
|
const panelRef = React.useRef(null)
|
|
1162
|
+
/** False once the section unmounts, so late responses stay silent. */
|
|
1163
|
+
const aliveRef = React.useRef(true)
|
|
1164
|
+
/** Number of writes in flight; the section allows only one at a time. */
|
|
1165
|
+
const activeWritesRef = React.useRef(0)
|
|
1166
|
+
/** Abort scopes of in-flight writes, all cancelled on unmount. */
|
|
1167
|
+
const writeScopesRef = React.useRef(new Set())
|
|
1086
1168
|
const closeDialog = React.useCallback(() => setDialog(null), [])
|
|
1087
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* Claim the single write slot. A second click while one mutation is in
|
|
1172
|
+
* flight is ignored, so two rows can never race the Host lock.
|
|
1173
|
+
*
|
|
1174
|
+
* @returns {boolean} whether the write may proceed.
|
|
1175
|
+
*/
|
|
1176
|
+
function beginWrite() {
|
|
1177
|
+
if (!aliveRef.current || activeWritesRef.current > 0) return false
|
|
1178
|
+
activeWritesRef.current += 1
|
|
1179
|
+
return true
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* @returns {void}
|
|
1184
|
+
*/
|
|
1185
|
+
function endWrite() {
|
|
1186
|
+
activeWritesRef.current = Math.max(0, activeWritesRef.current - 1)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* @param {string} sessionId row whose write is now pending.
|
|
1191
|
+
* @returns {void}
|
|
1192
|
+
*/
|
|
1193
|
+
function markRowPending(sessionId) {
|
|
1194
|
+
setPendingRows((previous) => {
|
|
1195
|
+
if (previous.has(sessionId)) return previous
|
|
1196
|
+
const next = new Set(previous)
|
|
1197
|
+
next.add(sessionId)
|
|
1198
|
+
return next
|
|
1199
|
+
})
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* @param {string} sessionId row whose write settled.
|
|
1204
|
+
* @returns {void}
|
|
1205
|
+
*/
|
|
1206
|
+
function clearRowPending(sessionId) {
|
|
1207
|
+
setPendingRows((previous) => {
|
|
1208
|
+
if (!previous.has(sessionId)) return previous
|
|
1209
|
+
const next = new Set(previous)
|
|
1210
|
+
next.delete(sessionId)
|
|
1211
|
+
return next
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* @param {string} sessionId row whose owner-facing error is resolved.
|
|
1217
|
+
* @returns {void}
|
|
1218
|
+
*/
|
|
1219
|
+
function clearRowError(sessionId) {
|
|
1220
|
+
setRowErrors((previous) => {
|
|
1221
|
+
if (!(sessionId in previous)) return previous
|
|
1222
|
+
const next = { ...previous }
|
|
1223
|
+
delete next[sessionId]
|
|
1224
|
+
return next
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Open an abort scope for one write. It is cancelled by a hard timeout
|
|
1230
|
+
* and by the unmount cleanup, so a stuck request can never wedge the
|
|
1231
|
+
* page or outlive the settings section.
|
|
1232
|
+
*
|
|
1233
|
+
* @returns {{ signal: AbortSignal, timedOut: () => boolean, aborted: () => boolean, cancel: () => void, close: () => void }} the scope.
|
|
1234
|
+
*/
|
|
1235
|
+
function openWriteScope() {
|
|
1236
|
+
const controller = new AbortController()
|
|
1237
|
+
let timedOut = false
|
|
1238
|
+
const timer = setTimeout(() => {
|
|
1239
|
+
timedOut = true
|
|
1240
|
+
controller.abort()
|
|
1241
|
+
}, MUTATION_TIMEOUT_MS)
|
|
1242
|
+
const scope = {
|
|
1243
|
+
signal: controller.signal,
|
|
1244
|
+
timedOut: () => timedOut,
|
|
1245
|
+
aborted: () => controller.signal.aborted,
|
|
1246
|
+
cancel: () => controller.abort(),
|
|
1247
|
+
close: () => {
|
|
1248
|
+
clearTimeout(timer)
|
|
1249
|
+
writeScopesRef.current.delete(scope)
|
|
1250
|
+
},
|
|
1251
|
+
}
|
|
1252
|
+
writeScopesRef.current.add(scope)
|
|
1253
|
+
return scope
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Send one write request, returning a discriminated transport outcome
|
|
1258
|
+
* instead of throwing, so every caller localizes failures the same way.
|
|
1259
|
+
*
|
|
1260
|
+
* @param {string} pathname endpoint below the API prefix.
|
|
1261
|
+
* @param {object} [body] JSON request body.
|
|
1262
|
+
* @returns {Promise<
|
|
1263
|
+
* | { kind: 'response', status: number, ok: boolean, payload: any }
|
|
1264
|
+
* | { kind: 'timeout' }
|
|
1265
|
+
* | { kind: 'cancelled' }
|
|
1266
|
+
* | { kind: 'network' }
|
|
1267
|
+
* >} the outcome.
|
|
1268
|
+
*/
|
|
1269
|
+
async function sendMutation(pathname, body) {
|
|
1270
|
+
const scope = openWriteScope()
|
|
1271
|
+
try {
|
|
1272
|
+
const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
|
|
1273
|
+
return { kind: 'response', status, ok, payload }
|
|
1274
|
+
} catch {
|
|
1275
|
+
if (scope.timedOut()) return { kind: 'timeout' }
|
|
1276
|
+
if (scope.aborted()) return { kind: 'cancelled' }
|
|
1277
|
+
return { kind: 'network' }
|
|
1278
|
+
} finally {
|
|
1279
|
+
scope.close()
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* @param {'timeout' | 'network'} kind transport failure.
|
|
1285
|
+
* @returns {string} the localized message to show.
|
|
1286
|
+
*/
|
|
1287
|
+
function transportMessage(kind) {
|
|
1288
|
+
return kind === 'timeout' ? t('errorTimeout') : t('errorNetwork')
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1088
1291
|
const load = React.useCallback(() => {
|
|
1089
1292
|
if (abortRef.current !== null) abortRef.current.abort()
|
|
1090
1293
|
const controller = new AbortController()
|
|
1091
1294
|
abortRef.current = controller
|
|
1295
|
+
let timedOut = false
|
|
1296
|
+
const timer = setTimeout(() => {
|
|
1297
|
+
timedOut = true
|
|
1298
|
+
controller.abort()
|
|
1299
|
+
}, STATE_TIMEOUT_MS)
|
|
1092
1300
|
setView((previous) => ({
|
|
1093
1301
|
status: previous.data === null ? 'loading' : 'refreshing',
|
|
1094
1302
|
data: previous.data,
|
|
@@ -1109,9 +1317,15 @@ window.__ModuleLoader__.load({
|
|
|
1109
1317
|
setRowErrors({})
|
|
1110
1318
|
})
|
|
1111
1319
|
.catch(() => {
|
|
1112
|
-
if (controller.signal.aborted)
|
|
1320
|
+
if (controller.signal.aborted) {
|
|
1321
|
+
if (timedOut) {
|
|
1322
|
+
setView((previous) => ({ status: 'error', data: previous.data, error: t('errorTimeout') }))
|
|
1323
|
+
}
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1113
1326
|
setView((previous) => ({ status: 'error', data: previous.data, error: t('errorNetwork') }))
|
|
1114
1327
|
})
|
|
1328
|
+
.finally(() => clearTimeout(timer))
|
|
1115
1329
|
}, [])
|
|
1116
1330
|
|
|
1117
1331
|
React.useEffect(() => {
|
|
@@ -1123,6 +1337,15 @@ window.__ModuleLoader__.load({
|
|
|
1123
1337
|
|
|
1124
1338
|
React.useEffect(() => subscribeRevision(load), [load])
|
|
1125
1339
|
|
|
1340
|
+
React.useEffect(
|
|
1341
|
+
() => () => {
|
|
1342
|
+
aliveRef.current = false
|
|
1343
|
+
for (const scope of [...writeScopesRef.current]) scope.cancel()
|
|
1344
|
+
writeScopesRef.current.clear()
|
|
1345
|
+
},
|
|
1346
|
+
[],
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1126
1349
|
React.useEffect(() => {
|
|
1127
1350
|
const refresh = () => load()
|
|
1128
1351
|
const onVisibility = () => {
|
|
@@ -1136,6 +1359,15 @@ window.__ModuleLoader__.load({
|
|
|
1136
1359
|
}
|
|
1137
1360
|
}, [load])
|
|
1138
1361
|
|
|
1362
|
+
// Keep relative expiry text honest on a page left open for a long time.
|
|
1363
|
+
React.useEffect(() => {
|
|
1364
|
+
if (typeof window === 'undefined' || typeof window.setInterval !== 'function') return undefined
|
|
1365
|
+
const handle = window.setInterval(() => setClock(Date.now()), 60_000)
|
|
1366
|
+
return () => {
|
|
1367
|
+
if (typeof window.clearInterval === 'function') window.clearInterval(handle)
|
|
1368
|
+
}
|
|
1369
|
+
}, [])
|
|
1370
|
+
|
|
1139
1371
|
const data = view.data
|
|
1140
1372
|
const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
|
|
1141
1373
|
entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
|
|
@@ -1167,11 +1399,16 @@ window.__ModuleLoader__.load({
|
|
|
1167
1399
|
cleanupInfo.lastRun.error !== ''
|
|
1168
1400
|
? cleanupInfo.lastRun.error
|
|
1169
1401
|
: null
|
|
1170
|
-
const
|
|
1402
|
+
const loading = view.status === 'loading'
|
|
1403
|
+
const refreshing = view.status === 'refreshing'
|
|
1404
|
+
const readsBusy = loading || refreshing
|
|
1405
|
+
const pendingCount = pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0)
|
|
1406
|
+
/** Any write (row action or maintenance) is in flight. */
|
|
1407
|
+
const mutationBusy = pendingCount > 0
|
|
1171
1408
|
const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
|
|
1172
1409
|
const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
|
|
1173
1410
|
const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
|
|
1174
|
-
const now =
|
|
1411
|
+
const now = clock
|
|
1175
1412
|
const normalizedQuery = query.trim().toLowerCase()
|
|
1176
1413
|
|
|
1177
1414
|
/**
|
|
@@ -1204,6 +1441,9 @@ window.__ModuleLoader__.load({
|
|
|
1204
1441
|
const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
|
|
1205
1442
|
const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
|
|
1206
1443
|
const currentPage = Math.min(Math.max(1, page), pageCount)
|
|
1444
|
+
React.useEffect(() => {
|
|
1445
|
+
if (page > pageCount) setPage(pageCount)
|
|
1446
|
+
}, [page, pageCount])
|
|
1207
1447
|
const pageStart = (currentPage - 1) * PAGE_SIZE
|
|
1208
1448
|
const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
|
|
1209
1449
|
const filtersActive = normalizedQuery !== '' || filter !== 'all'
|
|
@@ -1265,35 +1505,41 @@ window.__ModuleLoader__.load({
|
|
|
1265
1505
|
* @returns {Promise<void>} completion of the operation.
|
|
1266
1506
|
*/
|
|
1267
1507
|
async function performRestore(entry, mode) {
|
|
1268
|
-
|
|
1508
|
+
if (!beginWrite()) return
|
|
1509
|
+
markRowPending(entry.sessionId)
|
|
1269
1510
|
try {
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1511
|
+
const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode })
|
|
1512
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1513
|
+
if (outcome.kind !== 'response') {
|
|
1514
|
+
fail(entry, transportMessage(outcome.kind))
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1517
|
+
if (!outcome.ok) {
|
|
1518
|
+
const code =
|
|
1519
|
+
outcome.payload !== null && outcome.payload !== undefined && outcome.payload.error !== undefined
|
|
1520
|
+
? outcome.payload.error.code
|
|
1521
|
+
: undefined
|
|
1276
1522
|
if (mode === 'normal' && code === 'WORKSPACE_MISSING') {
|
|
1277
1523
|
setDialog({ kind: 'orphan', entry })
|
|
1278
1524
|
return
|
|
1279
1525
|
}
|
|
1280
|
-
fail(entry, describeActionFailure(status, payload, t))
|
|
1526
|
+
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1281
1527
|
return
|
|
1282
1528
|
}
|
|
1283
|
-
const session = payload !== null && payload !== undefined ? payload.session : undefined
|
|
1529
|
+
const session = outcome.payload !== null && outcome.payload !== undefined ? outcome.payload.session : undefined
|
|
1284
1530
|
const orphan = session !== null && session !== undefined ? session.orphan === true : mode === 'orphan'
|
|
1285
1531
|
const title =
|
|
1286
1532
|
session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
|
|
1287
1533
|
? session.title
|
|
1288
1534
|
: entry.title
|
|
1535
|
+
clearRowError(entry.sessionId)
|
|
1289
1536
|
const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
|
|
1290
1537
|
bumpRevision()
|
|
1291
1538
|
ctx.contextMenus.notify(message, true)
|
|
1292
1539
|
announce(message)
|
|
1293
|
-
} catch {
|
|
1294
|
-
fail(entry, t('errorNetwork'))
|
|
1295
1540
|
} finally {
|
|
1296
|
-
|
|
1541
|
+
endWrite()
|
|
1542
|
+
if (aliveRef.current) clearRowPending(entry.sessionId)
|
|
1297
1543
|
}
|
|
1298
1544
|
}
|
|
1299
1545
|
|
|
@@ -1302,24 +1548,27 @@ window.__ModuleLoader__.load({
|
|
|
1302
1548
|
* @returns {Promise<void>} completion of the operation.
|
|
1303
1549
|
*/
|
|
1304
1550
|
async function performPurge(entry) {
|
|
1305
|
-
|
|
1551
|
+
if (!beginWrite()) return
|
|
1552
|
+
markRowPending(entry.sessionId)
|
|
1306
1553
|
try {
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1554
|
+
const outcome = await sendMutation('purge', { sessionId: entry.sessionId })
|
|
1555
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1556
|
+
if (outcome.kind !== 'response') {
|
|
1557
|
+
fail(entry, transportMessage(outcome.kind))
|
|
1558
|
+
return
|
|
1559
|
+
}
|
|
1560
|
+
if (!outcome.ok) {
|
|
1561
|
+
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1313
1562
|
return
|
|
1314
1563
|
}
|
|
1564
|
+
clearRowError(entry.sessionId)
|
|
1315
1565
|
const message = format(t('purgeSuccess'), { title: entry.title })
|
|
1316
1566
|
bumpRevision()
|
|
1317
1567
|
ctx.contextMenus.notify(message, true)
|
|
1318
1568
|
announce(message)
|
|
1319
|
-
} catch {
|
|
1320
|
-
fail(entry, t('errorNetwork'))
|
|
1321
1569
|
} finally {
|
|
1322
|
-
|
|
1570
|
+
endWrite()
|
|
1571
|
+
if (aliveRef.current) clearRowPending(entry.sessionId)
|
|
1323
1572
|
}
|
|
1324
1573
|
}
|
|
1325
1574
|
|
|
@@ -1328,23 +1577,26 @@ window.__ModuleLoader__.load({
|
|
|
1328
1577
|
* @returns {Promise<void>} completion of the operation.
|
|
1329
1578
|
*/
|
|
1330
1579
|
async function performForget(entry) {
|
|
1331
|
-
|
|
1580
|
+
if (!beginWrite()) return
|
|
1581
|
+
markRowPending(entry.sessionId)
|
|
1332
1582
|
try {
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
if (!ok) {
|
|
1338
|
-
fail(entry, describeActionFailure(status, payload, t))
|
|
1583
|
+
const outcome = await sendMutation('forget', { sessionId: entry.sessionId })
|
|
1584
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1585
|
+
if (outcome.kind !== 'response') {
|
|
1586
|
+
fail(entry, transportMessage(outcome.kind))
|
|
1339
1587
|
return
|
|
1340
1588
|
}
|
|
1589
|
+
if (!outcome.ok) {
|
|
1590
|
+
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
clearRowError(entry.sessionId)
|
|
1341
1594
|
bumpRevision()
|
|
1342
1595
|
ctx.contextMenus.notify(t('forgetSuccess'), true)
|
|
1343
1596
|
announce(t('forgetSuccess'))
|
|
1344
|
-
} catch {
|
|
1345
|
-
fail(entry, t('errorNetwork'))
|
|
1346
1597
|
} finally {
|
|
1347
|
-
|
|
1598
|
+
endWrite()
|
|
1599
|
+
if (aliveRef.current) clearRowPending(entry.sessionId)
|
|
1348
1600
|
}
|
|
1349
1601
|
}
|
|
1350
1602
|
|
|
@@ -1353,23 +1605,25 @@ window.__ModuleLoader__.load({
|
|
|
1353
1605
|
* @returns {Promise<void>} completion of the operation.
|
|
1354
1606
|
*/
|
|
1355
1607
|
async function performReveal(entry) {
|
|
1356
|
-
|
|
1608
|
+
if (!beginWrite()) return
|
|
1609
|
+
markRowPending(entry.sessionId)
|
|
1357
1610
|
try {
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1611
|
+
const outcome = await sendMutation('reveal', { sessionId: entry.sessionId })
|
|
1612
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1613
|
+
if (outcome.kind !== 'response') {
|
|
1614
|
+
fail(entry, transportMessage(outcome.kind))
|
|
1615
|
+
return
|
|
1616
|
+
}
|
|
1617
|
+
if (!outcome.ok) {
|
|
1618
|
+
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1365
1619
|
return
|
|
1366
1620
|
}
|
|
1621
|
+
clearRowError(entry.sessionId)
|
|
1367
1622
|
ctx.contextMenus.notify(t('revealSuccess'), true)
|
|
1368
1623
|
announce(t('revealSuccess'))
|
|
1369
|
-
} catch {
|
|
1370
|
-
fail(entry, t('errorNetwork'))
|
|
1371
1624
|
} finally {
|
|
1372
|
-
|
|
1625
|
+
endWrite()
|
|
1626
|
+
if (aliveRef.current) clearRowPending(entry.sessionId)
|
|
1373
1627
|
}
|
|
1374
1628
|
}
|
|
1375
1629
|
|
|
@@ -1379,6 +1633,7 @@ window.__ModuleLoader__.load({
|
|
|
1379
1633
|
*/
|
|
1380
1634
|
async function performCopy(entry) {
|
|
1381
1635
|
const copied = await copyText(entry.sessionId)
|
|
1636
|
+
if (!aliveRef.current) return
|
|
1382
1637
|
ctx.contextMenus.notify(copied ? t('copySuccess') : t('copyFailed'), copied)
|
|
1383
1638
|
announce(copied ? t('copySuccess') : t('copyFailed'))
|
|
1384
1639
|
}
|
|
@@ -1387,22 +1642,32 @@ window.__ModuleLoader__.load({
|
|
|
1387
1642
|
* @returns {Promise<void>} completion of the operation.
|
|
1388
1643
|
*/
|
|
1389
1644
|
async function runCleanup() {
|
|
1645
|
+
if (!beginWrite()) return
|
|
1390
1646
|
setCleaning(true)
|
|
1391
1647
|
try {
|
|
1392
|
-
const
|
|
1393
|
-
if (!
|
|
1394
|
-
|
|
1648
|
+
const outcome = await sendMutation('cleanup')
|
|
1649
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1650
|
+
if (outcome.kind !== 'response') {
|
|
1651
|
+
ctx.contextMenus.notify(transportMessage(outcome.kind), false)
|
|
1395
1652
|
return
|
|
1396
1653
|
}
|
|
1654
|
+
if (!outcome.ok) {
|
|
1655
|
+
ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
const payload = outcome.payload
|
|
1397
1659
|
const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
|
|
1398
|
-
const
|
|
1660
|
+
const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
|
|
1661
|
+
let message
|
|
1662
|
+
if (failed > 0 && purged > 0) message = format(t('cleanupPartial'), { purged, failed })
|
|
1663
|
+
else if (failed > 0) message = format(t('cleanupFailed'), { failed })
|
|
1664
|
+
else message = purged > 0 ? countText(t, 'cleanupDone', purged) : t('cleanupNone')
|
|
1399
1665
|
bumpRevision()
|
|
1400
|
-
ctx.contextMenus.notify(message,
|
|
1666
|
+
ctx.contextMenus.notify(message, failed === 0)
|
|
1401
1667
|
announce(message)
|
|
1402
|
-
} catch {
|
|
1403
|
-
ctx.contextMenus.notify(t('errorNetwork'), false)
|
|
1404
1668
|
} finally {
|
|
1405
|
-
|
|
1669
|
+
endWrite()
|
|
1670
|
+
if (aliveRef.current) setCleaning(false)
|
|
1406
1671
|
}
|
|
1407
1672
|
}
|
|
1408
1673
|
|
|
@@ -1412,13 +1677,20 @@ window.__ModuleLoader__.load({
|
|
|
1412
1677
|
* @returns {Promise<void>} completion of the operation.
|
|
1413
1678
|
*/
|
|
1414
1679
|
async function performPurgeAll() {
|
|
1680
|
+
if (!beginWrite()) return
|
|
1415
1681
|
setPurgingAll(true)
|
|
1416
1682
|
try {
|
|
1417
|
-
const
|
|
1418
|
-
if (!
|
|
1419
|
-
|
|
1683
|
+
const outcome = await sendMutation('purge-all')
|
|
1684
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1685
|
+
if (outcome.kind !== 'response') {
|
|
1686
|
+
ctx.contextMenus.notify(transportMessage(outcome.kind), false)
|
|
1687
|
+
return
|
|
1688
|
+
}
|
|
1689
|
+
if (!outcome.ok) {
|
|
1690
|
+
ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
|
|
1420
1691
|
return
|
|
1421
1692
|
}
|
|
1693
|
+
const payload = outcome.payload
|
|
1422
1694
|
const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
|
|
1423
1695
|
const forgotten =
|
|
1424
1696
|
payload !== null && payload !== undefined && typeof payload.forgotten === 'number' ? payload.forgotten : 0
|
|
@@ -1428,15 +1700,14 @@ window.__ModuleLoader__.load({
|
|
|
1428
1700
|
failed > 0
|
|
1429
1701
|
? format(t('purgeAllPartial'), { removed, failed })
|
|
1430
1702
|
: removed > 0
|
|
1431
|
-
?
|
|
1703
|
+
? countText(t, 'purgeAllDone', removed)
|
|
1432
1704
|
: t('purgeAllNone')
|
|
1433
1705
|
bumpRevision()
|
|
1434
1706
|
ctx.contextMenus.notify(message, failed === 0)
|
|
1435
1707
|
announce(message)
|
|
1436
|
-
} catch {
|
|
1437
|
-
ctx.contextMenus.notify(t('errorNetwork'), false)
|
|
1438
1708
|
} finally {
|
|
1439
|
-
|
|
1709
|
+
endWrite()
|
|
1710
|
+
if (aliveRef.current) setPurgingAll(false)
|
|
1440
1711
|
}
|
|
1441
1712
|
}
|
|
1442
1713
|
|
|
@@ -1444,13 +1715,20 @@ window.__ModuleLoader__.load({
|
|
|
1444
1715
|
* @returns {Promise<void>} completion of the operation.
|
|
1445
1716
|
*/
|
|
1446
1717
|
async function runRebuild() {
|
|
1718
|
+
if (!beginWrite()) return
|
|
1447
1719
|
setRebuilding(true)
|
|
1448
1720
|
try {
|
|
1449
|
-
const
|
|
1450
|
-
if (!
|
|
1451
|
-
|
|
1721
|
+
const outcome = await sendMutation('rebuild')
|
|
1722
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1723
|
+
if (outcome.kind !== 'response') {
|
|
1724
|
+
ctx.contextMenus.notify(transportMessage(outcome.kind), false)
|
|
1725
|
+
return
|
|
1726
|
+
}
|
|
1727
|
+
if (!outcome.ok) {
|
|
1728
|
+
ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
|
|
1452
1729
|
return
|
|
1453
1730
|
}
|
|
1731
|
+
const payload = outcome.payload
|
|
1454
1732
|
const count = payload !== null && payload !== undefined && typeof payload.entries === 'number' ? payload.entries : 0
|
|
1455
1733
|
const unindexedCount =
|
|
1456
1734
|
payload !== null && payload !== undefined && typeof payload.unindexed === 'number' ? payload.unindexed : 0
|
|
@@ -1461,10 +1739,9 @@ window.__ModuleLoader__.load({
|
|
|
1461
1739
|
bumpRevision()
|
|
1462
1740
|
ctx.contextMenus.notify(message, true)
|
|
1463
1741
|
announce(message)
|
|
1464
|
-
} catch {
|
|
1465
|
-
ctx.contextMenus.notify(t('errorNetwork'), false)
|
|
1466
1742
|
} finally {
|
|
1467
|
-
|
|
1743
|
+
endWrite()
|
|
1744
|
+
if (aliveRef.current) setRebuilding(false)
|
|
1468
1745
|
}
|
|
1469
1746
|
}
|
|
1470
1747
|
|
|
@@ -1558,7 +1835,7 @@ window.__ModuleLoader__.load({
|
|
|
1558
1835
|
if (subagentCount > 0) {
|
|
1559
1836
|
badges.push({
|
|
1560
1837
|
key: 'subagents',
|
|
1561
|
-
label:
|
|
1838
|
+
label: countText(t, 'badgeSubagents', subagentCount),
|
|
1562
1839
|
tone: 'info',
|
|
1563
1840
|
})
|
|
1564
1841
|
}
|
|
@@ -1607,7 +1884,7 @@ window.__ModuleLoader__.load({
|
|
|
1607
1884
|
onClick: () => {
|
|
1608
1885
|
void runRebuild()
|
|
1609
1886
|
},
|
|
1610
|
-
disabled:
|
|
1887
|
+
disabled: loading || mutationBusy,
|
|
1611
1888
|
},
|
|
1612
1889
|
rebuilding ? t('rebuilding') : t('rebuild'),
|
|
1613
1890
|
),
|
|
@@ -1644,9 +1921,7 @@ window.__ModuleLoader__.load({
|
|
|
1644
1921
|
const retryText =
|
|
1645
1922
|
cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
|
|
1646
1923
|
const cleanupMessage =
|
|
1647
|
-
cleanupFailed > 0
|
|
1648
|
-
? format(t('cleanupFailedBanner'), { count: cleanupFailed })
|
|
1649
|
-
: format(t('cleanupErrorBanner'), { message: cleanupError })
|
|
1924
|
+
cleanupFailed > 0 ? countText(t, 'cleanupFailedBanner', cleanupFailed) : t('cleanupErrorBanner')
|
|
1650
1925
|
banners.push(
|
|
1651
1926
|
h(
|
|
1652
1927
|
'div',
|
|
@@ -1654,7 +1929,7 @@ window.__ModuleLoader__.load({
|
|
|
1654
1929
|
h(TrashGlyph, { size: 16 }),
|
|
1655
1930
|
h(
|
|
1656
1931
|
'span',
|
|
1657
|
-
{ className: 'dsh-trash__bannerText' },
|
|
1932
|
+
{ className: 'dsh-trash__bannerText', title: cleanupError ?? undefined },
|
|
1658
1933
|
cleanupMessage,
|
|
1659
1934
|
retryText === '' ? null : ` ${retryText}`,
|
|
1660
1935
|
),
|
|
@@ -1666,7 +1941,7 @@ window.__ModuleLoader__.load({
|
|
|
1666
1941
|
onClick: () => {
|
|
1667
1942
|
void runCleanup()
|
|
1668
1943
|
},
|
|
1669
|
-
disabled:
|
|
1944
|
+
disabled: loading || mutationBusy || corrupt,
|
|
1670
1945
|
},
|
|
1671
1946
|
cleaning ? t('cleaning') : t('cleanupRetryNow'),
|
|
1672
1947
|
),
|
|
@@ -1699,7 +1974,7 @@ window.__ModuleLoader__.load({
|
|
|
1699
1974
|
h(
|
|
1700
1975
|
'span',
|
|
1701
1976
|
{ className: 'dsh-trash__stats' },
|
|
1702
|
-
|
|
1977
|
+
countText(t, 'statsCount', entries.length),
|
|
1703
1978
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
1704
1979
|
format(t('statsSize'), { size: formatBytes(totalBytes) }),
|
|
1705
1980
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
@@ -1716,7 +1991,7 @@ window.__ModuleLoader__.load({
|
|
|
1716
1991
|
onClick: () => {
|
|
1717
1992
|
void runCleanup()
|
|
1718
1993
|
},
|
|
1719
|
-
disabled:
|
|
1994
|
+
disabled: loading || mutationBusy || corrupt,
|
|
1720
1995
|
},
|
|
1721
1996
|
cleaning ? t('cleaning') : t('cleanupExpired'),
|
|
1722
1997
|
),
|
|
@@ -1726,8 +2001,8 @@ window.__ModuleLoader__.load({
|
|
|
1726
2001
|
type: 'button',
|
|
1727
2002
|
className: 'dsh-trash__button',
|
|
1728
2003
|
onClick: load,
|
|
1729
|
-
disabled:
|
|
1730
|
-
'aria-busy':
|
|
2004
|
+
disabled: loading || refreshing,
|
|
2005
|
+
'aria-busy': refreshing ? 'true' : 'false',
|
|
1731
2006
|
},
|
|
1732
2007
|
t('refresh'),
|
|
1733
2008
|
),
|
|
@@ -1737,7 +2012,7 @@ window.__ModuleLoader__.load({
|
|
|
1737
2012
|
type: 'button',
|
|
1738
2013
|
className: 'dsh-trash__button dsh-trash__button--danger',
|
|
1739
2014
|
onClick: () => setDialog({ kind: 'purgeAll', count: entries.length }),
|
|
1740
|
-
disabled:
|
|
2015
|
+
disabled: loading || mutationBusy || corrupt || entries.length === 0,
|
|
1741
2016
|
},
|
|
1742
2017
|
purgingAll ? t('purgingAll') : t('purgeAll'),
|
|
1743
2018
|
),
|
|
@@ -1863,8 +2138,15 @@ window.__ModuleLoader__.load({
|
|
|
1863
2138
|
'ul',
|
|
1864
2139
|
{ className: 'dsh-trash__list', 'aria-label': t('listLabel') },
|
|
1865
2140
|
visibleEntries.map((entry) => {
|
|
1866
|
-
const rowBusy =
|
|
1867
|
-
const
|
|
2141
|
+
const rowBusy = pendingRows.has(entry.sessionId)
|
|
2142
|
+
const rowError = rowErrors[entry.sessionId]
|
|
2143
|
+
const lastErrorCode = typeof entry.lastErrorCode === 'string' ? entry.lastErrorCode : null
|
|
2144
|
+
const lastErrorText =
|
|
2145
|
+
typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
2146
|
+
? t(LAST_ERROR_KEYS[lastErrorCode] ?? 'lastErrorIo')
|
|
2147
|
+
: null
|
|
2148
|
+
const error = rowError ?? lastErrorText
|
|
2149
|
+
const errorTitle = rowError === undefined && typeof entry.lastError === 'string' ? entry.lastError : undefined
|
|
1868
2150
|
const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
|
|
1869
2151
|
return h(
|
|
1870
2152
|
'li',
|
|
@@ -1904,7 +2186,11 @@ window.__ModuleLoader__.load({
|
|
|
1904
2186
|
h('span', null, formatBytes(entry.sizeBytes)),
|
|
1905
2187
|
),
|
|
1906
2188
|
error !== null && error !== undefined && error !== ''
|
|
1907
|
-
? h(
|
|
2189
|
+
? h(
|
|
2190
|
+
'div',
|
|
2191
|
+
{ className: 'dsh-trash__rowError', role: 'status', title: errorTitle },
|
|
2192
|
+
format(t('lastErrorLabel'), { message: error }),
|
|
2193
|
+
)
|
|
1908
2194
|
: null,
|
|
1909
2195
|
),
|
|
1910
2196
|
h(
|
|
@@ -1917,7 +2203,7 @@ window.__ModuleLoader__.load({
|
|
|
1917
2203
|
type: 'button',
|
|
1918
2204
|
className: 'dsh-trash__button',
|
|
1919
2205
|
'aria-label': format(t('actionRestoreFor'), { title }),
|
|
1920
|
-
disabled: rowBusy ||
|
|
2206
|
+
disabled: rowBusy || mutationBusy || corrupt || loading,
|
|
1921
2207
|
onClick: () => {
|
|
1922
2208
|
if (entry.requiresOrphanRestore === true) setDialog({ kind: 'orphan', entry })
|
|
1923
2209
|
else void performRestore(entry, 'normal')
|
|
@@ -1929,7 +2215,7 @@ window.__ModuleLoader__.load({
|
|
|
1929
2215
|
h(ActionMenu, {
|
|
1930
2216
|
label: format(t('actionMoreFor'), { title }),
|
|
1931
2217
|
items: menuItemsFor(entry),
|
|
1932
|
-
disabled: rowBusy ||
|
|
2218
|
+
disabled: rowBusy || mutationBusy || corrupt || loading,
|
|
1933
2219
|
}),
|
|
1934
2220
|
),
|
|
1935
2221
|
)
|
|
@@ -1983,7 +2269,7 @@ window.__ModuleLoader__.load({
|
|
|
1983
2269
|
className: 'dsh-trash__panel',
|
|
1984
2270
|
key: 'panel',
|
|
1985
2271
|
ref: panelRef,
|
|
1986
|
-
'aria-busy':
|
|
2272
|
+
'aria-busy': readsBusy ? 'true' : 'false',
|
|
1987
2273
|
},
|
|
1988
2274
|
panelBody,
|
|
1989
2275
|
pagination,
|
|
@@ -2069,7 +2355,7 @@ window.__ModuleLoader__.load({
|
|
|
2069
2355
|
: 0
|
|
2070
2356
|
const message =
|
|
2071
2357
|
subagentCount > 0
|
|
2072
|
-
?
|
|
2358
|
+
? countText(t, 'trashSuccessTree', subagentCount, { title })
|
|
2073
2359
|
: format(t('trashSuccess'), { title })
|
|
2074
2360
|
ctx.contextMenus.notify(message, true, {
|
|
2075
2361
|
label: t('undo'),
|