dsh-delete-session 0.1.7 → 0.1.9

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 +95 -16
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.7",
3
+ "version": "0.1.9",
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: '删除会话',
@@ -41,6 +44,11 @@ window.__ModuleLoader__.load({
41
44
  batchDone: (n) => `已删除 ${n} 个会话`,
42
45
  batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
43
46
  batchFailed: '批量删除失败:',
47
+ pageAsk: '删除时询问',
48
+ pageHintAsk: '每次删除会话前都会弹出确认框。',
49
+ pageHintAuto: '删除会话会直接执行,不再询问。',
50
+ pageSummaryAsk: '删除前询问',
51
+ pageSummaryAuto: '直接删除',
44
52
  }
45
53
 
46
54
  const en = {
@@ -65,22 +73,42 @@ window.__ModuleLoader__.load({
65
73
  batchDone: (n) => `Deleted ${n} session(s)`,
66
74
  batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
67
75
  batchFailed: 'Batch delete failed: ',
76
+ pageAsk: 'Ask before deleting',
77
+ pageHintAsk: 'Show a confirmation dialog before every session delete.',
78
+ pageHintAuto: 'Delete the session right away without asking.',
79
+ pageSummaryAsk: 'Ask before deleting',
80
+ pageSummaryAuto: 'Delete without asking',
68
81
  }
69
82
 
70
83
  let localeSvc = null
71
84
  let sessionsSvc = null
72
85
 
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.
86
+ // Once the user opts out ("删除时询问" unticked - either in the plugin page
87
+ // or via the dialog's "don't ask again") the confirmation dialog is skipped
88
+ // and the menu item deletes directly. Shift+click always opens the dialog
89
+ // again. The plugin page's checkbox and the dialog read the same key, and a
90
+ // subscription keeps the card in step when either one flips it.
76
91
  const SKIP_KEY = 'dsh-delete-session:skip-confirm'
92
+ const skipListeners = new Set()
77
93
 
78
94
  function skipConfirm() {
79
95
  try { return localStorage.getItem(SKIP_KEY) === '1' } catch { return false }
80
96
  }
81
97
 
82
- function setSkipConfirm() {
83
- try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode: ignore */ }
98
+ function setSkipConfirm(value) {
99
+ const skip = value === true
100
+ try {
101
+ if (skip) localStorage.setItem(SKIP_KEY, '1')
102
+ else localStorage.removeItem(SKIP_KEY)
103
+ } catch { /* private mode: ignore */ }
104
+ for (const listener of skipListeners) {
105
+ try { listener() } catch { /* a subscriber must not break the toggle */ }
106
+ }
107
+ }
108
+
109
+ function subscribeSkip(listener) {
110
+ skipListeners.add(listener)
111
+ return () => { skipListeners.delete(listener) }
84
112
  }
85
113
 
86
114
  function postDelete(sessionId) {
@@ -230,6 +258,8 @@ window.__ModuleLoader__.load({
230
258
  '.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
259
  '.dsdel-bar-btn:disabled{opacity:.5;cursor:default}',
232
260
  '.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
261
+ '.dsdel-page{display:flex;flex-direction:column;gap:8px}',
262
+ '.dsdel-page-hint{margin:0;color:var(--dsw-alias-label-tertiary,#888);font-size:13px;line-height:20px}',
233
263
  ].join('')
234
264
 
235
265
  function ensureStyle() {
@@ -359,7 +389,7 @@ window.__ModuleLoader__.load({
359
389
  cancel.disabled = true
360
390
  confirm.textContent = t('deleting')
361
391
  error.style.display = 'none'
362
- if (skipBox.checked) setSkipConfirm()
392
+ if (skipBox.checked) setSkipConfirm(true)
363
393
  pendingRemoval.add(info.id)
364
394
  scheduleDecorate()
365
395
  postDelete(info.id)
@@ -419,16 +449,26 @@ window.__ModuleLoader__.load({
419
449
  rows.forEach((row) => {
420
450
  const id = sessionIdFromRow(row)
421
451
  if (!id) return
422
- let check = row.querySelector(':scope > .dsdel-check')
423
- if (!check) {
424
- check = document.createElement('span')
425
- check.className = 'dsdel-check'
426
- check.setAttribute('data-dsh-delete-session', 'check')
427
- row.insertBefore(check, row.firstChild)
452
+ // Checkboxes belong to multi-select only: a single delete (or any other
453
+ // decorate pass outside the mode) must not plant them on every row.
454
+ if (!selecting) {
455
+ const stray = row.querySelector(':scope > .dsdel-check')
456
+ if (stray) stray.remove()
457
+ } else {
458
+ let check = row.querySelector(':scope > .dsdel-check')
459
+ if (!check) {
460
+ check = document.createElement('span')
461
+ check.className = 'dsdel-check'
462
+ check.setAttribute('data-dsh-delete-session', 'check')
463
+ row.insertBefore(check, row.firstChild)
464
+ }
465
+ check.classList.toggle('dsdel-check-on', selected.has(id))
428
466
  }
429
- check.classList.toggle('dsdel-check-on', selected.has(id))
430
467
  row.classList.toggle('dsdel-row-pending', pendingRemoval.has(id))
431
468
  })
469
+ if (!selecting) {
470
+ document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
471
+ }
432
472
  }
433
473
 
434
474
  function scheduleDecorate() {
@@ -664,6 +704,40 @@ window.__ModuleLoader__.load({
664
704
  window.dispatchEvent(new Event('resize'))
665
705
  }
666
706
 
707
+ // --- plugin page (slot plugins.row.config) ---------------------------------
708
+
709
+ // The manager's detail page for this bundle gives the component row a
710
+ // configure arrow once the bundle registers its own row.config entry (key
711
+ // `<bundle package>#<row id>`). The card carries the one setting the dialog
712
+ // also exposes: whether a session delete asks first.
713
+ function AskBeforeDeleteOption({ view }) {
714
+ const [ask, setAsk] = react.useState(!skipConfirm())
715
+ react.useEffect(() => subscribeSkip(() => setAsk(!skipConfirm())), [])
716
+ if (view === 'summary') return t(ask ? 'pageSummaryAsk' : 'pageSummaryAuto')
717
+ return react.createElement(
718
+ 'div',
719
+ { className: 'dsdel-page' },
720
+ react.createElement(primitives.Checkbox, {
721
+ checked: ask,
722
+ label: t('pageAsk'),
723
+ onChange: (checked) => {
724
+ setAsk(checked === true)
725
+ setSkipConfirm(checked !== true)
726
+ },
727
+ }),
728
+ react.createElement('p', { className: 'dsdel-page-hint' }, t(ask ? 'pageHintAsk' : 'pageHintAuto')),
729
+ )
730
+ }
731
+
732
+ function installSettings(slots) {
733
+ slots.inject('plugins.row.config', () =>
734
+ slots.register(
735
+ { name: 'plugins.row.config', key: 'dsh-delete-session#dsh-delete-session' },
736
+ AskBeforeDeleteOption,
737
+ ),
738
+ )
739
+ }
740
+
667
741
  function install() {
668
742
  if (typeof document === 'undefined' || !document.body) return null
669
743
  ensureStyle()
@@ -694,6 +768,11 @@ window.__ModuleLoader__.load({
694
768
  if (!localeSvc) {
695
769
  try { ctx.inject(['locale'], (sub) => { localeSvc = sub.locale }) } catch { /* optional */ }
696
770
  }
771
+ const slots = ctx.get('slots')
772
+ if (slots) installSettings(slots)
773
+ else {
774
+ try { ctx.inject(['slots'], (sub) => installSettings(sub.slots)) } catch { /* optional */ }
775
+ }
697
776
  const dispose = install()
698
777
  if (dispose) ctx.effect(() => dispose)
699
778
  }