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/src/client.js CHANGED
@@ -1,20 +1,29 @@
1
1
  // dsh-delete-session - client half.
2
2
  //
3
- // DSH's sidebar session-row "..." menu (rename / fork / archive) is rendered
4
- // by the core UI and offers no plugin slot for extra rows, so the delete item
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
- // * watch for the portalled session menu (body > [role="menu"]);
8
- // * walk the React fiber chain from that menu to the session node and read
9
- // its real id (props.node.id) - no title matching, no conversation jump;
10
- // * clone a native menu row so the new item keeps the core styling;
11
- // * open a risk-consent dialog and POST /dsh-delete-session/delete.
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 module is a classic client bundle (client-modules protocol): it
14
- // registers a factory with window.__ModuleLoader__ and returns apply().
15
- // The menu injection and dialog are DOM-only (theme tokens, no SDK) so they
16
- // survive core UI revisions that keep the menu contract; React is pulled in
17
- // only for the plugin-page settings card (slot `plugins.row.config`).
17
+ // 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
- desc: '将永久删除该会话及其全部对话记录(会话日志、工作区记账与统计),此操作不可恢复。',
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
- desc: 'This permanently deletes the session and all of its conversation records (log, workspace accounting and statistics). This action cannot be undone.',
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
- // Once the user opts out ("删除时询问" unticked - either in the plugin page
85
- // or via the dialog's "don't ask again") the confirmation dialog is skipped
86
- // and the menu item deletes directly. Shift+click always opens the dialog
87
- // again. The plugin page's checkbox and the dialog read the same key, and a
88
- // subscription keeps the card in step when either one flips it.
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
- function postDelete(sessionId) {
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({ sessionId }),
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
- // --- current-session handoff ------------------------------------------------
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 (on 0.1.7 it lands on an unavailable Agent Team row with a
137
- // disabled composer), which reads as "the delete did not work". When the
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.querySelector('style[data-dsh-delete-session]')) return
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
- function refreshList() {
294
- const svc = sessionsSvc
295
- if (svc) {
296
- try {
297
- if (typeof svc.refresh === 'function') { svc.refresh(); return }
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
- let fiber = findFiber(row)
424
- let guard = 0
425
- while (fiber && guard++ < 300) {
426
- const props = fiber.memoizedProps
427
- if (props && props.node && typeof props.node.id === 'string' && props.node.id) return props.node.id
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', pendingRemoval.has(id))
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
- count.textContent = batchRunning
473
- ? t('batchRunning')(batchDone + batchFailed, batchTotal)
474
- : t('selectedCount')(selected.size)
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 cancel = bar.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)')
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 = batchRunning
479
- if (del) del.disabled = batchRunning || selected.size === 0
480
- }
481
-
482
- function toggleSelect(id) {
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
- cancel.className = 'dsdel-bar-btn'
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
- scheduleDecorate()
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
- batchRunning = false
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
- pendingRemoval.clear()
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
- pendingRemoval.clear()
568
- scheduleDecorate()
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 (batchRunning) return
582
- const ids = [...selected]
899
+ if (state.batch !== null) return
900
+ const ids = [...state.selected]
583
901
  if (ids.length === 0) return
584
- batchRunning = true
585
- batchTotal = ids.length
586
- batchDone = 0
587
- batchFailed = 0
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
- batchDone += 1
918
+ state.batch.done += 1
604
919
  } catch {
605
- batchFailed += 1
920
+ state.batch.failed += 1
606
921
  failedIds.push(id)
607
- pendingRemoval.delete(id)
922
+ state.pending.delete(id)
608
923
  }
609
- scheduleDecorate()
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
- batchRunning = false
618
- if (batchFailed === 0) {
619
- toast(t('batchDone')(batchDone))
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')(batchDone, batchFailed), true)
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
- // --- sidebar menu injection ------------------------------------------------
635
-
636
- const TRASH_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M9 7V4h6v3"/></svg>'
637
- const SELECT_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="5" cy="7" r="2"/><circle cx="5" cy="17" r="2"/><path d="M11 7h9"/><path d="M11 17h9"/></svg>'
638
-
639
- // Clone a native menu row so an injected action keeps the core styling.
640
- function buildMenuItem(menuEl, label, iconSvg, danger, onClick) {
641
- const proto = menuEl.querySelector('[role="menuitem"]')
642
- if (!proto) return null
643
- const protoWrap = proto.parentElement
644
- const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
645
- if (protoWrap && protoWrap.className) wrap.className = protoWrap.className
646
- const button = document.createElement('button')
647
- button.type = 'button'
648
- button.setAttribute('role', 'menuitem')
649
- if (proto.className) button.className = proto.className
650
- if (danger) button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
651
- const icon = document.createElement('span')
652
- icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center'
653
- icon.innerHTML = iconSvg
654
- const text = document.createElement('span')
655
- text.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
656
- text.textContent = label
657
- button.appendChild(icon)
658
- button.appendChild(text)
659
- button.addEventListener('click', (e) => {
660
- e.stopPropagation()
661
- e.preventDefault()
662
- closeMenu()
663
- onClick(e)
664
- })
665
- wrap.appendChild(button)
666
- return wrap
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 augmentMenu(menuEl, info) {
670
- if (menuEl.querySelector('[data-dsh-delete-session="1"]')) return
671
- const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
672
- if (!viewport) return
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
- const selectWrap = buildMenuItem(menuEl, t('multiSelect'), SELECT_ICON, false, () => enterSelection())
675
- const deleteWrap = buildMenuItem(menuEl, t('menu'), TRASH_ICON, true, (e) => {
676
- if (skipConfirm() && !e.shiftKey) deleteNow(info)
677
- else openDialog(info)
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
- if (selectWrap) {
680
- selectWrap.firstChild.setAttribute('data-dsh-delete-session', 'select')
681
- viewport.appendChild(selectWrap)
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 (deleteWrap) {
684
- deleteWrap.firstChild.setAttribute('data-dsh-delete-session', '1')
685
- viewport.appendChild(deleteWrap)
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
- // The portalled menu measured its height before this row existed; nudge
689
- // the core placement so the grown card is re-clamped on screen.
690
- window.dispatchEvent(new Event('resize'))
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
- // --- plugin page (slot plugins.row.config) ---------------------------------
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
- if (view === 'summary') return t(ask ? 'pageSummaryAsk' : 'pageSummaryAuto')
703
- return react.createElement(
704
- 'div',
705
- { className: 'dsdel-page' },
706
- react.createElement(primitives.Checkbox, {
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: t('pageAsk'),
1566
+ label: translate('pageAsk'),
709
1567
  onChange: (checked) => {
710
1568
  setAsk(checked === true)
711
1569
  setSkipConfirm(checked !== true)
712
1570
  },
713
1571
  }),
714
- react.createElement('p', { className: 'dsdel-page-hint' }, t(ask ? 'pageHintAsk' : 'pageHintAuto')),
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
- function installSettings(slots) {
719
- slots.inject('plugins.row.config', () =>
720
- slots.register(
721
- { name: 'plugins.row.config', key: 'dsh-delete-session#dsh-delete-session' },
722
- AskBeforeDeleteOption,
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
- function install() {
728
- if (typeof document === 'undefined' || !document.body) return null
1719
+ // --- plugin ------------------------------------------------------------------
1720
+
1721
+ function apply(ctx) {
729
1722
  ensureStyle()
730
- const seen = new WeakSet()
731
- const scan = () => {
732
- const menus = document.querySelectorAll('body > [role="menu"]')
733
- menus.forEach((menuEl) => {
734
- if (seen.has(menuEl)) return
735
- let info = null
736
- try { info = sessionInfoFromMenu(menuEl) } catch { /* ignore */ }
737
- if (!info) return
738
- seen.add(menuEl)
739
- try { augmentMenu(menuEl, info) } catch { /* best-effort DOM shim */ }
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
- if (!localeSvc) {
755
- try { ctx.inject(['locale'], (sub) => { localeSvc = sub.locale }) } catch { /* optional */ }
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
- const slots = ctx.get('slots')
758
- if (slots) installSettings(slots)
1760
+ if (localeSvc) adoptLocale(localeSvc)
759
1761
  else {
760
- try { ctx.inject(['slots'], (sub) => installSettings(sub.slots)) } catch { /* optional */ }
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 }