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 +1 -1
- package/package.json +1 -1
- package/src/client.js +120 -84
- package/src/index.js +25 -2
- package/test/artifacts.host.test.js +89 -13
- package/test/checkbox.dom.test.js +310 -42
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只
|
|
|
24
24
|
- **对话框文案跟随开关** —— 勾选时写「并放进系统回收站」,取消勾选时写「永久删除…不可恢复」。你看到的永远是你实际会得到的
|
|
25
25
|
- **风险确认** —— 每次删除弹出确认对话框,显示会话标题与 ID。要不要弹这个框**只在设置页改**,弹窗里没有快捷勾选;**按住 Shift 点击**可以临时强制弹窗
|
|
26
26
|
- **子会话一起删** —— 子代理是**独立会话**(独立目录、独立日志),父会话删掉后它们会变成孤儿留在磁盘上。现在删除对话时会处理它派生的子会话:设置里勾选「同时删除子会话」就一起删;不勾选则弹窗列出子会话,由你勾选要删哪些(默认全选)
|
|
27
|
-
- **多选批量删除** ——
|
|
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.
|
|
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
|
-
|
|
263
|
-
|
|
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{
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
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
|
-
{
|
|
1040
|
-
{
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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).
|
|
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).
|
|
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.
|
|
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)
|
|
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)
|
|
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.
|
|
1481
|
-
assert.equal(barCount(harness).
|
|
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)
|
|
1533
|
+
clickBar(barMove(harness))
|
|
1494
1534
|
await flush()
|
|
1495
1535
|
await flush()
|
|
1496
1536
|
|
|
1497
|
-
assert.
|
|
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)
|
|
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.
|
|
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.
|
|
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)
|
|
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.
|
|
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)
|
|
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
|
+
})
|