dsh-delete-session 0.1.10 → 0.2.1

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