dsh-delete-session 0.1.4 → 0.1.5
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/README.md +6 -0
- package/package.json +1 -1
- package/src/client.js +216 -17
- package/src/index.js +51 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只
|
|
|
27
27
|
- **菜单内删除** —— 侧边栏会话 `...` 菜单直接多出「删除会话」;会话 ID 从菜单的 React fiber 读取,永远作用于你点的那一行,**不会切换当前会话**
|
|
28
28
|
- **风险确认** —— 首次删除弹出确认对话框:显示会话标题与 ID,勾选「我已了解,永久删除」后确认按钮才可用
|
|
29
29
|
- **确认一次后可跳过** —— 对话框里勾选「不再询问,以后直接删除」,之后点菜单项直接删除(左下角 toast 提示);**按住 Shift 点击**可临时恢复弹窗;清除 `localStorage['dsh-delete-session:skip-confirm']` 可永久恢复
|
|
30
|
+
- **多选批量删除** —— 会话菜单里的「多选」进入选择模式:每行左侧出现勾选圈,点整行即勾选(不会打开会话),底部操作条显示「已选 N 个 / 取消 / 删除」;点删除**立即批量执行**(无确认弹窗、无全选、所有行都可选),完成后 toast 汇总并刷新列表;Esc 或「取消」退出
|
|
30
31
|
- **真正的删除** —— 停止运行中的 agent(cancel + 等待空闲)→ flush / detach 活跃会话 → 删除磁盘会话目录(原始 ID 与 `session-` 前缀两种写法)→ 持久化服务复核 → 清理工作区记账,侧栏不会残留「未分组」孤儿
|
|
31
32
|
- **默认安全** —— 宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);被 DSH 打开占用的会话会被明确拒绝(409),绝不与活跃写者竞争
|
|
32
33
|
- **中英双语** —— 菜单项与对话框跟随 DSH 界面语言(zh / en)
|
|
@@ -54,6 +55,7 @@ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
|
|
|
54
55
|
2. 选择底部的「删除会话」
|
|
55
56
|
3. 首次:勾选「我已了解,永久删除」(想省事可同时勾选「不再询问,以后直接删除」)→ 点「删除」
|
|
56
57
|
4. 之后:直接删除,左下角出现 toast 提示
|
|
58
|
+
5. 批量删除:会话菜单 →「多选」→ 勾选多个会话 → 底部「删除」;Esc 或「取消」退出选择模式
|
|
57
59
|
|
|
58
60
|
<div align="center">
|
|
59
61
|
<a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg">
|
|
@@ -74,6 +76,7 @@ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
|
|
|
74
76
|
- 被 DSH 当前打开占用的会话:删除会自动等待写所有权释放(通常几秒)后完成;极少数仍被占用的情况会提示稍后重试或重启 DSH
|
|
75
77
|
- 菜单注入是 DOM shim:核心 UI 若调整菜单结构或行 fiber 形状,可能不再注入菜单项(不影响其他功能)
|
|
76
78
|
- 删除不可恢复:没有回收站,也没有撤销
|
|
79
|
+
- 批量删除目前**逐条顺序执行**:空闲会话很快,被 DSH 打开过的会话每条需要等待写所有权释放(最多数秒),一次选很多时整体耗时会变长(后续版本会并发化)
|
|
77
80
|
|
|
78
81
|
### 兼容性
|
|
79
82
|
|
|
@@ -109,6 +112,7 @@ This plugin puts Delete back where you already click: the session row's `...` me
|
|
|
109
112
|
- **Delete from the menu** — a "Delete session" row appears in the sidebar session `...` menu; the session id is read from the menu's React fiber, so the action always targets the row you clicked and **never switches the active conversation**
|
|
110
113
|
- **Risk consent** — the first delete opens a confirmation dialog with the session title and id; the confirm button unlocks only after ticking "I understand"
|
|
111
114
|
- **Confirm once, then skip** — tick "Don't ask again" and later deletes run directly from the menu (a toast reports the result); **hold Shift while clicking** to force the dialog back, or clear `localStorage['dsh-delete-session:skip-confirm']` to restore it permanently
|
|
115
|
+
- **Multi-select batch delete** — "Select multiple" in the session menu enters selection mode: every row gets a round checkbox, clicking a row toggles it (it never opens the conversation), and a bottom bar shows "N selected / Cancel / Delete". Delete runs **immediately** (no confirm dialog, no select-all, every row selectable) and reports a toast summary; Esc or Cancel exits
|
|
112
116
|
- **Real deletion** — stops a running agent (cancel + quiescence), flushes and detaches the live session, removes the on-disk session directories (both the raw and `session-` prefixed id), verifies through the persistence service, then cleans workspace accounting so no orphan shows up under "Ungrouped"
|
|
113
117
|
- **Safe by default** — the host route is loopback-only (loopback socket, loopback Host header, same-origin check); a session still held open by DSH is refused with 409 instead of racing the active writer
|
|
114
118
|
- **Bilingual** — the menu item and dialog follow the DSH interface language (zh / en)
|
|
@@ -136,6 +140,7 @@ Restart `dsh web` to apply.
|
|
|
136
140
|
2. Pick "Delete session" at the bottom
|
|
137
141
|
3. First time: tick "I understand - delete permanently" (tick "Don't ask again" too if you like) and confirm
|
|
138
142
|
4. Afterwards: the session is deleted directly, with a toast
|
|
143
|
+
5. Batch delete: session menu -> "Select multiple" -> tick rows -> "Delete" in the bottom bar; Esc or Cancel exits
|
|
139
144
|
|
|
140
145
|
<div align="center">
|
|
141
146
|
<a href="https://raw.githubusercontent.com/DDDMUC/dsh-delete-session/main/assets/dialog.jpg">
|
|
@@ -156,6 +161,7 @@ Restart `dsh web` to apply.
|
|
|
156
161
|
- A session DSH currently holds open: the delete waits for the write lease to be released (usually a few seconds) and then completes; in the rare case it stays busy, the dialog asks you to retry later or restart DSH
|
|
157
162
|
- The menu injection is a DOM shim: core UI changes to the menu markup or row fiber shape may stop the item from being injected (nothing else breaks)
|
|
158
163
|
- Deletion is permanent: there is no trash bin and no undo
|
|
164
|
+
- Batch delete currently runs **sequentially**: idle sessions are fast, while a session DSH has opened waits for its write lease (up to a few seconds), so a large selection takes longer (a future version will parallelize it)
|
|
159
165
|
|
|
160
166
|
### Compatibility
|
|
161
167
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-delete-session",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, then removes the session log, workspace accounting and live session state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/client.js
CHANGED
|
@@ -33,6 +33,13 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
busy: '该会话正被 DSH 打开,删除未能完成;请稍后重试(通常几秒后即可),或重启 DSH 后再试。',
|
|
34
34
|
notFound: '未找到该会话(可能已被删除)。',
|
|
35
35
|
untitled: '未命名会话',
|
|
36
|
+
multiSelect: '多选',
|
|
37
|
+
cancelSelect: '取消',
|
|
38
|
+
selectedCount: (n) => `已选 ${n} 个`,
|
|
39
|
+
deleteSelected: '删除',
|
|
40
|
+
batchDone: (n) => `已删除 ${n} 个会话`,
|
|
41
|
+
batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
|
|
42
|
+
batchFailed: '批量删除失败:',
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const en = {
|
|
@@ -49,6 +56,13 @@ window.__ModuleLoader__.load({
|
|
|
49
56
|
busy: 'This session is currently open in DSH; the delete did not complete. Try again in a moment (usually a few seconds), or restart DSH and retry.',
|
|
50
57
|
notFound: 'Session not found (it may already be deleted).',
|
|
51
58
|
untitled: 'Untitled session',
|
|
59
|
+
multiSelect: 'Select multiple',
|
|
60
|
+
cancelSelect: 'Cancel',
|
|
61
|
+
selectedCount: (n) => `${n} selected`,
|
|
62
|
+
deleteSelected: 'Delete',
|
|
63
|
+
batchDone: (n) => `Deleted ${n} session(s)`,
|
|
64
|
+
batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
|
|
65
|
+
batchFailed: 'Batch delete failed: ',
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
let localeSvc = null
|
|
@@ -158,6 +172,14 @@ window.__ModuleLoader__.load({
|
|
|
158
172
|
'.dsdel-btn:disabled{opacity:.5;cursor:default}',
|
|
159
173
|
'.dsdel-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
160
174
|
'.dsdel-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:Canvas;color:CanvasText;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);max-width:min(92vw,460px);cursor:pointer}',
|
|
175
|
+
'.dsdel-check{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.5));background:transparent;flex:none;margin-right:8px;box-sizing:border-box;cursor:pointer}',
|
|
176
|
+
'.dsdel-check-on{background:var(--dsw-alias-state-business-primary,#4c6ef5);border-color:var(--dsw-alias-state-business-primary,#4c6ef5)}',
|
|
177
|
+
'.dsdel-check-on::after{content:"";width:9px;height:5px;border-left:2px solid #fff;border-bottom:2px solid #fff;transform:rotate(-45deg) translateY(-1px)}',
|
|
178
|
+
'.dsdel-bar{position:fixed;left:12px;bottom:12px;z-index:2147483600;display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));box-shadow:0 12px 32px rgba(0,0,0,.28);color:var(--dsw-alias-label-primary,inherit);font-size:13px;background:linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),var(--dsw-alias-bg-base,#fff)}',
|
|
179
|
+
'.dsdel-bar-count{white-space:nowrap}',
|
|
180
|
+
'.dsdel-bar-btn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-fill-elevated,rgba(128,128,128,.08));color:var(--dsw-alias-label-primary,inherit);border-radius:8px;font-size:13px;cursor:pointer}',
|
|
181
|
+
'.dsdel-bar-btn:disabled{opacity:.5;cursor:default}',
|
|
182
|
+
'.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
161
183
|
].join('')
|
|
162
184
|
|
|
163
185
|
function ensureStyle() {
|
|
@@ -305,45 +327,222 @@ window.__ModuleLoader__.load({
|
|
|
305
327
|
})
|
|
306
328
|
}
|
|
307
329
|
|
|
330
|
+
// --- multi-select ----------------------------------------------------------
|
|
331
|
+
// Entered from the session menu ("多选"). While active, every session row
|
|
332
|
+
// gets a round checkbox, clicking a row toggles it instead of opening the
|
|
333
|
+
// conversation, and a bottom bar runs the batch delete. No select-all and
|
|
334
|
+
// no per-row disabling: every row is selectable and the batch deletes
|
|
335
|
+
// immediately (the single-session flow keeps its own consent dialog).
|
|
336
|
+
|
|
337
|
+
let selecting = false
|
|
338
|
+
const selected = new Set()
|
|
339
|
+
let bar = null
|
|
340
|
+
let decorateRaf = null
|
|
341
|
+
let rowObserver = null
|
|
342
|
+
|
|
343
|
+
function sessionIdFromRow(row) {
|
|
344
|
+
let fiber = findFiber(row)
|
|
345
|
+
let guard = 0
|
|
346
|
+
while (fiber && guard++ < 300) {
|
|
347
|
+
const props = fiber.memoizedProps
|
|
348
|
+
if (props && props.node && typeof props.node.id === 'string' && props.node.id) return props.node.id
|
|
349
|
+
fiber = fiber.return
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function decorateRows() {
|
|
355
|
+
const rows = document.querySelectorAll('[role="treeitem"]')
|
|
356
|
+
rows.forEach((row) => {
|
|
357
|
+
const id = sessionIdFromRow(row)
|
|
358
|
+
if (!id) return
|
|
359
|
+
let check = row.querySelector(':scope > .dsdel-check')
|
|
360
|
+
if (!check) {
|
|
361
|
+
check = document.createElement('span')
|
|
362
|
+
check.className = 'dsdel-check'
|
|
363
|
+
check.setAttribute('data-dsh-delete-session', 'check')
|
|
364
|
+
row.insertBefore(check, row.firstChild)
|
|
365
|
+
}
|
|
366
|
+
check.classList.toggle('dsdel-check-on', selected.has(id))
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function scheduleDecorate() {
|
|
371
|
+
if (decorateRaf !== null) return
|
|
372
|
+
decorateRaf = requestAnimationFrame(() => {
|
|
373
|
+
decorateRaf = null
|
|
374
|
+
try { decorateRows() } catch { /* best-effort DOM shim */ }
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function updateBar() {
|
|
379
|
+
if (!bar) return
|
|
380
|
+
const count = bar.querySelector('.dsdel-bar-count')
|
|
381
|
+
if (count) count.textContent = t('selectedCount')(selected.size)
|
|
382
|
+
const del = bar.querySelector('.dsdel-bar-danger')
|
|
383
|
+
if (del) del.disabled = selected.size === 0
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toggleSelect(id) {
|
|
387
|
+
if (selected.has(id)) selected.delete(id)
|
|
388
|
+
else selected.add(id)
|
|
389
|
+
scheduleDecorate()
|
|
390
|
+
updateBar()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function onSelectClickCapture(e) {
|
|
394
|
+
if (!selecting) return
|
|
395
|
+
const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null
|
|
396
|
+
if (!row) return
|
|
397
|
+
const id = sessionIdFromRow(row)
|
|
398
|
+
if (!id) return
|
|
399
|
+
e.preventDefault()
|
|
400
|
+
e.stopPropagation()
|
|
401
|
+
toggleSelect(id)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function onSelectKeydown(e) {
|
|
405
|
+
if (e.key !== 'Escape') return
|
|
406
|
+
e.stopPropagation()
|
|
407
|
+
exitSelection()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function buildBar() {
|
|
411
|
+
const el = document.createElement('div')
|
|
412
|
+
el.className = 'dsdel-bar'
|
|
413
|
+
el.setAttribute('role', 'toolbar')
|
|
414
|
+
const count = document.createElement('span')
|
|
415
|
+
count.className = 'dsdel-bar-count'
|
|
416
|
+
const cancel = document.createElement('button')
|
|
417
|
+
cancel.type = 'button'
|
|
418
|
+
cancel.className = 'dsdel-bar-btn'
|
|
419
|
+
cancel.textContent = t('cancelSelect')
|
|
420
|
+
cancel.addEventListener('click', (e) => { e.stopPropagation(); exitSelection() })
|
|
421
|
+
const del = document.createElement('button')
|
|
422
|
+
del.type = 'button'
|
|
423
|
+
del.className = 'dsdel-bar-btn dsdel-bar-danger'
|
|
424
|
+
del.textContent = t('deleteSelected')
|
|
425
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); runBatchDelete() })
|
|
426
|
+
el.appendChild(count)
|
|
427
|
+
el.appendChild(cancel)
|
|
428
|
+
el.appendChild(del)
|
|
429
|
+
document.body.appendChild(el)
|
|
430
|
+
return el
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function enterSelection() {
|
|
434
|
+
if (selecting) return
|
|
435
|
+
selecting = true
|
|
436
|
+
selected.clear()
|
|
437
|
+
ensureStyle()
|
|
438
|
+
if (!bar) bar = buildBar()
|
|
439
|
+
bar.style.display = 'flex'
|
|
440
|
+
document.addEventListener('click', onSelectClickCapture, true)
|
|
441
|
+
document.addEventListener('keydown', onSelectKeydown, true)
|
|
442
|
+
if (typeof MutationObserver === 'function') {
|
|
443
|
+
rowObserver = new MutationObserver(scheduleDecorate)
|
|
444
|
+
rowObserver.observe(document.body, { childList: true, subtree: true })
|
|
445
|
+
}
|
|
446
|
+
scheduleDecorate()
|
|
447
|
+
updateBar()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function exitSelection() {
|
|
451
|
+
if (!selecting) return
|
|
452
|
+
selecting = false
|
|
453
|
+
selected.clear()
|
|
454
|
+
if (bar) bar.style.display = 'none'
|
|
455
|
+
document.removeEventListener('click', onSelectClickCapture, true)
|
|
456
|
+
document.removeEventListener('keydown', onSelectKeydown, true)
|
|
457
|
+
if (rowObserver) { rowObserver.disconnect(); rowObserver = null }
|
|
458
|
+
document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function postDeleteMany(sessionIds) {
|
|
462
|
+
return fetch('/dsh-delete-session/delete-many', {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'content-type': 'application/json' },
|
|
465
|
+
body: JSON.stringify({ sessionIds }),
|
|
466
|
+
}).then(async (res) => {
|
|
467
|
+
let data = {}
|
|
468
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
469
|
+
if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
470
|
+
return data
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runBatchDelete() {
|
|
475
|
+
const ids = [...selected]
|
|
476
|
+
if (ids.length === 0) return
|
|
477
|
+
const del = bar ? bar.querySelector('.dsdel-bar-danger') : null
|
|
478
|
+
if (del) del.disabled = true
|
|
479
|
+
postDeleteMany(ids)
|
|
480
|
+
.then((data) => {
|
|
481
|
+
const failed = data.failed || 0
|
|
482
|
+
const deleted = data.deleted || 0
|
|
483
|
+
toast(failed > 0 ? t('batchPartial')(deleted, failed) : t('batchDone')(deleted), failed > 0)
|
|
484
|
+
exitSelection()
|
|
485
|
+
refreshList()
|
|
486
|
+
})
|
|
487
|
+
.catch((reason) => {
|
|
488
|
+
if (del) del.disabled = false
|
|
489
|
+
toast(t('batchFailed') + String((reason && reason.message) || reason), true)
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
308
493
|
// --- sidebar menu injection ------------------------------------------------
|
|
309
494
|
|
|
310
495
|
const TRASH_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M9 7V4h6v3"/></svg>'
|
|
496
|
+
const SELECT_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="5" cy="7" r="2"/><circle cx="5" cy="17" r="2"/><path d="M11 7h9"/><path d="M11 17h9"/></svg>'
|
|
311
497
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
|
|
315
|
-
if (!viewport) return
|
|
498
|
+
// Clone a native menu row so an injected action keeps the core styling.
|
|
499
|
+
function buildMenuItem(menuEl, label, iconSvg, danger, onClick) {
|
|
316
500
|
const proto = menuEl.querySelector('[role="menuitem"]')
|
|
317
|
-
if (!proto) return
|
|
501
|
+
if (!proto) return null
|
|
318
502
|
const protoWrap = proto.parentElement
|
|
319
503
|
const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
|
|
320
504
|
if (protoWrap && protoWrap.className) wrap.className = protoWrap.className
|
|
321
|
-
|
|
322
505
|
const button = document.createElement('button')
|
|
323
506
|
button.type = 'button'
|
|
324
507
|
button.setAttribute('role', 'menuitem')
|
|
325
|
-
button.setAttribute('data-dsh-delete-session', '1')
|
|
326
508
|
if (proto.className) button.className = proto.className
|
|
327
|
-
button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
|
|
328
|
-
|
|
509
|
+
if (danger) button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
|
|
329
510
|
const icon = document.createElement('span')
|
|
330
511
|
icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center'
|
|
331
|
-
icon.innerHTML =
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
512
|
+
icon.innerHTML = iconSvg
|
|
513
|
+
const text = document.createElement('span')
|
|
514
|
+
text.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
515
|
+
text.textContent = label
|
|
336
516
|
button.appendChild(icon)
|
|
337
|
-
button.appendChild(
|
|
517
|
+
button.appendChild(text)
|
|
338
518
|
button.addEventListener('click', (e) => {
|
|
339
519
|
e.stopPropagation()
|
|
340
520
|
e.preventDefault()
|
|
341
521
|
closeMenu()
|
|
522
|
+
onClick(e)
|
|
523
|
+
})
|
|
524
|
+
wrap.appendChild(button)
|
|
525
|
+
return wrap
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function augmentMenu(menuEl, info) {
|
|
529
|
+
if (menuEl.querySelector('[data-dsh-delete-session="1"]')) return
|
|
530
|
+
const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
|
|
531
|
+
if (!viewport) return
|
|
532
|
+
|
|
533
|
+
const selectWrap = buildMenuItem(menuEl, t('multiSelect'), SELECT_ICON, false, () => enterSelection())
|
|
534
|
+
const deleteWrap = buildMenuItem(menuEl, t('menu'), TRASH_ICON, true, (e) => {
|
|
342
535
|
if (skipConfirm() && !e.shiftKey) deleteNow(info)
|
|
343
536
|
else openDialog(info)
|
|
344
537
|
})
|
|
345
|
-
|
|
346
|
-
|
|
538
|
+
if (selectWrap) {
|
|
539
|
+
selectWrap.firstChild.setAttribute('data-dsh-delete-session', 'select')
|
|
540
|
+
viewport.appendChild(selectWrap)
|
|
541
|
+
}
|
|
542
|
+
if (deleteWrap) {
|
|
543
|
+
deleteWrap.firstChild.setAttribute('data-dsh-delete-session', '1')
|
|
544
|
+
viewport.appendChild(deleteWrap)
|
|
545
|
+
}
|
|
347
546
|
|
|
348
547
|
// The portalled menu measured its height before this row existed; nudge
|
|
349
548
|
// the core placement so the grown card is re-clamped on screen.
|
package/src/index.js
CHANGED
|
@@ -30,6 +30,7 @@ export const name = 'dsh-delete-session'
|
|
|
30
30
|
|
|
31
31
|
const ROUTE_PREFIX = '/dsh-delete-session'
|
|
32
32
|
const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
33
|
+
const MAX_BATCH = 100
|
|
33
34
|
|
|
34
35
|
class HttpError extends Error {
|
|
35
36
|
constructor(status, code, message) {
|
|
@@ -370,6 +371,56 @@ export function apply(ctx) {
|
|
|
370
371
|
}
|
|
371
372
|
},
|
|
372
373
|
}))
|
|
374
|
+
|
|
375
|
+
fiber.effect(() => webServer.register({
|
|
376
|
+
kind: 'exact',
|
|
377
|
+
path: `${ROUTE_PREFIX}/delete-many`,
|
|
378
|
+
handler: async (req, res) => {
|
|
379
|
+
if (!guard(req, res)) return
|
|
380
|
+
if (req.method !== 'POST') {
|
|
381
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
let body = {}
|
|
385
|
+
try {
|
|
386
|
+
const raw = await readBody(req)
|
|
387
|
+
if (raw) body = JSON.parse(raw)
|
|
388
|
+
} catch {
|
|
389
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const sessionIds = Array.isArray(body.sessionIds)
|
|
393
|
+
? [...new Set(body.sessionIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
|
|
394
|
+
: []
|
|
395
|
+
if (sessionIds.length === 0) {
|
|
396
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionIds required' })
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (sessionIds.length > MAX_BATCH) {
|
|
400
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
// Sequential on purpose: every item runs the same guarded pipeline
|
|
404
|
+
// (lease retry, directory sweep, workspace accounting), so a batch can
|
|
405
|
+
// never interleave two deletions of the same storage.
|
|
406
|
+
const results = []
|
|
407
|
+
for (const sessionId of sessionIds) {
|
|
408
|
+
try {
|
|
409
|
+
const result = await deleteSession(ctx, sessionId)
|
|
410
|
+
results.push({ sessionId, ok: true, removed: result.removed })
|
|
411
|
+
} catch (error) {
|
|
412
|
+
results.push({
|
|
413
|
+
sessionId,
|
|
414
|
+
ok: false,
|
|
415
|
+
code: error instanceof HttpError ? error.code : 'internal',
|
|
416
|
+
error: String((error && error.message) || error),
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const deleted = results.filter((item) => item.ok).length
|
|
421
|
+
sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
|
|
422
|
+
},
|
|
423
|
+
}))
|
|
373
424
|
}
|
|
374
425
|
|
|
375
426
|
const webServer = ctx.get('webServer')
|