dsh-delete-session 0.1.8 → 0.1.10

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.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/package.json +10 -2
  3. package/src/client.js +80 -25
package/README.md CHANGED
@@ -72,6 +72,14 @@ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
72
72
  - `src/client.js`(浏览器):经典客户端 bundle——监听 portal 到 body 的会话菜单(`body > [role="menu"]`),沿 React fiber 链读取会话节点(`props.node.id`),克隆原生菜单行保持样式,弹出确认对话框
73
73
  - 删除链路:停 agent → flush / detach 活跃会话 → 写所有权探测(被占用 → 409)→ 删除目录(两种 ID 写法 + 复核)→ 清理工作区记账
74
74
 
75
+ ### 测试
76
+
77
+ - `test/checkbox.dom.test.js` —— 勾选框回归测试(`node --test`,零依赖):把真实的 `src/client.js` 跑在一个可读的 DOM 实现上,只经由用户真正会碰的入口(注入的会话菜单项、插件自己生成的 DOM)驱动,断言 **「存在 `.dsdel-check` ⟺ 多选模式开启」**
78
+ - 覆盖的回归:单条删除(`deleteNow` / 对话框确认)会调用 `scheduleDecorate()`,而 v0.1.8 之前 `decorateRows()` **无条件**给每一行插入勾选框,于是删掉一个会话后所有行都长出了圆勾选框、底部操作条却仍然隐藏——一个「半开」的多选状态
79
+ - 这个测试是有意义的,不是空跑:把 `decorateRows()` 改回旧写法时其中 5 项会失败;改成「永不插入勾选框」时另有 4 项会失败
80
+ - 运行:`npm test`
81
+ - **尚未验证的一环(诚实说明)**:与真实 DSH 界面的耦合——真实 DOM 结构、真实 CSS 布局、以及 React 调和器在重渲染会话行时是否会丢弃注入的勾选框——只能由真实浏览器验证。上述测试覆盖的是插件的点击与装饰状态机本身
82
+
75
83
  ### 已知限制
76
84
 
77
85
  - 被 DSH 当前打开占用的会话:删除会自动等待写所有权释放(通常几秒)后完成;极少数仍被占用的情况会提示稍后重试或重启 DSH
@@ -158,6 +166,14 @@ Restart `dsh web` to apply.
158
166
  - `src/client.js` (browser): a classic client bundle — watches for the portalled session menu (`body > [role="menu"]`), walks the React fiber chain to the session node (`props.node.id`), clones a native menu row for styling, and opens the confirmation dialog
159
167
  - Pipeline: stop agent → flush / detach live session → write-lease probe (busy → 409) → remove directories (both id spellings + verification) → clean workspace accounting
160
168
 
169
+ ### Tests
170
+
171
+ - `test/checkbox.dom.test.js` — the multi-select checkbox regression test (`node --test`, zero dependencies): it runs the real `src/client.js` against a readable DOM implementation and drives it only through surfaces a user touches (the injected session-menu items and the DOM the plugin builds), asserting that **a `.dsdel-check` exists if and only if multi-select is on**
172
+ - The regression it covers: a plain single delete (`deleteNow` / the dialog's confirm handler) calls `scheduleDecorate()`, and before v0.1.8 `decorateRows()` planted a checkbox on **every** row unconditionally — so after deleting one session every row grew a round checkbox while the batch bar stayed hidden, a "half-on" multi-select state
173
+ - The test earns its keep: reverting `decorateRows()` to the old shape fails 5 of its cases, and a fix that never plants checkboxes fails 4
174
+ - Run it with `npm test`
175
+ - **Not verified (honest note):** the coupling to the real DSH UI — real DOM structure, real CSS layout, and whether React's reconciler drops injected checkboxes when it re-renders a session row — can only be checked in a real browser. What these tests cover is the plugin's own click and decoration state machine
176
+
161
177
  ### Known limitations
162
178
 
163
179
  - 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-delete-session",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -23,6 +23,12 @@
23
23
  "delete"
24
24
  ],
25
25
  "license": "MIT",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test \"test/*.test.js\""
31
+ },
26
32
  "repository": {
27
33
  "type": "git",
28
34
  "url": "git+https://github.com/DDDMUC/dsh-delete-session.git"
@@ -33,7 +39,9 @@
33
39
  },
34
40
  "client": {
35
41
  "inject": [
36
- "@deepseek-ai/dsh-client-runtime"
42
+ "@deepseek-ai/dsh-client-runtime",
43
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
44
+ "@deepseek-ai/dsh-client-ui-primitives"
37
45
  ],
38
46
  "platform": "web"
39
47
  }
package/src/client.js CHANGED
@@ -12,12 +12,15 @@
12
12
  //
13
13
  // The module is a classic client bundle (client-modules protocol): it
14
14
  // registers a factory with window.__ModuleLoader__ and returns apply().
15
- // No React, no SDK imports - theme tokens and DOM only, so it survives core
16
- // UI revisions that keep the menu contract.
15
+ // The menu injection and dialog are DOM-only (theme tokens, no SDK) so they
16
+ // survive core UI revisions that keep the menu contract; React is pulled in
17
+ // only for the plugin-page settings card (slot `plugins.row.config`).
17
18
  window.__ModuleLoader__.load({
18
19
  id: 'dsh-delete-session',
19
- factory: () => {
20
+ factory: (require) => {
20
21
  const NS = 'dsh-delete-session'
22
+ const react = require('react')
23
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
21
24
 
22
25
  const zh = {
23
26
  menu: '删除会话',
@@ -26,7 +29,6 @@ window.__ModuleLoader__.load({
26
29
  cancel: '取消',
27
30
  confirm: '删除',
28
31
  deleting: '删除中…',
29
- ack: '我已了解,永久删除',
30
32
  dontAsk: '不再询问,以后直接删除',
31
33
  done: '已删除会话',
32
34
  failed: '删除失败:',
@@ -41,6 +43,11 @@ window.__ModuleLoader__.load({
41
43
  batchDone: (n) => `已删除 ${n} 个会话`,
42
44
  batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
43
45
  batchFailed: '批量删除失败:',
46
+ pageAsk: '删除时询问',
47
+ pageHintAsk: '每次删除会话前都会弹出确认框。',
48
+ pageHintAuto: '删除会话会直接执行,不再询问。',
49
+ pageSummaryAsk: '删除前询问',
50
+ pageSummaryAuto: '直接删除',
44
51
  }
45
52
 
46
53
  const en = {
@@ -50,7 +57,6 @@ window.__ModuleLoader__.load({
50
57
  cancel: 'Cancel',
51
58
  confirm: 'Delete',
52
59
  deleting: 'Deleting...',
53
- ack: 'I understand - delete permanently',
54
60
  dontAsk: "Don't ask again",
55
61
  done: 'Session deleted',
56
62
  failed: 'Delete failed: ',
@@ -65,22 +71,42 @@ window.__ModuleLoader__.load({
65
71
  batchDone: (n) => `Deleted ${n} session(s)`,
66
72
  batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
67
73
  batchFailed: 'Batch delete failed: ',
74
+ pageAsk: 'Ask before deleting',
75
+ pageHintAsk: 'Show a confirmation dialog before every session delete.',
76
+ pageHintAuto: 'Delete the session right away without asking.',
77
+ pageSummaryAsk: 'Ask before deleting',
78
+ pageSummaryAuto: 'Delete without asking',
68
79
  }
69
80
 
70
81
  let localeSvc = null
71
82
  let sessionsSvc = null
72
83
 
73
- // Once the user ticks "don't ask again" the confirmation dialog is
74
- // skipped and the menu item deletes directly. Shift+click always opens
75
- // the dialog again, and clearing the key in localStorage restores it.
84
+ // Once the user opts out ("删除时询问" unticked - either in the plugin page
85
+ // or via the dialog's "don't ask again") the confirmation dialog is skipped
86
+ // and the menu item deletes directly. Shift+click always opens the dialog
87
+ // again. The plugin page's checkbox and the dialog read the same key, and a
88
+ // subscription keeps the card in step when either one flips it.
76
89
  const SKIP_KEY = 'dsh-delete-session:skip-confirm'
90
+ const skipListeners = new Set()
77
91
 
78
92
  function skipConfirm() {
79
93
  try { return localStorage.getItem(SKIP_KEY) === '1' } catch { return false }
80
94
  }
81
95
 
82
- function setSkipConfirm() {
83
- try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode: ignore */ }
96
+ function setSkipConfirm(value) {
97
+ const skip = value === true
98
+ try {
99
+ if (skip) localStorage.setItem(SKIP_KEY, '1')
100
+ else localStorage.removeItem(SKIP_KEY)
101
+ } catch { /* private mode: ignore */ }
102
+ for (const listener of skipListeners) {
103
+ try { listener() } catch { /* a subscriber must not break the toggle */ }
104
+ }
105
+ }
106
+
107
+ function subscribeSkip(listener) {
108
+ skipListeners.add(listener)
109
+ return () => { skipListeners.delete(listener) }
84
110
  }
85
111
 
86
112
  function postDelete(sessionId) {
@@ -230,6 +256,8 @@ window.__ModuleLoader__.load({
230
256
  '.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}',
231
257
  '.dsdel-bar-btn:disabled{opacity:.5;cursor:default}',
232
258
  '.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
259
+ '.dsdel-page{display:flex;flex-direction:column;gap:8px}',
260
+ '.dsdel-page-hint{margin:0;color:var(--dsw-alias-label-tertiary,#888);font-size:13px;line-height:20px}',
233
261
  ].join('')
234
262
 
235
263
  function ensureStyle() {
@@ -296,15 +324,6 @@ window.__ModuleLoader__.load({
296
324
  text.className = 'dsdel-text'
297
325
  text.textContent = t('desc')
298
326
 
299
- const ack = document.createElement('label')
300
- ack.className = 'dsdel-ack'
301
- const checkbox = document.createElement('input')
302
- checkbox.type = 'checkbox'
303
- const ackText = document.createElement('span')
304
- ackText.textContent = t('ack')
305
- ack.appendChild(checkbox)
306
- ack.appendChild(ackText)
307
-
308
327
  const skip = document.createElement('label')
309
328
  skip.className = 'dsdel-ack'
310
329
  const skipBox = document.createElement('input')
@@ -328,14 +347,12 @@ window.__ModuleLoader__.load({
328
347
  confirm.type = 'button'
329
348
  confirm.className = 'dsdel-btn dsdel-danger'
330
349
  confirm.textContent = t('confirm')
331
- confirm.disabled = true
332
350
  actions.appendChild(cancel)
333
351
  actions.appendChild(confirm)
334
352
 
335
353
  dialog.appendChild(title)
336
354
  dialog.appendChild(meta)
337
355
  dialog.appendChild(text)
338
- dialog.appendChild(ack)
339
356
  dialog.appendChild(skip)
340
357
  dialog.appendChild(error)
341
358
  dialog.appendChild(actions)
@@ -347,19 +364,18 @@ window.__ModuleLoader__.load({
347
364
  if (busy) return
348
365
  backdrop.remove()
349
366
  }
350
- checkbox.addEventListener('change', () => { confirm.disabled = !checkbox.checked })
351
367
  cancel.addEventListener('click', close)
352
368
  backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
353
369
  backdrop.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
354
370
 
355
371
  confirm.addEventListener('click', () => {
356
- if (busy || !checkbox.checked) return
372
+ if (busy) return
357
373
  busy = true
358
374
  confirm.disabled = true
359
375
  cancel.disabled = true
360
376
  confirm.textContent = t('deleting')
361
377
  error.style.display = 'none'
362
- if (skipBox.checked) setSkipConfirm()
378
+ if (skipBox.checked) setSkipConfirm(true)
363
379
  pendingRemoval.add(info.id)
364
380
  scheduleDecorate()
365
381
  postDelete(info.id)
@@ -374,7 +390,7 @@ window.__ModuleLoader__.load({
374
390
  busy = false
375
391
  pendingRemoval.delete(info.id)
376
392
  scheduleDecorate()
377
- confirm.disabled = !checkbox.checked
393
+ confirm.disabled = false
378
394
  cancel.disabled = false
379
395
  confirm.textContent = t('confirm')
380
396
  error.textContent = t('failed') + String((reason && reason.message) || reason)
@@ -674,6 +690,40 @@ window.__ModuleLoader__.load({
674
690
  window.dispatchEvent(new Event('resize'))
675
691
  }
676
692
 
693
+ // --- plugin page (slot plugins.row.config) ---------------------------------
694
+
695
+ // The manager's detail page for this bundle gives the component row a
696
+ // configure arrow once the bundle registers its own row.config entry (key
697
+ // `<bundle package>#<row id>`). The card carries the one setting the dialog
698
+ // also exposes: whether a session delete asks first.
699
+ function AskBeforeDeleteOption({ view }) {
700
+ const [ask, setAsk] = react.useState(!skipConfirm())
701
+ react.useEffect(() => subscribeSkip(() => setAsk(!skipConfirm())), [])
702
+ if (view === 'summary') return t(ask ? 'pageSummaryAsk' : 'pageSummaryAuto')
703
+ return react.createElement(
704
+ 'div',
705
+ { className: 'dsdel-page' },
706
+ react.createElement(primitives.Checkbox, {
707
+ checked: ask,
708
+ label: t('pageAsk'),
709
+ onChange: (checked) => {
710
+ setAsk(checked === true)
711
+ setSkipConfirm(checked !== true)
712
+ },
713
+ }),
714
+ react.createElement('p', { className: 'dsdel-page-hint' }, t(ask ? 'pageHintAsk' : 'pageHintAuto')),
715
+ )
716
+ }
717
+
718
+ function installSettings(slots) {
719
+ slots.inject('plugins.row.config', () =>
720
+ slots.register(
721
+ { name: 'plugins.row.config', key: 'dsh-delete-session#dsh-delete-session' },
722
+ AskBeforeDeleteOption,
723
+ ),
724
+ )
725
+ }
726
+
677
727
  function install() {
678
728
  if (typeof document === 'undefined' || !document.body) return null
679
729
  ensureStyle()
@@ -704,6 +754,11 @@ window.__ModuleLoader__.load({
704
754
  if (!localeSvc) {
705
755
  try { ctx.inject(['locale'], (sub) => { localeSvc = sub.locale }) } catch { /* optional */ }
706
756
  }
757
+ const slots = ctx.get('slots')
758
+ if (slots) installSettings(slots)
759
+ else {
760
+ try { ctx.inject(['slots'], (sub) => installSettings(sub.slots)) } catch { /* optional */ }
761
+ }
707
762
  const dispose = install()
708
763
  if (dispose) ctx.effect(() => dispose)
709
764
  }