dsh-delete-session 0.2.1 → 0.2.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/README.md CHANGED
@@ -24,7 +24,7 @@ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只
24
24
  - **对话框文案跟随开关** —— 勾选时写「并放进系统回收站」,取消勾选时写「永久删除…不可恢复」。你看到的永远是你实际会得到的
25
25
  - **风险确认** —— 每次删除弹出确认对话框,显示会话标题与 ID。要不要弹这个框**只在设置页改**,弹窗里没有快捷勾选;**按住 Shift 点击**可以临时强制弹窗
26
26
  - **子会话一起删** —— 子代理是**独立会话**(独立目录、独立日志),父会话删掉后它们会变成孤儿留在磁盘上。现在删除对话时会处理它派生的子会话:设置里勾选「同时删除子会话」就一起删;不勾选则弹窗列出子会话,由你勾选要删哪些(默认全选)
27
- - **多选批量删除** —— 会话菜单「多选」进入选择模式:勾选多行后底部操作条显示「已选 N 个 / 取消 / 删除」;点删除立即批量执行,被选中的行乐观隐藏、操作条实时显示进度,客户端 6 路并发、工作区记账串行;失败的行恢复显示并保持选中,方便重试;Esc 退出
27
+ - **多选批量删除** —— 会话菜单「多选」进入选择模式:勾选多行后,侧边栏页脚那一行(官方 `sidebar.footer.action` 槽位)会出现操作条(「已选 N 个 / 取消 / 删除」),位置由宿主排列,退出选择模式即消失;点删除立即批量执行,被选中的行乐观隐藏、操作条实时显示进度,客户端 6 路并发、工作区记账串行;失败的行恢复显示并保持选中,方便重试;Esc 退出
28
28
  - **产出文件隔离(可选,默认关闭)** —— 打开后,删除会话时还能把这个对话在工作目录里产出的文件**移进隔离夹**,而不是直接删掉;范围可选「仅新建」或「新建的和改过的」,模式可选「全部直接隔离」或「先列清单由你勾选」
29
29
  - **跳过 git 仓库内的文件(默认开)** —— 在项目里干过活的对话,产出文件里往往就是项目源码。开启后仓库内的文件一律不碰,避免删一个对话把项目搬空
30
30
  - **删除后回到新会话** —— 若删除的正是当前打开的会话,自动切到一个全新会话,而不是落到已不可用的邻居页
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-delete-session",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, sends the session log, workspace accounting and live session state to the system trash (or removes them outright), and, when opted in, quarantines the files the conversation produced.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -259,8 +259,32 @@ window.__ModuleLoader__.load({
259
259
  const raw = typeof injected === 'function' ? injected : t
260
260
  if (raw.__dsdelLocalized === true) return raw
261
261
  const wrapped = (key, a, b) => {
262
- const value = raw(key, a, b)
263
- return typeof value === 'function' ? value(a, b) : value
262
+ // Our own dictionary answers first, and it is resolved HERE rather than
263
+ // through the host. `dsh-client-locale` interpolates whatever it finds as
264
+ // soon as params are present - it calls `template.replace(...)` - while
265
+ // most of these entries are functions. Handing it params made the host
266
+ // throw "template.replace is not a function", and the slot boundary turned
267
+ // that into an empty `data-slot-error` anchor: the bar vanished, Cancel
268
+ // and all. Calling it without params is no better: the host (and this
269
+ // module's own `t`) invoke function entries with no arguments, which
270
+ // produces "已选 undefined 个".
271
+ const dictionary = browserLang() === 'en' ? en : zh
272
+ const local = dictionary[key]
273
+ if (local !== undefined) {
274
+ if (typeof local === 'function') return local(a, b)
275
+ if (typeof local === 'string' && local.includes('{') && a !== undefined) {
276
+ const params = a !== null && typeof a === 'object'
277
+ ? a
278
+ : { n: a, done: a, total: b, ok: a, fail: b, path: a }
279
+ return local.replace(/\{(\w+)\}/g, (match, name) => (
280
+ params[name] === undefined ? match : String(params[name])
281
+ ))
282
+ }
283
+ return local
284
+ }
285
+ // A key this plugin does not ship: let the host answer.
286
+ const value = raw(key)
287
+ return typeof value === 'function' ? value(a, b) : (value === undefined || value === null ? key : value)
264
288
  }
265
289
  wrapped.__dsdelLocalized = true
266
290
  return wrapped
@@ -652,9 +676,19 @@ window.__ModuleLoader__.load({
652
676
  '.dsdel-check-on::after{content:"";width:9px;height:5px;border-left:2px solid #fff;border-bottom:2px solid #fff;transform:rotate(-45deg) translateY(-1px)}',
653
677
  // 乐观删除:行先隐藏,失败时随 decorate 恢复
654
678
  '.dsdel-row-pending{display:none!important}',
655
- '.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)}',
656
- '.dsdel-bar-count{white-space:nowrap}',
657
- '.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}',
679
+ '.dsdel-bar{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;box-sizing:border-box;font-size:13px}',
680
+ // The sidebar foot stacks three things: the footer-action row, the usage
681
+ // card and the account row. While a selection runs only the action row
682
+ // stays, so the bar sits where the account row used to end the sidebar.
683
+ 'body.dsdel-selecting [class*="_footArea"] > *:not([class*="_footerActions"]){display:none}',
684
+ // The other footer buttons are named POSITIVELY, by the attributes their
685
+ // plugins put on them, and never by "everything except the bar": the bar
686
+ // turned out not to be a direct child of the slot container, so an
687
+ // exclusion rule silently hid the bar together with its Cancel button.
688
+ 'body.dsdel-selecting [data-slot="sidebar.footer.action"] > [data-dsh-plugin]{display:none}',
689
+ 'body.dsdel-selecting [data-slot="sidebar.footer.action"] > [class*="_entryRow"]{display:none}',
690
+ '.dsdel-bar-count{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary,inherit)}',
691
+ '.dsdel-bar-btn{flex:0 0 auto;white-space:nowrap;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}',
658
692
  '.dsdel-bar-btn:disabled{opacity:.5;cursor:default}',
659
693
  '.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
660
694
  '.dsdel-page{display:flex;flex-direction:column;gap:8px}',
@@ -722,7 +756,8 @@ window.__ModuleLoader__.load({
722
756
  // per-row disabling: every row is selectable and the batch deletes
723
757
  // immediately (the single-session flow keeps its own consent dialog).
724
758
 
725
- let bar = null
759
+ // The bar lives in the sidebar footer slot: the host owns where that row sits,
760
+ // so there is no element here to create, dock or hide.
726
761
  let decorateRaf = null
727
762
  let rowObserver = null
728
763
 
@@ -766,7 +801,6 @@ window.__ModuleLoader__.load({
766
801
  document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
767
802
  }
768
803
  }
769
-
770
804
  function scheduleDecorate() {
771
805
  if (typeof requestAnimationFrame !== 'function') return
772
806
  if (decorateRaf !== null) return
@@ -789,74 +823,69 @@ window.__ModuleLoader__.load({
789
823
  return api
790
824
  }
791
825
 
792
- function updateBar() {
793
- if (!bar) return
794
- const count = bar.querySelector('.dsdel-bar-count')
795
- if (count) {
796
- if (state.batch === null) {
797
- count.textContent = t('selectedCount', state.selected.size)
798
- } else {
799
- // A move reports processed-so-far (success + failure + skip) in one
800
- // number; a delete counts its own two buckets.
801
- const done = state.batch.kind === 'move'
802
- ? state.batch.done
803
- : state.batch.done + state.batch.failed
804
- count.textContent = state.batch.kind === 'move'
805
- ? t('batchMoving', done, state.batch.total)
806
- : t('batchRunning', done, state.batch.total)
807
- }
808
- }
809
- const busy = state.batch !== null
810
- const empty = state.selected.size === 0
811
- const cancel = bar.querySelector('.dsdel-bar-cancel')
812
- const move = bar.querySelector('.dsdel-bar-move')
813
- const del = bar.querySelector('.dsdel-bar-danger')
814
- if (cancel) cancel.disabled = busy
815
- // Move and delete both need something selected, and both are inert while a
816
- // batch is already running.
817
- if (move) move.disabled = busy || empty
818
- if (del) del.disabled = busy || empty
819
- }
820
-
821
- function buildBar() {
822
- const el = document.createElement('div')
823
- el.className = 'dsdel-bar'
824
- el.setAttribute('role', 'toolbar')
825
- const count = document.createElement('span')
826
- count.className = 'dsdel-bar-count'
827
- const cancel = document.createElement('button')
828
- cancel.type = 'button'
829
- // Named so the bar never has to guess which plain button is Cancel.
830
- cancel.className = 'dsdel-bar-btn dsdel-bar-cancel'
831
- cancel.textContent = t('cancelSelect')
832
- cancel.addEventListener('click', (e) => { e.stopPropagation(); exitSelection() })
833
- const del = document.createElement('button')
834
- del.type = 'button'
835
- del.className = 'dsdel-bar-btn dsdel-bar-danger'
836
- del.textContent = t('deleteSelected')
837
- del.addEventListener('click', (e) => { e.stopPropagation(); runBatchDelete() })
838
- el.appendChild(count)
839
- el.appendChild(cancel)
840
- // One more button when dsh-session-relocate is installed; otherwise the bar
841
- // is exactly what it was before.
842
- if (relocateApi() !== null) {
843
- const move = document.createElement('button')
844
- move.type = 'button'
845
- move.className = 'dsdel-bar-btn dsdel-bar-move'
846
- move.textContent = t('moveSelected')
847
- move.addEventListener('click', (e) => { e.stopPropagation(); runBatchMove() })
848
- el.appendChild(move)
849
- }
850
- el.appendChild(del)
851
- document.body.appendChild(el)
852
- return el
826
+ /**
827
+ * The batch bar, rendered into the host's `sidebar.footer.action` seat.
828
+ *
829
+ * The host decides where that row sits, how wide it is and whether the account
830
+ * chip shares it. Occupying the slot means this plugin never measures, docks,
831
+ * hides or restores host DOM: the row gains our controls while a selection is
832
+ * running, and loses them the moment it ends.
833
+ */
834
+ function BulkBar({ t: injected }) {
835
+ const translate = localizedTranslator(injected)
836
+ const view = useSnapshot()
837
+ // Outside selection mode the footer keeps its normal face.
838
+ if (!view.selecting) return null
839
+ const busy = view.batch !== null
840
+ const empty = view.selectedCount === 0
841
+ // A move reports processed-so-far (success + failure + skip) in one number;
842
+ // a delete counts its own two buckets.
843
+ const done = view.batch === null ? 0 : view.batch.kind === 'move' ? view.batch.done : view.batch.done + view.batch.failed
844
+ const label = view.batch === null
845
+ ? translate('selectedCount', view.selectedCount)
846
+ : view.batch.kind === 'move'
847
+ ? translate('batchMoving', done, view.batch.total)
848
+ : translate('batchRunning', done, view.batch.total)
849
+ return h('div', { className: 'dsdel-bar', role: 'toolbar' },
850
+ h('span', { key: 'count', className: 'dsdel-bar-count' }, label),
851
+ h('button', {
852
+ key: 'cancel',
853
+ type: 'button',
854
+ className: 'dsdel-bar-btn dsdel-bar-cancel',
855
+ disabled: busy,
856
+ onClick: (event) => {
857
+ if (event && event.stopPropagation) event.stopPropagation()
858
+ exitSelection()
859
+ },
860
+ }, translate('cancelSelect')),
861
+ // One more button when dsh-session-relocate is installed; otherwise the bar
862
+ // is exactly what it was before.
863
+ relocateApi() === null ? null : h('button', {
864
+ key: 'move',
865
+ type: 'button',
866
+ className: 'dsdel-bar-btn dsdel-bar-move',
867
+ disabled: busy || empty,
868
+ onClick: (event) => {
869
+ if (event && event.stopPropagation) event.stopPropagation()
870
+ runBatchMove()
871
+ },
872
+ }, translate('moveSelected')),
873
+ h('button', {
874
+ key: 'delete',
875
+ type: 'button',
876
+ className: 'dsdel-bar-btn dsdel-bar-danger',
877
+ disabled: busy || empty,
878
+ onClick: (event) => {
879
+ if (event && event.stopPropagation) event.stopPropagation()
880
+ runBatchDelete()
881
+ },
882
+ }, translate('deleteSelected')),
883
+ )
853
884
  }
854
-
855
885
  function toggleSelect(id) {
856
886
  if (state.selected.has(id)) state.selected.delete(id)
857
887
  else state.selected.add(id)
858
888
  publish()
859
- updateBar()
860
889
  }
861
890
 
862
891
  function onSelectClickCapture(e) {
@@ -877,13 +906,24 @@ window.__ModuleLoader__.load({
877
906
  exitSelection()
878
907
  }
879
908
 
909
+ /**
910
+ * Mark the page while the mode runs. The marker is the only hook the
911
+ * stylesheet needs to let the account row step aside for the bar.
912
+ */
913
+ function markSelecting(on) {
914
+ if (typeof document === 'undefined' || !document.body) return
915
+ const classes = document.body.classList
916
+ if (!classes) return
917
+ if (on) classes.add('dsdel-selecting')
918
+ else classes.remove('dsdel-selecting')
919
+ }
920
+
880
921
  function enterSelection() {
881
922
  if (state.selecting) return
882
923
  state.selecting = true
883
924
  state.selected.clear()
884
925
  ensureStyle()
885
- if (!bar) bar = buildBar()
886
- bar.style.display = 'flex'
926
+ markSelecting(true)
887
927
  document.addEventListener('click', onSelectClickCapture, true)
888
928
  document.addEventListener('keydown', onSelectKeydown, true)
889
929
  if (typeof MutationObserver === 'function') {
@@ -891,7 +931,6 @@ window.__ModuleLoader__.load({
891
931
  rowObserver.observe(document.body, { childList: true, subtree: true })
892
932
  }
893
933
  publish()
894
- updateBar()
895
934
  }
896
935
 
897
936
  function exitSelection(options) {
@@ -904,7 +943,7 @@ window.__ModuleLoader__.load({
904
943
  state.pending.clear()
905
944
  document.querySelectorAll('.dsdel-row-pending').forEach((el) => el.classList.remove('dsdel-row-pending'))
906
945
  }
907
- if (bar) bar.style.display = 'none'
946
+ markSelecting(false)
908
947
  document.removeEventListener('click', onSelectClickCapture, true)
909
948
  document.removeEventListener('keydown', onSelectKeydown, true)
910
949
  if (rowObserver) { rowObserver.disconnect(); rowObserver = null }
@@ -936,7 +975,6 @@ window.__ModuleLoader__.load({
936
975
  state.selected.clear()
937
976
  for (const id of ids) state.pending.add(id)
938
977
  publish()
939
- updateBar()
940
978
 
941
979
  const failedIds = []
942
980
  let cursor = 0
@@ -955,7 +993,6 @@ window.__ModuleLoader__.load({
955
993
  state.pending.delete(id)
956
994
  }
957
995
  publish()
958
- updateBar()
959
996
  }
960
997
  }
961
998
  const workers = []
@@ -973,7 +1010,6 @@ window.__ModuleLoader__.load({
973
1010
  for (const id of failedIds) state.selected.add(id)
974
1011
  toast(t('batchPartial', ok, failed), true)
975
1012
  publish()
976
- updateBar()
977
1013
  }
978
1014
  refreshList()
979
1015
  for (const id of ids) {
@@ -997,7 +1033,6 @@ window.__ModuleLoader__.load({
997
1033
 
998
1034
  state.batch = { kind: 'move', total: ids.length, done: 0, failed: 0 }
999
1035
  publish()
1000
- updateBar()
1001
1036
 
1002
1037
  let result
1003
1038
  try {
@@ -1006,13 +1041,11 @@ window.__ModuleLoader__.load({
1006
1041
  if (state.batch === null || state.batch.kind !== 'move') return
1007
1042
  if (typeof done === 'number' && Number.isFinite(done)) state.batch.done = Math.max(0, done)
1008
1043
  if (typeof total === 'number' && Number.isFinite(total)) state.batch.total = Math.max(1, total)
1009
- updateBar()
1010
1044
  },
1011
1045
  })
1012
1046
  } catch (reason) {
1013
1047
  state.batch = null
1014
1048
  publish()
1015
- updateBar()
1016
1049
  toast(t('failed') + String((reason && reason.message) || reason), true)
1017
1050
  return
1018
1051
  }
@@ -1024,7 +1057,6 @@ window.__ModuleLoader__.load({
1024
1057
  if (result && (result.canceled === true || result.cancelled === true)) {
1025
1058
  // The user backed out of the target picker; keep the selection for a retry.
1026
1059
  publish()
1027
- updateBar()
1028
1060
  return
1029
1061
  }
1030
1062
 
@@ -1930,12 +1962,16 @@ window.__ModuleLoader__.load({
1930
1962
  { name: 'plugins.row.config', key: `${NS}#${NS}` },
1931
1963
  AskBeforeDeleteOption,
1932
1964
  ))
1965
+ // The batch bar rides the official footer seat, so the host owns its
1966
+ // position and the row it shares with the account chip.
1967
+ slots.inject('sidebar.footer.action', () => slots.register(
1968
+ { name: 'sidebar.footer.action', id: `${NS}-bulkbar`, order: 30, locale: NS },
1969
+ BulkBar,
1970
+ ))
1933
1971
 
1934
- // The batch bar and its row observer are the only long-lived DOM state;
1935
- // drop them when the plugin unloads.
1972
+ // Leaving the mode disconnects the row observer and strips the checkboxes.
1936
1973
  return () => {
1937
1974
  exitSelection()
1938
- if (bar) { bar.remove(); bar = null }
1939
1975
  }
1940
1976
  }
1941
1977
 
package/src/index.js CHANGED
@@ -117,6 +117,10 @@ function collectDescendants(headers, sessionId) {
117
117
  const byParent = new Map()
118
118
  for (const header of headers) {
119
119
  if (typeof header.parentSession !== 'string') continue
120
+ // Only subagents belong to a parent. A fork records where it branched from,
121
+ // but it is a conversation of its own and the user can open it like any other:
122
+ // sweeping those up deletes work nobody asked to delete.
123
+ if (header.origin !== 'subagent') continue
120
124
  const key = bareSessionId(header.parentSession)
121
125
  const list = byParent.get(key)
122
126
  if (list === undefined) byParent.set(key, [header])
@@ -144,7 +148,10 @@ function collectDescendants(headers, sessionId) {
144
148
  */
145
149
  function collectOrphans(headers) {
146
150
  const present = new Set(headers.map((entry) => bareSessionId(entry.id)))
147
- return headers.filter((entry) => typeof entry.parentSession === 'string'
151
+ // Same rule as the descendant walk: a fork whose source is gone is still a
152
+ // conversation of its own, not an abandoned child.
153
+ return headers.filter((entry) => entry.origin === 'subagent'
154
+ && typeof entry.parentSession === 'string'
148
155
  && !present.has(bareSessionId(entry.parentSession)))
149
156
  }
150
157
 
@@ -770,11 +777,27 @@ async function deleteSession(ctx, sessionId, options) {
770
777
  const children = []
771
778
  for (const childId of childSessionIds) {
772
779
  try {
780
+ // A subagent can still be running while its parent is deleted. Removing the
781
+ // log out from under it is what made the agent's next write fail with ENOENT,
782
+ // so a child gets the same guard as the parent: stop it, flush it, and wait
783
+ // for the write lease before a single byte moves.
784
+ await quiesceAgent(ctx, childId)
785
+ await flushAndDetach(ctx, childId)
786
+ await assertNoActiveWriter(ctx, childId)
773
787
  const childFiles = await retireSessionFiles(childId, recycle)
774
788
  await withWorkspaceLock(() => detachFromWorkspace(ctx, childId))
775
789
  children.push({ id: childId, removed: childFiles.removed, caches: childFiles.caches })
776
790
  } catch (error) {
777
- children.push({ id: childId, removed: 0, caches: 0, error: String((error && error.message) || error) })
791
+ // Never delete a child that could not be quieted: something is still writing
792
+ // to it. Report it so the caller can try again once it settles.
793
+ const busy = error instanceof HttpError && error.code === 'busy'
794
+ children.push({
795
+ id: childId,
796
+ removed: 0,
797
+ caches: 0,
798
+ skipped: busy ? 'busy' : 'error',
799
+ error: String((error && error.message) || error),
800
+ })
778
801
  }
779
802
  }
780
803
 
@@ -954,7 +954,13 @@ test('the switch decides between staging for the bin and removing outright', asy
954
954
 
955
955
  // --- subagent sessions are separate sessions, linked by parentSession ------
956
956
 
957
- const header = (id, parentSession) => (parentSession === undefined ? { id } : { id, parentSession })
957
+ /** A session with no parent at all. */
958
+ const header = (id) => ({ id })
959
+ /** A real subagent: DSH marks these with `origin`, and they belong to their parent. */
960
+ const child = (id, parentSession) => ({ id, parentSession, origin: 'subagent', delegationDepth: 1 })
961
+ /** A fork: it records where it branched from, but it is a conversation of its own. */
962
+ const fork = (id, parentSession) => ({ id, parentSession, isSeeded: true, delegationDepth: 0 })
963
+
958
964
 
959
965
  function fakeContext(services) {
960
966
  return { get: (name) => services[name] }
@@ -963,8 +969,8 @@ function fakeContext(services) {
963
969
  test('a delete finds the grandchildren behind a child, not just the children', () => {
964
970
  const headers = [
965
971
  header('session-parent'),
966
- header('session-child', 'session-parent'),
967
- header('session-grandchild', 'session-child'),
972
+ child('session-child', 'session-parent'),
973
+ child('session-grandchild', 'session-child'),
968
974
  ]
969
975
 
970
976
  const found = internals.collectDescendants(headers, 'session-parent')
@@ -976,7 +982,7 @@ test('a delete finds the grandchildren behind a child, not just the children', (
976
982
  test('a parent link matches whether or not either id carries the prefix', () => {
977
983
  const headers = [
978
984
  header('session-parent'),
979
- header('child-uuid', 'parent-uuid'),
985
+ child('child-uuid', 'parent-uuid'),
980
986
  ]
981
987
 
982
988
  const found = internals.collectDescendants(headers, 'parent-uuid')
@@ -987,7 +993,7 @@ test('a parent link matches whether or not either id carries the prefix', () =>
987
993
  test('a session that names itself as its parent does not loop forever', () => {
988
994
  const headers = [
989
995
  header('session-parent'),
990
- header('session-child', 'session-child'),
996
+ child('session-child', 'session-child'),
991
997
  ]
992
998
 
993
999
  const found = internals.collectDescendants(headers, 'session-parent')
@@ -995,11 +1001,36 @@ test('a session that names itself as its parent does not loop forever', () => {
995
1001
  assert.deepEqual(found, [], 'a self-parented session is not its own descendant')
996
1002
  })
997
1003
 
1004
+ test('a forked conversation is not a child of the conversation it branched from', () => {
1005
+ const headers = [
1006
+ header('session-parent'),
1007
+ fork('session-fork', 'session-parent'),
1008
+ ]
1009
+
1010
+ const found = internals.collectDescendants(headers, 'session-parent')
1011
+
1012
+ assert.deepEqual(found, [], 'a fork is a visible conversation, not a subagent')
1013
+ })
1014
+
1015
+ test('a fork is never offered in the dialog, so it can never be ticked', () => {
1016
+ const headers = [
1017
+ header('session-parent'),
1018
+ fork('session-fork', 'session-parent'),
1019
+ child('session-real-child', 'session-parent'),
1020
+ ]
1021
+
1022
+ const listed = internals.describeChildren(headers, 'session-parent')
1023
+
1024
+ assert.deepEqual(listed.map((c) => c.id), ['session-real-child'])
1025
+ const picked = internals.selectChildDeletes(headers, 'session-parent', ['session-fork', 'session-real-child'])
1026
+ assert.deepEqual(picked, ['session-real-child'], 'even a forged fork id is dropped')
1027
+ })
1028
+
998
1029
  test('an orphan is a session whose parent is gone', () => {
999
1030
  const headers = [
1000
1031
  header('session-parent'),
1001
- header('session-kept', 'session-parent'),
1002
- header('session-orphan', 'session-deleted-long-ago'),
1032
+ child('session-kept', 'session-parent'),
1033
+ child('session-orphan', 'session-deleted-long-ago'),
1003
1034
  ]
1004
1035
 
1005
1036
  const orphans = internals.collectOrphans(headers)
@@ -1036,8 +1067,8 @@ test('a host that can list nothing yields no headers rather than throwing', asyn
1036
1067
  test('the child list carries a depth so nesting is visible in the dialog', () => {
1037
1068
  const headers = [
1038
1069
  header('session-parent'),
1039
- { id: 'session-child', parentSession: 'session-parent', cwd: 'E:\\work' },
1040
- { id: 'session-grandchild', parentSession: 'session-child', cwd: 'E:\\work' },
1070
+ { ...child('session-child', 'session-parent'), cwd: 'E:\\work' },
1071
+ { ...child('session-grandchild', 'session-child'), cwd: 'E:\\work' },
1041
1072
  ]
1042
1073
 
1043
1074
  const children = internals.describeChildren(headers, 'session-parent')
@@ -1051,8 +1082,8 @@ test('the child list carries a depth so nesting is visible in the dialog', () =>
1051
1082
  test('only the ids the user picked, and only real descendants, are deleted', () => {
1052
1083
  const headers = [
1053
1084
  header('session-parent'),
1054
- header('session-child', 'session-parent'),
1055
- header('session-grandchild', 'session-child'),
1085
+ child('session-child', 'session-parent'),
1086
+ child('session-grandchild', 'session-child'),
1056
1087
  header('session-unrelated'),
1057
1088
  ]
1058
1089
 
@@ -1068,8 +1099,8 @@ test('only the ids the user picked, and only real descendants, are deleted', ()
1068
1099
  test('a picked child is matched by its bare uuid but deleted under its own id', () => {
1069
1100
  const headers = [
1070
1101
  header('session-parent'),
1071
- header('session-grandchild', 'session-child'),
1072
- header('session-child', 'session-parent'),
1102
+ child('session-grandchild', 'session-child'),
1103
+ child('session-child', 'session-parent'),
1073
1104
  ]
1074
1105
 
1075
1106
  // The dialog may hold either spelling; the delete always uses the header's own.
@@ -1139,3 +1170,48 @@ test('the delete reports which children failed', async () => {
1139
1170
 
1140
1171
  assert.equal(result.children.length, 0, 'an invalid id never becomes a child delete')
1141
1172
  })
1173
+
1174
+ test('a child is probed for a live writer before its files go', async () => {
1175
+ const parent = plantSession(PARENT_ID)
1176
+ const child = plantSession(CHILD_ID)
1177
+ const probed = []
1178
+
1179
+ // A host service that answers the write-lease probe for whoever asks.
1180
+ const ctx = {
1181
+ get: (name) => (name === 'sessionPersistence'
1182
+ ? {
1183
+ open: async (id) => { probed.push(id); return { close: async () => {} } },
1184
+ stat: async () => undefined,
1185
+ }
1186
+ : undefined),
1187
+ }
1188
+
1189
+ await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
1190
+
1191
+ assert.ok(probed.includes(CHILD_ID), 'the child gets the same write-lease probe the parent gets')
1192
+ assert.equal(fs.existsSync(child.dir), false, 'and it is deleted once the probe comes back clear')
1193
+ })
1194
+
1195
+ test('a child that is still open is left alone and reported as skipped', async () => {
1196
+ const parent = plantSession(PARENT_ID)
1197
+ const child = plantSession(CHILD_ID)
1198
+
1199
+ // Busy on every attempt: the guard gives up and the child must survive.
1200
+ const ctx = {
1201
+ get: (name) => (name === 'sessionPersistence'
1202
+ ? {
1203
+ open: async (id) => {
1204
+ if (id === CHILD_ID) throw new Error('session is busy: another writer holds it')
1205
+ return { close: async () => {} }
1206
+ },
1207
+ stat: async () => undefined,
1208
+ }
1209
+ : undefined),
1210
+ }
1211
+
1212
+ const result = await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
1213
+
1214
+ assert.equal(fs.existsSync(parent.dir), false, 'the parent still goes')
1215
+ assert.equal(fs.existsSync(child.dir), true, 'a busy child is not deleted under its writer')
1216
+ assert.equal(result.children[0].skipped, 'busy', 'and the report says why')
1217
+ })
@@ -191,6 +191,10 @@ function matches(node, selector) {
191
191
  return node._classes.has(selector.slice(1))
192
192
  }
193
193
 
194
+ // `[class*="_footArea"]` - how the bar finds the host's sidebar footer.
195
+ const classContains = /^\[class\*="([^"]*)"\]$/.exec(selector)
196
+ if (classContains !== null) return node.className.includes(classContains[1])
197
+
194
198
  const attrEquals = /^\[([\w-]+)="([^"]*)"\]$/.exec(selector)
195
199
  if (attrEquals !== null) return node.attributes[attrEquals[1]] === attrEquals[2]
196
200
 
@@ -265,6 +269,9 @@ async function loadPlugin(options = {}) {
265
269
  location: { reload() {} },
266
270
  dispatchEvent() {},
267
271
  Event: class { constructor(type) { this.type = type } },
272
+ // Viewport height: the docked bar is anchored to the footer's bottom edge,
273
+ // so the distance has to be computed against a real number.
274
+ innerHeight: 800,
268
275
  }
269
276
  globalThis.localStorage = makeStorage()
270
277
  // Node exposes `navigator` as a getter-only global, so it needs defining rather
@@ -404,9 +411,17 @@ async function loadPlugin(options = {}) {
404
411
  effect: (fn) => { const dispose = fn(); return () => { if (typeof dispose === 'function') dispose() } },
405
412
  })
406
413
 
414
+ // Mirrors `dsh-client-locale`: a function-valued entry is handed back
415
+ // UNTOUCHED (the caller invokes it), and a string is returned as-is when no
416
+ // params are given. Invoking here would hide the difference that once broke
417
+ // the real host.
407
418
  const t = (key, a, b) => {
408
419
  const value = locales[NS]?.zh?.[key]
409
- return typeof value === 'function' ? value(a, b) : value ?? key
420
+ if (value === undefined) return key
421
+ if (typeof value === 'function') return value
422
+ if (a === undefined) return value
423
+ const params = a !== null && typeof a === 'object' ? a : { n: a, done: a, total: b, ok: a, fail: b, path: a }
424
+ return value.replace(/{(w+)}/g, (match, name) => (params[name] === undefined ? match : String(params[name])))
410
425
  }
411
426
 
412
427
  return { document, requests, rows, observers, slotRegistrations, locales, primitives: primitivesStub, t }
@@ -507,8 +522,31 @@ const totalCheckboxes = (harness) => harness.document.querySelectorAll('.dsdel-c
507
522
  const selectedCheckboxes = (harness) =>
508
523
  harness.document.querySelectorAll('.dsdel-check').filter((el) => el.classList.contains('dsdel-check-on')).length
509
524
 
510
- const bar = (harness) =>
511
- walk(harness.document.body).find((node) => node._classes.has('dsdel-bar')) ?? null
525
+ const FOOTER_SLOT = 'sidebar.footer.action'
526
+
527
+ /**
528
+ * The batch bar is a slot occupant now: render the registered component the way
529
+ * the host would and inspect the element tree it returns. `null` means the
530
+ * footer is unoccupied, which is what "not selecting" looks like.
531
+ */
532
+ const footerEntry = (harness) =>
533
+ harness.slotRegistrations.find((entry) => entry.spec.name === FOOTER_SLOT) ?? null
534
+
535
+ const bar = (harness) => {
536
+ const entry = footerEntry(harness)
537
+ if (entry === null) return null
538
+ const node = entry.Component({})
539
+ return node === null || node === undefined ? null : node
540
+ }
541
+
542
+ const barChild = (harness, key) => {
543
+ const node = bar(harness)
544
+ if (node === null || node.props === undefined) return null
545
+ return findByKey(node.props.children, key)[0] ?? null
546
+ }
547
+
548
+ /** Invoke a bar button the way the host would dispatch a click. */
549
+ const clickBar = (node) => node.props.onClick({ stopPropagation() {} })
512
550
 
513
551
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
514
552
 
@@ -778,18 +816,16 @@ test('leaving multi-select strips every checkbox', async () => {
778
816
 
779
817
  selectRow(harness).element.props.onSelect()
780
818
  await flush()
781
- const control = bar(harness)
782
- assert.ok(control, 'the bar exists')
819
+ assert.ok(bar(harness), 'the bar exists')
783
820
  assert.equal(totalCheckboxes(harness), 2)
784
821
 
785
822
  // Cancel is the bar's non-danger button.
786
- const cancel = control.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)')
787
- cancel.fire('click', { stopPropagation() {} })
823
+ clickBar(barCancel(harness))
788
824
  await flush()
789
825
 
790
826
  assert.equal(totalCheckboxes(harness), 0, 'no checkbox survives the mode')
791
827
  assert.deepEqual(rowCheckboxes(harness), [0, 0])
792
- assert.notEqual(control.style.display, 'flex', 'the bar is hidden again')
828
+ assert.equal(bar(harness), null, 'and the footer is empty again')
793
829
  })
794
830
 
795
831
  // --- the two modes must not leak into each other ----------------------------
@@ -801,8 +837,7 @@ test('entering, leaving, then deleting plants nothing', async () => {
801
837
 
802
838
  selectRow(harness).element.props.onSelect()
803
839
  await flush()
804
- const control = bar(harness)
805
- control.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)').fire('click', { stopPropagation() {} })
840
+ clickBar(barCancel(harness))
806
841
  await flush()
807
842
 
808
843
  // Leaving the mode removed the observer that decorates; a later delete must
@@ -810,7 +845,7 @@ test('entering, leaving, then deleting plants nothing', async () => {
810
845
  await decorateViaSingleDelete(harness, SESSION_A)
811
846
 
812
847
  assert.equal(totalCheckboxes(harness), 0, 'the mode leaves no residue behind')
813
- assert.notEqual(control.style.display, 'flex', 'the bar stays hidden')
848
+ assert.equal(bar(harness), null, 'the footer stays empty')
814
849
  })
815
850
 
816
851
  test('a row whose row key changes still gets no checkbox outside the mode', async () => {
@@ -1352,11 +1387,16 @@ test('the browse button opens a picker that walks directories and commits one',
1352
1387
  // button appears between cancel and delete; when it is not, the bar is exactly
1353
1388
  // what it was before and no other logic changes.
1354
1389
 
1355
- const barButtons = (harness) => bar(harness).querySelectorAll('.dsdel-bar-btn')
1356
- const barMove = (harness) => bar(harness).querySelector('.dsdel-bar-move')
1357
- const barCancel = (harness) => bar(harness).querySelector('.dsdel-bar-cancel')
1358
- const barDelete = (harness) => bar(harness).querySelector('.dsdel-bar-danger')
1359
- const barCount = (harness) => bar(harness).querySelector('.dsdel-bar-count')
1390
+ const barButtons = (harness) => {
1391
+ const node = bar(harness)
1392
+ if (node === null || node.props === undefined) return []
1393
+ return (node.props.children || []).filter((child) => child !== null && child !== undefined
1394
+ && child.props !== undefined && String(child.props.className || '').includes('dsdel-bar-btn'))
1395
+ }
1396
+ const barMove = (harness) => barChild(harness, 'move')
1397
+ const barCancel = (harness) => barChild(harness, 'cancel')
1398
+ const barDelete = (harness) => barChild(harness, 'delete')
1399
+ const barCount = (harness) => barChild(harness, 'count')
1360
1400
  const toasts = (harness) => harness.document.querySelectorAll('.dsdel-toast').map((el) => el.textContent)
1361
1401
 
1362
1402
  function installRelocate(moveSelection) {
@@ -1378,7 +1418,7 @@ test('without dsh-session-relocate the bar keeps exactly cancel and delete', asy
1378
1418
  await enterMulti(harness)
1379
1419
 
1380
1420
  assert.equal(barMove(harness), null, 'no move button is offered')
1381
- assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '删除'])
1421
+ assert.deepEqual(barButtons(harness).map((el) => el.props.children), ['取消', '删除'])
1382
1422
  })
1383
1423
 
1384
1424
  test('a relocate plugin that does not claim bulk move changes nothing', async () => {
@@ -1386,7 +1426,7 @@ test('a relocate plugin that does not claim bulk move changes nothing', async ()
1386
1426
  globalThis.window.__DSH_SESSION_RELOCATE__ = { version: '0.2.0', supportsBulkMove: false, moveSelection: async () => ({}) }
1387
1427
  mountRow(harness, SESSION_A)
1388
1428
  await enterMulti(harness)
1389
- assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '删除'])
1429
+ assert.deepEqual(barButtons(harness).map((el) => el.props.children), ['取消', '删除'])
1390
1430
  })
1391
1431
 
1392
1432
  test('with dsh-session-relocate the move button sits between cancel and delete', async () => {
@@ -1395,7 +1435,7 @@ test('with dsh-session-relocate the move button sits between cancel and delete',
1395
1435
  mountRow(harness, SESSION_A)
1396
1436
  await enterMulti(harness)
1397
1437
 
1398
- assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '移动', '删除'], 'left to right')
1438
+ assert.deepEqual(barButtons(harness).map((el) => el.props.children), ['取消', '移动', '删除'], 'left to right')
1399
1439
  })
1400
1440
 
1401
1441
  test('the move button is disabled until something is selected', async () => {
@@ -1404,13 +1444,13 @@ test('the move button is disabled until something is selected', async () => {
1404
1444
  const first = mountRow(harness, SESSION_A)
1405
1445
  await enterMulti(harness)
1406
1446
 
1407
- assert.equal(barMove(harness).disabled, true, 'nothing selected')
1408
- assert.equal(barDelete(harness).disabled, true, 'and delete agrees')
1409
- assert.equal(barCancel(harness).disabled, false, 'cancel is always live')
1447
+ assert.equal(barMove(harness).props.disabled, true, 'nothing selected')
1448
+ assert.equal(barDelete(harness).props.disabled, true, 'and delete agrees')
1449
+ assert.equal(barCancel(harness).props.disabled, false, 'cancel is always live')
1410
1450
 
1411
1451
  clickRowIn(harness, first)
1412
1452
  await flush()
1413
- assert.equal(barMove(harness).disabled, false, 'one selection is enough')
1453
+ assert.equal(barMove(harness).props.disabled, false, 'one selection is enough')
1414
1454
  })
1415
1455
 
1416
1456
  test('a move reports progress, then summarises, exits and refreshes', async () => {
@@ -1430,23 +1470,23 @@ test('a move reports progress, then summarises, exits and refreshes', async () =
1430
1470
  clickRowIn(harness, second)
1431
1471
  await flush()
1432
1472
 
1433
- barMove(harness).fire('click', { stopPropagation() {} })
1473
+ clickBar(barMove(harness))
1434
1474
  await flush()
1435
1475
 
1436
1476
  assert.deepEqual(seen.ids, [SESSION_A.id, SESSION_B.id], 'the whole selection is handed over')
1437
- assert.equal(barCount(harness).textContent, '正在移动 0/2…')
1438
- assert.equal(barMove(harness).disabled, true, 'inert while the move runs')
1439
- assert.equal(barDelete(harness).disabled, true)
1477
+ assert.equal(barCount(harness).props.children, '正在移动 0/2…')
1478
+ assert.equal(barMove(harness).props.disabled, true, 'inert while the move runs')
1479
+ assert.equal(barDelete(harness).props.disabled, true)
1440
1480
 
1441
1481
  seen.hooks.onProgress(1, 2)
1442
- assert.equal(barCount(harness).textContent, '正在移动 1/2…')
1482
+ assert.equal(barCount(harness).props.children, '正在移动 1/2…')
1443
1483
 
1444
1484
  finish({ moved: 2, failed: 0, skipped: 0, total: 2, results: [] })
1445
1485
  await flush()
1446
1486
  await flush()
1447
1487
 
1448
1488
  assert.deepEqual(toasts(harness), ['已移动 2 个'], 'one summary line')
1449
- assert.notEqual(bar(harness).style.display, 'flex', 'multi-select exited')
1489
+ assert.equal(bar(harness), null, 'multi-select exited')
1450
1490
  })
1451
1491
 
1452
1492
  test('a partial move reports both numbers', async () => {
@@ -1457,7 +1497,7 @@ test('a partial move reports both numbers', async () => {
1457
1497
  clickRowIn(harness, first)
1458
1498
  await flush()
1459
1499
 
1460
- barMove(harness).fire('click', { stopPropagation() {} })
1500
+ clickBar(barMove(harness))
1461
1501
  await flush()
1462
1502
  await flush()
1463
1503
 
@@ -1472,14 +1512,14 @@ test('cancelling the target picker keeps the selection and says nothing', async
1472
1512
  clickRowIn(harness, first)
1473
1513
  await flush()
1474
1514
 
1475
- barMove(harness).fire('click', { stopPropagation() {} })
1515
+ clickBar(barMove(harness))
1476
1516
  await flush()
1477
1517
  await flush()
1478
1518
 
1479
1519
  assert.deepEqual(toasts(harness), [], 'nothing happened, so nothing is reported')
1480
- assert.equal(bar(harness).style.display, 'flex', 'multi-select is still open')
1481
- assert.equal(barCount(harness).textContent, '已选 1 个', 'the count is back')
1482
- assert.equal(barMove(harness).disabled, false, 'the retry is available')
1520
+ assert.ok(bar(harness), 'multi-select is still open')
1521
+ assert.equal(barCount(harness).props.children, '已选 1 个', 'the count is back')
1522
+ assert.equal(barMove(harness).props.disabled, false, 'the retry is available')
1483
1523
  })
1484
1524
 
1485
1525
  test('a throwing relocate call ends the batch and reports the error', async () => {
@@ -1490,11 +1530,11 @@ test('a throwing relocate call ends the batch and reports the error', async () =
1490
1530
  clickRowIn(harness, first)
1491
1531
  await flush()
1492
1532
 
1493
- barMove(harness).fire('click', { stopPropagation() {} })
1533
+ clickBar(barMove(harness))
1494
1534
  await flush()
1495
1535
  await flush()
1496
1536
 
1497
- assert.equal(bar(harness).style.display, 'flex', 'still in multi-select')
1537
+ assert.ok(bar(harness), 'still in multi-select')
1498
1538
  assert.equal(toasts(harness).length, 1, 'the failure is reported')
1499
1539
  assert.match(toasts(harness)[0], /relocate exploded/)
1500
1540
  })
@@ -1508,17 +1548,17 @@ test('Escape stays inert while a move is running', async () => {
1508
1548
  clickRowIn(harness, first)
1509
1549
  await flush()
1510
1550
 
1511
- barMove(harness).fire('click', { stopPropagation() {} })
1551
+ clickBar(barMove(harness))
1512
1552
  await flush()
1513
1553
 
1514
1554
  harness.document.dispatchToDocument('keydown', { key: 'Escape', stopPropagation() {} })
1515
1555
  await flush()
1516
- assert.equal(bar(harness).style.display, 'flex', 'Escape is ignored mid-move')
1556
+ assert.ok(bar(harness), 'Escape is ignored mid-move')
1517
1557
 
1518
1558
  finish({ moved: 1, failed: 0, skipped: 0, total: 1, results: [] })
1519
1559
  await flush()
1520
1560
  await flush()
1521
- assert.notEqual(bar(harness).style.display, 'flex', 'and the mode ends when the move does')
1561
+ assert.equal(bar(harness), null, 'and the mode ends when the move does')
1522
1562
  })
1523
1563
 
1524
1564
  // --- the result contract is enforced, not guessed ---------------------------
@@ -1531,12 +1571,12 @@ test('the older "cancelled" spelling of the cancel flag is still honoured', asyn
1531
1571
  clickRowIn(harness, first)
1532
1572
  await flush()
1533
1573
 
1534
- barMove(harness).fire('click', { stopPropagation() {} })
1574
+ clickBar(barMove(harness))
1535
1575
  await flush()
1536
1576
  await flush()
1537
1577
 
1538
1578
  assert.deepEqual(toasts(harness), [], 'silent, exactly like the "canceled" spelling')
1539
- assert.equal(bar(harness).style.display, 'flex', 'still in multi-select')
1579
+ assert.ok(bar(harness), 'still in multi-select')
1540
1580
  })
1541
1581
 
1542
1582
  test('a result without the agreed counts is reported instead of guessed', async () => {
@@ -1549,7 +1589,7 @@ test('a result without the agreed counts is reported instead of guessed', async
1549
1589
  clickRowIn(harness, first)
1550
1590
  await flush()
1551
1591
 
1552
- barMove(harness).fire('click', { stopPropagation() {} })
1592
+ clickBar(barMove(harness))
1553
1593
  await flush()
1554
1594
  await flush()
1555
1595
 
@@ -1945,3 +1985,231 @@ test('the page ends with a quiet link to the repository', async () => {
1945
1985
  assert.equal(cta.props.rel, 'noreferrer')
1946
1986
  assert.equal(cta.props.children, '行,给你一颗 Star')
1947
1987
  })
1988
+
1989
+ // --- the batch bar occupies the official footer seat ------------------------
1990
+ //
1991
+ // It used to be a floating card injected into `document.body`, and for a while it
1992
+ // measured the sidebar footer and covered it. Both were the same mistake: a plugin
1993
+ // guessing at host layout. The bar is a slot occupant now - the host places it, so
1994
+ // there is nothing to measure, dock, hide or restore.
1995
+
1996
+ test('the bar registers into the official footer slot with a locale', async () => {
1997
+ const harness = await loadPlugin()
1998
+ const entry = footerEntry(harness)
1999
+
2000
+ assert.ok(entry, 'the footer seat is occupied by a registration')
2001
+ assert.equal(entry.spec.id, 'dsh-delete-session-bulkbar')
2002
+ assert.equal(entry.spec.locale, NS, 'and it asks for the plugin locale namespace')
2003
+ assert.equal(typeof entry.Component, 'function')
2004
+ })
2005
+
2006
+ test('the footer stays empty until multi-select starts', async () => {
2007
+ const harness = await loadPlugin()
2008
+ mountRow(harness, SESSION_A)
2009
+
2010
+ assert.equal(bar(harness), null, 'nothing is rendered into the footer outside the mode')
2011
+
2012
+ selectRow(harness).element.props.onSelect()
2013
+ await flush()
2014
+
2015
+ assert.ok(bar(harness), 'and it appears the moment the mode starts')
2016
+ })
2017
+
2018
+ test('the footer is empty again once the mode ends', async () => {
2019
+ const harness = await loadPlugin()
2020
+ mountRow(harness, SESSION_A)
2021
+
2022
+ selectRow(harness).element.props.onSelect()
2023
+ await flush()
2024
+ assert.ok(bar(harness), 'the bar is there while selecting')
2025
+
2026
+ clickBar(barCancel(harness))
2027
+
2028
+ assert.equal(bar(harness), null, 'and leaves the footer as it found it')
2029
+ })
2030
+
2031
+ test('the bar reads the count from the store, not from the DOM', async () => {
2032
+ const harness = await loadPlugin()
2033
+ const first = mountRow(harness, SESSION_A)
2034
+ mountRow(harness, SESSION_B)
2035
+
2036
+ selectRow(harness).element.props.onSelect()
2037
+ await flush()
2038
+
2039
+ assert.equal(barCount(harness).props.children, '已选 0 个', 'nothing selected yet')
2040
+
2041
+ harness.document.dispatchToDocument('click', {
2042
+ target: first,
2043
+ preventDefault() {},
2044
+ stopPropagation() {},
2045
+ })
2046
+ await flush()
2047
+
2048
+ assert.equal(barCount(harness).props.children, '已选 1 个', 'the same store the rows write to')
2049
+ })
2050
+
2051
+ test('the plugin no longer injects a bar element into the document', async () => {
2052
+ const harness = await loadPlugin()
2053
+ mountRow(harness, SESSION_A)
2054
+
2055
+ selectRow(harness).element.props.onSelect()
2056
+ await flush()
2057
+
2058
+ // Render the occupant first: a component that only misbehaves when the host
2059
+ // actually mounts it would otherwise slip past this check.
2060
+ assert.ok(bar(harness), 'the footer occupant renders')
2061
+
2062
+ const injected = walk(harness.document.body).filter((node) => node._classes.has('dsdel-bar'))
2063
+ assert.deepEqual(injected, [], 'no DOM bar: the footer slot is the only home')
2064
+ })
2065
+
2066
+ // --- the bar replaces the account row instead of stacking above it ----------
2067
+
2068
+ test('selecting puts a marker on the page so the account row can step aside', async () => {
2069
+ const harness = await loadPlugin()
2070
+ mountRow(harness, SESSION_A)
2071
+
2072
+ selectRow(harness).element.props.onSelect()
2073
+ await flush()
2074
+
2075
+ // The bar shares the sidebar foot with the account chip, so leaving the chip in
2076
+ // place stacks two rows. The marker is what lets one stylesheet rule hide it,
2077
+ // instead of measuring the host's box and covering it.
2078
+ assert.equal(harness.document.body._classes.has('dsdel-selecting'), true, 'the page is marked')
2079
+ })
2080
+
2081
+ test('leaving selection takes the marker away again', async () => {
2082
+ const harness = await loadPlugin()
2083
+ mountRow(harness, SESSION_A)
2084
+
2085
+ selectRow(harness).element.props.onSelect()
2086
+ await flush()
2087
+ clickBar(barCancel(harness))
2088
+
2089
+ assert.equal(harness.document.body._classes.has('dsdel-selecting'), false, 'the account row comes back')
2090
+ })
2091
+
2092
+ test('the injected stylesheet hides the account row while the marker is on', async () => {
2093
+ const harness = await loadPlugin()
2094
+ // `ensureStyle` tags its <style> through `dataset.dshDeleteSession = '1'`.
2095
+ const style = harness.document.head.querySelector('[data-dsh-delete-session="1"]')
2096
+ assert.ok(style, 'the plugin injected its stylesheet')
2097
+ const css = String(style._text ?? '')
2098
+
2099
+ // The foot stacks the action row, the usage card and the account row; only the
2100
+ // action row survives, so the bar ends the sidebar where the account row did.
2101
+ assert.match(
2102
+ css,
2103
+ /body\.dsdel-selecting \[class\*="_footArea"\] > \*:not\(\[class\*="_footerActions"\]\)/,
2104
+ 'the usage card and the account row step aside',
2105
+ )
2106
+ // The other footer buttons live in a slot container marked `display: contents`.
2107
+ // They are named POSITIVELY — by the attributes their own plugins set — never by
2108
+ // "everything except the bar": the bar is not a direct child of that container,
2109
+ // and an exclusion rule hid it together with its Cancel button.
2110
+ assert.match(
2111
+ css,
2112
+ /body\.dsdel-selecting \[data-slot="sidebar\.footer\.action"\] > \[data-dsh-plugin\]/,
2113
+ 'other plugins\' footer buttons are named one by one',
2114
+ )
2115
+ assert.doesNotMatch(
2116
+ css,
2117
+ /\[data-slot="sidebar\.footer\.action"\] > \*:not\(/,
2118
+ 'no exclusion rule may touch that slot — it cannot tell the bar apart',
2119
+ )
2120
+ assert.match(css, /display:\s*none/, 'both rules hide rather than shift')
2121
+ })
2122
+
2123
+ test('the stylesheet never hides the bar itself', async () => {
2124
+ const harness = await loadPlugin()
2125
+ const style = harness.document.head.querySelector('[data-dsh-delete-session="1"]')
2126
+ const css = String(style._text ?? '')
2127
+
2128
+ // Nothing may hide the footer by exclusion. Three attempts at "everything
2129
+ // except the bar" took the bar itself down, because the bar is not a direct
2130
+ // child of the container those rules addressed.
2131
+ assert.doesNotMatch(
2132
+ css,
2133
+ /\[class\*="_footerActions"\] > \*:not\(\.dsdel-bar\)/,
2134
+ 'no rule may target the bar as a direct child of the action row',
2135
+ )
2136
+ assert.doesNotMatch(
2137
+ css,
2138
+ /\[class\*="_footerActions"\] > \*:not\(:has/,
2139
+ 'nor hide the action row wholesale',
2140
+ )
2141
+ assert.doesNotMatch(
2142
+ css,
2143
+ /\[data-slot="sidebar\.footer\.action"\] > \*:not\(\.dsdel-bar\)/,
2144
+ 'nor the slot container by exclusion',
2145
+ )
2146
+ // The bar itself must be addressed by nothing but its own appearance rules.
2147
+ assert.doesNotMatch(
2148
+ css,
2149
+ /body\.dsdel-selecting [^{]*\.dsdel-bar\b(?!-)/,
2150
+ 'no selecting-state rule names the bar at all',
2151
+ )
2152
+ })
2153
+
2154
+ test('an interpolating host translator never receives our function entries', async () => {
2155
+ const harness = await loadPlugin()
2156
+ const dict = harness.locales[NS].zh
2157
+ // `dsh-client-locale` interpolates whatever it finds as soon as params are
2158
+ // given: it calls `template.replace(...)`. Handing it a function-valued entry
2159
+ // throws "template.replace is not a function", and a slot entry that throws is
2160
+ // replaced by the host's `data-slot-error` anchor — the bar vanishes whole.
2161
+ const strict = (key, params) => {
2162
+ const value = dict[key]
2163
+ if (value === undefined) return key
2164
+ if (params === undefined) return value
2165
+ return value.replace(/\{(\w+)\}/g, (match, name) => (params && name in params ? String(params[name]) : match))
2166
+ }
2167
+
2168
+ mountRow(harness, SESSION_A)
2169
+ selectRow(harness).element.props.onSelect()
2170
+ await flush()
2171
+
2172
+ const node = footerEntry(harness).Component({ t: strict })
2173
+ assert.ok(node, 'the bar still renders')
2174
+ assert.equal(node.props.children[0].props.children, '已选 0 个', 'and the count is formatted locally')
2175
+ })
2176
+
2177
+ test('a placeholder string is interpolated locally, not by the host', async () => {
2178
+ const harness = await loadPlugin({ children: CHILDREN })
2179
+ const dict = harness.locales[NS].zh
2180
+ const strict = (key, params) => {
2181
+ const value = dict[key]
2182
+ if (value === undefined) return key
2183
+ if (params === undefined) return value
2184
+ return value.replace(/{(w+)}/g, (match, name) => (params && name in params ? String(params[name]) : match))
2185
+ }
2186
+
2187
+ const row = mountRow(harness, SESSION_A)
2188
+ const { element } = deleteRow(harness, SESSION_A)
2189
+ element.props.onSelect({ shiftKey: false })
2190
+ await flush()
2191
+
2192
+ // `childrenHint` is the one template-with-placeholder in the dictionary; the
2193
+ // host must not be asked to fill it, or it would receive a function entry.
2194
+ const dialogEntry = harness.slotRegistrations.find(
2195
+ (entry) => entry.spec.name === 'shell.overlay' && entry.spec.id === `${NS}-dialog`,
2196
+ )
2197
+ const modal = dialogEntry.Component({ t: strict })
2198
+ const kids = (modal.props.children ?? []).filter(Boolean)
2199
+ const section = kids.find((child) => child.props && child.props.key === 'children')
2200
+ assert.ok(section, 'the child list renders')
2201
+ assert.equal(section.props.children[0].props.children, '这个对话有 2 个子会话(子代理),勾选的会一起删除。')
2202
+ assert.ok(row)
2203
+ })
2204
+
2205
+ test('the bar buttons keep their width instead of wrapping', async () => {
2206
+ const harness = await loadPlugin()
2207
+ const style = harness.document.head.querySelector('[data-dsh-delete-session="1"]')
2208
+ const css = String(style._text ?? '')
2209
+
2210
+ // In a flex row the buttons are the only shrinkable things next to a growing
2211
+ // count, so a long progress label squeezed them into two-character columns.
2212
+ assert.match(css, /\.dsdel-bar-btn\{[^}]*flex:0 0 auto/, 'buttons never shrink')
2213
+ assert.match(css, /\.dsdel-bar-btn\{[^}]*white-space:nowrap/, 'and never wrap their label')
2214
+ assert.match(css, /\.dsdel-bar-count\{[^}]*text-overflow:ellipsis/, 'the count gives way instead')
2215
+ })