@device1999/dsh-session-archive-delete 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1083 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-session-archive-delete',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+
7
+ const React = require('react')
8
+ const NS = 'dsh-session-archive-delete'
9
+
10
+ // 官方组件库:菜单项图标与按钮样式都从这里取,保证和原生菜单项同源、同尺寸、同对齐
11
+ let primitives = {}
12
+ try {
13
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives') || {}
14
+ } catch {
15
+ primitives = {}
16
+ }
17
+
18
+ const MENU_SLOT = 'sidebar.workspaces.session.menu.item'
19
+ const SETTINGS_SLOT = 'settings.section'
20
+ const OVERLAY_SLOT = 'shell.overlay'
21
+ const EVENT = 'dsh-session-archive-delete:confirm'
22
+
23
+ // 地址栏为空时的提示:此时使用默认位置(工作区下的 DSH-归档会话)
24
+ const DEFAULT_HINT = '默认(工作区\\DSH-归档会话)'
25
+
26
+ // 设置页标题与左栏导航文字必须一致,导航图标靠这个文字匹配
27
+ const SETTINGS_LABEL = '备份删除'
28
+
29
+ // 会话列表快照(取标题用):由 apply 时注入的 sessions 服务提供
30
+ let sessionsList = null
31
+
32
+ /** 从会话列表快照里读一行的标题 */
33
+ function titleOf(sessionId) {
34
+ try {
35
+ const snap = sessionsList && sessionsList.getSnapshot ? sessionsList.getSnapshot() : null
36
+ const row = snap && snap.byId ? snap.byId[sessionId] : null
37
+ if (row) {
38
+ const t = row.title || row.name || (row.header && row.header.title)
39
+ if (typeof t === 'string' && t.trim()) return t.trim()
40
+ }
41
+ } catch {}
42
+ return ''
43
+ }
44
+
45
+
46
+ let SETTINGS = { mode: 'both', backupRoot: '', confirm: true, dateStyle: 'default' }
47
+ let EFFECTIVE_ROOT = ''
48
+
49
+ // 只打浏览器控制台,不落盘、不上报
50
+ function log(...args) {
51
+ try {
52
+ console.log('[' + NS + ']', ...args)
53
+ } catch {}
54
+ }
55
+
56
+ function loadSettings() {
57
+ return fetch('/dsh-session-archive-delete/config')
58
+ .then((r) => r.json())
59
+ .then((d) => {
60
+ if (d && d.ok && d.settings) SETTINGS = { ...SETTINGS, ...d.settings }
61
+ if (d && d.effectiveBackupRoot) EFFECTIVE_ROOT = d.effectiveBackupRoot
62
+ return SETTINGS
63
+ })
64
+ .catch(() => SETTINGS)
65
+ }
66
+
67
+ function saveSettings(patch) {
68
+ return fetch('/dsh-session-archive-delete/settings', {
69
+ method: 'POST',
70
+ headers: { 'content-type': 'application/json' },
71
+ body: JSON.stringify({ settings: { ...SETTINGS, ...patch } }),
72
+ })
73
+ .then((r) => r.json())
74
+ .then((d) => {
75
+ if (d && d.ok) SETTINGS = { ...SETTINGS, ...d.settings }
76
+ if (d && d.effectiveBackupRoot) EFFECTIVE_ROOT = d.effectiveBackupRoot
77
+ return d
78
+ })
79
+ }
80
+
81
+ const S = {
82
+ // 与参考实现 dsh-session-menu 的菜单行尺寸一致:min-height 34、padding 6/8、gap 6
83
+ // 颜色用危险色,与原生「删除」类菜单项一致
84
+ item: {
85
+ display: 'flex', alignItems: 'center', gap: '6px', width: '100%', minHeight: '34px',
86
+ padding: '6px 8px', border: '0', borderRadius: 'var(--dsw-radius-md, 6px)',
87
+ background: 'none', color: 'var(--dsw-alias-state-error-primary, #e5484d)',
88
+ font: 'inherit', fontSize: '13px', lineHeight: '20px', textAlign: 'left', cursor: 'pointer',
89
+ },
90
+ // 设置页里「备份目录」行:可点击的文件夹选择块
91
+ picker: {
92
+ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', minHeight: '34px',
93
+ padding: '6px 10px', borderRadius: '8px', boxSizing: 'border-box',
94
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
95
+ background: 'var(--dsw-alias-bg-layer-2, rgba(128,128,128,.06))',
96
+ color: 'var(--dsw-alias-label-primary, inherit)', font: 'inherit', fontSize: '12px',
97
+ textAlign: 'left', cursor: 'pointer',
98
+ },
99
+ pickerPath: {
100
+ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
101
+ fontFamily: 'ui-monospace, Consolas, monospace',
102
+ },
103
+ pickerHint: {
104
+ flex: 'none', color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', fontSize: '11px',
105
+ borderLeft: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))', paddingLeft: '8px',
106
+ },
107
+ wrap: {
108
+ position: 'fixed', inset: 0, zIndex: 2147483000, display: 'flex',
109
+ alignItems: 'center', justifyContent: 'center', background: 'rgba(15,18,24,.32)',
110
+ },
111
+ dialog: {
112
+ width: 'min(460px, calc(100vw - 32px))', padding: '20px 20px 16px', borderRadius: '10px',
113
+ background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, inherit)',
114
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
115
+ boxShadow: '0 20px 64px rgba(0,0,0,.3)', fontSize: '13px', lineHeight: '20px',
116
+ },
117
+ title: { fontSize: '15px', fontWeight: 600, margin: '0 0 10px' },
118
+ meta: { color: 'var(--dsw-alias-label-secondary, #8a8a8e)', margin: '0 0 10px', wordBreak: 'break-all' },
119
+ warn: { color: 'var(--dsw-alias-state-error-primary, #e5484d)', margin: '0 0 10px' },
120
+ ok: { color: 'var(--dsw-alias-state-success-primary, #16845b)', margin: '0 0 10px' },
121
+ footer: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px' },
122
+ cancel: {
123
+ padding: '6px 14px', borderRadius: '8px',
124
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
125
+ background: 'transparent', color: 'inherit', fontSize: '13px', cursor: 'pointer',
126
+ },
127
+ confirm: {
128
+ padding: '6px 14px', borderRadius: '8px',
129
+ border: '1px solid var(--dsw-alias-state-error-primary, #e5484d)',
130
+ background: 'var(--dsw-alias-state-error-primary, #e5484d)', color: '#fff',
131
+ fontSize: '13px', cursor: 'pointer',
132
+ },
133
+ input: {
134
+ width: '100%', padding: '6px 8px', borderRadius: '6px', fontSize: '12px',
135
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
136
+ background: 'transparent', color: 'inherit', boxSizing: 'border-box',
137
+ },
138
+ }
139
+
140
+ /** 确认框标题文案 */
141
+ function dialogTitle(mode) {
142
+ return mode === 'delete-only' ? '彻底删除会话?(不备份)' : '备份删除会话?'
143
+ }
144
+
145
+ // ---------------------------------------------------------------------
146
+ // 确认框:确认后调用宿主 /session 真正备份 + 删除
147
+ // ---------------------------------------------------------------------
148
+ function ConfirmDialog() {
149
+ const [target, setTarget] = React.useState(null)
150
+ const [busy, setBusy] = React.useState(false)
151
+ const [done, setDone] = React.useState(null)
152
+ const [error, setError] = React.useState(null)
153
+ const [backupAt, setBackupAt] = React.useState('')
154
+
155
+ React.useEffect(() => {
156
+ const on = (e) => {
157
+ setTarget(e && e.detail ? e.detail : {})
158
+ setBusy(false)
159
+ setDone(null)
160
+ setError(null)
161
+ setBackupAt('')
162
+ // 每次打开都重新取一次生效路径,避免设置改过后仍显示旧值
163
+ loadSettings()
164
+ .then(() => setBackupAt(EFFECTIVE_ROOT))
165
+ .catch(() => setBackupAt(EFFECTIVE_ROOT))
166
+ }
167
+ window.addEventListener(EVENT, on)
168
+ return () => window.removeEventListener(EVENT, on)
169
+ }, [])
170
+
171
+ /** 删除成功后刷新侧栏并跳到下一个会话 */
172
+ function afterDeleted(sessionId) {
173
+ const svc = window.__DSH_SESSION_ARCHIVE_DELETE_SESSIONS__ || null
174
+ if (!svc) return
175
+ try {
176
+ const snap = svc.list && svc.list.getSnapshot ? svc.list.getSnapshot() : null
177
+ const removedCurrent = snap && snap.current === sessionId
178
+ const refresh = typeof svc.refresh === 'function' ? svc.refresh() : svc.refreshList ? svc.refreshList() : null
179
+ if (removedCurrent && refresh) {
180
+ Promise.resolve(refresh).then(() => {
181
+ const s2 = svc.list.getSnapshot()
182
+ const next = (s2 && s2.ids ? s2.ids : []).find((x) => x !== sessionId)
183
+ if (next && typeof svc.open === 'function') svc.open(next)
184
+ })
185
+ }
186
+ } catch {}
187
+ }
188
+
189
+ function run() {
190
+ if (busy || !target) return
191
+ setBusy(true)
192
+ setError(null)
193
+ fetch('/dsh-session-archive-delete/session', {
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json' },
196
+ body: JSON.stringify({
197
+ sessionId: target.sessionId,
198
+ title: target.title || '',
199
+ mode: target.mode,
200
+ }),
201
+ })
202
+ .then(async (r) => {
203
+ let d = {}
204
+ try {
205
+ d = await r.json()
206
+ } catch {}
207
+ if (!r.ok || !d.ok) throw new Error(d.error || 'failed')
208
+ return d
209
+ })
210
+ .then((d) => {
211
+ setBusy(false)
212
+ setDone(d)
213
+ afterDeleted(target.sessionId)
214
+ })
215
+ .catch((err) => {
216
+ setBusy(false)
217
+ setError((err && err.message) || String(err))
218
+ })
219
+ }
220
+
221
+ if (!target) return null
222
+
223
+ const deleteOnly = (target.mode || SETTINGS.mode) === 'delete-only'
224
+ const close = () => {
225
+ if (busy) return
226
+ setTarget(null)
227
+ setDone(null)
228
+ setError(null)
229
+ }
230
+
231
+ if (done) {
232
+ return React.createElement(
233
+ 'div',
234
+ { style: S.wrap, onClick: close },
235
+ React.createElement(
236
+ 'div',
237
+ { style: S.dialog, onClick: (e) => e.stopPropagation() },
238
+ React.createElement('div', { style: S.title }, deleteOnly ? '已彻底删除' : '已备份删除'),
239
+ React.createElement(
240
+ 'div',
241
+ { style: S.ok },
242
+ done.backup && done.backup.name ? '备份:' + done.backup.name : '未备份(直接删除模式)',
243
+ ),
244
+ React.createElement(
245
+ 'div',
246
+ { style: S.meta },
247
+ (done.result && done.result.stopped ? '已停止运行中的任务。' : '') +
248
+ (done.result && done.result.ledger ? '已清理归档记录。' : '') +
249
+ (Array.isArray(done.subagents) && done.subagents.length > 0
250
+ ? '同时处理了 ' + done.subagents.length + ' 个子代理会话' +
251
+ (done.subagents.some((x) => !x.ok) ? '(其中 ' + done.subagents.filter((x) => !x.ok).length + ' 个失败)' : '') +
252
+ '。'
253
+ : ''),
254
+ ),
255
+ React.createElement(
256
+ 'div',
257
+ { style: S.footer },
258
+ React.createElement('button', { type: 'button', style: S.confirm, onClick: close }, '完成'),
259
+ ),
260
+ ),
261
+ )
262
+ }
263
+
264
+ return React.createElement(
265
+ 'div',
266
+ { style: S.wrap, onClick: busy ? undefined : close },
267
+ React.createElement(
268
+ 'div',
269
+ { style: S.dialog, onClick: (e) => e.stopPropagation() },
270
+ React.createElement(
271
+ 'div',
272
+ { style: S.title },
273
+ React.createElement('div', { style: S.title }, dialogTitle(target.mode || SETTINGS.mode)),
274
+ ),
275
+ React.createElement(
276
+ 'div',
277
+ { style: S.meta },
278
+ '会话:' + (target.title || '未命名会话'),
279
+ target.sessionId ? React.createElement('br', null) : null,
280
+ target.sessionId ? 'ID:' + target.sessionId : null,
281
+ deleteOnly ? null : React.createElement('br', null),
282
+ deleteOnly ? null : '备份到:' + (backupAt || '(读取中)'),
283
+ ),
284
+ React.createElement(
285
+ 'div',
286
+ { style: deleteOnly ? S.warn : S.meta },
287
+ deleteOnly
288
+ ? '当前模式:直接删除,不备份。此操作不可恢复。'
289
+ : '将先把该会话完整备份到本地,校验通过后才删除原会话;备份失败会中止删除。',
290
+ ),
291
+ error ? React.createElement('div', { style: S.warn }, '失败:' + error) : null,
292
+ React.createElement(
293
+ 'div',
294
+ { style: S.footer },
295
+ React.createElement('button', { type: 'button', style: S.cancel, disabled: busy, onClick: close }, '取消'),
296
+ React.createElement(
297
+ 'button',
298
+ { type: 'button', style: S.confirm, disabled: busy, onClick: run },
299
+ busy ? '处理中…' : '确认',
300
+ ),
301
+ ),
302
+ ),
303
+ )
304
+ }
305
+
306
+ // ---------------------------------------------------------------------
307
+ // 菜单项:两个组件,按模式显隐
308
+ // both(默认) → 两个都显示
309
+ // backup-then-delete → 只显示「备份删除」
310
+ // delete-only → 只显示「彻底删除」
311
+ // ---------------------------------------------------------------------
312
+ const TrashIcon = primitives.IconTrashOutlineRegular || null
313
+
314
+ /** 打开确认框(两个菜单项共用) */
315
+ function openConfirm(closeMenu, sessionId, propTitle, modeOverride) {
316
+ if (typeof closeMenu === 'function') closeMenu(false)
317
+ loadSettings().then(() => {
318
+ const title = titleOf(sessionId) || (typeof propTitle === 'string' ? propTitle : '')
319
+ window.dispatchEvent(
320
+ new CustomEvent(EVENT, {
321
+ detail: {
322
+ sessionId,
323
+ title,
324
+ mode: modeOverride || SETTINGS.mode,
325
+ },
326
+ }),
327
+ )
328
+ })
329
+ }
330
+
331
+ function menuIcon() {
332
+ return TrashIcon
333
+ ? React.createElement(TrashIcon, { size: 16 })
334
+ : React.createElement('svg', {
335
+ width: 16,
336
+ height: 16,
337
+ viewBox: '0 0 16 16',
338
+ fill: 'none',
339
+ 'aria-hidden': 'true',
340
+ style: { flex: 'none' },
341
+ })
342
+ }
343
+
344
+ /** 「备份删除」项:both 与 backup-then-delete 模式显示 */
345
+ function BackupDeleteMenuItem(props) {
346
+ const sessionId = (props && props.sessionId) || ''
347
+ const propTitle =
348
+ (props && (props.displayTitle || props.title || (props.session && (props.session.title || props.session.name)))) || ''
349
+ const useMenuOpenState = props && props.useMenuOpenState
350
+
351
+ let closeMenu
352
+ if (typeof useMenuOpenState === 'function') {
353
+ const state = useMenuOpenState()
354
+ if (Array.isArray(state) && typeof state[1] === 'function') closeMenu = state[1]
355
+ }
356
+
357
+ const onClick = () => openConfirm(closeMenu, sessionId, propTitle, 'backup-then-delete')
358
+
359
+ // 该项自身的可见性:both 或 backup-then-delete
360
+ if (SETTINGS.mode === 'delete-only') return null
361
+
362
+ return React.createElement(
363
+ 'button',
364
+ {
365
+ type: 'button',
366
+ role: 'menuitem',
367
+ style: S.item,
368
+ onClick,
369
+ onMouseEnter: (e) => {
370
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.14))'
371
+ },
372
+ onMouseLeave: (e) => {
373
+ e.currentTarget.style.background = 'transparent'
374
+ },
375
+ },
376
+ menuIcon(),
377
+ React.createElement('span', null, '备份删除'),
378
+ )
379
+ }
380
+
381
+ /** 「彻底删除」项:both 与 delete-only 模式显示 */
382
+ function DeleteOnlyMenuItem(props) {
383
+ const sessionId = (props && props.sessionId) || ''
384
+ const propTitle =
385
+ (props && (props.displayTitle || props.title || (props.session && (props.session.title || props.session.name)))) || ''
386
+ const useMenuOpenState = props && props.useMenuOpenState
387
+
388
+ let closeMenu
389
+ if (typeof useMenuOpenState === 'function') {
390
+ const state = useMenuOpenState()
391
+ if (Array.isArray(state) && typeof state[1] === 'function') closeMenu = state[1]
392
+ }
393
+
394
+ const onClick = () => openConfirm(closeMenu, sessionId, propTitle, 'delete-only')
395
+
396
+ // 该项自身的可见性:both 或 delete-only
397
+ if (SETTINGS.mode === 'backup-then-delete') return null
398
+
399
+ return React.createElement(
400
+ 'button',
401
+ {
402
+ type: 'button',
403
+ role: 'menuitem',
404
+ style: S.item,
405
+ onClick,
406
+ onMouseEnter: (e) => {
407
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.14))'
408
+ },
409
+ onMouseLeave: (e) => {
410
+ e.currentTarget.style.background = 'transparent'
411
+ },
412
+ },
413
+ menuIcon(),
414
+ React.createElement('span', null, '彻底删除'),
415
+ )
416
+ }
417
+
418
+ // ---------------------------------------------------------------------
419
+ // 设置页:模式切换 + 备份目录
420
+ // ---------------------------------------------------------------------
421
+
422
+ /** 把路径拆成可点击的层级:D:\work\DSH-归档会话 → [D:\, D:\work, D:\work\DSH-归档会话] */
423
+ function splitPath(p) {
424
+ const s = String(p || '')
425
+ if (!s) return []
426
+ const parts = s.split(/[\\/]+/).filter(Boolean)
427
+ const out = []
428
+ let cur = ''
429
+ for (const part of parts) {
430
+ cur = cur ? cur.replace(/[\\/]+$/, '') + '\\' + part : part + (part.endsWith(':') ? '\\' : '')
431
+ out.push({ label: part, full: cur })
432
+ }
433
+ return out
434
+ }
435
+
436
+ /**
437
+ * 备份目录选择器:
438
+ * - 地址栏本身可编辑,回车/失焦即保存(保存前先校验路径是否有效)
439
+ * - 「默认」按钮:清空并恢复默认(工作区下的 DSH-归档会话)
440
+ * - 「浏览…」直接弹 Windows 系统文件夹对话框
441
+ * - 宿主不支持原生对话框(非 Windows / 找不到 pwsh)时回退到页面内浏览
442
+ */
443
+ function FolderPicker(props) {
444
+ const [open, setOpen] = React.useState(false)
445
+ const [cwd, setCwd] = React.useState('')
446
+ const [dirs, setDirs] = React.useState([])
447
+ const [err, setErr] = React.useState('')
448
+ const [loading, setLoading] = React.useState(false)
449
+ const [picking, setPicking] = React.useState(false)
450
+ // 显示值由组件自己持有:选完立刻显示,不等父组件保存往返
451
+ const [value, setValue] = React.useState((props && props.savedRoot) || '')
452
+ // 保存失败(路径无效)的提示
453
+ const [saveErr, setSaveErr] = React.useState('')
454
+ // 屏蔽 onBlur 保存:点「浏览」时输入框会失焦
455
+ const suppressBlurRef = React.useRef(false)
456
+ // 跟踪外部传入的已保存值,只在它真的变化时同步(避免覆盖用户正在输入的内容)
457
+ const lastExternalRef = React.useRef((props && props.savedRoot) || '')
458
+
459
+ // 当前生效路径(用于「浏览」对话框的初始目录)
460
+ const current = (props && typeof props.current === 'string' && props.current) || EFFECTIVE_ROOT || SETTINGS.backupRoot || ''
461
+
462
+ /** 校验路径并保存;无效则提示并回滚显示,不写入设置 */
463
+ function commit(raw) {
464
+ const v = String(raw || '').trim()
465
+ if (v === lastExternalRef.current) return
466
+ fetch('/dsh-session-archive-delete/check-path', {
467
+ method: 'POST',
468
+ headers: { 'content-type': 'application/json' },
469
+ body: JSON.stringify({ path: v }),
470
+ })
471
+ .then((r) => r.json())
472
+ .then((d) => {
473
+ if (!d || !d.ok) {
474
+ setSaveErr((d && d.error) || '路径无效')
475
+ setValue(lastExternalRef.current)
476
+ return
477
+ }
478
+ setSaveErr('')
479
+ lastExternalRef.current = v
480
+ setValue(v)
481
+ props.onPick(v, d.resolved)
482
+ })
483
+ .catch((e) => {
484
+ setSaveErr('校验路径失败:' + String((e && e.message) || e))
485
+ setValue(lastExternalRef.current)
486
+ })
487
+ }
488
+
489
+ React.useEffect(() => {
490
+ const next = (props && props.savedRoot) || ''
491
+ if (next !== lastExternalRef.current) {
492
+ lastExternalRef.current = next
493
+ setValue(next)
494
+ }
495
+ })
496
+
497
+ React.useEffect(() => {
498
+ if (!open) return
499
+ load(current)
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
501
+ }, [open])
502
+
503
+ /** 先试系统对话框;只有宿主明确报告「不支持」时才返回 false 让调用方回退 */
504
+ function pickNative() {
505
+ setPicking(true)
506
+ // 关键:选中的路径先用 ref 钉住。
507
+ // 点「浏览」会让输入框失焦,若此时父组件还没保存,onBlur 会用空 draft 把路径冲掉。
508
+ suppressBlurRef.current = true
509
+ return fetch('/dsh-session-archive-delete/pick-folder', {
510
+ method: 'POST',
511
+ headers: { 'content-type': 'application/json' },
512
+ body: JSON.stringify({ initial: current }),
513
+ })
514
+ .then((r) => r.json())
515
+ .then((d) => {
516
+ setPicking(false)
517
+ log('pick-folder 返回:', d)
518
+ // 只要宿主能弹窗(supported !== false),就不再回退到页面内浏览
519
+ if (d && d.ok === false && d.supported === false) {
520
+ suppressBlurRef.current = false
521
+ return false
522
+ }
523
+ if (d && d.ok && d.path) {
524
+ const picked = d.path
525
+ // 先本地显示,再交给父组件持久化;onBlur 期间已被 suppress 掉
526
+ lastExternalRef.current = picked
527
+ setValue(picked)
528
+ setSaveErr('')
529
+ log('选中路径 →', picked)
530
+ props.onPick(picked, picked)
531
+ return true
532
+ }
533
+ suppressBlurRef.current = false
534
+ if (d && d.ok && !d.path) {
535
+ // 用户取消:保持原路径,不弹第二个窗口
536
+ return true
537
+ }
538
+ // 有错误但宿主支持原生对话框:提示错误,同样不回退
539
+ if (d && d.error) {
540
+ log('pick-folder 失败:', d.error)
541
+ return true
542
+ }
543
+ return false
544
+ })
545
+ .catch((e) => {
546
+ setPicking(false)
547
+ log('pick-folder 请求异常:', e)
548
+ return false
549
+ })
550
+ }
551
+
552
+ function onBrowse() {
553
+ if (props.disabled || picking) return
554
+ // 点浏览的瞬间输入框会失焦,先屏蔽一次 onBlur,避免用旧草稿覆盖路径
555
+ suppressBlurRef.current = true
556
+ pickNative().then((handled) => {
557
+ if (!handled) {
558
+ // 宿主明确不支持原生对话框 → 回退到页面内浏览
559
+ suppressBlurRef.current = false
560
+ setOpen(true)
561
+ }
562
+ })
563
+ }
564
+
565
+ function load(dirPath) {
566
+ setLoading(true)
567
+ setErr('')
568
+ fetch('/dsh-session-archive-delete/browse?dir=' + encodeURIComponent(dirPath))
569
+ .then((r) => r.json())
570
+ .then((d) => {
571
+ setLoading(false)
572
+ if (!d || !d.ok) {
573
+ setErr((d && d.error) || '无法读取目录')
574
+ return
575
+ }
576
+ setCwd(d.dir)
577
+ setDirs(Array.isArray(d.dirs) ? d.dirs : [])
578
+ })
579
+ .catch((e) => {
580
+ setLoading(false)
581
+ setErr(String((e && e.message) || e))
582
+ })
583
+ }
584
+
585
+ const crumbs = splitPath(cwd)
586
+ const parent = crumbs.length > 1 ? crumbs[crumbs.length - 2].full : ''
587
+
588
+ const overlay = open
589
+ ? React.createElement(
590
+ 'div',
591
+ {
592
+ style: S.wrap,
593
+ onClick: () => setOpen(false),
594
+ onKeyDown: (e) => {
595
+ if (e.key === 'Escape') setOpen(false)
596
+ },
597
+ },
598
+ React.createElement(
599
+ 'div',
600
+ { style: { ...S.dialog, width: 'min(560px, calc(100vw - 32px))' }, onClick: (e) => e.stopPropagation() },
601
+ React.createElement('div', { style: S.title }, '选择备份文件夹'),
602
+ // 面包屑
603
+ React.createElement(
604
+ 'div',
605
+ { style: { ...S.meta, display: 'flex', flexWrap: 'wrap', gap: '2px', alignItems: 'center' } },
606
+ crumbs.map((c, i) =>
607
+ React.createElement(
608
+ 'span',
609
+ { key: c.full },
610
+ React.createElement(
611
+ 'a',
612
+ {
613
+ role: 'button',
614
+ tabIndex: 0,
615
+ style: { color: 'var(--dsw-alias-brand-primary, #3b82f6)', cursor: 'pointer', textDecoration: 'none' },
616
+ onClick: () => load(c.full),
617
+ onKeyDown: (e) => {
618
+ if (e.key === 'Enter') load(c.full)
619
+ },
620
+ },
621
+ c.label,
622
+ ),
623
+ i < crumbs.length - 1 ? React.createElement('span', null, ' › ') : null,
624
+ ),
625
+ ),
626
+ ),
627
+ // 列表
628
+ React.createElement(
629
+ 'div',
630
+ {
631
+ style: {
632
+ maxHeight: '320px', overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
633
+ borderRadius: '8px', marginTop: '8px', minHeight: '120px',
634
+ },
635
+ },
636
+ loading
637
+ ? React.createElement('div', { style: { ...S.meta, padding: '12px' } }, '读取中…')
638
+ : err
639
+ ? React.createElement('div', { style: { ...S.warn, padding: '12px' } }, err)
640
+ : dirs.length === 0
641
+ ? React.createElement('div', { style: { ...S.meta, padding: '12px' } }, '(这个文件夹里没有子文件夹)')
642
+ : dirs.map((d) =>
643
+ React.createElement(
644
+ 'button',
645
+ {
646
+ key: d.path,
647
+ type: 'button',
648
+ style: { ...S.item, color: 'inherit', padding: '6px 10px' },
649
+ onClick: () => load(d.path),
650
+ onMouseEnter: (e) => {
651
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.14))'
652
+ },
653
+ onMouseLeave: (e) => {
654
+ e.currentTarget.style.background = 'none'
655
+ },
656
+ },
657
+ React.createElement('span', { style: { flex: '1', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' } }, '📁 ' + d.name),
658
+ d.hasChildren ? React.createElement('span', { style: S.pickerHint }, '›') : null,
659
+ ),
660
+ ),
661
+ ),
662
+ React.createElement(
663
+ 'div',
664
+ { style: { ...S.meta, marginTop: '10px', fontSize: '12px' } },
665
+ '选中文件夹:' + (cwd || '—'),
666
+ ),
667
+ React.createElement(
668
+ 'div',
669
+ { style: S.footer },
670
+ parent
671
+ ? React.createElement('button', { type: 'button', style: S.cancel, onClick: () => load(parent) }, '↑ 上一级')
672
+ : null,
673
+ React.createElement('button', { type: 'button', style: S.cancel, onClick: () => setOpen(false) }, '取消'),
674
+ React.createElement(
675
+ 'button',
676
+ {
677
+ type: 'button',
678
+ style: { ...S.cancel, borderColor: 'var(--dsw-alias-brand-primary, #3b82f6)', color: 'var(--dsw-alias-brand-primary, #3b82f6)' },
679
+ disabled: !cwd || loading,
680
+ onClick: () => {
681
+ setOpen(false)
682
+ props.onPick(cwd)
683
+ },
684
+ },
685
+ '选择这个文件夹',
686
+ ),
687
+ ),
688
+ ),
689
+ )
690
+ : null
691
+
692
+ return React.createElement(
693
+ 'div',
694
+ null,
695
+ // 地址栏:可直接编辑;右侧「默认」和「浏览…」两个按钮
696
+ React.createElement(
697
+ 'div',
698
+ { style: { ...S.picker, opacity: props.disabled || picking ? 0.6 : 1, padding: '4px 4px 4px 10px' } },
699
+ React.createElement('span', { style: { flex: 'none' } }, '📁'),
700
+ React.createElement('input', {
701
+ type: 'text',
702
+ value: value,
703
+ placeholder: DEFAULT_HINT,
704
+ spellCheck: false,
705
+ style: { ...S.input, flex: 1, minWidth: 0, border: 'none', background: 'transparent', padding: '4px 0' },
706
+ onChange: (e) => {
707
+ setValue(e.target.value)
708
+ if (saveErr) setSaveErr('')
709
+ },
710
+ onBlur: () => {
711
+ // 点了「浏览」导致的失焦:不要保存,否则会用旧值冲掉刚选中的路径
712
+ if (suppressBlurRef.current) {
713
+ suppressBlurRef.current = false
714
+ return
715
+ }
716
+ commit(value)
717
+ },
718
+ onKeyDown: (e) => {
719
+ if (e.key === 'Enter') {
720
+ e.currentTarget.blur()
721
+ } else if (e.key === 'Escape') {
722
+ setValue(lastExternalRef.current)
723
+ setSaveErr('')
724
+ suppressBlurRef.current = true
725
+ e.currentTarget.blur()
726
+ }
727
+ },
728
+ }),
729
+ React.createElement(
730
+ 'button',
731
+ {
732
+ type: 'button',
733
+ title: '恢复默认位置(工作区下的 DSH-归档会话)',
734
+ style: {
735
+ ...S.cancel,
736
+ flex: 'none',
737
+ padding: '4px 10px',
738
+ marginLeft: '6px',
739
+ fontSize: '12px',
740
+ },
741
+ disabled: props.disabled || picking,
742
+ onClick: () => {
743
+ suppressBlurRef.current = true
744
+ setValue('')
745
+ setSaveErr('')
746
+ commit('')
747
+ },
748
+ },
749
+ '默认',
750
+ ),
751
+ React.createElement(
752
+ 'button',
753
+ {
754
+ type: 'button',
755
+ title: '用系统文件夹对话框选择',
756
+ style: {
757
+ ...S.cancel,
758
+ flex: 'none',
759
+ padding: '4px 10px',
760
+ marginLeft: '6px',
761
+ fontSize: '12px',
762
+ },
763
+ disabled: props.disabled || picking,
764
+ onClick: onBrowse,
765
+ },
766
+ picking ? '等待选择…' : '浏览…',
767
+ ),
768
+ ),
769
+ saveErr
770
+ ? React.createElement(
771
+ 'div',
772
+ { style: { marginTop: '4px', color: 'var(--dsw-alias-state-error-primary, #e5484d)', fontSize: '12px' } },
773
+ saveErr,
774
+ )
775
+ : null,
776
+ overlay,
777
+ )
778
+ }
779
+
780
+ function SettingsSection() {
781
+ const [mode, setMode] = React.useState(SETTINGS.mode)
782
+ const [dateStyle, setDateStyle] = React.useState(SETTINGS.dateStyle || 'default')
783
+ const [root, setRoot] = React.useState(SETTINGS.backupRoot)
784
+ const [saving, setSaving] = React.useState(false)
785
+ const [msg, setMsg] = React.useState('')
786
+
787
+ React.useEffect(() => {
788
+ loadSettings().then((s) => {
789
+ setMode(s.mode)
790
+ setDateStyle(s.dateStyle || 'default')
791
+ setRoot(s.backupRoot || '')
792
+ })
793
+ }, [])
794
+
795
+ const persist = (patch) => {
796
+ setSaving(true)
797
+ setMsg('')
798
+ return saveSettings(patch).then((d) => {
799
+ setSaving(false)
800
+ setMsg(d && d.ok ? '已保存' : '保存失败:' + ((d && d.error) || '未知错误'))
801
+ return d
802
+ })
803
+ }
804
+
805
+ // EFFECTIVE_ROOT 是模块级普通变量,保存后不会自己触发重渲染——
806
+ // 必须改用组件状态,确认框与选择器读到的才是新路径
807
+ const [effective, setEffective] = React.useState(EFFECTIVE_ROOT)
808
+ React.useEffect(() => {
809
+ loadSettings().then(() => setEffective(EFFECTIVE_ROOT))
810
+ }, [])
811
+
812
+ return React.createElement(
813
+ 'div',
814
+ { style: { padding: '4px 2px', fontSize: '13px', lineHeight: '22px' } },
815
+ React.createElement('h3', { style: { fontSize: '14px', margin: '0 0 8px' } }, SETTINGS_LABEL),
816
+ React.createElement(
817
+ 'div',
818
+ { style: { color: 'var(--dsw-alias-label-secondary, #8a8a8e)' } },
819
+ '在侧栏会话「…」菜单最下方增加删除选项;默认同时显示「备份删除」与「彻底删除」。',
820
+ ),
821
+ React.createElement(
822
+ 'div',
823
+ { style: { marginTop: '12px' } },
824
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '删除模式'),
825
+ React.createElement(
826
+ 'select',
827
+ {
828
+ value: mode,
829
+ disabled: saving,
830
+ style: S.input,
831
+ onChange: (e) => {
832
+ const v = e.target.value
833
+ setMode(v)
834
+ persist({ mode: v })
835
+ },
836
+ },
837
+ React.createElement('option', { value: 'both' }, '「备份删除」+「彻底删除」同时显示'),
838
+ React.createElement('option', { value: 'backup-then-delete' }, '「备份删除」'),
839
+ React.createElement('option', { value: 'delete-only' }, '「彻底删除」'),
840
+ ),
841
+ ),
842
+ React.createElement(
843
+ 'div',
844
+ { style: { marginTop: '12px' } },
845
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '备份文件夹命名'),
846
+ React.createElement(
847
+ 'select',
848
+ {
849
+ value: dateStyle,
850
+ disabled: saving,
851
+ style: S.input,
852
+ onChange: (e) => {
853
+ const v = e.target.value
854
+ setDateStyle(v)
855
+ persist({ dateStyle: v })
856
+ },
857
+ },
858
+ React.createElement('option', { value: 'default' }, '默认:日期+主题(20261020_标题)'),
859
+ React.createElement('option', { value: 'id-title' }, 'ID前八位+主题(a1b2c3d4-标题)'),
860
+ React.createElement('option', { value: 'title' }, '纯主题(标题)'),
861
+ React.createElement('option', { value: 'id-title-date' }, '前八位+主题+日期(a1b2c3d4-标题_20261020)'),
862
+ ),
863
+ React.createElement(
864
+ 'div',
865
+ { style: { marginTop: '4px', color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', fontSize: '12px' } },
866
+ '重名自动加 -2/-3;标题过长自动截断。',
867
+ ),
868
+ ),
869
+ React.createElement(
870
+ 'div',
871
+ { style: { marginTop: '12px' } },
872
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '备份目录'),
873
+ React.createElement(FolderPicker, {
874
+ disabled: saving,
875
+ current: effective,
876
+ savedRoot: root,
877
+ onPick: (p, resolved) => {
878
+ setRoot(p)
879
+ // 立即更新「当前实际备份到」,清空时也用宿主算出的默认路径
880
+ if (resolved) setEffective(resolved)
881
+ persist({ backupRoot: p }).then(() => setEffective(EFFECTIVE_ROOT))
882
+ },
883
+ }),
884
+ React.createElement(
885
+ 'div',
886
+ { style: { marginTop: '6px', color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', fontSize: '12px' } },
887
+ '当前实际备份到:' + (effective || '(读取中)'),
888
+ ),
889
+ React.createElement(
890
+ 'div',
891
+ { style: { marginTop: '2px', color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', fontSize: '12px' } },
892
+ '每次删除前会把该会话完整复制到这里并校验;不能设在 ~/.dsh 里面。',
893
+ ),
894
+ ),
895
+ msg
896
+ ? React.createElement(
897
+ 'div',
898
+ {
899
+ style: {
900
+ marginTop: '8px',
901
+ color:
902
+ msg.indexOf('失败') >= 0
903
+ ? 'var(--dsw-alias-state-error-primary, #e5484d)'
904
+ : 'var(--dsw-alias-label-secondary, #8a8a8e)',
905
+ },
906
+ },
907
+ msg,
908
+ )
909
+ : null,
910
+ )
911
+ }
912
+
913
+ // ---------------------------------------------------------------------
914
+ // 设置左栏导航图标:把本插件那一行的齿轮换成垃圾桶
915
+ //
916
+ // DSH 外壳按 settings.section 的 id 选图标,未知 id 一律回退成齿轮,
917
+ // 且没有公开的 icon 字段。所以等设置对话框挂载后,按 label 文字
918
+ // 精确找到本插件的导航按钮,打上标记,再由 CSS 用截图替换成垃圾桶图标。
919
+ // 只命中文字完全一致的那一个按钮,不影响其他插件。
920
+ // ---------------------------------------------------------------------
921
+ const NAV_MARKER = 'data-session-archive-delete-nav'
922
+ const NAV_ICON_SEL = '[data-session-archive-delete-nav-icon]'
923
+
924
+ // 16×16 垃圾桶,线条风格(与其他导航图标一致,避免实心块显得"失真")
925
+ const TRASH_SVG_INNER =
926
+ '<path d="M2.75 4.5h10.5"/>' +
927
+ '<path d="M6.1 4.5V3.4c0-.5.4-.9.9-.9h1.9c.5 0 .9.4.9.9v1.1"/>' +
928
+ '<path d="M4.1 4.5l.55 8.05c.03.5.45.9.95.9h4.7c.5 0 .92-.4.95-.9l.55-8.05"/>' +
929
+ '<path d="M6.7 7.1v4.1"/>' +
930
+ '<path d="M9.3 7.1v4.1"/>'
931
+
932
+ function makeTrashSvg() {
933
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
934
+ svg.setAttribute('viewBox', '0 0 16 16')
935
+ svg.setAttribute('width', '16')
936
+ svg.setAttribute('height', '16')
937
+ svg.setAttribute('fill', 'none')
938
+ svg.setAttribute('stroke', 'currentColor')
939
+ svg.setAttribute('stroke-width', '1.2')
940
+ svg.setAttribute('stroke-linecap', 'round')
941
+ svg.setAttribute('stroke-linejoin', 'round')
942
+ svg.setAttribute('aria-hidden', 'true')
943
+ svg.setAttribute(NAV_ICON_SEL.replace(/^\[|\]$/g, ''), '1')
944
+ svg.style.cssText = 'width:16px;height:16px;flex:none;display:block;'
945
+ svg.innerHTML = TRASH_SVG_INNER
946
+ return svg
947
+ }
948
+
949
+ /**
950
+ * 把本插件在设置左栏那一行的图标换成垃圾桶。
951
+ *
952
+ * DSH 外壳按 settings.section 的 id 选图标,未知 id 一律给齿轮,也没有公开的 icon 字段。
953
+ * 这里在设置对话框挂载后,按 label 文字精确找到本插件那一行,
954
+ * 直接 **替换** 它内部的原生 <svg>(不用 CSS,避免误伤新插入的图标)。
955
+ */
956
+ function installNavIcon(labelText) {
957
+ if (typeof document === 'undefined') return () => {}
958
+ let disposed = false
959
+ let scheduled = false
960
+
961
+ const apply = () => {
962
+ scheduled = false
963
+ if (disposed) return
964
+ let buttons = []
965
+ try {
966
+ buttons = document.querySelectorAll('[role="dialog"] nav button')
967
+ } catch {
968
+ return
969
+ }
970
+ const want = String(labelText || '').trim()
971
+ if (!want) return
972
+
973
+ // 诊断:记录每轮扫描到的按钮文字,便于判断选择器是否命中
974
+ if (buttons.length > 0) {
975
+ const texts = []
976
+ for (const b of buttons) texts.push(String(b.textContent || '').trim())
977
+ if (!apply._logged || apply._logged !== buttons.length + '|' + texts.join(',')) {
978
+ apply._logged = buttons.length + '|' + texts.join(',')
979
+ log('设置导航按钮(' + buttons.length + '):' + texts.join(' / '))
980
+ }
981
+ }
982
+
983
+ for (const b of buttons) {
984
+ const match = b.textContent && b.textContent.trim() === want
985
+ if (!match) {
986
+ b.removeAttribute(NAV_MARKER)
987
+ continue
988
+ }
989
+ b.setAttribute(NAV_MARKER, '')
990
+ // 已经换过就跳过,避免反复替换造成闪烁
991
+ if (b.querySelector(NAV_ICON_SEL)) continue
992
+ const nativeSvg = b.querySelector('svg')
993
+ const trash = makeTrashSvg()
994
+ if (nativeSvg) {
995
+ nativeSvg.replaceWith(trash)
996
+ log('已替换导航图标(原生 svg 存在)')
997
+ } else {
998
+ b.insertBefore(trash, b.firstChild)
999
+ log('已插入导航图标(原生 svg 不存在,插入到最前)')
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ // MutationObserver 会因为我们的改动再次触发,用 microtask 合并,避免抖动
1005
+ const sync = () => {
1006
+ if (scheduled || disposed) return
1007
+ scheduled = true
1008
+ Promise.resolve().then(apply)
1009
+ }
1010
+
1011
+ apply()
1012
+ let observer = null
1013
+ try {
1014
+ observer = new MutationObserver(sync)
1015
+ observer.observe(document.body, { childList: true, subtree: true })
1016
+ } catch {}
1017
+
1018
+ return () => {
1019
+ disposed = true
1020
+ if (observer) observer.disconnect()
1021
+ document.querySelectorAll('[' + NAV_MARKER + ']').forEach((el) => el.removeAttribute(NAV_MARKER))
1022
+ }
1023
+ }
1024
+
1025
+ // ---------------------------------------------------------------------
1026
+ /** 从 sessions 服务里拿到 list 快照(取标题用) */
1027
+ function adoptSessions(svc) {
1028
+ try {
1029
+ const list = svc && svc.list
1030
+ if (list && typeof list.getSnapshot === 'function' && typeof list.subscribe === 'function') {
1031
+ sessionsList = list
1032
+ }
1033
+ } catch {}
1034
+ }
1035
+
1036
+ // apply
1037
+ // ---------------------------------------------------------------------
1038
+ function apply(ctx) {
1039
+ log('client half applying')
1040
+ try {
1041
+ const svc = ctx.get ? ctx.get('sessions') : null
1042
+ window.__DSH_SESSION_ARCHIVE_DELETE_SESSIONS__ = svc
1043
+ adoptSessions(svc)
1044
+ if (!svc && typeof ctx.inject === 'function') {
1045
+ ctx.inject(['sessions'], (sub) => {
1046
+ window.__DSH_SESSION_ARCHIVE_DELETE_SESSIONS__ = sub.sessions
1047
+ adoptSessions(sub.sessions)
1048
+ })
1049
+ }
1050
+ } catch {}
1051
+
1052
+ ctx.slots.inject(OVERLAY_SLOT, function* () {
1053
+ yield ctx.slots.register({ name: OVERLAY_SLOT, id: 'session-archive-delete-dialog', order: 200 }, ConfirmDialog)
1054
+ })
1055
+
1056
+ ctx.slots.inject(MENU_SLOT, function* () {
1057
+ yield ctx.slots.register({ name: MENU_SLOT, id: 'session-archive-delete-menu-backup', order: 900 }, BackupDeleteMenuItem)
1058
+ yield ctx.slots.register({ name: MENU_SLOT, id: 'session-archive-delete-menu-delete', order: 910 }, DeleteOnlyMenuItem)
1059
+ })
1060
+
1061
+ ctx.slots.inject(SETTINGS_SLOT, function* () {
1062
+ yield ctx.slots.register(
1063
+ { name: SETTINGS_SLOT, id: 'session-archive-delete-settings', order: 90, label: () => SETTINGS_LABEL },
1064
+ SettingsSection,
1065
+ )
1066
+ })
1067
+
1068
+ // 把设置左栏里本插件那一行的齿轮换成垃圾桶(只认文字匹配的那一个按钮)
1069
+ try {
1070
+ ctx.effect(() => installNavIcon(SETTINGS_LABEL), NS + ': settings nav icon')
1071
+ } catch {
1072
+ installNavIcon(SETTINGS_LABEL)
1073
+ }
1074
+
1075
+ void loadSettings()
1076
+ }
1077
+
1078
+ exports.apply = apply
1079
+ exports.inject = ['slots']
1080
+ exports.name = NS
1081
+ return module.exports
1082
+ },
1083
+ })