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