dsh-delete-session 0.1.10 → 0.2.1
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/LICENSE +21 -21
- package/README.md +199 -96
- package/cordis.patch.yml +7 -7
- package/package.json +3 -2
- package/src/client.js +1590 -400
- package/src/index.js +2213 -517
- package/test/artifacts.host.test.js +1141 -0
- package/test/checkbox.dom.test.js +1947 -0
package/src/client.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
// dsh-delete-session - client half.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// is injected at the DOM level:
|
|
3
|
+
// Integration is entirely through the host's public slot contract - no menu
|
|
4
|
+
// scraping, no React internals:
|
|
6
5
|
//
|
|
7
|
-
// *
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// * `sidebar.workspaces.session.menu.item` - two rows registered beside the
|
|
7
|
+
// shipped pin/rename/fork/archive rows: "Select multiple" (order 409) and
|
|
8
|
+
// "Delete session" (order 410, in the host's danger colour). Each entry
|
|
9
|
+
// renders one `role="menuitem"` button through ui-primitives'
|
|
10
|
+
// `MenuItemButton`, so it inherits the host styling and joins the menu's
|
|
11
|
+
// own keyboard walk and focus return.
|
|
12
|
+
// * `shell.overlay` - the confirmation dialog, rendered with the host's
|
|
13
|
+
// `Modal` primitive instead of a hand-built backdrop.
|
|
14
|
+
// * `plugins.row.config` - the plugin page card that decides whether a
|
|
15
|
+
// delete asks first.
|
|
12
16
|
//
|
|
13
|
-
// The
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// The one surface with no usable slot is the multi-select checkbox on each
|
|
18
|
+
// Session row: `sidebar.session.row.leading` is the *else* branch of the host's
|
|
19
|
+
// `showStatus ? <SessionStatusDots/> : renderSlot(...)`, so it disappears the
|
|
20
|
+
// moment a row is busy - exactly when a batch delete is most likely. Those
|
|
21
|
+
// checkboxes therefore stay a DOM decoration over `[role="treeitem"]`, and row
|
|
22
|
+
// identity comes from the host's own `data-row-key="session:<id>"` attribute
|
|
23
|
+
// rather than from a walk up the React fiber tree.
|
|
24
|
+
//
|
|
25
|
+
// The module is a classic client bundle (client-modules protocol): it registers
|
|
26
|
+
// a factory with `window.__ModuleLoader__` and returns apply().
|
|
18
27
|
window.__ModuleLoader__.load({
|
|
19
28
|
id: 'dsh-delete-session',
|
|
20
29
|
factory: (require) => {
|
|
@@ -22,70 +31,315 @@ window.__ModuleLoader__.load({
|
|
|
22
31
|
const react = require('react')
|
|
23
32
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
24
33
|
|
|
34
|
+
const { createElement: h, Fragment } = react
|
|
35
|
+
|
|
25
36
|
const zh = {
|
|
26
37
|
menu: '删除会话',
|
|
38
|
+
multiSelect: '多选',
|
|
27
39
|
title: '删除会话',
|
|
28
|
-
|
|
40
|
+
descRecycle: '将把该会话及其全部对话记录(会话日志、工作区记账与统计)从列表中移除,并放进系统回收站。',
|
|
41
|
+
descPermanent: '将永久删除该会话及其全部对话记录(会话日志、工作区记账与统计),此操作不可恢复。',
|
|
29
42
|
cancel: '取消',
|
|
30
43
|
confirm: '删除',
|
|
31
44
|
deleting: '删除中…',
|
|
32
|
-
dontAsk: '不再询问,以后直接删除',
|
|
33
45
|
done: '已删除会话',
|
|
34
46
|
failed: '删除失败:',
|
|
35
47
|
busy: '该会话正被 DSH 打开,删除未能完成;请稍后重试(通常几秒后即可),或重启 DSH 后再试。',
|
|
36
48
|
notFound: '未找到该会话(可能已被删除)。',
|
|
37
49
|
untitled: '未命名会话',
|
|
38
|
-
multiSelect: '多选',
|
|
39
50
|
cancelSelect: '取消',
|
|
40
51
|
selectedCount: (n) => `已选 ${n} 个`,
|
|
41
52
|
batchRunning: (done, total) => `正在删除 ${done}/${total}…`,
|
|
53
|
+
moveSelected: '移动',
|
|
54
|
+
batchMoving: (done, total) => `正在移动 ${done}/${total}…`,
|
|
55
|
+
batchMoved: (ok, fail) => (fail > 0 ? `已移动 ${ok} 个 / ${fail} 个失败` : `已移动 ${ok} 个`),
|
|
56
|
+
moveBadResult: (fields) => `移动结果缺少字段:${fields}`,
|
|
42
57
|
deleteSelected: '删除',
|
|
43
58
|
batchDone: (n) => `已删除 ${n} 个会话`,
|
|
44
59
|
batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
|
|
45
60
|
batchFailed: '批量删除失败:',
|
|
46
61
|
pageAsk: '删除时询问',
|
|
62
|
+
pageRecycle: '删除至回收站',
|
|
63
|
+
pageSyncChildren: '同时删除子会话',
|
|
64
|
+
starAsk: '⭐ 顺手留颗 Star,作者能高兴一整天',
|
|
65
|
+
starCta: '行,给你一颗 Star',
|
|
66
|
+
pageSyncChildrenOn: '删除对话时,把它派生的子代理会话一起删掉。',
|
|
67
|
+
pageSyncChildrenOff: '不勾选时,删除前会列出子会话,由你勾选要删哪些。',
|
|
68
|
+
childrenHint: '这个对话有 {n} 个子会话(子代理),勾选的会一起删除。',
|
|
69
|
+
pageRecycleOn: '删除后放进系统回收站,可以还原。',
|
|
70
|
+
pageRecycleOff: '删除后直接从硬盘移除,不可恢复。',
|
|
47
71
|
pageHintAsk: '每次删除会话前都会弹出确认框。',
|
|
48
72
|
pageHintAuto: '删除会话会直接执行,不再询问。',
|
|
73
|
+
// The produced-file list only exists inside the dialog, so ask mode keeps a
|
|
74
|
+
// delete dialog even when the confirmation above is turned off.
|
|
75
|
+
pageHintAutoWithArtifacts: '删除会话仍会弹窗——产出文件要先列清单给你勾选。',
|
|
49
76
|
pageSummaryAsk: '删除前询问',
|
|
50
77
|
pageSummaryAuto: '直接删除',
|
|
78
|
+
pageArtifactsOff: '不处理产出文件(保持当前行为)',
|
|
79
|
+
pageHintArtifactsOff: '删除会话只清理对话记录,完全不碰工作目录里的文件。',
|
|
80
|
+
pageHintArtifactsOn: '删除会话时还会处理这个对话在工作目录里碰过的文件。',
|
|
81
|
+
pageArtifactsAuto: '产出文件默认全部进隔离夹',
|
|
82
|
+
pageHintArtifactsAuto: '按下面的范围把匹配的文件全部移入隔离夹,不再询问。',
|
|
83
|
+
pageHintArtifactsAsk: '删除会话时会列出文件清单,由你勾选。',
|
|
84
|
+
pageScopeCreated: '仅新建的文件',
|
|
85
|
+
pageScopeAll: '新建的和改过的文件',
|
|
86
|
+
pageScopeCreatedHint: '只动这个对话自己创建的文件;它改过的既有文件一律不动。',
|
|
87
|
+
pageScopeAllHint: '还会包含这个对话改写过的既有文件 —— 移走它们会改变工作目录里原有的内容。',
|
|
88
|
+
pageSkipGit: '跳过 git 仓库内的文件',
|
|
89
|
+
pageSkipGitOn: '只处理不在 git 仓库里的文件;仓库里的代码不会被搬走。',
|
|
90
|
+
pageSkipGitOff: '仓库里的文件也会被搬进隔离夹 —— 删一个在项目里干过活的对话,可能把项目源码一起搬走。',
|
|
91
|
+
pageSummaryArtifactsOff: '不处理产出文件',
|
|
92
|
+
pageSummaryArtifactsAuto: '产出文件自动隔离',
|
|
93
|
+
pageSummaryArtifactsAsk: '产出文件先询问',
|
|
94
|
+
pageRootLabel: '隔离夹保存位置',
|
|
95
|
+
pageRootPlaceholder: '留空则使用默认路径',
|
|
96
|
+
pageRootBrowse: '选择文件夹…',
|
|
97
|
+
pageRootOpen: '打开',
|
|
98
|
+
rootOpened: '已在文件管理器中打开',
|
|
99
|
+
rootOpenFailed: '打开失败:',
|
|
100
|
+
pickerOpening: '已打开系统文件夹对话框,请在弹出的窗口里选择',
|
|
101
|
+
pageRootReset: '用默认',
|
|
102
|
+
pageRootDefaultHint: (path) => `当前使用默认路径:${path}`,
|
|
103
|
+
pageRootCustomHint: '隔离夹会建在这个目录下,每次删除生成一个带时间戳的子目录',
|
|
104
|
+
pickerTitle: '选择隔离夹保存位置',
|
|
105
|
+
pickerLoading: '读取中…',
|
|
106
|
+
pickerEmpty: '这个目录下没有子目录',
|
|
107
|
+
pickerChoose: '选择此目录',
|
|
108
|
+
pickerTruncated: '子目录太多,只列出前 500 个',
|
|
109
|
+
pickerDefault: (path) => `默认路径:${path}`,
|
|
110
|
+
rootSet: '保存位置已更新',
|
|
111
|
+
artifactSection: '本对话产出的文件',
|
|
112
|
+
artifactLoading: '正在扫描…',
|
|
113
|
+
artifactFailed: '扫描失败:',
|
|
114
|
+
artifactNone: '没有需要处理的文件。',
|
|
115
|
+
artifactCount: (created, modified) => `新建 ${created} 个 · 改过 ${modified} 个`,
|
|
116
|
+
artifactAutoNote: (n) => `删除后将把 ${n} 个文件移入隔离夹`,
|
|
117
|
+
// Auto mode does not scan, so it can state the outcome but not a count.
|
|
118
|
+
artifactAutoOnly: '产出文件会自动全部移入隔离夹,这里不再逐个列出。',
|
|
119
|
+
artifactRefused: (n) => `另有 ${n} 项已跳过`,
|
|
120
|
+
artifactSelected: (n) => `已选 ${n} 个`,
|
|
121
|
+
artifactMore: (n) => `另有 ${n} 个未列出`,
|
|
122
|
+
artifactTruncated: '清单过长已截断,未列出的不会处理。',
|
|
123
|
+
artifactScopeCreated: '范围:仅新建',
|
|
124
|
+
artifactScopeAll: '范围:新建 + 改过',
|
|
125
|
+
artifactModifiedWarn: '改过的既有文件(默认不选):',
|
|
126
|
+
doneQuarantine: (n) => `已删除会话,${n} 个文件已移入隔离夹`,
|
|
127
|
+
doneQuarantineFailed: (ok, fail) => `已删除会话,隔离 ${ok} 个、${fail} 个失败`,
|
|
51
128
|
}
|
|
52
129
|
|
|
53
130
|
const en = {
|
|
54
131
|
menu: 'Delete session',
|
|
132
|
+
multiSelect: 'Select multiple',
|
|
55
133
|
title: 'Delete session',
|
|
56
|
-
|
|
134
|
+
descRecycle: 'This removes the session and all of its conversation records (log, workspace accounting and statistics) from the list and puts them in the system trash.',
|
|
135
|
+
descPermanent: 'This permanently deletes the session and all of its conversation records (log, workspace accounting and statistics). This cannot be undone.',
|
|
57
136
|
cancel: 'Cancel',
|
|
58
137
|
confirm: 'Delete',
|
|
59
138
|
deleting: 'Deleting...',
|
|
60
|
-
dontAsk: "Don't ask again",
|
|
61
139
|
done: 'Session deleted',
|
|
62
140
|
failed: 'Delete failed: ',
|
|
63
141
|
busy: 'This session is currently open in DSH; the delete did not complete. Try again in a moment (usually a few seconds), or restart DSH and retry.',
|
|
64
142
|
notFound: 'Session not found (it may already be deleted).',
|
|
65
143
|
untitled: 'Untitled session',
|
|
66
|
-
multiSelect: 'Select multiple',
|
|
67
144
|
cancelSelect: 'Cancel',
|
|
68
145
|
selectedCount: (n) => `${n} selected`,
|
|
69
146
|
batchRunning: (done, total) => `Deleting ${done}/${total}...`,
|
|
147
|
+
moveSelected: 'Move',
|
|
148
|
+
batchMoving: (done, total) => `Moving ${done}/${total}...`,
|
|
149
|
+
batchMoved: (ok, fail) => (fail > 0 ? `Moved ${ok}, ${fail} failed` : `Moved ${ok}`),
|
|
150
|
+
moveBadResult: (fields) => `Move result is missing fields: ${fields}`,
|
|
70
151
|
deleteSelected: 'Delete',
|
|
71
152
|
batchDone: (n) => `Deleted ${n} session(s)`,
|
|
72
153
|
batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
|
|
73
154
|
batchFailed: 'Batch delete failed: ',
|
|
74
155
|
pageAsk: 'Ask before deleting',
|
|
156
|
+
pageRecycle: 'Delete to the Recycle Bin',
|
|
157
|
+
pageSyncChildren: 'Delete child sessions too',
|
|
158
|
+
starAsk: '⭐ A star would make the author\'s day',
|
|
159
|
+
starCta: 'Fine — have one',
|
|
160
|
+
pageSyncChildrenOn: 'A delete also removes the subagent sessions it spawned.',
|
|
161
|
+
pageSyncChildrenOff: 'When off, the dialog lists the child sessions and you tick the ones to delete.',
|
|
162
|
+
childrenHint: 'This conversation has {n} child sessions (subagents). Ticked ones are deleted too.',
|
|
163
|
+
pageRecycleOn: 'The session goes to the system trash and can be restored.',
|
|
164
|
+
pageRecycleOff: 'The session is removed from the disk right away and cannot be restored.',
|
|
75
165
|
pageHintAsk: 'Show a confirmation dialog before every session delete.',
|
|
76
166
|
pageHintAuto: 'Delete the session right away without asking.',
|
|
167
|
+
pageHintAutoWithArtifacts: 'A delete still opens a dialog, because the produced-file list has to be shown first.',
|
|
77
168
|
pageSummaryAsk: 'Ask before deleting',
|
|
78
169
|
pageSummaryAuto: 'Delete without asking',
|
|
170
|
+
pageArtifactsOff: 'Leave produced files alone (current behaviour)',
|
|
171
|
+
pageHintArtifactsOff: 'Deleting a session clears its record only and never touches files in the working directory.',
|
|
172
|
+
pageHintArtifactsOn: 'Deleting a session also handles the files this conversation touched in its working directory.',
|
|
173
|
+
pageArtifactsAuto: 'Send produced files straight to quarantine',
|
|
174
|
+
pageHintArtifactsAuto: 'Every file in the scope below moves to quarantine without asking.',
|
|
175
|
+
pageHintArtifactsAsk: 'Deleting a session lists the files and lets you tick what to move.',
|
|
176
|
+
pageScopeCreated: 'Only files it created',
|
|
177
|
+
pageScopeAll: 'Created and changed files',
|
|
178
|
+
pageScopeCreatedHint: 'Touches only files this conversation created; files it merely edited are never moved.',
|
|
179
|
+
pageScopeAllHint: 'Also includes pre-existing files this conversation rewrote — moving them changes what was already in the working directory.',
|
|
180
|
+
pageSkipGit: 'Skip files inside a git repository',
|
|
181
|
+
pageSkipGitOn: 'Only files outside git repositories are handled; code inside a repository is never moved.',
|
|
182
|
+
pageSkipGitOff: 'Repository files move into quarantine too — deleting a conversation that worked in a project can carry the project source away.',
|
|
183
|
+
pageSummaryArtifactsOff: 'Produced files left alone',
|
|
184
|
+
pageSummaryArtifactsAuto: 'Produced files auto-quarantined',
|
|
185
|
+
pageSummaryArtifactsAsk: 'Produced files ask first',
|
|
186
|
+
pageRootLabel: 'Quarantine folder location',
|
|
187
|
+
pageRootPlaceholder: 'Leave empty to use the default path',
|
|
188
|
+
pageRootBrowse: 'Choose folder…',
|
|
189
|
+
pageRootOpen: 'Open',
|
|
190
|
+
rootOpened: 'Opened in the file manager',
|
|
191
|
+
rootOpenFailed: 'Could not open: ',
|
|
192
|
+
pickerOpening: 'The system folder dialog is open; pick a folder in that window',
|
|
193
|
+
pageRootReset: 'Use default',
|
|
194
|
+
pageRootDefaultHint: (path) => `Using the default path: ${path}`,
|
|
195
|
+
pageRootCustomHint: 'The quarantine folder is created here, one timestamped subfolder per delete',
|
|
196
|
+
pickerTitle: 'Choose where quarantined files go',
|
|
197
|
+
pickerLoading: 'Reading…',
|
|
198
|
+
pickerEmpty: 'No subfolders here',
|
|
199
|
+
pickerChoose: 'Use this folder',
|
|
200
|
+
pickerTruncated: 'Too many subfolders: only the first 500 are listed',
|
|
201
|
+
pickerDefault: (path) => `Default path: ${path}`,
|
|
202
|
+
rootSet: 'Location updated',
|
|
203
|
+
artifactSection: 'Files this conversation produced',
|
|
204
|
+
artifactLoading: 'Scanning...',
|
|
205
|
+
artifactFailed: 'Scan failed: ',
|
|
206
|
+
artifactNone: 'Nothing to handle.',
|
|
207
|
+
artifactCount: (created, modified) => `${created} created · ${modified} changed`,
|
|
208
|
+
artifactAutoNote: (n) => `${n} file(s) will move to quarantine on delete`,
|
|
209
|
+
artifactAutoOnly: 'Produced files move to quarantine automatically; they are not listed here.',
|
|
210
|
+
artifactRefused: (n) => `${n} more were skipped`,
|
|
211
|
+
artifactSelected: (n) => `${n} selected`,
|
|
212
|
+
artifactMore: (n) => `${n} more not listed`,
|
|
213
|
+
artifactTruncated: 'The list was truncated; anything unlisted is left alone.',
|
|
214
|
+
artifactScopeCreated: 'Scope: created only',
|
|
215
|
+
artifactScopeAll: 'Scope: created + changed',
|
|
216
|
+
artifactModifiedWarn: 'Pre-existing files it changed (unticked by default):',
|
|
217
|
+
doneQuarantine: (n) => `Session deleted, ${n} file(s) moved to quarantine`,
|
|
218
|
+
doneQuarantineFailed: (ok, fail) => `Session deleted, ${ok} quarantined and ${fail} failed`,
|
|
79
219
|
}
|
|
80
220
|
|
|
81
|
-
let localeSvc = null
|
|
82
221
|
let sessionsSvc = null
|
|
222
|
+
let localeSvc = null
|
|
223
|
+
|
|
224
|
+
function browserLang() {
|
|
225
|
+
if (typeof navigator === 'undefined') return 'zh'
|
|
226
|
+
for (const tag of (navigator.languages || []).concat([navigator.language])) {
|
|
227
|
+
const primary = String(tag || '').toLowerCase().split('-')[0]
|
|
228
|
+
if (primary === 'zh' || primary === 'en') return primary
|
|
229
|
+
}
|
|
230
|
+
return 'zh'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The DOM decoration layer (row checkboxes, batch bar, toast) is not a slot
|
|
234
|
+
// occupant, so it has no injected `t`; it uses the built-in dictionaries and
|
|
235
|
+
// honours the registered `locale` namespace when the host provides one.
|
|
236
|
+
// Slot components receive the host-injected `t` and use that instead.
|
|
237
|
+
function t(key, a, b) {
|
|
238
|
+
if (localeSvc && typeof localeSvc.translate === 'function') {
|
|
239
|
+
try {
|
|
240
|
+
const text = localeSvc.translate(NS, key)
|
|
241
|
+
if (typeof text === 'string' && text !== key) return text
|
|
242
|
+
} catch {
|
|
243
|
+
// fall back to the built-in dictionaries
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const value = (browserLang() === 'en' ? en : zh)[key]
|
|
247
|
+
return typeof value === 'function' ? value(a, b) : value || key
|
|
248
|
+
}
|
|
83
249
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Wrap a host-injected `t` so function-valued entries render their value.
|
|
252
|
+
*
|
|
253
|
+
* The locale contract leaves a function entry untouched: `translate(key, a, b)`
|
|
254
|
+
* returns the arrow function itself, and the caller is the one that invokes it.
|
|
255
|
+
* Using the injected `t` directly therefore printed the function's own source
|
|
256
|
+
* in the dialog ("(created, modified) => ...") instead of the numbers.
|
|
257
|
+
*/
|
|
258
|
+
function localizedTranslator(injected) {
|
|
259
|
+
const raw = typeof injected === 'function' ? injected : t
|
|
260
|
+
if (raw.__dsdelLocalized === true) return raw
|
|
261
|
+
const wrapped = (key, a, b) => {
|
|
262
|
+
const value = raw(key, a, b)
|
|
263
|
+
return typeof value === 'function' ? value(a, b) : value
|
|
264
|
+
}
|
|
265
|
+
wrapped.__dsdelLocalized = true
|
|
266
|
+
return wrapped
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- state -----------------------------------------------------------------
|
|
270
|
+
// One module-level store shared by the slot components and the DOM layer.
|
|
271
|
+
// Components read it through `useSnapshot`, so each stays a plain function of
|
|
272
|
+
// the store with no local state - which also keeps it drivable from the
|
|
273
|
+
// offline test without a React renderer.
|
|
274
|
+
|
|
275
|
+
const state = {
|
|
276
|
+
/** { sessionId, title, busy, error, skip } while a confirmation dialog is up. */
|
|
277
|
+
dialog: null,
|
|
278
|
+
selecting: false,
|
|
279
|
+
selected: new Set(),
|
|
280
|
+
/** Rows hidden optimistically while their delete request is in flight. */
|
|
281
|
+
pending: new Set(),
|
|
282
|
+
/** { total, done, failed } while a batch delete runs, else null. */
|
|
283
|
+
batch: null,
|
|
284
|
+
/** Folder-picker state while it is open, else null. */
|
|
285
|
+
picker: null,
|
|
286
|
+
/** The host's default quarantine root, fetched once so the card can name it. */
|
|
287
|
+
defaultRoot: null,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const listeners = new Set()
|
|
291
|
+
|
|
292
|
+
function buildSnapshot() {
|
|
293
|
+
return {
|
|
294
|
+
dialog: state.dialog,
|
|
295
|
+
selecting: state.selecting,
|
|
296
|
+
selected: state.selected,
|
|
297
|
+
pending: state.pending,
|
|
298
|
+
batch: state.batch,
|
|
299
|
+
picker: state.picker,
|
|
300
|
+
defaultRoot: state.defaultRoot,
|
|
301
|
+
selectedCount: state.selected.size,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `useSyncExternalStore` needs a value that is stable between notifications.
|
|
306
|
+
let snapshot = buildSnapshot()
|
|
307
|
+
|
|
308
|
+
function getSnapshot() {
|
|
309
|
+
return snapshot
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function subscribe(listener) {
|
|
313
|
+
listeners.add(listener)
|
|
314
|
+
return () => { listeners.delete(listener) }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function publish() {
|
|
318
|
+
snapshot = buildSnapshot()
|
|
319
|
+
for (const listener of listeners) {
|
|
320
|
+
try { listener() } catch { /* a subscriber must not break the mutation */ }
|
|
321
|
+
}
|
|
322
|
+
scheduleDecorate()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// `useSyncExternalStore` where the host's React has it (React 18+), otherwise
|
|
326
|
+
// a subscribe/effect pair. Chosen once at module scope, so no hook is called
|
|
327
|
+
// conditionally during a render.
|
|
328
|
+
const useSnapshot = typeof react.useSyncExternalStore === 'function'
|
|
329
|
+
? () => react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
330
|
+
: () => {
|
|
331
|
+
const [value, setValue] = react.useState(getSnapshot)
|
|
332
|
+
react.useEffect(() => subscribe(() => setValue(getSnapshot())), [])
|
|
333
|
+
return value
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- confirmation preference ----------------------------------------------
|
|
337
|
+
|
|
338
|
+
// Once the user opts out ("删除时询问" unticked - either in the plugin page or
|
|
339
|
+
// via the dialog's "don't ask again") the confirmation dialog is skipped and
|
|
340
|
+
// the menu item deletes directly. The plugin page's checkbox and the dialog
|
|
341
|
+
// read the same key, and a subscription keeps the card in step when either
|
|
342
|
+
// one flips it.
|
|
89
343
|
const SKIP_KEY = 'dsh-delete-session:skip-confirm'
|
|
90
344
|
const skipListeners = new Set()
|
|
91
345
|
|
|
@@ -109,11 +363,220 @@ window.__ModuleLoader__.load({
|
|
|
109
363
|
return () => { skipListeners.delete(listener) }
|
|
110
364
|
}
|
|
111
365
|
|
|
112
|
-
|
|
366
|
+
// --- artifact preferences --------------------------------------------------
|
|
367
|
+
// The produced-files feature ships OFF: `OFF_KEY` is ticked unless the user
|
|
368
|
+
// explicitly clears it, so an existing install keeps behaving exactly as it
|
|
369
|
+
// did before the feature existed. When it is on, `AUTO_KEY` decides whether
|
|
370
|
+
// the dialog asks, and `SCOPE_KEY` decides what the candidate set is.
|
|
371
|
+
|
|
372
|
+
const OFF_KEY = 'dsh-delete-session:artifacts-off'
|
|
373
|
+
const AUTO_KEY = 'dsh-delete-session:artifacts-auto'
|
|
374
|
+
const SCOPE_KEY = 'dsh-delete-session:artifact-scope'
|
|
375
|
+
/** Where the timestamped quarantine folder is created. Empty means the DSH home. */
|
|
376
|
+
const ROOT_KEY = 'dsh-delete-session:quarantine-root'
|
|
377
|
+
/** Skip files inside a git working tree when quarantining. */
|
|
378
|
+
const SKIP_GIT_KEY = 'dsh-delete-session:skip-git'
|
|
379
|
+
/** Whether a delete goes to the system trash instead of vanishing. */
|
|
380
|
+
const RECYCLE_KEY = 'dsh-delete-session:recycle'
|
|
381
|
+
/** Whether a delete takes the subagent sessions with it, without asking. */
|
|
382
|
+
const SYNC_CHILDREN_KEY = 'dsh-delete-session:sync-children'
|
|
383
|
+
/** How many artifact rows the dialog lists before collapsing the rest. */
|
|
384
|
+
const ARTIFACT_ROWS_SHOWN = 40
|
|
385
|
+
const prefListeners = new Set()
|
|
386
|
+
|
|
387
|
+
function readPref(key, fallback) {
|
|
388
|
+
try {
|
|
389
|
+
const value = localStorage.getItem(key)
|
|
390
|
+
return value === null ? fallback : value
|
|
391
|
+
} catch {
|
|
392
|
+
return fallback
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function writePref(key, value) {
|
|
397
|
+
try {
|
|
398
|
+
if (value === null) localStorage.removeItem(key)
|
|
399
|
+
else localStorage.setItem(key, value)
|
|
400
|
+
} catch {
|
|
401
|
+
// private mode: the preference simply does not persist
|
|
402
|
+
}
|
|
403
|
+
for (const listener of prefListeners) {
|
|
404
|
+
try { listener() } catch { /* a subscriber must not break the toggle */ }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function subscribePrefs(listener) {
|
|
409
|
+
prefListeners.add(listener)
|
|
410
|
+
return () => { prefListeners.delete(listener) }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Whether the produced-files feature is off. Off unless explicitly enabled. */
|
|
414
|
+
function artifactsOff() {
|
|
415
|
+
return readPref(OFF_KEY, '1') !== '0'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function setArtifactsOff(off) {
|
|
419
|
+
writePref(OFF_KEY, off ? null : '0')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Whether the whole candidate set moves without asking. */
|
|
423
|
+
function artifactsAuto() {
|
|
424
|
+
return readPref(AUTO_KEY, '0') === '1'
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function setArtifactsAuto(auto) {
|
|
428
|
+
writePref(AUTO_KEY, auto ? '1' : null)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Candidate scope: files this session created, or those plus the ones it changed. */
|
|
432
|
+
function artifactScope() {
|
|
433
|
+
return readPref(SCOPE_KEY, 'created') === 'all' ? 'all' : 'created'
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setArtifactScope(scope) {
|
|
437
|
+
writePref(SCOPE_KEY, scope === 'all' ? 'all' : 'created')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Skip files that live inside a git working tree. On by default: a session
|
|
442
|
+
* started in a repository produces that repository's source, and moving it
|
|
443
|
+
* out from under the project is never what "clean up the files this chat
|
|
444
|
+
* created" means.
|
|
445
|
+
*/
|
|
446
|
+
function skipGitRepos() {
|
|
447
|
+
return readPref(SKIP_GIT_KEY, '1') === '1'
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function setSkipGitRepos(skip) {
|
|
451
|
+
writePref(SKIP_GIT_KEY, skip ? null : '0')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Send the session to the system trash rather than removing it outright.
|
|
456
|
+
* On by default: a wrong click should be undoable unless the user said otherwise.
|
|
457
|
+
*/
|
|
458
|
+
function recycleSession() {
|
|
459
|
+
return readPref(RECYCLE_KEY, '1') === '1'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function setRecycleSession(on) {
|
|
463
|
+
writePref(RECYCLE_KEY, on ? null : '0')
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Take the child sessions along without asking. Off by default: a subagent is
|
|
468
|
+
* a conversation of its own, so it is offered rather than swept up.
|
|
469
|
+
*/
|
|
470
|
+
function syncChildren() {
|
|
471
|
+
return readPref(SYNC_CHILDREN_KEY, '0') === '1'
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function setSyncChildren(on) {
|
|
475
|
+
writePref(SYNC_CHILDREN_KEY, on ? '1' : null)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The chosen quarantine root, or '' for "use the host default". */
|
|
479
|
+
function quarantineRoot() {
|
|
480
|
+
return readPref(ROOT_KEY, '')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function setQuarantineRoot(value) {
|
|
484
|
+
writePref(ROOT_KEY, typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- host calls ------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
/** The read-only artifact scan the dialog renders. */
|
|
490
|
+
function postArtifacts(sessionId) {
|
|
491
|
+
const root = quarantineRoot()
|
|
492
|
+
const payload = root === '' ? { sessionId } : { sessionId, root }
|
|
493
|
+
payload.skipGit = skipGitRepos()
|
|
494
|
+
return fetch('/dsh-delete-session/artifacts', {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
headers: { 'content-type': 'application/json' },
|
|
497
|
+
body: JSON.stringify(payload),
|
|
498
|
+
}).then(async (res) => {
|
|
499
|
+
let data = {}
|
|
500
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
501
|
+
if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
502
|
+
return data
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** One directory listing for the folder picker. */
|
|
507
|
+
function postBrowse(target) {
|
|
508
|
+
return fetch('/dsh-delete-session/browse', {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
headers: { 'content-type': 'application/json' },
|
|
511
|
+
body: JSON.stringify(target === undefined || target === '' ? {} : { path: target }),
|
|
512
|
+
}).then(async (res) => {
|
|
513
|
+
let data = {}
|
|
514
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
515
|
+
if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
516
|
+
return data
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Ask the host to raise the real Windows folder dialog. The request stays open
|
|
522
|
+
* while the user browses, so `code` is carried on failures: `unsupported`
|
|
523
|
+
* means this platform has no native dialog and the in-page picker should run.
|
|
524
|
+
*/
|
|
525
|
+
function postPick() {
|
|
526
|
+
return fetch('/dsh-delete-session/pick', {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: { 'content-type': 'application/json' },
|
|
529
|
+
body: JSON.stringify({}),
|
|
530
|
+
}).then(async (res) => {
|
|
531
|
+
let data = {}
|
|
532
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
533
|
+
if (!res.ok || !data.ok) {
|
|
534
|
+
const error = new Error((data && data.error) || `HTTP ${res.status}`)
|
|
535
|
+
error.code = data && data.code
|
|
536
|
+
throw error
|
|
537
|
+
}
|
|
538
|
+
return data
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Ask the host to show a directory in the OS file manager. */
|
|
543
|
+
function postReveal(target) {
|
|
544
|
+
return fetch('/dsh-delete-session/reveal', {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: { 'content-type': 'application/json' },
|
|
547
|
+
body: JSON.stringify({ path: target }),
|
|
548
|
+
}).then(async (res) => {
|
|
549
|
+
let data = {}
|
|
550
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
551
|
+
if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
552
|
+
return data
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Show the quarantine folder the current settings would write to. */
|
|
557
|
+
function openQuarantineRoot(target) {
|
|
558
|
+
if (typeof target !== 'string' || target === '') return
|
|
559
|
+
postReveal(target)
|
|
560
|
+
.then(() => toast(t('rootOpened')))
|
|
561
|
+
.catch((reason) => toast(t('rootOpenFailed') + String((reason && reason.message) || reason), true))
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function postDelete(sessionId, quarantine, childSessionIds) {
|
|
565
|
+
const root = quarantineRoot()
|
|
566
|
+
const payload = quarantine === undefined ? { sessionId } : { sessionId, quarantine }
|
|
567
|
+
if (root !== '') payload.root = root
|
|
568
|
+
// Subagent sessions are sessions of their own, so they travel as a list the
|
|
569
|
+
// host can verify rather than something it has to discover.
|
|
570
|
+
if (Array.isArray(childSessionIds) && childSessionIds.length > 0) {
|
|
571
|
+
payload.childSessionIds = childSessionIds
|
|
572
|
+
}
|
|
573
|
+
// Whether the files go to the system trash is a per-delete choice, so it
|
|
574
|
+
// travels with the request rather than being read from the host config.
|
|
575
|
+
payload.recycle = recycleSession()
|
|
113
576
|
return fetch('/dsh-delete-session/delete', {
|
|
114
577
|
method: 'POST',
|
|
115
578
|
headers: { 'content-type': 'application/json' },
|
|
116
|
-
body: JSON.stringify(
|
|
579
|
+
body: JSON.stringify(payload),
|
|
117
580
|
}).then(async (res) => {
|
|
118
581
|
let data = {}
|
|
119
582
|
try { data = await res.json() } catch { /* keep {} */ }
|
|
@@ -130,12 +593,22 @@ window.__ModuleLoader__.load({
|
|
|
130
593
|
})
|
|
131
594
|
}
|
|
132
595
|
|
|
133
|
-
|
|
596
|
+
function refreshList() {
|
|
597
|
+
const svc = sessionsSvc
|
|
598
|
+
if (svc) {
|
|
599
|
+
try {
|
|
600
|
+
if (typeof svc.refresh === 'function') { svc.refresh(); return }
|
|
601
|
+
if (typeof svc.refreshList === 'function') { svc.refreshList(); return }
|
|
602
|
+
} catch {
|
|
603
|
+
// fall through to reload
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
try { window.location.reload() } catch { /* ignore */ }
|
|
607
|
+
}
|
|
134
608
|
|
|
135
609
|
// Deleting the session the main view is showing leaves the app to restore a
|
|
136
|
-
// stale neighbour (
|
|
137
|
-
//
|
|
138
|
-
// deleted session is the one the app has open, open a fresh session instead.
|
|
610
|
+
// stale neighbour (an unavailable Agent Team row with a disabled composer),
|
|
611
|
+
// which reads as "the delete did not work". Open a fresh session instead.
|
|
139
612
|
function deletedVariants(id) {
|
|
140
613
|
return id.startsWith('session-') ? [id, id.slice('session-'.length)] : [id, `session-${id}`]
|
|
141
614
|
}
|
|
@@ -168,83 +641,11 @@ window.__ModuleLoader__.load({
|
|
|
168
641
|
}
|
|
169
642
|
}
|
|
170
643
|
|
|
171
|
-
function deleteNow(info) {
|
|
172
|
-
pendingRemoval.add(info.id)
|
|
173
|
-
scheduleDecorate()
|
|
174
|
-
postDelete(info.id)
|
|
175
|
-
.then(() => {
|
|
176
|
-
toast(t('done'))
|
|
177
|
-
refreshList()
|
|
178
|
-
settlePendingSoon()
|
|
179
|
-
leaveIfCurrent(info.id)
|
|
180
|
-
})
|
|
181
|
-
.catch((reason) => {
|
|
182
|
-
pendingRemoval.delete(info.id)
|
|
183
|
-
scheduleDecorate()
|
|
184
|
-
toast(t('failed') + String((reason && reason.message) || reason), true)
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function browserLang() {
|
|
189
|
-
if (typeof navigator === 'undefined') return 'zh'
|
|
190
|
-
for (const tag of (navigator.languages || []).concat([navigator.language])) {
|
|
191
|
-
const primary = String(tag || '').toLowerCase().split('-')[0]
|
|
192
|
-
if (primary === 'zh' || primary === 'en') return primary
|
|
193
|
-
}
|
|
194
|
-
return 'zh'
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function t(key) {
|
|
198
|
-
if (localeSvc && typeof localeSvc.translate === 'function') {
|
|
199
|
-
try {
|
|
200
|
-
const text = localeSvc.translate(NS, key)
|
|
201
|
-
if (typeof text === 'string' && text !== key) return text
|
|
202
|
-
} catch {
|
|
203
|
-
// fall back to the built-in dictionaries
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return (browserLang() === 'en' ? en : zh)[key] || key
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// --- fiber access ----------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
function findFiber(el) {
|
|
212
|
-
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
|
|
213
|
-
return key ? el[key] : null
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Walk up from the portalled menu element to the session row component
|
|
217
|
-
// that carries `node.id`. A menu without such a node is not a session
|
|
218
|
-
// menu (settings, header dropdowns) and is left alone.
|
|
219
|
-
function sessionInfoFromMenu(menuEl) {
|
|
220
|
-
let fiber = findFiber(menuEl)
|
|
221
|
-
let guard = 0
|
|
222
|
-
while (fiber && guard++ < 300) {
|
|
223
|
-
const props = fiber.memoizedProps
|
|
224
|
-
if (props && props.node && typeof props.node.id === 'string' && props.node.id) {
|
|
225
|
-
const title = props.node.title || props.node.name || null
|
|
226
|
-
return { id: props.node.id, title: typeof title === 'string' ? title : null }
|
|
227
|
-
}
|
|
228
|
-
fiber = fiber.return
|
|
229
|
-
}
|
|
230
|
-
return null
|
|
231
|
-
}
|
|
232
|
-
|
|
233
644
|
// --- styles ----------------------------------------------------------------
|
|
645
|
+
// Only what the DOM decoration layer owns. The confirmation dialog is the
|
|
646
|
+
// host's `Modal`, so it brings no backdrop CSS of its own.
|
|
234
647
|
|
|
235
648
|
const CSS = [
|
|
236
|
-
'.dsdel-backdrop{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center;padding:16px}',
|
|
237
|
-
'.dsdel-dialog{width:min(420px,92vw);border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));border-radius:14px;padding:18px;box-shadow:0 16px 48px rgba(0,0,0,.32);display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary,inherit);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);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%)}',
|
|
238
|
-
'.dsdel-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary,inherit);margin:0}',
|
|
239
|
-
'.dsdel-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);margin:0;word-break:break-all}',
|
|
240
|
-
'.dsdel-meta{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary,#888);margin:0;word-break:break-all}',
|
|
241
|
-
'.dsdel-ack{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,inherit);cursor:pointer}',
|
|
242
|
-
'.dsdel-ack input{width:15px;height:15px;accent-color:var(--dsw-alias-state-error-primary,#e5484d);cursor:pointer;flex:none}',
|
|
243
|
-
'.dsdel-error{font-size:12px;line-height:16px;color:var(--dsw-alias-state-error-primary,#e5484d);margin:0}',
|
|
244
|
-
'.dsdel-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}',
|
|
245
|
-
'.dsdel-btn{appearance:none;min-height:32px;padding:0 14px;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}',
|
|
246
|
-
'.dsdel-btn:disabled{opacity:.5;cursor:default}',
|
|
247
|
-
'.dsdel-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
248
649
|
'.dsdel-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:Canvas;color:CanvasText;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);max-width:min(92vw,460px);cursor:pointer}',
|
|
249
650
|
'.dsdel-check{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.5));background:transparent;flex:none;margin-right:8px;box-sizing:border-box;cursor:pointer}',
|
|
250
651
|
'.dsdel-check-on{background:var(--dsw-alias-state-business-primary,#4c6ef5);border-color:var(--dsw-alias-state-business-primary,#4c6ef5)}',
|
|
@@ -258,10 +659,38 @@ window.__ModuleLoader__.load({
|
|
|
258
659
|
'.dsdel-bar-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
259
660
|
'.dsdel-page{display:flex;flex-direction:column;gap:8px}',
|
|
260
661
|
'.dsdel-page-hint{margin:0;color:var(--dsw-alias-label-tertiary,#888);font-size:13px;line-height:20px}',
|
|
662
|
+
'.dsdel-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}',
|
|
663
|
+
'.dsdel-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}',
|
|
664
|
+
'.dsdel-root-input{flex:1 1 220px;min-width:160px;min-height:30px;padding:0 10px;border:1px solid var(--dsw-alias-border-secondary,#d0d0d0);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:13px}',
|
|
665
|
+
'.dsdel-picker-path{margin:0 0 8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);word-break:break-all}',
|
|
666
|
+
// A wider confirmation dialog: the class reaches it when `Modal` forwards
|
|
667
|
+
// `className`, and the `:has()` rule catches the host-owned container when
|
|
668
|
+
// it does not. Both are harmless if the other one is the one that applies.
|
|
669
|
+
'.dsdel-dialog-wide{width:min(94vw,980px)!important;max-width:min(94vw,980px)!important}',
|
|
670
|
+
'[role="dialog"]:has(.dsdel-modal-meta),[aria-modal="true"]:has(.dsdel-modal-meta){width:min(94vw,980px);max-width:min(94vw,980px)}',
|
|
671
|
+
'.dsdel-picker-list{display:flex;flex-direction:column;gap:2px;max-height:280px;overflow:auto;margin:0 0 8px}',
|
|
672
|
+
'.dsdel-picker-row{appearance:none;text-align:left;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer}',
|
|
673
|
+
'.dsdel-picker-row:hover{background:var(--dsw-alias-fill-secondary,rgba(127,127,127,0.12))}',
|
|
674
|
+
'.dsdel-modal-text{margin:0;font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);word-break:break-all}',
|
|
675
|
+
'.dsdel-modal-meta{margin:0 0 8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary,#888);word-break:break-all}',
|
|
676
|
+
'.dsdel-modal-error{margin:8px 0 0;font-size:12px;line-height:16px;color:var(--dsw-alias-state-error-primary,#e5484d)}',
|
|
677
|
+
// ui-primitives' `Button` has no danger variant (the host styles its own
|
|
678
|
+
// destructive buttons with a package-local CSS-module class), so the
|
|
679
|
+
// confirm button carries this class instead.
|
|
680
|
+
'.dsdel-danger-btn{border-color:var(--dsw-alias-state-error-primary,#e5484d)!important;background:var(--dsw-alias-state-error-primary,#e5484d)!important;color:#fff!important}',
|
|
681
|
+
'.dsdel-danger-btn:disabled{opacity:.5}',
|
|
261
682
|
].join('')
|
|
262
683
|
|
|
263
684
|
function ensureStyle() {
|
|
264
|
-
if (document
|
|
685
|
+
if (typeof document === 'undefined') return
|
|
686
|
+
const existing = document.querySelector('style[data-dsh-delete-session]')
|
|
687
|
+
if (existing !== null) {
|
|
688
|
+
// This tag outlives the module instance: when a newer build re-executes in
|
|
689
|
+
// the same document, bailing out here would keep the old stylesheet and
|
|
690
|
+
// pair it with the new markup. Rewriting it keeps the two in step.
|
|
691
|
+
if (existing.textContent !== CSS) existing.textContent = CSS
|
|
692
|
+
return
|
|
693
|
+
}
|
|
265
694
|
const style = document.createElement('style')
|
|
266
695
|
style.dataset.dshDeleteSession = '1'
|
|
267
696
|
style.textContent = CSS
|
|
@@ -270,12 +699,8 @@ window.__ModuleLoader__.load({
|
|
|
270
699
|
|
|
271
700
|
// --- feedback --------------------------------------------------------------
|
|
272
701
|
|
|
273
|
-
function closeMenu() {
|
|
274
|
-
try { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) } catch { /* ignore */ }
|
|
275
|
-
try { document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) } catch { /* ignore */ }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
702
|
function toast(text, isError) {
|
|
703
|
+
if (typeof document === 'undefined' || !document.body) return
|
|
279
704
|
const el = document.createElement('div')
|
|
280
705
|
el.className = 'dsdel-toast'
|
|
281
706
|
el.setAttribute('role', 'status')
|
|
@@ -290,154 +715,39 @@ window.__ModuleLoader__.load({
|
|
|
290
715
|
setTimeout(kill, 3200)
|
|
291
716
|
}
|
|
292
717
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (typeof svc.refreshList === 'function') { svc.refreshList(); return }
|
|
299
|
-
} catch {
|
|
300
|
-
// fall through to reload
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
try { window.location.reload() } catch { /* ignore */ }
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// --- dialog ----------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
function openDialog(info) {
|
|
309
|
-
ensureStyle()
|
|
310
|
-
const backdrop = document.createElement('div')
|
|
311
|
-
backdrop.className = 'dsdel-backdrop'
|
|
312
|
-
const dialog = document.createElement('div')
|
|
313
|
-
dialog.className = 'dsdel-dialog'
|
|
314
|
-
|
|
315
|
-
const title = document.createElement('h3')
|
|
316
|
-
title.className = 'dsdel-title'
|
|
317
|
-
title.textContent = t('title')
|
|
318
|
-
|
|
319
|
-
const meta = document.createElement('p')
|
|
320
|
-
meta.className = 'dsdel-meta'
|
|
321
|
-
meta.textContent = `${info.title || t('untitled')} · ${info.id}`
|
|
322
|
-
|
|
323
|
-
const text = document.createElement('p')
|
|
324
|
-
text.className = 'dsdel-text'
|
|
325
|
-
text.textContent = t('desc')
|
|
326
|
-
|
|
327
|
-
const skip = document.createElement('label')
|
|
328
|
-
skip.className = 'dsdel-ack'
|
|
329
|
-
const skipBox = document.createElement('input')
|
|
330
|
-
skipBox.type = 'checkbox'
|
|
331
|
-
const skipText = document.createElement('span')
|
|
332
|
-
skipText.textContent = t('dontAsk')
|
|
333
|
-
skip.appendChild(skipBox)
|
|
334
|
-
skip.appendChild(skipText)
|
|
335
|
-
|
|
336
|
-
const error = document.createElement('p')
|
|
337
|
-
error.className = 'dsdel-error'
|
|
338
|
-
error.style.display = 'none'
|
|
339
|
-
|
|
340
|
-
const actions = document.createElement('div')
|
|
341
|
-
actions.className = 'dsdel-actions'
|
|
342
|
-
const cancel = document.createElement('button')
|
|
343
|
-
cancel.type = 'button'
|
|
344
|
-
cancel.className = 'dsdel-btn'
|
|
345
|
-
cancel.textContent = t('cancel')
|
|
346
|
-
const confirm = document.createElement('button')
|
|
347
|
-
confirm.type = 'button'
|
|
348
|
-
confirm.className = 'dsdel-btn dsdel-danger'
|
|
349
|
-
confirm.textContent = t('confirm')
|
|
350
|
-
actions.appendChild(cancel)
|
|
351
|
-
actions.appendChild(confirm)
|
|
352
|
-
|
|
353
|
-
dialog.appendChild(title)
|
|
354
|
-
dialog.appendChild(meta)
|
|
355
|
-
dialog.appendChild(text)
|
|
356
|
-
dialog.appendChild(skip)
|
|
357
|
-
dialog.appendChild(error)
|
|
358
|
-
dialog.appendChild(actions)
|
|
359
|
-
backdrop.appendChild(dialog)
|
|
360
|
-
document.body.appendChild(backdrop)
|
|
361
|
-
|
|
362
|
-
let busy = false
|
|
363
|
-
const close = () => {
|
|
364
|
-
if (busy) return
|
|
365
|
-
backdrop.remove()
|
|
366
|
-
}
|
|
367
|
-
cancel.addEventListener('click', close)
|
|
368
|
-
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
|
|
369
|
-
backdrop.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
370
|
-
|
|
371
|
-
confirm.addEventListener('click', () => {
|
|
372
|
-
if (busy) return
|
|
373
|
-
busy = true
|
|
374
|
-
confirm.disabled = true
|
|
375
|
-
cancel.disabled = true
|
|
376
|
-
confirm.textContent = t('deleting')
|
|
377
|
-
error.style.display = 'none'
|
|
378
|
-
if (skipBox.checked) setSkipConfirm(true)
|
|
379
|
-
pendingRemoval.add(info.id)
|
|
380
|
-
scheduleDecorate()
|
|
381
|
-
postDelete(info.id)
|
|
382
|
-
.then(() => {
|
|
383
|
-
backdrop.remove()
|
|
384
|
-
toast(t('done'))
|
|
385
|
-
refreshList()
|
|
386
|
-
settlePendingSoon()
|
|
387
|
-
leaveIfCurrent(info.id)
|
|
388
|
-
})
|
|
389
|
-
.catch((reason) => {
|
|
390
|
-
busy = false
|
|
391
|
-
pendingRemoval.delete(info.id)
|
|
392
|
-
scheduleDecorate()
|
|
393
|
-
confirm.disabled = false
|
|
394
|
-
cancel.disabled = false
|
|
395
|
-
confirm.textContent = t('confirm')
|
|
396
|
-
error.textContent = t('failed') + String((reason && reason.message) || reason)
|
|
397
|
-
error.style.display = 'block'
|
|
398
|
-
})
|
|
399
|
-
})
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// --- multi-select ----------------------------------------------------------
|
|
403
|
-
// Entered from the session menu ("多选"). While active, every session row
|
|
404
|
-
// gets a round checkbox, clicking a row toggles it instead of opening the
|
|
405
|
-
// conversation, and a bottom bar runs the batch delete. No select-all and
|
|
406
|
-
// no per-row disabling: every row is selectable and the batch deletes
|
|
718
|
+
// --- multi-select row decoration -------------------------------------------
|
|
719
|
+
// Entered from the session menu ("多选"). While active, every session row gets
|
|
720
|
+
// a round checkbox, clicking a row toggles it instead of opening the
|
|
721
|
+
// conversation, and a bottom bar runs the batch delete. No select-all and no
|
|
722
|
+
// per-row disabling: every row is selectable and the batch deletes
|
|
407
723
|
// immediately (the single-session flow keeps its own consent dialog).
|
|
408
724
|
|
|
409
|
-
let selecting = false
|
|
410
|
-
const selected = new Set()
|
|
411
|
-
// 乐观 UI:已发出删除请求的行立即隐藏(decorate 时持续生效,React 重渲染也不会闪回);
|
|
412
|
-
// 请求失败时从集合移除,行随下一次 decorate 恢复显示。
|
|
413
|
-
const pendingRemoval = new Set()
|
|
414
|
-
let batchRunning = false
|
|
415
|
-
let batchTotal = 0
|
|
416
|
-
let batchDone = 0
|
|
417
|
-
let batchFailed = 0
|
|
418
725
|
let bar = null
|
|
419
726
|
let decorateRaf = null
|
|
420
727
|
let rowObserver = null
|
|
421
728
|
|
|
729
|
+
// Row identity comes from the host's own drag-and-drop key rather than from
|
|
730
|
+
// React internals: every Session row carries `data-row-key="session:<id>"`.
|
|
731
|
+
// A row without it is simply not selectable.
|
|
732
|
+
const ROW_KEY_PREFIX = 'session:'
|
|
733
|
+
|
|
422
734
|
function sessionIdFromRow(row) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
fiber = fiber.return
|
|
429
|
-
}
|
|
430
|
-
return null
|
|
735
|
+
if (!row || typeof row.getAttribute !== 'function') return null
|
|
736
|
+
const key = row.getAttribute('data-row-key')
|
|
737
|
+
if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) return null
|
|
738
|
+
const id = key.slice(ROW_KEY_PREFIX.length)
|
|
739
|
+
return id === '' ? null : id
|
|
431
740
|
}
|
|
432
741
|
|
|
433
742
|
function decorateRows() {
|
|
743
|
+
if (typeof document === 'undefined') return
|
|
434
744
|
const rows = document.querySelectorAll('[role="treeitem"]')
|
|
435
745
|
rows.forEach((row) => {
|
|
436
746
|
const id = sessionIdFromRow(row)
|
|
437
747
|
if (!id) return
|
|
438
748
|
// Checkboxes belong to multi-select only: a single delete (or any other
|
|
439
749
|
// decorate pass outside the mode) must not plant them on every row.
|
|
440
|
-
if (!selecting) {
|
|
750
|
+
if (!state.selecting) {
|
|
441
751
|
const stray = row.querySelector(':scope > .dsdel-check')
|
|
442
752
|
if (stray) stray.remove()
|
|
443
753
|
} else {
|
|
@@ -448,16 +758,17 @@ window.__ModuleLoader__.load({
|
|
|
448
758
|
check.setAttribute('data-dsh-delete-session', 'check')
|
|
449
759
|
row.insertBefore(check, row.firstChild)
|
|
450
760
|
}
|
|
451
|
-
check.classList.toggle('dsdel-check-on', selected.has(id))
|
|
761
|
+
check.classList.toggle('dsdel-check-on', state.selected.has(id))
|
|
452
762
|
}
|
|
453
|
-
row.classList.toggle('dsdel-row-pending',
|
|
763
|
+
row.classList.toggle('dsdel-row-pending', state.pending.has(id))
|
|
454
764
|
})
|
|
455
|
-
if (!selecting) {
|
|
765
|
+
if (!state.selecting) {
|
|
456
766
|
document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
|
|
457
767
|
}
|
|
458
768
|
}
|
|
459
769
|
|
|
460
770
|
function scheduleDecorate() {
|
|
771
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
461
772
|
if (decorateRaf !== null) return
|
|
462
773
|
decorateRaf = requestAnimationFrame(() => {
|
|
463
774
|
decorateRaf = null
|
|
@@ -465,43 +776,46 @@ window.__ModuleLoader__.load({
|
|
|
465
776
|
})
|
|
466
777
|
}
|
|
467
778
|
|
|
779
|
+
/**
|
|
780
|
+
* The bulk-move face `dsh-session-relocate` publishes on `window`. Read when
|
|
781
|
+
* the bar is built rather than cached at module load, so a relocate plugin
|
|
782
|
+
* that registers after this one is still picked up.
|
|
783
|
+
*/
|
|
784
|
+
function relocateApi() {
|
|
785
|
+
if (typeof window === 'undefined') return null
|
|
786
|
+
const api = window.__DSH_SESSION_RELOCATE__
|
|
787
|
+
if (!api || api.supportsBulkMove !== true) return null
|
|
788
|
+
if (typeof api.moveSelection !== 'function') return null
|
|
789
|
+
return api
|
|
790
|
+
}
|
|
791
|
+
|
|
468
792
|
function updateBar() {
|
|
469
793
|
if (!bar) return
|
|
470
794
|
const count = bar.querySelector('.dsdel-bar-count')
|
|
471
795
|
if (count) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
}
|
|
475
808
|
}
|
|
476
|
-
const
|
|
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')
|
|
477
813
|
const del = bar.querySelector('.dsdel-bar-danger')
|
|
478
|
-
if (cancel) cancel.disabled =
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (selected.has(id)) selected.delete(id)
|
|
484
|
-
else selected.add(id)
|
|
485
|
-
scheduleDecorate()
|
|
486
|
-
updateBar()
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function onSelectClickCapture(e) {
|
|
490
|
-
if (!selecting) return
|
|
491
|
-
const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null
|
|
492
|
-
if (!row) return
|
|
493
|
-
const id = sessionIdFromRow(row)
|
|
494
|
-
if (!id) return
|
|
495
|
-
e.preventDefault()
|
|
496
|
-
e.stopPropagation()
|
|
497
|
-
toggleSelect(id)
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function onSelectKeydown(e) {
|
|
501
|
-
if (e.key !== 'Escape') return
|
|
502
|
-
if (batchRunning) return
|
|
503
|
-
e.stopPropagation()
|
|
504
|
-
exitSelection()
|
|
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
|
|
505
819
|
}
|
|
506
820
|
|
|
507
821
|
function buildBar() {
|
|
@@ -512,7 +826,8 @@ window.__ModuleLoader__.load({
|
|
|
512
826
|
count.className = 'dsdel-bar-count'
|
|
513
827
|
const cancel = document.createElement('button')
|
|
514
828
|
cancel.type = 'button'
|
|
515
|
-
|
|
829
|
+
// Named so the bar never has to guess which plain button is Cancel.
|
|
830
|
+
cancel.className = 'dsdel-bar-btn dsdel-bar-cancel'
|
|
516
831
|
cancel.textContent = t('cancelSelect')
|
|
517
832
|
cancel.addEventListener('click', (e) => { e.stopPropagation(); exitSelection() })
|
|
518
833
|
const del = document.createElement('button')
|
|
@@ -522,15 +837,50 @@ window.__ModuleLoader__.load({
|
|
|
522
837
|
del.addEventListener('click', (e) => { e.stopPropagation(); runBatchDelete() })
|
|
523
838
|
el.appendChild(count)
|
|
524
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
|
+
}
|
|
525
850
|
el.appendChild(del)
|
|
526
851
|
document.body.appendChild(el)
|
|
527
852
|
return el
|
|
528
853
|
}
|
|
529
854
|
|
|
855
|
+
function toggleSelect(id) {
|
|
856
|
+
if (state.selected.has(id)) state.selected.delete(id)
|
|
857
|
+
else state.selected.add(id)
|
|
858
|
+
publish()
|
|
859
|
+
updateBar()
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function onSelectClickCapture(e) {
|
|
863
|
+
if (!state.selecting) return
|
|
864
|
+
const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null
|
|
865
|
+
if (!row) return
|
|
866
|
+
const id = sessionIdFromRow(row)
|
|
867
|
+
if (!id) return
|
|
868
|
+
e.preventDefault()
|
|
869
|
+
e.stopPropagation()
|
|
870
|
+
toggleSelect(id)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function onSelectKeydown(e) {
|
|
874
|
+
if (e.key !== 'Escape') return
|
|
875
|
+
if (state.batch !== null) return
|
|
876
|
+
e.stopPropagation()
|
|
877
|
+
exitSelection()
|
|
878
|
+
}
|
|
879
|
+
|
|
530
880
|
function enterSelection() {
|
|
531
|
-
if (selecting) return
|
|
532
|
-
selecting = true
|
|
533
|
-
selected.clear()
|
|
881
|
+
if (state.selecting) return
|
|
882
|
+
state.selecting = true
|
|
883
|
+
state.selected.clear()
|
|
534
884
|
ensureStyle()
|
|
535
885
|
if (!bar) bar = buildBar()
|
|
536
886
|
bar.style.display = 'flex'
|
|
@@ -540,18 +890,18 @@ window.__ModuleLoader__.load({
|
|
|
540
890
|
rowObserver = new MutationObserver(scheduleDecorate)
|
|
541
891
|
rowObserver.observe(document.body, { childList: true, subtree: true })
|
|
542
892
|
}
|
|
543
|
-
|
|
893
|
+
publish()
|
|
544
894
|
updateBar()
|
|
545
895
|
}
|
|
546
896
|
|
|
547
897
|
function exitSelection(options) {
|
|
548
|
-
if (!selecting) return
|
|
549
|
-
selecting = false
|
|
550
|
-
selected.clear()
|
|
551
|
-
|
|
898
|
+
if (!state.selecting) return
|
|
899
|
+
state.selecting = false
|
|
900
|
+
state.selected.clear()
|
|
901
|
+
state.batch = null
|
|
552
902
|
const keepPending = !!(options && options.keepPending)
|
|
553
903
|
if (!keepPending) {
|
|
554
|
-
|
|
904
|
+
state.pending.clear()
|
|
555
905
|
document.querySelectorAll('.dsdel-row-pending').forEach((el) => el.classList.remove('dsdel-row-pending'))
|
|
556
906
|
}
|
|
557
907
|
if (bar) bar.style.display = 'none'
|
|
@@ -559,13 +909,14 @@ window.__ModuleLoader__.load({
|
|
|
559
909
|
document.removeEventListener('keydown', onSelectKeydown, true)
|
|
560
910
|
if (rowObserver) { rowObserver.disconnect(); rowObserver = null }
|
|
561
911
|
document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
|
|
912
|
+
publish()
|
|
562
913
|
}
|
|
563
914
|
|
|
564
915
|
// 成功删除后行要保持隐藏直到列表刷新落地,之后再清掉乐观状态(避免行“闪回”)。
|
|
565
916
|
function settlePendingSoon() {
|
|
566
917
|
setTimeout(() => {
|
|
567
|
-
|
|
568
|
-
|
|
918
|
+
state.pending.clear()
|
|
919
|
+
publish()
|
|
569
920
|
}, 2500)
|
|
570
921
|
}
|
|
571
922
|
|
|
@@ -578,16 +929,13 @@ window.__ModuleLoader__.load({
|
|
|
578
929
|
const BATCH_CONCURRENCY = 6
|
|
579
930
|
|
|
580
931
|
function runBatchDelete() {
|
|
581
|
-
if (
|
|
582
|
-
const ids = [...selected]
|
|
932
|
+
if (state.batch !== null) return
|
|
933
|
+
const ids = [...state.selected]
|
|
583
934
|
if (ids.length === 0) return
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
selected.clear()
|
|
589
|
-
for (const id of ids) pendingRemoval.add(id)
|
|
590
|
-
scheduleDecorate()
|
|
935
|
+
state.batch = { kind: 'delete', total: ids.length, done: 0, failed: 0 }
|
|
936
|
+
state.selected.clear()
|
|
937
|
+
for (const id of ids) state.pending.add(id)
|
|
938
|
+
publish()
|
|
591
939
|
updateBar()
|
|
592
940
|
|
|
593
941
|
const failedIds = []
|
|
@@ -600,13 +948,13 @@ window.__ModuleLoader__.load({
|
|
|
600
948
|
const id = ids[index]
|
|
601
949
|
try {
|
|
602
950
|
await postDelete(id)
|
|
603
|
-
|
|
951
|
+
state.batch.done += 1
|
|
604
952
|
} catch {
|
|
605
|
-
|
|
953
|
+
state.batch.failed += 1
|
|
606
954
|
failedIds.push(id)
|
|
607
|
-
|
|
955
|
+
state.pending.delete(id)
|
|
608
956
|
}
|
|
609
|
-
|
|
957
|
+
publish()
|
|
610
958
|
updateBar()
|
|
611
959
|
}
|
|
612
960
|
}
|
|
@@ -614,14 +962,17 @@ window.__ModuleLoader__.load({
|
|
|
614
962
|
for (let i = 0; i < Math.min(BATCH_CONCURRENCY, ids.length); i += 1) workers.push(worker())
|
|
615
963
|
|
|
616
964
|
Promise.all(workers).then(() => {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
965
|
+
const failed = state.batch ? state.batch.failed : 0
|
|
966
|
+
const ok = state.batch ? state.batch.done : 0
|
|
967
|
+
state.batch = null
|
|
968
|
+
if (failed === 0) {
|
|
969
|
+
toast(t('batchDone', ok))
|
|
620
970
|
exitSelection({ keepPending: true })
|
|
621
971
|
settlePendingSoon()
|
|
622
972
|
} else {
|
|
623
|
-
for (const id of failedIds) selected.add(id)
|
|
624
|
-
toast(t('batchPartial'
|
|
973
|
+
for (const id of failedIds) state.selected.add(id)
|
|
974
|
+
toast(t('batchPartial', ok, failed), true)
|
|
975
|
+
publish()
|
|
625
976
|
updateBar()
|
|
626
977
|
}
|
|
627
978
|
refreshList()
|
|
@@ -631,136 +982,975 @@ window.__ModuleLoader__.load({
|
|
|
631
982
|
})
|
|
632
983
|
}
|
|
633
984
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
if (
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
985
|
+
/**
|
|
986
|
+
* Hand the selection to dsh-session-relocate. That plugin owns the target
|
|
987
|
+
* picker and the move itself; this side runs the progress readout and the
|
|
988
|
+
* summary. A cancelled picker leaves the selection untouched and says
|
|
989
|
+
* nothing, because nothing happened.
|
|
990
|
+
*/
|
|
991
|
+
async function runBatchMove() {
|
|
992
|
+
if (state.batch !== null) return
|
|
993
|
+
const api = relocateApi()
|
|
994
|
+
if (api === null) return
|
|
995
|
+
const ids = [...state.selected]
|
|
996
|
+
if (ids.length === 0) return
|
|
997
|
+
|
|
998
|
+
state.batch = { kind: 'move', total: ids.length, done: 0, failed: 0 }
|
|
999
|
+
publish()
|
|
1000
|
+
updateBar()
|
|
1001
|
+
|
|
1002
|
+
let result
|
|
1003
|
+
try {
|
|
1004
|
+
result = await api.moveSelection(ids, {
|
|
1005
|
+
onProgress: (done, total) => {
|
|
1006
|
+
if (state.batch === null || state.batch.kind !== 'move') return
|
|
1007
|
+
if (typeof done === 'number' && Number.isFinite(done)) state.batch.done = Math.max(0, done)
|
|
1008
|
+
if (typeof total === 'number' && Number.isFinite(total)) state.batch.total = Math.max(1, total)
|
|
1009
|
+
updateBar()
|
|
1010
|
+
},
|
|
1011
|
+
})
|
|
1012
|
+
} catch (reason) {
|
|
1013
|
+
state.batch = null
|
|
1014
|
+
publish()
|
|
1015
|
+
updateBar()
|
|
1016
|
+
toast(t('failed') + String((reason && reason.message) || reason), true)
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
state.batch = null
|
|
1021
|
+
// `canceled` is the contract spelling; `cancelled` is also honoured because
|
|
1022
|
+
// the contract's first draft used it. Nothing else about the result is
|
|
1023
|
+
// guessed: a missing count is reported, never substituted.
|
|
1024
|
+
if (result && (result.canceled === true || result.cancelled === true)) {
|
|
1025
|
+
// The user backed out of the target picker; keep the selection for a retry.
|
|
1026
|
+
publish()
|
|
1027
|
+
updateBar()
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const missing = ['moved', 'failed', 'total'].filter((key) => !result || typeof result[key] !== 'number')
|
|
1032
|
+
if (missing.length > 0) {
|
|
1033
|
+
// Falling back here would turn "genuinely zero failures" and "the field
|
|
1034
|
+
// was not sent" into the same summary, so the shape is enforced instead.
|
|
1035
|
+
toast(t('moveBadResult', missing.join(', ')), true)
|
|
1036
|
+
exitSelection()
|
|
1037
|
+
refreshList()
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
toast(t('batchMoved', result.moved, result.failed), result.failed > 0)
|
|
1042
|
+
exitSelection()
|
|
1043
|
+
refreshList()
|
|
1044
|
+
if (result.failed === 0) {
|
|
1045
|
+
for (const id of ids) leaveIfCurrent(id)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// --- single delete ---------------------------------------------------------
|
|
1050
|
+
|
|
1051
|
+
function deleteNow(info) {
|
|
1052
|
+
state.pending.add(info.sessionId)
|
|
1053
|
+
publish()
|
|
1054
|
+
// The no-dialog path can only honour auto mode: ask mode needs the list,
|
|
1055
|
+
// and the menu row forces the dialog in that case. With the feature off no
|
|
1056
|
+
// quarantine field is sent at all, keeping the request identical to the
|
|
1057
|
+
// version that predates the feature.
|
|
1058
|
+
const quarantine = !artifactsOff() && artifactsAuto() ? { scope: artifactScope() } : undefined
|
|
1059
|
+
postDelete(info.sessionId, quarantine)
|
|
1060
|
+
.then((result) => {
|
|
1061
|
+
const outcome = result && result.quarantine ? result.quarantine : null
|
|
1062
|
+
if (outcome !== null && outcome.failed.length > 0) {
|
|
1063
|
+
toast(t('doneQuarantineFailed', outcome.moved.length, outcome.failed.length), true)
|
|
1064
|
+
} else if (outcome !== null && outcome.moved.length > 0) {
|
|
1065
|
+
toast(t('doneQuarantine', outcome.moved.length))
|
|
1066
|
+
} else {
|
|
1067
|
+
toast(t('done'))
|
|
1068
|
+
}
|
|
1069
|
+
refreshList()
|
|
1070
|
+
settlePendingSoon()
|
|
1071
|
+
leaveIfCurrent(info.sessionId)
|
|
1072
|
+
})
|
|
1073
|
+
.catch((reason) => {
|
|
1074
|
+
state.pending.delete(info.sessionId)
|
|
1075
|
+
publish()
|
|
1076
|
+
toast(t('failed') + String((reason && reason.message) || reason), true)
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The rows a scope selects, in list order. */
|
|
1081
|
+
function candidatePaths(plan, scope) {
|
|
1082
|
+
if (plan === null || plan === undefined) return []
|
|
1083
|
+
const rows = scope === 'all' ? [...plan.created, ...plan.modified] : [...plan.created]
|
|
1084
|
+
return rows.map((row) => row.path)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function openDeleteDialog(sessionId, title) {
|
|
1088
|
+
// With the feature off the dialog carries no artifact section at all and
|
|
1089
|
+
// the delete request stays byte-identical to the previous version.
|
|
1090
|
+
//
|
|
1091
|
+
// Auto mode sends `{ scope }` and the host derives the whole set itself, so
|
|
1092
|
+
// the scan result would never be read: scanning there only burns time before
|
|
1093
|
+
// the dialog can appear. Ask mode is the one that needs the listing.
|
|
1094
|
+
const artifacts = artifactsOff()
|
|
1095
|
+
? null
|
|
1096
|
+
: artifactsAuto()
|
|
1097
|
+
? { auto: true, loading: false, plan: null, error: null, selected: new Set() }
|
|
1098
|
+
: { auto: false, loading: true, plan: null, error: null, selected: new Set() }
|
|
1099
|
+
state.dialog = {
|
|
1100
|
+
sessionId,
|
|
1101
|
+
title: title || null,
|
|
1102
|
+
busy: false,
|
|
1103
|
+
error: null,
|
|
1104
|
+
artifacts,
|
|
1105
|
+
// The subagent sessions this conversation spawned. Filled in after the
|
|
1106
|
+
// dialog opens: the list costs one host call, and on most conversations
|
|
1107
|
+
// there is nothing to list.
|
|
1108
|
+
children: { loading: true, list: [], selected: new Set() },
|
|
1109
|
+
}
|
|
1110
|
+
publish()
|
|
1111
|
+
if (artifacts !== null && artifacts.auto !== true) loadArtifacts(sessionId)
|
|
1112
|
+
loadChildren(sessionId)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* List the subagent sessions behind this conversation. With the switch on they
|
|
1117
|
+
* are all taken with the parent; with it off every one starts ticked and the
|
|
1118
|
+
* dialog lets the user narrow the set.
|
|
1119
|
+
*/
|
|
1120
|
+
function loadChildren(sessionId) {
|
|
1121
|
+
fetch(`/dsh-delete-session/children`, {
|
|
1122
|
+
method: 'POST',
|
|
1123
|
+
headers: { 'content-type': 'application/json' },
|
|
1124
|
+
body: JSON.stringify({ sessionId }),
|
|
664
1125
|
})
|
|
665
|
-
|
|
666
|
-
|
|
1126
|
+
.then(async (res) => {
|
|
1127
|
+
let data = {}
|
|
1128
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
1129
|
+
const list = res.ok && Array.isArray(data.children) ? data.children : []
|
|
1130
|
+
const dialog = state.dialog
|
|
1131
|
+
if (dialog === null || dialog.sessionId !== sessionId) return
|
|
1132
|
+
dialog.children = {
|
|
1133
|
+
loading: false,
|
|
1134
|
+
list,
|
|
1135
|
+
selected: new Set(list.map((child) => child.id)),
|
|
1136
|
+
}
|
|
1137
|
+
publish()
|
|
1138
|
+
})
|
|
1139
|
+
.catch(() => {
|
|
1140
|
+
const dialog = state.dialog
|
|
1141
|
+
if (dialog === null || dialog.sessionId !== sessionId) return
|
|
1142
|
+
// A host that cannot list children still deletes the parent; reporting
|
|
1143
|
+
// an error here would block a delete that is otherwise fine.
|
|
1144
|
+
dialog.children = { loading: false, list: [], selected: new Set() }
|
|
1145
|
+
publish()
|
|
1146
|
+
})
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** The children this delete should take along. */
|
|
1150
|
+
function childSessionIdsFor(dialog) {
|
|
1151
|
+
if (dialog.children === undefined || dialog.children === null) return []
|
|
1152
|
+
if (syncChildren()) return dialog.children.list.map((child) => child.id)
|
|
1153
|
+
return dialog.children.list
|
|
1154
|
+
.filter((child) => dialog.children.selected.has(child.id))
|
|
1155
|
+
.map((child) => child.id)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Fetch the read-only scan and preselect the current scope's candidates. */
|
|
1159
|
+
function loadArtifacts(sessionId) {
|
|
1160
|
+
const settle = (mutate) => {
|
|
1161
|
+
const dialog = state.dialog
|
|
1162
|
+
if (dialog === null || dialog.artifacts === null || dialog.sessionId !== sessionId) return
|
|
1163
|
+
mutate(dialog.artifacts)
|
|
1164
|
+
publish()
|
|
1165
|
+
}
|
|
1166
|
+
postArtifacts(sessionId)
|
|
1167
|
+
.then((plan) => settle((artifacts) => {
|
|
1168
|
+
artifacts.loading = false
|
|
1169
|
+
artifacts.plan = plan
|
|
1170
|
+
artifacts.selected = new Set(candidatePaths(plan, artifactScope()))
|
|
1171
|
+
}))
|
|
1172
|
+
.catch((reason) => settle((artifacts) => {
|
|
1173
|
+
artifacts.loading = false
|
|
1174
|
+
artifacts.error = String((reason && reason.message) || reason)
|
|
1175
|
+
}))
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function closeDeleteDialog() {
|
|
1179
|
+
if (state.dialog === null || state.dialog.busy) return
|
|
1180
|
+
state.dialog = null
|
|
1181
|
+
publish()
|
|
667
1182
|
}
|
|
668
1183
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1184
|
+
// --- quarantine folder picker ----------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Open the folder picker at the configured root. The browser cannot hand a
|
|
1188
|
+
* page a real filesystem path, so the listing comes from the host instead.
|
|
1189
|
+
*/
|
|
1190
|
+
function openPicker() {
|
|
1191
|
+
state.picker = {
|
|
1192
|
+
loading: true,
|
|
1193
|
+
error: null,
|
|
1194
|
+
path: '',
|
|
1195
|
+
parent: null,
|
|
1196
|
+
entries: [],
|
|
1197
|
+
roots: [],
|
|
1198
|
+
truncated: false,
|
|
1199
|
+
defaultRoot: state.defaultRoot,
|
|
1200
|
+
}
|
|
1201
|
+
publish()
|
|
1202
|
+
loadPicker(quarantineRoot())
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function closePicker() {
|
|
1206
|
+
state.picker = null
|
|
1207
|
+
publish()
|
|
1208
|
+
}
|
|
673
1209
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1210
|
+
function loadPicker(target) {
|
|
1211
|
+
postBrowse(target)
|
|
1212
|
+
.then((listing) => {
|
|
1213
|
+
if (state.picker === null) return
|
|
1214
|
+
state.picker = {
|
|
1215
|
+
loading: false,
|
|
1216
|
+
error: null,
|
|
1217
|
+
path: listing.path,
|
|
1218
|
+
parent: listing.parent,
|
|
1219
|
+
entries: listing.entries,
|
|
1220
|
+
roots: listing.roots,
|
|
1221
|
+
truncated: listing.truncated,
|
|
1222
|
+
defaultRoot: listing.defaultRoot,
|
|
1223
|
+
}
|
|
1224
|
+
state.defaultRoot = listing.defaultRoot
|
|
1225
|
+
publish()
|
|
1226
|
+
})
|
|
1227
|
+
.catch((reason) => {
|
|
1228
|
+
if (state.picker === null) return
|
|
1229
|
+
state.picker = {
|
|
1230
|
+
...state.picker,
|
|
1231
|
+
loading: false,
|
|
1232
|
+
error: String((reason && reason.message) || reason),
|
|
1233
|
+
}
|
|
1234
|
+
publish()
|
|
1235
|
+
})
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Commit the directory the picker is showing. */
|
|
1239
|
+
function pickCurrent() {
|
|
1240
|
+
const picker = state.picker
|
|
1241
|
+
if (picker === null || picker.loading || picker.path === '') return
|
|
1242
|
+
setQuarantineRoot(picker.path)
|
|
1243
|
+
closePicker()
|
|
1244
|
+
toast(t('rootSet'))
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Prefer the OS folder dialog, which is the one the user actually expects.
|
|
1249
|
+
* It only exists on Windows; everywhere else the in-page picker takes over.
|
|
1250
|
+
*/
|
|
1251
|
+
let folderDialogOpen = false
|
|
1252
|
+
function chooseFolder() {
|
|
1253
|
+
if (folderDialogOpen) return
|
|
1254
|
+
folderDialogOpen = true
|
|
1255
|
+
// The dialog can open behind the browser window, so say it was raised.
|
|
1256
|
+
toast(t('pickerOpening'))
|
|
1257
|
+
postPick()
|
|
1258
|
+
.then((picked) => {
|
|
1259
|
+
if (picked.cancelled === true) return
|
|
1260
|
+
setQuarantineRoot(picked.path)
|
|
1261
|
+
toast(t('rootSet'))
|
|
1262
|
+
})
|
|
1263
|
+
.catch((reason) => {
|
|
1264
|
+
if (reason && reason.code === 'unsupported') {
|
|
1265
|
+
openPicker()
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
toast(t('failed') + String((reason && reason.message) || reason), true)
|
|
1269
|
+
})
|
|
1270
|
+
.finally(() => {
|
|
1271
|
+
folderDialogOpen = false
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Learn the host default once, so the settings card can name it. */
|
|
1276
|
+
function loadDefaultRoot() {
|
|
1277
|
+
if (state.defaultRoot !== null) return
|
|
1278
|
+
postBrowse('')
|
|
1279
|
+
.then((listing) => {
|
|
1280
|
+
state.defaultRoot = listing.defaultRoot
|
|
1281
|
+
publish()
|
|
1282
|
+
})
|
|
1283
|
+
.catch(() => {
|
|
1284
|
+
// the card falls back to a generic label; nothing to report here
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Build the quarantine request for the open dialog. The host re-derives the
|
|
1290
|
+
* candidate set and intersects it with `paths`, so this can only narrow what
|
|
1291
|
+
* the user was shown - never widen it.
|
|
1292
|
+
*/
|
|
1293
|
+
function quarantineRequest(dialog) {
|
|
1294
|
+
if (dialog.artifacts === null) return undefined
|
|
1295
|
+
const scope = artifactScope()
|
|
1296
|
+
const skipGit = skipGitRepos()
|
|
1297
|
+
// In auto mode no `paths` is sent: the host takes the whole set it derives.
|
|
1298
|
+
if (artifactsAuto()) return { scope, skipGit }
|
|
1299
|
+
const allowed = new Set(candidatePaths(dialog.artifacts.plan, scope))
|
|
1300
|
+
const paths = [...dialog.artifacts.selected].filter((path) => allowed.has(path))
|
|
1301
|
+
return { scope, paths, skipGit }
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// The "don't ask again" flag is committed only when the user actually
|
|
1305
|
+
// confirms, exactly as before - ticking the box and then cancelling must not
|
|
1306
|
+
// change the preference.
|
|
1307
|
+
function confirmDelete() {
|
|
1308
|
+
const dialog = state.dialog
|
|
1309
|
+
if (dialog === null || dialog.busy) return
|
|
1310
|
+
const quarantine = quarantineRequest(dialog)
|
|
1311
|
+
const childSessionIds = childSessionIdsFor(dialog)
|
|
1312
|
+
dialog.busy = true
|
|
1313
|
+
dialog.error = null
|
|
1314
|
+
state.pending.add(dialog.sessionId)
|
|
1315
|
+
publish()
|
|
1316
|
+
postDelete(dialog.sessionId, quarantine, childSessionIds)
|
|
1317
|
+
.then((result) => {
|
|
1318
|
+
state.dialog = null
|
|
1319
|
+
const outcome = result && result.quarantine ? result.quarantine : null
|
|
1320
|
+
if (outcome !== null && outcome.failed.length > 0) {
|
|
1321
|
+
toast(t('doneQuarantineFailed', outcome.moved.length, outcome.failed.length), true)
|
|
1322
|
+
} else if (outcome !== null && outcome.moved.length > 0) {
|
|
1323
|
+
toast(t('doneQuarantine', outcome.moved.length))
|
|
1324
|
+
} else {
|
|
1325
|
+
toast(t('done'))
|
|
1326
|
+
}
|
|
1327
|
+
refreshList()
|
|
1328
|
+
settlePendingSoon()
|
|
1329
|
+
leaveIfCurrent(dialog.sessionId)
|
|
1330
|
+
publish()
|
|
1331
|
+
})
|
|
1332
|
+
.catch((reason) => {
|
|
1333
|
+
state.pending.delete(dialog.sessionId)
|
|
1334
|
+
dialog.busy = false
|
|
1335
|
+
dialog.error = t('failed') + String((reason && reason.message) || reason)
|
|
1336
|
+
publish()
|
|
1337
|
+
})
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// --- menu rows (slot: sidebar.workspaces.session.menu.item) ----------------
|
|
1341
|
+
|
|
1342
|
+
/** Inline glyphs for the two rows; the host exposes no matching pair. */
|
|
1343
|
+
const TRASH_PATHS = ['M4 7h16', 'M10 11v6', 'M14 11v6', 'M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13', 'M9 7V4h6v3']
|
|
1344
|
+
const SELECT_PATHS = ['M5 7m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0', 'M5 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0', 'M11 7h9', 'M11 17h9']
|
|
1345
|
+
|
|
1346
|
+
function glyph(paths) {
|
|
1347
|
+
return h(
|
|
1348
|
+
'svg',
|
|
1349
|
+
{
|
|
1350
|
+
width: 16,
|
|
1351
|
+
height: 16,
|
|
1352
|
+
viewBox: '0 0 24 24',
|
|
1353
|
+
fill: 'none',
|
|
1354
|
+
stroke: 'currentColor',
|
|
1355
|
+
strokeWidth: 1.8,
|
|
1356
|
+
strokeLinecap: 'round',
|
|
1357
|
+
strokeLinejoin: 'round',
|
|
1358
|
+
},
|
|
1359
|
+
paths.map((d) => h('path', { key: d, d })),
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ui-primitives' `MenuItemButton` takes a real `danger` parameter (verified
|
|
1364
|
+
// against the shipped component), which puts the host's own danger class on
|
|
1365
|
+
// the button. Passing it is what keeps the delete row in the host's danger
|
|
1366
|
+
// colour - icon included - rather than a plugin guess.
|
|
1367
|
+
//
|
|
1368
|
+
// No `separatorBefore`: this migration moves the rows onto the host's slot
|
|
1369
|
+
// contract and must not change how the menu looks.
|
|
1370
|
+
|
|
1371
|
+
/** "Select multiple" row (order 409, directly above the delete row). */
|
|
1372
|
+
function SelectMultipleMenuItem({ useMenuOpenState, t: translate }) {
|
|
1373
|
+
const [, setMenuOpen] = useMenuOpenState()
|
|
1374
|
+
return h(primitives.MenuItemButton, {
|
|
1375
|
+
icon: glyph(SELECT_PATHS),
|
|
1376
|
+
onSelect: () => {
|
|
1377
|
+
setMenuOpen(false)
|
|
1378
|
+
enterSelection()
|
|
1379
|
+
},
|
|
1380
|
+
children: translate('multiSelect'),
|
|
678
1381
|
})
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** "Delete session" row (order 410, in the host's danger colour). */
|
|
1385
|
+
function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, t: translate }) {
|
|
1386
|
+
const [, setMenuOpen] = useMenuOpenState()
|
|
1387
|
+
return h(primitives.MenuItemButton, {
|
|
1388
|
+
danger: true,
|
|
1389
|
+
icon: glyph(TRASH_PATHS),
|
|
1390
|
+
onSelect: (event) => {
|
|
1391
|
+
setMenuOpen(false)
|
|
1392
|
+
// Ask mode keeps the dialog even when "don't ask again" is ticked: the
|
|
1393
|
+
// file list only exists there, and skipping it would make the feature
|
|
1394
|
+
// silently do nothing.
|
|
1395
|
+
const needsDialog = !artifactsOff() && !artifactsAuto()
|
|
1396
|
+
// Shift-click always asks, even after "don't ask again" was ticked.
|
|
1397
|
+
if (skipConfirm() && !needsDialog && !(event && event.shiftKey)) deleteNow({ sessionId })
|
|
1398
|
+
else openDeleteDialog(sessionId, displayTitle)
|
|
1399
|
+
},
|
|
1400
|
+
children: translate('menu'),
|
|
1401
|
+
})
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// --- confirmation dialog (slot: shell.overlay) -----------------------------
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The produced-files section: the scan summary, then either the tick list
|
|
1408
|
+
* (ask mode) or a single line stating how many files will move (auto mode).
|
|
1409
|
+
* A null `artifacts` means the feature is off and the dialog says nothing.
|
|
1410
|
+
*/
|
|
1411
|
+
/**
|
|
1412
|
+
* The subagent sessions behind this conversation. Hidden while the switch
|
|
1413
|
+
* already answers the question, and while the list is still loading.
|
|
1414
|
+
*/
|
|
1415
|
+
function renderChildren(dialog, translate) {
|
|
1416
|
+
const children = dialog.children
|
|
1417
|
+
if (children === undefined || children === null) return null
|
|
1418
|
+
if (children.loading) return null
|
|
1419
|
+
if (children.list.length === 0) return null
|
|
1420
|
+
if (syncChildren()) return null
|
|
1421
|
+
return h('div', { key: 'children', className: 'dsdel-modal-children' },
|
|
1422
|
+
h('p', { key: 'hint', className: 'dsdel-modal-text' },
|
|
1423
|
+
translate('childrenHint', children.list.length)),
|
|
1424
|
+
...children.list.map((child) => h(primitives.Checkbox, {
|
|
1425
|
+
key: `child:${child.id}`,
|
|
1426
|
+
checked: children.selected.has(child.id),
|
|
1427
|
+
label: child.cwd ? `${child.id} · ${child.cwd}` : child.id,
|
|
1428
|
+
onChange: (checked) => {
|
|
1429
|
+
if (checked === true) children.selected.add(child.id)
|
|
1430
|
+
else children.selected.delete(child.id)
|
|
1431
|
+
publish()
|
|
1432
|
+
},
|
|
1433
|
+
})),
|
|
1434
|
+
)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function renderArtifacts(artifacts, translate) {
|
|
1438
|
+
if (artifacts === null) return null
|
|
1439
|
+
// Auto mode never scanned, so there is no plan to describe: name the
|
|
1440
|
+
// outcome instead of showing a list nobody asked for.
|
|
1441
|
+
if (artifacts.auto === true) {
|
|
1442
|
+
return h('div', { key: 'artifacts' }, [
|
|
1443
|
+
h('p', { key: 'head', className: 'dsdel-modal-text', style: { marginTop: '12px', fontWeight: 600 } },
|
|
1444
|
+
translate('artifactSection')),
|
|
1445
|
+
h('p', { key: 'auto', className: 'dsdel-modal-text' }, translate('artifactAutoOnly')),
|
|
1446
|
+
])
|
|
1447
|
+
}
|
|
1448
|
+
if (artifacts.loading) {
|
|
1449
|
+
return h('p', { key: 'artifacts', className: 'dsdel-modal-meta', style: { marginTop: '12px' } },
|
|
1450
|
+
translate('artifactLoading'))
|
|
682
1451
|
}
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
1452
|
+
if (artifacts.error !== null) {
|
|
1453
|
+
return h('p', { key: 'artifacts', className: 'dsdel-modal-error', role: 'alert' },
|
|
1454
|
+
translate('artifactFailed') + artifacts.error)
|
|
1455
|
+
}
|
|
1456
|
+
const plan = artifacts.plan
|
|
1457
|
+
if (plan === null) return null
|
|
1458
|
+
const scope = artifactScope()
|
|
1459
|
+
const auto = artifactsAuto()
|
|
1460
|
+
const rows = scope === 'all' ? [...plan.created, ...plan.modified] : [...plan.created]
|
|
1461
|
+
|
|
1462
|
+
const elements = [
|
|
1463
|
+
h('p', { key: 'head', className: 'dsdel-modal-text', style: { marginTop: '12px', fontWeight: 600 } },
|
|
1464
|
+
translate('artifactSection')),
|
|
1465
|
+
h('p', { key: 'count', className: 'dsdel-modal-meta' },
|
|
1466
|
+
`${translate('artifactCount', plan.created.length, plan.modified.length)} · ${translate(scope === 'all' ? 'artifactScopeAll' : 'artifactScopeCreated')}`),
|
|
1467
|
+
]
|
|
1468
|
+
|
|
1469
|
+
if (rows.length === 0) {
|
|
1470
|
+
elements.push(h('p', { key: 'none', className: 'dsdel-modal-meta' }, translate('artifactNone')))
|
|
1471
|
+
} else if (auto) {
|
|
1472
|
+
elements.push(h('p', { key: 'auto', className: 'dsdel-modal-text' }, translate('artifactAutoNote', rows.length)))
|
|
1473
|
+
} else {
|
|
1474
|
+
elements.push(h('p', { key: 'selected', className: 'dsdel-modal-meta' },
|
|
1475
|
+
translate('artifactSelected', artifacts.selected.size)))
|
|
1476
|
+
for (const row of rows.slice(0, ARTIFACT_ROWS_SHOWN)) {
|
|
1477
|
+
elements.push(h('label', {
|
|
1478
|
+
key: `row:${row.path}`,
|
|
1479
|
+
className: 'dsdel-modal-text',
|
|
1480
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0', cursor: 'pointer' },
|
|
1481
|
+
},
|
|
1482
|
+
h('input', {
|
|
1483
|
+
type: 'checkbox',
|
|
1484
|
+
checked: artifacts.selected.has(row.path),
|
|
1485
|
+
onChange: (e) => {
|
|
1486
|
+
if (e && e.target && e.target.checked) artifacts.selected.add(row.path)
|
|
1487
|
+
else artifacts.selected.delete(row.path)
|
|
1488
|
+
publish()
|
|
1489
|
+
},
|
|
1490
|
+
}),
|
|
1491
|
+
h('span', { style: { wordBreak: 'break-all' } }, row.displayPath || row.path),
|
|
1492
|
+
))
|
|
1493
|
+
}
|
|
1494
|
+
if (rows.length > ARTIFACT_ROWS_SHOWN) {
|
|
1495
|
+
elements.push(h('p', { key: 'more', className: 'dsdel-modal-meta' },
|
|
1496
|
+
translate('artifactMore', rows.length - ARTIFACT_ROWS_SHOWN)))
|
|
1497
|
+
}
|
|
686
1498
|
}
|
|
687
1499
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1500
|
+
if (plan.refused.length > 0) {
|
|
1501
|
+
elements.push(h('p', { key: 'refused', className: 'dsdel-modal-meta' },
|
|
1502
|
+
translate('artifactRefused', plan.refused.length)))
|
|
1503
|
+
}
|
|
1504
|
+
if (plan.truncated) {
|
|
1505
|
+
elements.push(h('p', { key: 'truncated', className: 'dsdel-modal-meta' }, translate('artifactTruncated')))
|
|
1506
|
+
}
|
|
1507
|
+
return h('div', { key: 'artifacts' }, elements)
|
|
691
1508
|
}
|
|
692
1509
|
|
|
693
|
-
|
|
1510
|
+
/** Folder picker overlay: walk directories, then commit one. */
|
|
1511
|
+
function QuarantinePicker({ t: injected }) {
|
|
1512
|
+
const translate = localizedTranslator(injected)
|
|
1513
|
+
const view = useSnapshot()
|
|
1514
|
+
const picker = view.picker
|
|
1515
|
+
if (picker === null) return null
|
|
1516
|
+
|
|
1517
|
+
const children = []
|
|
1518
|
+
if (picker.loading) {
|
|
1519
|
+
children.push(h('p', { key: 'loading', className: 'dsdel-modal-meta' }, translate('pickerLoading')))
|
|
1520
|
+
} else if (picker.error !== null) {
|
|
1521
|
+
children.push(h('p', { key: 'error', className: 'dsdel-modal-error', role: 'alert' }, picker.error))
|
|
1522
|
+
} else {
|
|
1523
|
+
children.push(h('p', { key: 'path', className: 'dsdel-picker-path' }, picker.path))
|
|
1524
|
+
|
|
1525
|
+
const up = []
|
|
1526
|
+
if (picker.parent !== null) {
|
|
1527
|
+
up.push(h('button', {
|
|
1528
|
+
key: 'up',
|
|
1529
|
+
type: 'button',
|
|
1530
|
+
className: 'dsdel-picker-row',
|
|
1531
|
+
onClick: () => loadPicker(picker.parent),
|
|
1532
|
+
}, '..'))
|
|
1533
|
+
}
|
|
1534
|
+
for (const drive of picker.roots) {
|
|
1535
|
+
up.push(h('button', {
|
|
1536
|
+
key: `drive:${drive.path}`,
|
|
1537
|
+
type: 'button',
|
|
1538
|
+
className: 'dsdel-picker-row',
|
|
1539
|
+
onClick: () => loadPicker(drive.path),
|
|
1540
|
+
}, drive.name))
|
|
1541
|
+
}
|
|
1542
|
+
if (up.length > 0) children.push(h('div', { key: 'up', className: 'dsdel-picker-list' }, up))
|
|
1543
|
+
|
|
1544
|
+
if (picker.entries.length === 0) {
|
|
1545
|
+
children.push(h('p', { key: 'empty', className: 'dsdel-modal-meta' }, translate('pickerEmpty')))
|
|
1546
|
+
} else {
|
|
1547
|
+
children.push(h('div', { key: 'list', className: 'dsdel-picker-list' },
|
|
1548
|
+
picker.entries.map((entry) => h('button', {
|
|
1549
|
+
key: entry.path,
|
|
1550
|
+
type: 'button',
|
|
1551
|
+
className: 'dsdel-picker-row',
|
|
1552
|
+
onClick: () => loadPicker(entry.path),
|
|
1553
|
+
}, `${entry.name}/`))))
|
|
1554
|
+
}
|
|
1555
|
+
if (picker.truncated) {
|
|
1556
|
+
children.push(h('p', { key: 'truncated', className: 'dsdel-modal-meta' }, translate('pickerTruncated')))
|
|
1557
|
+
}
|
|
1558
|
+
children.push(h('p', { key: 'default', className: 'dsdel-modal-meta' },
|
|
1559
|
+
translate('pickerDefault', picker.defaultRoot || '-')))
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
return h(primitives.Modal, {
|
|
1563
|
+
open: true,
|
|
1564
|
+
onClose: () => closePicker(),
|
|
1565
|
+
closeLabel: translate('cancel'),
|
|
1566
|
+
title: translate('pickerTitle'),
|
|
1567
|
+
footer: [
|
|
1568
|
+
h(primitives.Button, {
|
|
1569
|
+
key: 'cancel',
|
|
1570
|
+
variant: 'outline',
|
|
1571
|
+
onClick: () => closePicker(),
|
|
1572
|
+
children: translate('cancel'),
|
|
1573
|
+
}),
|
|
1574
|
+
h(primitives.Button, {
|
|
1575
|
+
key: 'choose',
|
|
1576
|
+
disabled: picker.loading || picker.path === '',
|
|
1577
|
+
onClick: () => pickCurrent(),
|
|
1578
|
+
children: translate('pickerChoose'),
|
|
1579
|
+
}),
|
|
1580
|
+
],
|
|
1581
|
+
children,
|
|
1582
|
+
})
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function DeleteSessionDialog({ t: injected }) {
|
|
1586
|
+
const translate = localizedTranslator(injected)
|
|
1587
|
+
const view = useSnapshot()
|
|
1588
|
+
const dialog = view.dialog
|
|
1589
|
+
if (dialog === null) return null
|
|
1590
|
+
|
|
1591
|
+
const footer = h(
|
|
1592
|
+
Fragment,
|
|
1593
|
+
null,
|
|
1594
|
+
h(primitives.Button, {
|
|
1595
|
+
variant: 'outline',
|
|
1596
|
+
disabled: dialog.busy,
|
|
1597
|
+
onClick: () => closeDeleteDialog(),
|
|
1598
|
+
children: translate('cancel'),
|
|
1599
|
+
}),
|
|
1600
|
+
h(primitives.Button, {
|
|
1601
|
+
variant: 'outline',
|
|
1602
|
+
className: 'dsdel-danger-btn',
|
|
1603
|
+
disabled: dialog.busy,
|
|
1604
|
+
onClick: () => confirmDelete(),
|
|
1605
|
+
children: dialog.busy ? translate('deleting') : translate('confirm'),
|
|
1606
|
+
}),
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
return h(primitives.Modal, {
|
|
1610
|
+
open: true,
|
|
1611
|
+
// The produced-file list needs more room than the default dialog gives.
|
|
1612
|
+
// `Modal` does not document a width prop, so the class, the inline style
|
|
1613
|
+
// and the `:has()` rule in the stylesheet all try, and whichever the host
|
|
1614
|
+
// honours wins.
|
|
1615
|
+
className: 'dsdel-dialog-wide',
|
|
1616
|
+
style: { width: 'min(94vw, 980px)', maxWidth: 'min(94vw, 980px)' },
|
|
1617
|
+
onClose: () => closeDeleteDialog(),
|
|
1618
|
+
closeLabel: translate('cancel'),
|
|
1619
|
+
title: translate('title'),
|
|
1620
|
+
footer,
|
|
1621
|
+
children: [
|
|
1622
|
+
h('p', { key: 'meta', className: 'dsdel-modal-meta' },
|
|
1623
|
+
`${dialog.title || translate('untitled')} · ${dialog.sessionId}`),
|
|
1624
|
+
// The sentence has to match the switch: one promises the Recycle Bin,
|
|
1625
|
+
// the other promises exactly what an unchecked box means.
|
|
1626
|
+
h('p', { key: 'desc', className: 'dsdel-modal-text' },
|
|
1627
|
+
translate(recycleSession() ? 'descRecycle' : 'descPermanent')),
|
|
1628
|
+
renderArtifacts(dialog.artifacts, translate),
|
|
1629
|
+
renderChildren(dialog, translate),
|
|
1630
|
+
// Whether a delete asks first is a setting, not a dialog affordance:
|
|
1631
|
+
// it lives on the plugin page only, so this dialog never changes it.
|
|
1632
|
+
dialog.error !== null
|
|
1633
|
+
? h('p', { key: 'error', className: 'dsdel-modal-error', role: 'alert' }, dialog.error)
|
|
1634
|
+
: null,
|
|
1635
|
+
],
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// --- plugin page card (slot: plugins.row.config) ---------------------------
|
|
694
1640
|
|
|
695
1641
|
// The manager's detail page for this bundle gives the component row a
|
|
696
1642
|
// configure arrow once the bundle registers its own row.config entry (key
|
|
697
1643
|
// `<bundle package>#<row id>`). The card carries the one setting the dialog
|
|
698
1644
|
// also exposes: whether a session delete asks first.
|
|
699
|
-
function AskBeforeDeleteOption({ view }) {
|
|
1645
|
+
function AskBeforeDeleteOption({ view, t: injected }) {
|
|
1646
|
+
// Unlike the menu rows and the dialog, this entry is registered without a
|
|
1647
|
+
// `locale`, so the host injects no `t` here. Falling back to the
|
|
1648
|
+
// module-level translator (same dictionaries) keeps the card working
|
|
1649
|
+
// instead of throwing on an undefined call.
|
|
1650
|
+
const translate = localizedTranslator(injected)
|
|
1651
|
+
// `view` is already the prop name (page or summary), so the store snapshot
|
|
1652
|
+
// needs its own binding here.
|
|
1653
|
+
const pageSnapshot = useSnapshot()
|
|
1654
|
+
// The host owns the default path, so it is fetched once per mount.
|
|
1655
|
+
const defaultRootValue = () => (pageSnapshot && pageSnapshot.defaultRoot) || ''
|
|
700
1656
|
const [ask, setAsk] = react.useState(!skipConfirm())
|
|
1657
|
+
const [recycle, setRecycle] = react.useState(recycleSession())
|
|
1658
|
+
const [syncChildrenState, setSyncChildrenState] = react.useState(syncChildren())
|
|
1659
|
+
const [off, setOff] = react.useState(artifactsOff())
|
|
1660
|
+
const [auto, setAuto] = react.useState(artifactsAuto())
|
|
1661
|
+
const [scope, setScope] = react.useState(artifactScope())
|
|
1662
|
+
const [skipGit, setSkipGit] = react.useState(skipGitRepos())
|
|
1663
|
+
const [root, setRoot] = react.useState(quarantineRoot())
|
|
701
1664
|
react.useEffect(() => subscribeSkip(() => setAsk(!skipConfirm())), [])
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1665
|
+
react.useEffect(() => { loadDefaultRoot() }, [])
|
|
1666
|
+
react.useEffect(() => subscribePrefs(() => {
|
|
1667
|
+
setOff(artifactsOff())
|
|
1668
|
+
setAuto(artifactsAuto())
|
|
1669
|
+
setScope(artifactScope())
|
|
1670
|
+
setRoot(quarantineRoot())
|
|
1671
|
+
}), [])
|
|
1672
|
+
|
|
1673
|
+
if (view === 'summary') {
|
|
1674
|
+
if (off) return translate('pageSummaryArtifactsOff')
|
|
1675
|
+
return translate(auto ? 'pageSummaryArtifactsAuto' : 'pageSummaryArtifactsAsk')
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const children = [
|
|
1679
|
+
h(primitives.Checkbox, {
|
|
1680
|
+
key: 'ask',
|
|
707
1681
|
checked: ask,
|
|
708
|
-
label:
|
|
1682
|
+
label: translate('pageAsk'),
|
|
709
1683
|
onChange: (checked) => {
|
|
710
1684
|
setAsk(checked === true)
|
|
711
1685
|
setSkipConfirm(checked !== true)
|
|
712
1686
|
},
|
|
713
1687
|
}),
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1688
|
+
h('p', { key: 'askHint', className: 'dsdel-page-hint' },
|
|
1689
|
+
// Ask mode needs the dialog for its file list, so "delete straight away"
|
|
1690
|
+
// would be a promise this combination does not keep.
|
|
1691
|
+
translate(ask ? 'pageHintAsk' : (!off && !auto ? 'pageHintAutoWithArtifacts' : 'pageHintAuto'))),
|
|
1692
|
+
|
|
1693
|
+
h(primitives.Checkbox, {
|
|
1694
|
+
key: 'recycle',
|
|
1695
|
+
checked: recycle,
|
|
1696
|
+
label: translate('pageRecycle'),
|
|
1697
|
+
onChange: (checked) => {
|
|
1698
|
+
setRecycle(checked === true)
|
|
1699
|
+
setRecycleSession(checked === true)
|
|
1700
|
+
},
|
|
1701
|
+
}),
|
|
1702
|
+
h('p', { key: 'recycleHint', className: 'dsdel-page-hint' },
|
|
1703
|
+
translate(recycle ? 'pageRecycleOn' : 'pageRecycleOff')),
|
|
717
1704
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1705
|
+
h(primitives.Checkbox, {
|
|
1706
|
+
key: 'syncChildren',
|
|
1707
|
+
checked: syncChildrenState,
|
|
1708
|
+
label: translate('pageSyncChildren'),
|
|
1709
|
+
onChange: (checked) => {
|
|
1710
|
+
setSyncChildrenState(checked === true)
|
|
1711
|
+
setSyncChildren(checked === true)
|
|
1712
|
+
},
|
|
1713
|
+
}),
|
|
1714
|
+
h('p', { key: 'syncChildrenHint', className: 'dsdel-page-hint' },
|
|
1715
|
+
translate(syncChildrenState ? 'pageSyncChildrenOn' : 'pageSyncChildrenOff')),
|
|
726
1716
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1717
|
+
h(primitives.Checkbox, {
|
|
1718
|
+
key: 'off',
|
|
1719
|
+
checked: off,
|
|
1720
|
+
label: translate('pageArtifactsOff'),
|
|
1721
|
+
onChange: (checked) => {
|
|
1722
|
+
setOff(checked === true)
|
|
1723
|
+
setArtifactsOff(checked === true)
|
|
1724
|
+
},
|
|
1725
|
+
}),
|
|
1726
|
+
h('p', { key: 'offHint', className: 'dsdel-page-hint' }, translate(off ? 'pageHintArtifactsOff' : 'pageHintArtifactsOn')),
|
|
1727
|
+
]
|
|
1728
|
+
|
|
1729
|
+
// Everything below the master switch only matters while the feature is on;
|
|
1730
|
+
// leaving it rendered-but-inert would suggest a setting that does nothing.
|
|
1731
|
+
// The layout is inline rather than a class: a stylesheet injected into the
|
|
1732
|
+
// host page has already proven able to lag behind a rebuilt module, and a
|
|
1733
|
+
// group whose indentation depends on it can silently collapse.
|
|
1734
|
+
if (!off) {
|
|
1735
|
+
children.push(
|
|
1736
|
+
h('div', {
|
|
1737
|
+
key: 'artifacts-sub',
|
|
1738
|
+
style: {
|
|
1739
|
+
display: 'flex',
|
|
1740
|
+
flexDirection: 'column',
|
|
1741
|
+
gap: '8px',
|
|
1742
|
+
marginLeft: '10px',
|
|
1743
|
+
paddingLeft: '14px',
|
|
1744
|
+
borderLeft: '2px solid rgba(127,127,127,0.35)',
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
h(primitives.Checkbox, {
|
|
1748
|
+
key: 'auto',
|
|
1749
|
+
checked: auto,
|
|
1750
|
+
label: translate('pageArtifactsAuto'),
|
|
1751
|
+
onChange: (checked) => {
|
|
1752
|
+
setAuto(checked === true)
|
|
1753
|
+
setArtifactsAuto(checked === true)
|
|
1754
|
+
},
|
|
1755
|
+
}),
|
|
1756
|
+
h('p', { key: 'autoHint', className: 'dsdel-page-hint' }, translate(auto ? 'pageHintArtifactsAuto' : 'pageHintArtifactsAsk')),
|
|
1757
|
+
h('div', {
|
|
1758
|
+
key: 'scope',
|
|
1759
|
+
style: {
|
|
1760
|
+
display: 'flex',
|
|
1761
|
+
flexDirection: 'column',
|
|
1762
|
+
gap: '8px',
|
|
1763
|
+
marginLeft: '2px',
|
|
1764
|
+
paddingLeft: '14px',
|
|
1765
|
+
borderLeft: '2px solid rgba(127,127,127,0.2)',
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
h(primitives.Checkbox, {
|
|
1769
|
+
key: 'scopeCreated',
|
|
1770
|
+
checked: scope === 'created',
|
|
1771
|
+
label: translate('pageScopeCreated'),
|
|
1772
|
+
onChange: (checked) => {
|
|
1773
|
+
if (checked !== true) return
|
|
1774
|
+
setScope('created')
|
|
1775
|
+
setArtifactScope('created')
|
|
1776
|
+
},
|
|
1777
|
+
}),
|
|
1778
|
+
h(primitives.Checkbox, {
|
|
1779
|
+
key: 'scopeAll',
|
|
1780
|
+
checked: scope === 'all',
|
|
1781
|
+
label: translate('pageScopeAll'),
|
|
1782
|
+
onChange: (checked) => {
|
|
1783
|
+
if (checked !== true) return
|
|
1784
|
+
setScope('all')
|
|
1785
|
+
setArtifactScope('all')
|
|
1786
|
+
},
|
|
1787
|
+
}),
|
|
1788
|
+
),
|
|
1789
|
+
h('p', { key: 'scopeHint', className: 'dsdel-page-hint' }, translate(scope === 'all' ? 'pageScopeAllHint' : 'pageScopeCreatedHint')),
|
|
1790
|
+
h(primitives.Checkbox, {
|
|
1791
|
+
key: 'skipGit',
|
|
1792
|
+
checked: skipGit,
|
|
1793
|
+
label: translate('pageSkipGit'),
|
|
1794
|
+
onChange: (checked) => {
|
|
1795
|
+
setSkipGit(checked === true)
|
|
1796
|
+
setSkipGitRepos(checked === true)
|
|
1797
|
+
},
|
|
1798
|
+
}),
|
|
1799
|
+
h('p', { key: 'skipGitHint', className: 'dsdel-page-hint' },
|
|
1800
|
+
translate(skipGit ? 'pageSkipGitOn' : 'pageSkipGitOff')),
|
|
1801
|
+
h('div', { key: 'root', style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '2px' } },
|
|
1802
|
+
h('p', { key: 'rootLabel', className: 'dsdel-page-hint' }, translate('pageRootLabel')),
|
|
1803
|
+
h('div', { key: 'rootRow', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
1804
|
+
h('input', {
|
|
1805
|
+
key: 'rootInput',
|
|
1806
|
+
type: 'text',
|
|
1807
|
+
className: 'dsdel-root-input',
|
|
1808
|
+
value: root,
|
|
1809
|
+
placeholder: defaultRootValue() || translate('pageRootPlaceholder'),
|
|
1810
|
+
spellCheck: false,
|
|
1811
|
+
onChange: (event) => {
|
|
1812
|
+
setQuarantineRoot((event && event.target && event.target.value) || '')
|
|
1813
|
+
},
|
|
1814
|
+
}),
|
|
1815
|
+
h(primitives.Button, {
|
|
1816
|
+
key: 'browse',
|
|
1817
|
+
variant: 'outline',
|
|
1818
|
+
onClick: () => chooseFolder(),
|
|
1819
|
+
children: translate('pageRootBrowse'),
|
|
1820
|
+
}),
|
|
1821
|
+
h(primitives.Button, {
|
|
1822
|
+
key: 'open',
|
|
1823
|
+
variant: 'outline',
|
|
1824
|
+
onClick: () => openQuarantineRoot(root === '' ? defaultRootValue() : root),
|
|
1825
|
+
children: translate('pageRootOpen'),
|
|
1826
|
+
}),
|
|
1827
|
+
h(primitives.Button, {
|
|
1828
|
+
key: 'reset',
|
|
1829
|
+
variant: 'outline',
|
|
1830
|
+
disabled: root === '',
|
|
1831
|
+
onClick: () => setQuarantineRoot(''),
|
|
1832
|
+
children: translate('pageRootReset'),
|
|
1833
|
+
}),
|
|
1834
|
+
),
|
|
1835
|
+
h('p', { key: 'rootHint', className: 'dsdel-page-hint' },
|
|
1836
|
+
root === ''
|
|
1837
|
+
? translate('pageRootDefaultHint', defaultRootValue() || '-')
|
|
1838
|
+
: translate('pageRootCustomHint')),
|
|
1839
|
+
),
|
|
1840
|
+
),
|
|
1841
|
+
)
|
|
741
1842
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1843
|
+
|
|
1844
|
+
// A quiet link to the repository, last in the card. It is a nudge, not a
|
|
1845
|
+
// setting, so it never competes with the controls above it — and it is a
|
|
1846
|
+
// plain anchor, so clicking it opens the page instead of switching tabs here.
|
|
1847
|
+
children.push(h('div', { key: 'star', className: 'dsdel-star' },
|
|
1848
|
+
h('span', { key: 'ask' }, translate('starAsk')),
|
|
1849
|
+
h('a', {
|
|
1850
|
+
key: 'cta',
|
|
1851
|
+
className: 'dsdel-star-link',
|
|
1852
|
+
href: 'https://github.com/DDDMUC/dsh-delete-session',
|
|
1853
|
+
target: '_blank',
|
|
1854
|
+
rel: 'noreferrer',
|
|
1855
|
+
}, translate('starCta'))))
|
|
1856
|
+
|
|
1857
|
+
return h('div', { className: 'dsdel-page' }, children)
|
|
746
1858
|
}
|
|
747
1859
|
|
|
1860
|
+
// --- plugin ------------------------------------------------------------------
|
|
1861
|
+
|
|
748
1862
|
function apply(ctx) {
|
|
1863
|
+
ensureStyle()
|
|
1864
|
+
// Capability bits other plugins read. Announced before any slot registers,
|
|
1865
|
+
// so a relocate plugin that boots after this one finds it in place; the
|
|
1866
|
+
// event covers the reverse order.
|
|
1867
|
+
if (typeof window !== 'undefined') {
|
|
1868
|
+
window.__DSH_DELETE_SESSION__ = { supportsBulkMove: true }
|
|
1869
|
+
try {
|
|
1870
|
+
if (typeof window.dispatchEvent === 'function' && typeof window.Event === 'function') {
|
|
1871
|
+
window.dispatchEvent(new window.Event('dsh-session-delete:ready'))
|
|
1872
|
+
}
|
|
1873
|
+
} catch {
|
|
1874
|
+
// a host without DOM events still reads the global
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
749
1878
|
sessionsSvc = ctx.get('sessions') || null
|
|
750
1879
|
if (!sessionsSvc) {
|
|
751
1880
|
try { ctx.inject(['sessions'], (sub) => { sessionsSvc = sub.sessions }) } catch { /* optional */ }
|
|
752
1881
|
}
|
|
753
1882
|
localeSvc = ctx.get('locale') || null
|
|
754
|
-
|
|
755
|
-
|
|
1883
|
+
|
|
1884
|
+
// Slot components are handed a `t` bound to this namespace, so the
|
|
1885
|
+
// dictionaries must reach the host - on BOTH arrival paths. If `locale`
|
|
1886
|
+
// only turns up through `ctx.inject`, a registration living on the
|
|
1887
|
+
// synchronous branch would never run and the menu would fall back to
|
|
1888
|
+
// printing raw keys ("multiSelect", "menu"). `ctx.effect` ties the
|
|
1889
|
+
// registration's lifetime to the plugin, matching ui-workspace.
|
|
1890
|
+
let localeRegistered = false
|
|
1891
|
+
const adoptLocale = (svc) => {
|
|
1892
|
+
localeSvc = svc || null
|
|
1893
|
+
if (localeRegistered || !localeSvc || typeof localeSvc.register !== 'function') return
|
|
1894
|
+
localeRegistered = true
|
|
1895
|
+
const register = () => localeSvc.register(NS, { zh, en })
|
|
1896
|
+
try {
|
|
1897
|
+
if (typeof ctx.effect === 'function') ctx.effect(register, `${NS}: dictionaries`)
|
|
1898
|
+
else register()
|
|
1899
|
+
} catch { /* already registered */ }
|
|
756
1900
|
}
|
|
757
|
-
|
|
758
|
-
if (slots) installSettings(slots)
|
|
1901
|
+
if (localeSvc) adoptLocale(localeSvc)
|
|
759
1902
|
else {
|
|
760
|
-
try { ctx.inject(['
|
|
1903
|
+
try { ctx.inject(['locale'], (sub) => adoptLocale(sub.locale)) } catch { /* optional */ }
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const install = (slots) => {
|
|
1907
|
+
// Session menu rows. A package-namespaced id keeps them beside the
|
|
1908
|
+
// shipped pin(100)/rename(200)/fork(300)/archive(400)/session-id(450).
|
|
1909
|
+
slots.inject('sidebar.workspaces.session.menu.item', () => slots.register(
|
|
1910
|
+
{ name: 'sidebar.workspaces.session.menu.item', id: `${NS}-multi`, order: 409, locale: NS },
|
|
1911
|
+
SelectMultipleMenuItem,
|
|
1912
|
+
))
|
|
1913
|
+
slots.inject('sidebar.workspaces.session.menu.item', () => slots.register(
|
|
1914
|
+
{ name: 'sidebar.workspaces.session.menu.item', id: NS, order: 410, locale: NS },
|
|
1915
|
+
DeleteSessionMenuItem,
|
|
1916
|
+
))
|
|
1917
|
+
// The confirmation dialog rides the frame-wide overlay seat.
|
|
1918
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
1919
|
+
{ name: 'shell.overlay', id: `${NS}-dialog`, locale: NS },
|
|
1920
|
+
DeleteSessionDialog,
|
|
1921
|
+
))
|
|
1922
|
+
// The save-path picker is a second overlay: the settings card cannot host
|
|
1923
|
+
// a modal of its own.
|
|
1924
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
1925
|
+
{ name: 'shell.overlay', id: `${NS}-picker`, order: 1, locale: NS },
|
|
1926
|
+
QuarantinePicker,
|
|
1927
|
+
))
|
|
1928
|
+
// The plugin page card.
|
|
1929
|
+
slots.inject('plugins.row.config', () => slots.register(
|
|
1930
|
+
{ name: 'plugins.row.config', key: `${NS}#${NS}` },
|
|
1931
|
+
AskBeforeDeleteOption,
|
|
1932
|
+
))
|
|
1933
|
+
|
|
1934
|
+
// The batch bar and its row observer are the only long-lived DOM state;
|
|
1935
|
+
// drop them when the plugin unloads.
|
|
1936
|
+
return () => {
|
|
1937
|
+
exitSelection()
|
|
1938
|
+
if (bar) { bar.remove(); bar = null }
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const slots = ctx.get('slots')
|
|
1943
|
+
if (slots) {
|
|
1944
|
+
const dispose = install(slots)
|
|
1945
|
+
ctx.effect(() => dispose)
|
|
1946
|
+
} else {
|
|
1947
|
+
try {
|
|
1948
|
+
ctx.inject(['slots'], (sub) => {
|
|
1949
|
+
const dispose = install(sub.slots)
|
|
1950
|
+
ctx.effect(() => dispose)
|
|
1951
|
+
})
|
|
1952
|
+
} catch { /* no slot surface on this profile */ }
|
|
761
1953
|
}
|
|
762
|
-
const dispose = install()
|
|
763
|
-
if (dispose) ctx.effect(() => dispose)
|
|
764
1954
|
}
|
|
765
1955
|
|
|
766
1956
|
return { apply }
|