dsh-sessions-manager 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +152 -0
- package/README.md +152 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
- package/assets/screenshot-session-submenu.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1582 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1159 -0
- package/lib/index.js.map +7 -0
- package/package.json +73 -0
- package/src/client/index.jsx +1466 -0
- package/src/index.js +1158 -0
- package/src/zstd-frame.js +137 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sessions-manager — browser half: renders a single "会话管理" settings
|
|
3
|
+
* section (settings.section list slot) that unifies archived-session
|
|
4
|
+
* management and cross-workspace moving. It talks to the host half's
|
|
5
|
+
* /archived-sessions/* JSON routes by fetch, showing every conversation with
|
|
6
|
+
* its archive state and offering archive / restore / delete / move (and batch)
|
|
7
|
+
* actions. All DOM/runtime wiring failures are logged, never thrown — a thrown
|
|
8
|
+
* plugin apply takes down the whole web-shell boot.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-sessions-manager/client
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
|
|
15
|
+
export const inject = ['slots']
|
|
16
|
+
|
|
17
|
+
const PANEL_PREFS_KEY = 'dsm-panel-prefs-v1'
|
|
18
|
+
function loadPanelPrefs() {
|
|
19
|
+
try { return JSON.parse(localStorage.getItem(PANEL_PREFS_KEY) || '{}') || {} } catch (e) { return {} }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CSS = `
|
|
23
|
+
.archv{--dsm-radius-tag:4px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
|
|
24
|
+
.archv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
|
|
25
|
+
.archv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
|
|
26
|
+
.archv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
|
|
27
|
+
.archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
|
|
28
|
+
.archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:10px;color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
|
|
29
|
+
.archv-errretry{appearance:none;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:11px;cursor:pointer;flex:none}
|
|
30
|
+
.sess-filter{display:flex;align-items:center;gap:6px;margin:0 0 12px}
|
|
31
|
+
.sess-fbtn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:999px;font-size:12px;font-weight:500;cursor:pointer}
|
|
32
|
+
.sess-fbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
33
|
+
.sess-fbtn-on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
34
|
+
.sess-tools{display:grid;grid-template-columns:minmax(180px,1fr) minmax(140px,.7fr) minmax(130px,.55fr);gap:8px;margin:0 0 8px}
|
|
35
|
+
.sess-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
36
|
+
.sess-field label{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
|
37
|
+
.sess-field input,.sess-field select{box-sizing:border-box;width:100%;min-height:36px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
|
|
38
|
+
.sess-results{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 0 4px}
|
|
39
|
+
.archv button:focus-visible,.archv input:focus-visible,.archv select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
|
|
40
|
+
.sess-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 2px 4px;margin-bottom:4px}
|
|
41
|
+
.sess-btntext{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}
|
|
42
|
+
.archv-list{display:flex;flex-direction:column;gap:8px}
|
|
43
|
+
.archv-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
|
|
44
|
+
.archv-card:hover{border-color:var(--dsw-alias-border-l4)}
|
|
45
|
+
.archv-card-exp{border-color:var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1)}
|
|
46
|
+
.archv-row{display:flex;align-items:center;gap:14px;width:100%;min-width:0}
|
|
47
|
+
.archv-main{min-width:0;display:flex;flex-direction:column;gap:4px}
|
|
48
|
+
.archv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
49
|
+
.archv-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;min-width:0}
|
|
50
|
+
.archv-wtag{display:inline-flex;align-items:center;gap:4px;max-width:100%;font-size:11px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
51
|
+
.archv-wgone{border-style:dashed;color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 7%,transparent)}
|
|
52
|
+
.archv-active{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent)}
|
|
53
|
+
.archv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
|
|
54
|
+
.archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
|
|
55
|
+
.archv-dot{color:var(--dsw-alias-border-l3);flex:none}
|
|
56
|
+
.archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
|
|
57
|
+
.archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
|
|
58
|
+
.archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
|
|
59
|
+
.archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
|
|
60
|
+
.archv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
61
|
+
.archv-btn:disabled{opacity:.5;cursor:default}
|
|
62
|
+
.archv-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
|
|
63
|
+
.archv-del:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
|
|
64
|
+
.archv-go{color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,transparent)}
|
|
65
|
+
.archv-go:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
66
|
+
.archv-empty{display:flex;align-items:center;gap:10px;padding:20px 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
67
|
+
.archv-skel{display:flex;flex-direction:column;gap:8px}
|
|
68
|
+
.archv-skel-card{height:58px;border-radius:12px;background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
|
|
69
|
+
.archv-skel-card::after{content:'';position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--dsw-alias-fill-elevated) 75%,transparent),transparent);animation:archv-shimmer 1.4s infinite}
|
|
70
|
+
.archv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);display:flex;align-items:center;gap:8px;animation:archv-pop .18s ease-out;max-width:min(90vw,420px)}
|
|
71
|
+
.archv-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-top-color:var(--dsw-alias-label-secondary);border-radius:50%;animation:archv-rot .8s linear infinite;flex:none}
|
|
72
|
+
@keyframes archv-shimmer{100%{transform:translateX(100%)}}
|
|
73
|
+
@keyframes archv-rot{to{transform:rotate(360deg)}}
|
|
74
|
+
@keyframes archv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
|
|
75
|
+
@media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn{transition:none}.archv-status,.archv-spin{animation:none}}
|
|
76
|
+
@media (max-width:640px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}.sess-tools{grid-template-columns:1fr}.sess-fbtn,.archv-btn{min-height:40px}}
|
|
77
|
+
.mv-sheet{width:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-fill-subtle)}
|
|
78
|
+
.mv-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
79
|
+
.mv-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
|
|
80
|
+
.mv-sheet-close{appearance:none;width:26px;height:26px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:16px;line-height:1}
|
|
81
|
+
.mv-sheet-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
82
|
+
.mv-seg{display:flex;gap:2px;padding:2px;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;width:100%}
|
|
83
|
+
.mv-segbtn{appearance:none;flex:1;min-height:30px;padding:0 12px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
|
84
|
+
.mv-segbtn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
|
85
|
+
.mv-segbtn-on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgb(0 0 0/.08)}
|
|
86
|
+
.mv-field{display:flex;flex-direction:column;gap:6px}
|
|
87
|
+
.mv-field label.mv-field-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}
|
|
88
|
+
.mv-field select,.mv-field input[type=text]{box-sizing:border-box;appearance:none;width:100%;min-height:34px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit}
|
|
89
|
+
.mv-field select:focus-visible,.mv-field input[type=text]:focus-visible,.mv-sheet-close:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
|
|
90
|
+
.mv-browse-row{display:flex;align-items:center;gap:8px}
|
|
91
|
+
.mv-browse-row input[type=text]{flex:1;min-width:0}
|
|
92
|
+
.mv-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:2px}
|
|
93
|
+
@media (max-width:640px){.archv-row{flex-wrap:wrap}.mv-sheet{padding:12px}}
|
|
94
|
+
.dtl-sheet{margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-fill-subtle)}
|
|
95
|
+
.dtl-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
96
|
+
.dtl-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
|
|
97
|
+
.dtl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:10px}
|
|
98
|
+
.dtl-cell{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-fill-elevated);min-width:0}
|
|
99
|
+
.dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
100
|
+
.dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
|
|
101
|
+
.dtl-sec{margin-top:12px}
|
|
102
|
+
.dtl-sec-t{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px}
|
|
103
|
+
.dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
|
|
104
|
+
.dtl-tag{display:inline-flex;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:2px 8px}
|
|
105
|
+
.dtl-list{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
|
|
106
|
+
.dtl-list code,.dtl-paths code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-primary);word-break:break-all}
|
|
107
|
+
.dtl-filetool{color:var(--dsw-alias-label-tertiary)}
|
|
108
|
+
.dtl-paths{display:flex;flex-direction:column;gap:4px;font-size:11.5px;color:var(--dsw-alias-label-secondary);word-break:break-all}
|
|
109
|
+
.more-wrap{position:relative;flex:none}
|
|
110
|
+
.more-btn{appearance:none;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;line-height:1}
|
|
111
|
+
.more-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
112
|
+
.more-menu{position:absolute;top:calc(100% + 4px);right:0;z-index:60;min-width:160px;padding:5px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 10px 32px rgb(0 0 0/.24);display:flex;flex-direction:column;gap:1px}
|
|
113
|
+
.more-item{appearance:none;display:flex;align-items:center;gap:8px;width:100%;padding:7px 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary);border-radius:7px;font-size:12.5px;cursor:pointer;white-space:nowrap;text-align:left}
|
|
114
|
+
.more-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
115
|
+
.more-item-danger{color:var(--dsw-alias-state-error-primary)}
|
|
116
|
+
.more-item-danger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
|
|
117
|
+
.dlg-backdrop{position:fixed;inset:0;z-index:80;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:16px}
|
|
118
|
+
.dlg{width:min(420px,92vw);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:14px;padding:18px;box-shadow:0 16px 48px rgb(0 0 0/.28);display:flex;flex-direction:column;gap:12px}
|
|
119
|
+
.dlg-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary);margin:0}
|
|
120
|
+
.dlg-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0;word-break:break-all}
|
|
121
|
+
.dlg-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
122
|
+
`
|
|
123
|
+
|
|
124
|
+
function fmtDate(iso) {
|
|
125
|
+
if (!iso) return null
|
|
126
|
+
const d = new Date(iso)
|
|
127
|
+
if (isNaN(d.getTime())) return null
|
|
128
|
+
try {
|
|
129
|
+
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(d)
|
|
130
|
+
} catch {
|
|
131
|
+
return String(iso)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fmtBytes(n) {
|
|
136
|
+
if (!n && n !== 0) return null
|
|
137
|
+
if (n < 1024) return `${n} B`
|
|
138
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
139
|
+
let v = n
|
|
140
|
+
let u = -1
|
|
141
|
+
do { v /= 1024; u++ } while (v >= 1024 && u < units.length - 1)
|
|
142
|
+
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[u]}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pathName(p) {
|
|
146
|
+
if (!p) return null
|
|
147
|
+
const parts = String(p).replace(/\\+$/, '').split(/[/\\]/)
|
|
148
|
+
return parts[parts.length - 1] || p
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function postJSON(path, body) {
|
|
152
|
+
const res = await fetch(path, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
155
|
+
body: JSON.stringify(body || {}),
|
|
156
|
+
})
|
|
157
|
+
const data = await res.json().catch(() => null)
|
|
158
|
+
if (!res.ok || !data) throw new Error((data && (data.error || data.message)) || `request failed (${res.status})`)
|
|
159
|
+
return data
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Left-nav icon swap: DSH's settings shell renders a shared fallback gear for
|
|
163
|
+
// every custom section and settings.section has no per-section icon field.
|
|
164
|
+
// Like dsh-better-sidebar, we mark our own row by matching its visible label
|
|
165
|
+
// text, then CSS swaps the gear for an archive-box glyph. The marker owns no
|
|
166
|
+
// shell structure and is removed on fiber disposal (HMR-safe, ships with the
|
|
167
|
+
// plugin).
|
|
168
|
+
const NAV_CSS = `
|
|
169
|
+
[data-dsh-nav-sessions] > svg:first-child { display: none; }
|
|
170
|
+
[data-dsh-nav-sessions]::before {
|
|
171
|
+
content: ''; flex: none; width: 16px; height: 16px; background: currentColor;
|
|
172
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http://www.w3.org/2000/svg'%20width%3D'24'%20height%3D'24'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%208v13H3V8'/%3E%3Cpath%20d%3D'M1%203h22v5H1z'/%3E%3Cpath%20d%3D'M10%2012h4'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
173
|
+
mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http://www.w3.org/2000/svg'%20width%3D'24'%20height%3D'24'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%208v13H3V8'/%3E%3Cpath%20d%3D'M1%203h22v5H1z'/%3E%3Cpath%20d%3D'M10%2012h4'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
174
|
+
}
|
|
175
|
+
`
|
|
176
|
+
|
|
177
|
+
function markSettingsNav() {
|
|
178
|
+
const LABEL = '会话管理'
|
|
179
|
+
let disposed = false
|
|
180
|
+
const sync = () => {
|
|
181
|
+
if (disposed) return
|
|
182
|
+
const buttons = document.querySelectorAll('[role="dialog"] nav button')
|
|
183
|
+
for (const b of buttons) {
|
|
184
|
+
const t = (b.textContent || '').trim()
|
|
185
|
+
if (t === LABEL) b.setAttribute('data-dsh-nav-sessions', '')
|
|
186
|
+
else b.removeAttribute('data-dsh-nav-sessions')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sync()
|
|
190
|
+
const obs = new MutationObserver(sync)
|
|
191
|
+
obs.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
192
|
+
return () => {
|
|
193
|
+
disposed = true
|
|
194
|
+
obs.disconnect()
|
|
195
|
+
document.querySelectorAll('[data-dsh-nav-sessions]').forEach((e) => e.removeAttribute('data-dsh-nav-sessions'))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function installSettingsNavIcons(ctx) {
|
|
200
|
+
const styleEl = document.createElement('style')
|
|
201
|
+
styleEl.textContent = NAV_CSS
|
|
202
|
+
document.head.appendChild(styleEl)
|
|
203
|
+
const dispose = markSettingsNav()
|
|
204
|
+
ctx.effect(() => () => {
|
|
205
|
+
dispose()
|
|
206
|
+
if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function SessionPanel({ workspacesSvc }) {
|
|
211
|
+
const [sessions, setSessions] = useState(null)
|
|
212
|
+
const [workspaces, setWorkspaces] = useState([])
|
|
213
|
+
const initialPrefs = useRef(loadPanelPrefs()).current
|
|
214
|
+
const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
|
|
215
|
+
const [query, setQuery] = useState('')
|
|
216
|
+
const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
|
|
217
|
+
const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
|
|
218
|
+
const [selected, setSelected] = useState({})
|
|
219
|
+
const [delTarget, setDelTarget] = useState(null)
|
|
220
|
+
const [confirmBatch, setConfirmBatch] = useState(false)
|
|
221
|
+
const [busy, setBusy] = useState(null)
|
|
222
|
+
const [openMove, setOpenMove] = useState(null)
|
|
223
|
+
const [moveMode, setMoveMode] = useState('existing')
|
|
224
|
+
const [targetWs, setTargetWs] = useState('')
|
|
225
|
+
const [newPath, setNewPath] = useState('')
|
|
226
|
+
const [error, setError] = useState(null)
|
|
227
|
+
const [toast, setToast] = useState(null)
|
|
228
|
+
const [picking, setPicking] = useState(false)
|
|
229
|
+
const [trash, setTrash] = useState([])
|
|
230
|
+
const [trashBusy, setTrashBusy] = useState(null)
|
|
231
|
+
const [trashSettings, setTrashSettings] = useState({ retentionDays: 0 })
|
|
232
|
+
const [trashCheck, setTrashCheck] = useState(null)
|
|
233
|
+
const [purgeTarget, setPurgeTarget] = useState(null)
|
|
234
|
+
const [details, setDetails] = useState({})
|
|
235
|
+
const [openDetails, setOpenDetails] = useState(null)
|
|
236
|
+
const [detailsLoading, setDetailsLoading] = useState(null)
|
|
237
|
+
const [openMenu, setOpenMenu] = useState(null)
|
|
238
|
+
const timer = useRef(null)
|
|
239
|
+
const menuRef = useRef(null)
|
|
240
|
+
const dialogRef = useRef(null)
|
|
241
|
+
|
|
242
|
+
const showToast = (msg) => {
|
|
243
|
+
if (timer.current) clearTimeout(timer.current)
|
|
244
|
+
setToast(msg)
|
|
245
|
+
timer.current = setTimeout(() => setToast(null), 2400)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const refresh = () => {
|
|
249
|
+
setError(null)
|
|
250
|
+
Promise.all([
|
|
251
|
+
postJSON('/archived-sessions/sessions', {}),
|
|
252
|
+
postJSON('/archived-sessions/workspaces', {}),
|
|
253
|
+
])
|
|
254
|
+
.then(([s, works]) => {
|
|
255
|
+
// Permanently-purged sessions must never re-appear here, even if DSH's
|
|
256
|
+
// in-memory session index still lists them after the file was unlinked.
|
|
257
|
+
const purged = dsmLoadPurged()
|
|
258
|
+
const visible = (s.items || []).filter((x) => !purged.has(String(x.sessionId)))
|
|
259
|
+
setSessions(visible)
|
|
260
|
+
setWorkspaces(works.items || [])
|
|
261
|
+
setSelected({})
|
|
262
|
+
setDelTarget(null)
|
|
263
|
+
setConfirmBatch(false)
|
|
264
|
+
if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
|
|
265
|
+
loadTrash()
|
|
266
|
+
})
|
|
267
|
+
.catch((e) => setError(String((e && e.message) || e)))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
refresh()
|
|
272
|
+
return () => { if (timer.current) clearTimeout(timer.current) }
|
|
273
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
274
|
+
}, [])
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
|
|
278
|
+
}, [filter, workspaceFilter, sortBy])
|
|
279
|
+
|
|
280
|
+
// Close the ⋯ menu on outside click / Escape (no full-screen backdrop).
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (openMenu === null) return
|
|
283
|
+
const onDown = (e) => {
|
|
284
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) setOpenMenu(null)
|
|
285
|
+
}
|
|
286
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpenMenu(null) }
|
|
287
|
+
document.addEventListener('mousedown', onDown)
|
|
288
|
+
document.addEventListener('keydown', onKey)
|
|
289
|
+
return () => {
|
|
290
|
+
document.removeEventListener('mousedown', onDown)
|
|
291
|
+
document.removeEventListener('keydown', onKey)
|
|
292
|
+
}
|
|
293
|
+
}, [openMenu])
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!delTarget && !purgeTarget) return
|
|
297
|
+
const previous = document.activeElement
|
|
298
|
+
const onKey = (e) => {
|
|
299
|
+
if (e.key === 'Escape') { setDelTarget(null); setPurgeTarget(null) }
|
|
300
|
+
}
|
|
301
|
+
document.addEventListener('keydown', onKey)
|
|
302
|
+
requestAnimationFrame(() => dialogRef.current && dialogRef.current.querySelector('button')?.focus())
|
|
303
|
+
return () => {
|
|
304
|
+
document.removeEventListener('keydown', onKey)
|
|
305
|
+
if (previous && typeof previous.focus === 'function') previous.focus()
|
|
306
|
+
}
|
|
307
|
+
}, [delTarget, purgeTarget])
|
|
308
|
+
|
|
309
|
+
const wsPath = (id) => {
|
|
310
|
+
const w = workspaces.find((x) => x.workspaceId === id)
|
|
311
|
+
return w ? w.path : ''
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const archivedList = sessions ? sessions.filter((x) => x.archived) : []
|
|
315
|
+
const activeList = sessions ? sessions.filter((x) => !x.archived) : []
|
|
316
|
+
const list = useMemo(() => {
|
|
317
|
+
if (filter === 'trash') return []
|
|
318
|
+
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : sessions || []
|
|
319
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
320
|
+
const filtered = base.filter((item) => {
|
|
321
|
+
if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
|
|
322
|
+
if (!needle) return true
|
|
323
|
+
return [item.title, item.sessionId, item.workspaceTitle, item.workspacePath].some((value) => String(value || '').toLocaleLowerCase().includes(needle))
|
|
324
|
+
})
|
|
325
|
+
return [...filtered].sort((a, b) => {
|
|
326
|
+
if (sortBy === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
|
327
|
+
if (sortBy === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN')
|
|
328
|
+
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
|
329
|
+
})
|
|
330
|
+
}, [sessions, filter, query, workspaceFilter, sortBy])
|
|
331
|
+
const selIds = Object.keys(selected).filter((k) => selected[k])
|
|
332
|
+
|
|
333
|
+
const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
|
|
334
|
+
const clearSel = () => setSelected({})
|
|
335
|
+
const selectAll = () => {
|
|
336
|
+
const o = {}
|
|
337
|
+
list.forEach((x) => { o[x.sessionId] = true })
|
|
338
|
+
setSelected(o)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const act = (action, it) => {
|
|
342
|
+
if (busy) return
|
|
343
|
+
setBusy(it.sessionId)
|
|
344
|
+
postJSON('/archived-sessions/' + action, { sessionId: it.sessionId })
|
|
345
|
+
.then(() => {
|
|
346
|
+
setBusy(null)
|
|
347
|
+
const n = it.title || it.sessionId
|
|
348
|
+
showToast(action === 'archive' ? `已归档「${n}」` : `已恢复「${n}」`)
|
|
349
|
+
refresh()
|
|
350
|
+
})
|
|
351
|
+
.catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const doDeleteConfirmed = () => {
|
|
355
|
+
if (!delTarget || busy) return
|
|
356
|
+
setBusy(delTarget.sessionId)
|
|
357
|
+
postJSON('/archived-sessions/delete', { sessionId: delTarget.sessionId })
|
|
358
|
+
.then(() => {
|
|
359
|
+
setBusy(null)
|
|
360
|
+
const n = delTarget.title || delTarget.sessionId
|
|
361
|
+
showToast(`已删除 ${n}(已移入回收站)`)
|
|
362
|
+
setDelTarget(null)
|
|
363
|
+
refresh()
|
|
364
|
+
})
|
|
365
|
+
.catch((e) => { setBusy(null); setDelTarget(null); setError(String((e && e.message) || e)) })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const loadTrash = () => {
|
|
369
|
+
postJSON('/archived-sessions/trash/list', {})
|
|
370
|
+
.then((r) => { setTrash(r.items || []); setTrashSettings(r.settings || { retentionDays: 0 }) })
|
|
371
|
+
.catch(() => {})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const updateRetention = (days) => {
|
|
375
|
+
setTrashBusy('__settings')
|
|
376
|
+
postJSON('/archived-sessions/trash/settings', { retentionDays: Number(days) })
|
|
377
|
+
.then((r) => { setTrashBusy(null); setTrashSettings(r.settings); showToast('已更新自动清理策略') })
|
|
378
|
+
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const verifyTrash = () => {
|
|
382
|
+
setTrashBusy('__verify')
|
|
383
|
+
postJSON('/archived-sessions/trash/verify', {})
|
|
384
|
+
.then((r) => { setTrashBusy(null); setTrashCheck(r); showToast(r.missing ? `发现 ${r.missing} 条日志缺失` : '回收站校验通过') })
|
|
385
|
+
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const restoreTrash = (sid) => {
|
|
389
|
+
if (trashBusy) return
|
|
390
|
+
setTrashBusy(sid)
|
|
391
|
+
postJSON('/archived-sessions/trash/restore', { sessionId: sid })
|
|
392
|
+
.then(() => { setTrashBusy(null); showToast('已恢复会话'); loadTrash(); refresh() })
|
|
393
|
+
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
394
|
+
}
|
|
395
|
+
const purgeTrash = (sid) => {
|
|
396
|
+
if (trashBusy) return
|
|
397
|
+
setTrashBusy(sid)
|
|
398
|
+
postJSON('/archived-sessions/trash/purge', { sessionId: sid })
|
|
399
|
+
.then(() => { setTrashBusy(null); showToast('已彻底删除'); dsmMarkPurged([sid]); dsmLoadTrashIds(); loadTrash() })
|
|
400
|
+
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
401
|
+
}
|
|
402
|
+
const purgeAllTrash = () => {
|
|
403
|
+
if (trashBusy || !trash.length) return
|
|
404
|
+
setTrashBusy('__all')
|
|
405
|
+
const all = trash.map((t) => t.sessionId)
|
|
406
|
+
postJSON('/archived-sessions/trash/purge-many', { sessionIds: all })
|
|
407
|
+
.then((result) => {
|
|
408
|
+
setTrashBusy(null)
|
|
409
|
+
const rows = result.results || []
|
|
410
|
+
const succeeded = rows.filter((item) => item.ok).map((item) => item.sessionId)
|
|
411
|
+
const failed = rows.filter((item) => !item.ok)
|
|
412
|
+
if (succeeded.length) dsmMarkPurged(succeeded)
|
|
413
|
+
dsmLoadTrashIds()
|
|
414
|
+
loadTrash()
|
|
415
|
+
if (failed.length) {
|
|
416
|
+
const first = failed[0]
|
|
417
|
+
setError(`清理完成 ${succeeded.length} 条,失败 ${failed.length} 条:${first.error || first.sessionId}`)
|
|
418
|
+
} else showToast('已清空回收站')
|
|
419
|
+
})
|
|
420
|
+
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const doMove = (it) => {
|
|
424
|
+
const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
|
|
425
|
+
if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
|
|
426
|
+
setBusy(it.sessionId)
|
|
427
|
+
setError(null)
|
|
428
|
+
postJSON('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
|
|
429
|
+
.then((r) => {
|
|
430
|
+
setBusy(null)
|
|
431
|
+
setOpenMove(null)
|
|
432
|
+
setMoveMode('existing')
|
|
433
|
+
setNewPath('')
|
|
434
|
+
showToast(`已把「${it.title || it.sessionId}」移到 ${r.workspaceTitle || targetPath}`)
|
|
435
|
+
refresh()
|
|
436
|
+
})
|
|
437
|
+
.catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const doBatch = (action) => {
|
|
441
|
+
if (!selIds.length || busy) return
|
|
442
|
+
if (action === 'delete-many') {
|
|
443
|
+
if (!confirmBatch) { setConfirmBatch(true); return }
|
|
444
|
+
setConfirmBatch(false)
|
|
445
|
+
}
|
|
446
|
+
setBusy('__batch__')
|
|
447
|
+
postJSON('/archived-sessions/' + action, { sessionIds: selIds })
|
|
448
|
+
.then((r) => {
|
|
449
|
+
setBusy(null)
|
|
450
|
+
const n = (r && (r.archived || r.restored || r.deleted)) || selIds.length
|
|
451
|
+
showToast(`已处理 ${n} 个会话`)
|
|
452
|
+
refresh()
|
|
453
|
+
})
|
|
454
|
+
.catch((e) => { setBusy(null); setConfirmBatch(false); setError(String((e && e.message) || e)) })
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const openMoveFor = (it) => {
|
|
458
|
+
if (openMove === it.sessionId) { setOpenMove(null); return }
|
|
459
|
+
setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
|
|
460
|
+
setMoveMode('existing')
|
|
461
|
+
setNewPath('')
|
|
462
|
+
setOpenMove(it.sessionId)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const pickDirectory = async () => {
|
|
466
|
+
if (!workspacesSvc || picking) return
|
|
467
|
+
setPicking(true)
|
|
468
|
+
try {
|
|
469
|
+
const p = await workspacesSvc.pickDirectory()
|
|
470
|
+
if (p) { setNewPath(p); setMoveMode('new') }
|
|
471
|
+
} catch (e) {
|
|
472
|
+
setError(String((e && e.message) || e))
|
|
473
|
+
} finally {
|
|
474
|
+
setPicking(false)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const toggleDetails = (it) => {
|
|
479
|
+
if (openDetails === it.sessionId) { setOpenDetails(null); return }
|
|
480
|
+
if (details[it.sessionId]) { setOpenDetails(it.sessionId); return }
|
|
481
|
+
setDetailsLoading(it.sessionId)
|
|
482
|
+
postJSON('/archived-sessions/details', { sessionId: it.sessionId })
|
|
483
|
+
.then((d) => {
|
|
484
|
+
setDetails((m) => ({ ...m, [it.sessionId]: d }))
|
|
485
|
+
setDetailsLoading(null)
|
|
486
|
+
setOpenDetails(it.sessionId)
|
|
487
|
+
})
|
|
488
|
+
.catch((e) => { setDetailsLoading(null); setError(String((e && e.message) || e)) })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const workspaceTag = (it) => {
|
|
492
|
+
if (it.hasWorkspace && it.workspaceGone) {
|
|
493
|
+
return <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>工作区已删 · {pathName(it.workspacePath) || '?'}</span>
|
|
494
|
+
}
|
|
495
|
+
const wName = it.workspaceTitle || pathName(it.workspacePath)
|
|
496
|
+
if (wName) return <span className="archv-wtag" title={it.workspacePath || ''}>{wName}</span>
|
|
497
|
+
return <span className="archv-wtag">未分组</span>
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const runMenu = (id, it) => {
|
|
501
|
+
setOpenMenu(null)
|
|
502
|
+
if (id === 'restore') act('restore', it)
|
|
503
|
+
else if (id === 'archive') act('archive', it)
|
|
504
|
+
else if (id === 'delete') setDelTarget(it)
|
|
505
|
+
else if (id === 'move') openMoveFor(it)
|
|
506
|
+
else if (id === 'details') toggleDetails(it)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const rowMenu = (it) => {
|
|
510
|
+
const items = it.archived
|
|
511
|
+
? [
|
|
512
|
+
['restore', '恢复'],
|
|
513
|
+
['move', openMove === it.sessionId ? '收起移动' : '移动'],
|
|
514
|
+
['details', openDetails === it.sessionId ? '收起详情' : '详情'],
|
|
515
|
+
['delete', '删除'],
|
|
516
|
+
]
|
|
517
|
+
: [['archive', '归档'], ['move', '移动'], ['details', '详情'], ['delete', '删除']]
|
|
518
|
+
return (
|
|
519
|
+
<div ref={openMenu === it.sessionId ? menuRef : null} className="more-wrap">
|
|
520
|
+
<button
|
|
521
|
+
type="button"
|
|
522
|
+
className="more-btn"
|
|
523
|
+
aria-label="更多操作"
|
|
524
|
+
aria-haspopup="true"
|
|
525
|
+
aria-expanded={openMenu === it.sessionId}
|
|
526
|
+
onClick={(e) => { e.stopPropagation(); setOpenMenu(openMenu === it.sessionId ? null : it.sessionId) }}
|
|
527
|
+
>⋯</button>
|
|
528
|
+
{openMenu === it.sessionId && (
|
|
529
|
+
<div className="more-menu" role="menu">
|
|
530
|
+
{items.map(([id, label]) => (
|
|
531
|
+
<button
|
|
532
|
+
key={id}
|
|
533
|
+
type="button"
|
|
534
|
+
role="menuitem"
|
|
535
|
+
className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
|
|
536
|
+
onClick={() => runMenu(id, it)}
|
|
537
|
+
>{label}</button>
|
|
538
|
+
))}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
</div>
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<div className="archv" role="region" aria-label="会话管理">
|
|
547
|
+
<style>{CSS}</style>
|
|
548
|
+
<div className="archv-head">
|
|
549
|
+
<h2 className="archv-title">会话管理</h2>
|
|
550
|
+
{sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
|
|
551
|
+
</div>
|
|
552
|
+
<p className="archv-sub">
|
|
553
|
+
统一管理全部会话:归档 / 恢复 / 移动到其他工作区 / 会话详情 / 批量操作,删除会先进入回收站,可在回收站内恢复或彻底清理。
|
|
554
|
+
</p>
|
|
555
|
+
{error && (
|
|
556
|
+
<div className="archv-err" role="alert">
|
|
557
|
+
<span>{error}</span>
|
|
558
|
+
<button type="button" className="archv-errretry" onClick={refresh}>重试</button>
|
|
559
|
+
</div>
|
|
560
|
+
)}
|
|
561
|
+
{sessions === null ? (
|
|
562
|
+
<div className="archv-skel" aria-label="加载中">
|
|
563
|
+
{[0, 1, 2].map((i) => <div key={i} className="archv-skel-card" />)}
|
|
564
|
+
</div>
|
|
565
|
+
) : (
|
|
566
|
+
<>
|
|
567
|
+
<div className="sess-filter" role="tablist" aria-label="会话筛选">
|
|
568
|
+
<button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
|
|
569
|
+
<button type="button" role="tab" aria-selected={filter === 'active'} className={'sess-fbtn' + (filter === 'active' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('active'); clearSel(); setConfirmBatch(false) }}>活动 ({activeList.length})</button>
|
|
570
|
+
<button type="button" role="tab" aria-selected={filter === 'archived'} className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
|
|
571
|
+
<button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{filter !== 'trash' && (
|
|
575
|
+
<>
|
|
576
|
+
<div className="sess-tools" aria-label="查找和整理会话">
|
|
577
|
+
<div className="sess-field">
|
|
578
|
+
<label htmlFor="dsm-search">搜索会话</label>
|
|
579
|
+
<input id="dsm-search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="标题、会话 ID 或工作区" />
|
|
580
|
+
</div>
|
|
581
|
+
<div className="sess-field">
|
|
582
|
+
<label htmlFor="dsm-workspace-filter">工作区</label>
|
|
583
|
+
<select id="dsm-workspace-filter" value={workspaceFilter} onChange={(e) => setWorkspaceFilter(e.target.value)}>
|
|
584
|
+
<option value="all">全部工作区</option>
|
|
585
|
+
{workspaces.map((w) => <option key={w.workspaceId} value={w.path}>{w.title}</option>)}
|
|
586
|
+
</select>
|
|
587
|
+
</div>
|
|
588
|
+
<div className="sess-field">
|
|
589
|
+
<label htmlFor="dsm-sort">排序</label>
|
|
590
|
+
<select id="dsm-sort" value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
|
591
|
+
<option value="newest">最新创建</option><option value="oldest">最早创建</option><option value="title">标题 A–Z</option>
|
|
592
|
+
</select>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
<div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : sessions.length} 个` : ''}</div>
|
|
596
|
+
</>
|
|
597
|
+
)}
|
|
598
|
+
|
|
599
|
+
{filter !== 'trash' && list.length > 0 && (
|
|
600
|
+
<div className="sess-batch">
|
|
601
|
+
<span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
|
|
602
|
+
<button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
|
|
603
|
+
{selIds.length > 0 && (
|
|
604
|
+
<>
|
|
605
|
+
{filter === 'archived' && (
|
|
606
|
+
<>
|
|
607
|
+
<button type="button" className="archv-btn" disabled={busy !== null} onClick={() => doBatch('restore-many')}>恢复所选</button>
|
|
608
|
+
<button type="button" className="archv-btn archv-del" disabled={busy !== null} onClick={() => doBatch('delete-many')}>{confirmBatch ? '确认删除所选?' : '删除所选'}</button>
|
|
609
|
+
</>
|
|
610
|
+
)}
|
|
611
|
+
{filter !== 'archived' && (
|
|
612
|
+
<button type="button" className="archv-btn" disabled={busy !== null} onClick={() => doBatch('archive-many')}>归档所选</button>
|
|
613
|
+
)}
|
|
614
|
+
<button type="button" className="archv-btn" onClick={clearSel}>取消选择</button>
|
|
615
|
+
</>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
|
|
620
|
+
{filter !== 'trash' && list.length === 0 ? (
|
|
621
|
+
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
|
|
622
|
+
) : filter !== 'trash' ? (
|
|
623
|
+
<div className="archv-list" role="list">
|
|
624
|
+
{list.map((it) => {
|
|
625
|
+
const date = fmtDate(it.createdAt)
|
|
626
|
+
const expanded = openMove === it.sessionId
|
|
627
|
+
return (
|
|
628
|
+
<div key={it.sessionId} className={'archv-card' + (expanded ? ' archv-card-exp' : '')} role="listitem">
|
|
629
|
+
<div className="archv-row">
|
|
630
|
+
<input
|
|
631
|
+
type="checkbox"
|
|
632
|
+
className="archv-check"
|
|
633
|
+
checked={!!selected[it.sessionId]}
|
|
634
|
+
onChange={() => toggle(it.sessionId)}
|
|
635
|
+
aria-label={'选择 ' + (it.title || it.sessionId)}
|
|
636
|
+
/>
|
|
637
|
+
<div className="archv-body">
|
|
638
|
+
<div className="archv-main">
|
|
639
|
+
<div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
|
|
640
|
+
<div className="archv-meta">
|
|
641
|
+
{it.archived ? <span className="archv-wtag archv-wgone">已归档</span> : <span className="archv-wtag archv-active">活动</span>}
|
|
642
|
+
{workspaceTag(it)}
|
|
643
|
+
{date && <><span className="archv-dot">·</span><span className="archv-date">{date}</span></>}
|
|
644
|
+
<span className="archv-id">{it.sessionId}</span>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
{rowMenu(it)}
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
{expanded && (
|
|
651
|
+
<div className="mv-sheet" role="region" aria-label="移动到工作区">
|
|
652
|
+
<div className="mv-sheet-head">
|
|
653
|
+
<h3 className="mv-sheet-title">移动到工作区</h3>
|
|
654
|
+
<button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setOpenMove(null)}>×</button>
|
|
655
|
+
</div>
|
|
656
|
+
<div className="mv-seg" role="tablist">
|
|
657
|
+
<button type="button" role="tab" aria-selected={moveMode === 'existing'} className={'mv-segbtn' + (moveMode === 'existing' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('existing')}>已有工作区</button>
|
|
658
|
+
<button type="button" role="tab" aria-selected={moveMode === 'new'} className={'mv-segbtn' + (moveMode === 'new' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('new')}>新建目录</button>
|
|
659
|
+
</div>
|
|
660
|
+
{moveMode === 'existing' ? (
|
|
661
|
+
<div className="mv-field">
|
|
662
|
+
<label className="mv-field-label" htmlFor="mv-target-ws">目标工作区</label>
|
|
663
|
+
<select id="mv-target-ws" value={targetWs} onChange={(e) => setTargetWs(e.target.value)}>
|
|
664
|
+
{workspaces.length === 0 && <option value="">(暂无工作区)</option>}
|
|
665
|
+
{workspaces.map((w) => (
|
|
666
|
+
<option key={w.workspaceId} value={w.workspaceId}>{w.title} · {w.path}</option>
|
|
667
|
+
))}
|
|
668
|
+
</select>
|
|
669
|
+
</div>
|
|
670
|
+
) : (
|
|
671
|
+
<div className="mv-field">
|
|
672
|
+
<label className="mv-field-label" htmlFor="mv-new-path">新工作区目录路径</label>
|
|
673
|
+
<div className="mv-browse-row">
|
|
674
|
+
<input
|
|
675
|
+
id="mv-new-path"
|
|
676
|
+
type="text"
|
|
677
|
+
value={newPath}
|
|
678
|
+
onChange={(e) => setNewPath(e.target.value)}
|
|
679
|
+
placeholder="例如 /Users/you/Projects/demo 或 ~/demo"
|
|
680
|
+
/>
|
|
681
|
+
<button
|
|
682
|
+
type="button"
|
|
683
|
+
className="archv-btn"
|
|
684
|
+
onClick={pickDirectory}
|
|
685
|
+
disabled={busy !== null || picking || !workspacesSvc}
|
|
686
|
+
title={!workspacesSvc ? '当前运行环境不支持系统目录选择' : '打开系统目录选择窗口'}
|
|
687
|
+
>
|
|
688
|
+
{picking ? '选择中…' : '浏览…'}
|
|
689
|
+
</button>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
)}
|
|
693
|
+
<div className="mv-foot">
|
|
694
|
+
<button type="button" className="archv-btn" onClick={() => setOpenMove(null)}>取消</button>
|
|
695
|
+
<button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => doMove(it)}>
|
|
696
|
+
{busy === it.sessionId && <span className="archv-spin" aria-hidden="true" />}确认移动
|
|
697
|
+
</button>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
{openDetails === it.sessionId && (
|
|
702
|
+
<div className="dtl-sheet" role="region" aria-label="会话详情">
|
|
703
|
+
<div className="dtl-sheet-head">
|
|
704
|
+
<h3 className="dtl-sheet-title">会话详情</h3>
|
|
705
|
+
<button type="button" className="mv-sheet-close" aria-label="关闭详情" onClick={() => setOpenDetails(null)}>×</button>
|
|
706
|
+
</div>
|
|
707
|
+
{detailsLoading === it.sessionId ? (
|
|
708
|
+
<div className="archv-skel" aria-label="加载中">{[0, 1].map((i) => <div key={i} className="archv-skel-card" />)}</div>
|
|
709
|
+
) : (() => {
|
|
710
|
+
const d = details[it.sessionId]
|
|
711
|
+
if (!d) return <div className="dtl-paths">暂无详情</div>
|
|
712
|
+
const st = d.stats || {}
|
|
713
|
+
const tools = st.toolCounts ? Object.entries(st.toolCounts).sort((a, b) => b[1] - a[1]).slice(0, 10) : []
|
|
714
|
+
return (
|
|
715
|
+
<div>
|
|
716
|
+
<div className="dtl-grid">
|
|
717
|
+
<div className="dtl-cell"><span className="dtl-k">磁盘占用</span><span className="dtl-v">{fmtBytes(d.sizeBytes) || '—'}</span></div>
|
|
718
|
+
<div className="dtl-cell"><span className="dtl-k">轮次 / 步骤</span><span className="dtl-v">{st.turns ?? 0} / {st.steps ?? 0}</span></div>
|
|
719
|
+
<div className="dtl-cell"><span className="dtl-k">用户 / 助手</span><span className="dtl-v">{st.userMessages ?? 0} / {st.assistantMessages ?? 0}</span></div>
|
|
720
|
+
<div className="dtl-cell"><span className="dtl-k">工具调用</span><span className="dtl-v">{st.toolCalls ?? 0}</span></div>
|
|
721
|
+
<div className="dtl-cell"><span className="dtl-k">图片附件</span><span className="dtl-v">{st.attachments ?? 0}</span></div>
|
|
722
|
+
<div className="dtl-cell"><span className="dtl-k">创建 / 更新</span><span className="dtl-v">{fmtDate(d.createdAt) || '—'} · {fmtDate(d.updatedAt) || '—'}</span></div>
|
|
723
|
+
</div>
|
|
724
|
+
{tools.length > 0 && (
|
|
725
|
+
<div className="dtl-sec"><div className="dtl-sec-t">工具使用</div>
|
|
726
|
+
<div className="dtl-tags">{tools.map(([t, c]) => <span className="dtl-tag" key={t}>{t} ×{c}</span>)}</div>
|
|
727
|
+
</div>
|
|
728
|
+
)}
|
|
729
|
+
{st.fetches && st.fetches.length > 0 && (
|
|
730
|
+
<div className="dtl-sec"><div className="dtl-sec-t">搜索 / 抓取</div>
|
|
731
|
+
<ul className="dtl-list">{st.fetches.map((f, i) => <li key={i}>{f.tool}{f.query ? ` 「${f.query}」` : ''}</li>)}</ul>
|
|
732
|
+
</div>
|
|
733
|
+
)}
|
|
734
|
+
{d.files && d.files.length > 0 && (
|
|
735
|
+
<div className="dtl-sec"><div className="dtl-sec-t">写过的文件({d.files.length})</div>
|
|
736
|
+
<ul className="dtl-list">{d.files.map((f, i) => <li key={i}><code>{f.path}</code> <span className="dtl-filetool">({f.tool})</span></li>)}</ul>
|
|
737
|
+
</div>
|
|
738
|
+
)}
|
|
739
|
+
{d.lineage && (d.lineage.parentSessionId || (d.lineage.children && d.lineage.children.length > 0) || (d.lineage.subagents && d.lineage.subagents.length > 0)) && (
|
|
740
|
+
<div className="dtl-sec"><div className="dtl-sec-t">血统</div>
|
|
741
|
+
<div className="dtl-paths">
|
|
742
|
+
{d.lineage.parentSessionId && <div>父会话: <code>{d.lineage.parentSessionId}</code></div>}
|
|
743
|
+
{d.lineage.children && d.lineage.children.length > 0 && <div>子会话 ({d.lineage.children.length}): <code>{d.lineage.children.join(', ')}</code></div>}
|
|
744
|
+
{d.lineage.subagents && d.lineage.subagents.length > 0 && <div>子代理 ({d.lineage.subagents.length}): <code>{d.lineage.subagents.join(', ')}</code></div>}
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
)}
|
|
748
|
+
</div>
|
|
749
|
+
)
|
|
750
|
+
})()}
|
|
751
|
+
</div>
|
|
752
|
+
)}
|
|
753
|
+
</div>
|
|
754
|
+
)
|
|
755
|
+
})}
|
|
756
|
+
</div>
|
|
757
|
+
) : null}
|
|
758
|
+
</>
|
|
759
|
+
)}
|
|
760
|
+
{toast && <div className="archv-status" role="status">{toast}</div>}
|
|
761
|
+
{delTarget && (
|
|
762
|
+
<div className="dlg-backdrop" onClick={() => setDelTarget(null)}>
|
|
763
|
+
<div ref={dialogRef} className="dlg" role="alertdialog" aria-modal="true" aria-label="删除会话" onClick={(e) => e.stopPropagation()}>
|
|
764
|
+
<h3 className="dlg-title">删除会话</h3>
|
|
765
|
+
<p className="dlg-text">确认删除「{delTarget.title || delTarget.sessionId}」?将移入回收站,可在本页底部「回收站」中恢复或彻底删除。</p>
|
|
766
|
+
<div className="dlg-actions">
|
|
767
|
+
<button type="button" className="archv-btn" disabled={busy !== null} onClick={() => setDelTarget(null)}>取消</button>
|
|
768
|
+
<button type="button" className="archv-btn archv-del" disabled={busy !== null} onClick={doDeleteConfirmed}>移入回收站</button>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
</div>
|
|
772
|
+
)}
|
|
773
|
+
{filter === 'trash' && <section className="dsm-trash" aria-label="回收站">
|
|
774
|
+
<div className="dsm-trash-h">
|
|
775
|
+
<h3>回收站</h3>
|
|
776
|
+
<span className="dsm-trash-count">{trash.length ? `(${trash.length} 个待清理)` : '(空)'}</span>
|
|
777
|
+
</div>
|
|
778
|
+
<div className="sess-tools">
|
|
779
|
+
<div className="sess-field">
|
|
780
|
+
<label htmlFor="dsm-retention">自动清理</label>
|
|
781
|
+
<select id="dsm-retention" value={trashSettings.retentionDays || 0} disabled={trashBusy !== null} onChange={(e) => updateRetention(e.target.value)}>
|
|
782
|
+
<option value="0">不自动清理</option><option value="7">保留 7 天</option><option value="30">保留 30 天</option><option value="90">保留 90 天</option>
|
|
783
|
+
</select>
|
|
784
|
+
</div>
|
|
785
|
+
<div className="sess-field"><label>数据检查</label><button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={verifyTrash}>{trashBusy === '__verify' ? '校验中…' : '校验日志完整性'}</button></div>
|
|
786
|
+
<div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
|
|
787
|
+
</div>
|
|
788
|
+
{trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失。</div>}
|
|
789
|
+
{trash.length === 0 ? (
|
|
790
|
+
<div className="dsm-trash-empty">回收站为空。删除的会话会先进入这里,可恢复或彻底删除。</div>
|
|
791
|
+
) : (
|
|
792
|
+
<div className="dsm-trash-list">
|
|
793
|
+
{trash.map((t) => (
|
|
794
|
+
<div key={t.sessionId} className="dsm-trash-row">
|
|
795
|
+
<span className="dsm-trash-name" title={t.sessionId}>{t.title || t.sessionId}</span>
|
|
796
|
+
<span className="dsm-trash-date">{fmtDate(t.deletedAt)}</span>
|
|
797
|
+
<span className="dsm-trash-actions">
|
|
798
|
+
<button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
|
|
799
|
+
<button type="button" className="archv-btn archv-del" disabled={trashBusy !== null} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
|
|
800
|
+
</span>
|
|
801
|
+
</div>
|
|
802
|
+
))}
|
|
803
|
+
</div>
|
|
804
|
+
)}
|
|
805
|
+
</section>}
|
|
806
|
+
{purgeTarget && (
|
|
807
|
+
<div className="dlg-backdrop" onClick={() => setPurgeTarget(null)}>
|
|
808
|
+
<div ref={dialogRef} className="dlg" role="alertdialog" aria-modal="true" aria-labelledby="dsm-purge-title" onClick={(e) => e.stopPropagation()}>
|
|
809
|
+
<h3 id="dsm-purge-title" className="dlg-title">永久删除{purgeTarget === '__all' ? '全部回收站会话' : '这个会话'}?</h3>
|
|
810
|
+
<p className="dlg-text">此操作会物理删除日志,无法恢复。归档、筛选和重新安装插件都不能找回这些数据。</p>
|
|
811
|
+
<div className="dlg-actions">
|
|
812
|
+
<button type="button" className="archv-btn" onClick={() => setPurgeTarget(null)}>取消</button>
|
|
813
|
+
<button type="button" className="archv-btn archv-del" onClick={() => { const target = purgeTarget; setPurgeTarget(null); target === '__all' ? purgeAllTrash() : purgeTrash(target) }}>确认永久删除</button>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
</div>
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ---- Main-page sidebar session "⋯" menu augmentation (DOM shim) -----------
|
|
823
|
+
// DSH core renders the sidebar session list (dsh-client-ui-workspace) with a
|
|
824
|
+
// per-row "⋯" Menu whose items (rename / fork / archive) are hardcoded — there
|
|
825
|
+
// is NO plugin slot for adding per-session menu items, and the row DOM carries
|
|
826
|
+
// no sessionId. To honor the request we augment the already-opened portalled
|
|
827
|
+
// menu via DOM: we watch for a [role="menu"] portalled to document.body whose
|
|
828
|
+
// React fiber chain reaches a SessionNodeItem (i.e. it is a session menu), read
|
|
829
|
+
// the session id (and current cwd) off that fiber, then clone an existing menu
|
|
830
|
+
// item to append "移动会话" / "删除会话". This is intentionally a DOM shim and is
|
|
831
|
+
// fragile against DSH UI updates (class names / fiber shape / menu markup).
|
|
832
|
+
const SIDEBAR_AUG_CSS = `
|
|
833
|
+
.dsm-backdrop{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:16px}
|
|
834
|
+
.dsm-dlg{width:min(420px,92vw);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:14px;padding:18px;box-shadow:0 16px 48px rgb(0 0 0/.28);display:flex;flex-direction:column;gap:12px}
|
|
835
|
+
.dsm-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary);margin:0}
|
|
836
|
+
.dsm-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0;word-break:break-all}
|
|
837
|
+
.dsm-body{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto}
|
|
838
|
+
.dsm-loading,.dsm-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:4px 2px}
|
|
839
|
+
.dsm-err{font-size:12px;color:var(--dsw-alias-state-error-primary);padding:4px 2px}
|
|
840
|
+
.dsm-opt{appearance:none;display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);border-radius:9px;font-size:12.5px;cursor:pointer;text-align:left}
|
|
841
|
+
.dsm-opt:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l4)}
|
|
842
|
+
.dsm-opt:disabled{opacity:.55;cursor:default}
|
|
843
|
+
.dsm-opt-cur{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
844
|
+
.dsm-opt-name{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
|
845
|
+
.dsm-opt-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none;margin-left:8px}
|
|
846
|
+
.dsm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
847
|
+
.dsm-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer}
|
|
848
|
+
.dsm-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
849
|
+
.dsm-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
|
|
850
|
+
.dsm-del:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
|
|
851
|
+
.dsm-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);max-width:min(90vw,420px)}
|
|
852
|
+
.dsm-sub{position:fixed;z-index:1100;box-sizing:border-box;min-width:190px;max-width:320px;padding:4px;display:flex;flex-direction:column;gap:0;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
|
|
853
|
+
.dsm-sub-loading,.dsm-sub-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:6px 10px}
|
|
854
|
+
.dsm-sub-err{font-size:12px;color:var(--dsw-alias-state-error-primary);padding:6px 10px}
|
|
855
|
+
.dsm-sub-item{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:none;border-radius:8px;background:transparent;cursor:pointer;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary);text-align:left}
|
|
856
|
+
.dsm-sub-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
857
|
+
.dsm-sub-item:disabled{opacity:.5;cursor:default}
|
|
858
|
+
.dsm-sub-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
859
|
+
.dsm-sub-cur{font-size:11px;color:var(--dsw-alias-state-business-primary);flex:none;margin-left:8px}
|
|
860
|
+
.dsm-dot{position:absolute;left:6px;top:50%;transform:translateY(-50%);width:8px;height:8px;border-radius:50%;box-sizing:border-box;cursor:pointer;pointer-events:auto;z-index:1}
|
|
861
|
+
.dsm-dot-unread-manual{background:var(--dsw-alias-state-business-primary)}
|
|
862
|
+
.dsm-dot-waiting{background:var(--dsw-alias-state-warning-primary)}
|
|
863
|
+
.dsm-dot-unread{background:var(--dsw-alias-state-success-primary)}
|
|
864
|
+
.dsm-drag-source{opacity:.48}
|
|
865
|
+
.dsm-drop-target{position:relative;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)!important;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px;border-radius:8px}
|
|
866
|
+
.dsm-drop-target::after{content:'移动到这里';position:absolute;right:8px;top:50%;transform:translateY(-50%);padding:1px 6px;border-radius:4px;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-inverted);font-size:10px;font-weight:600;line-height:16px;pointer-events:none}
|
|
867
|
+
.dsm-drag-busy{cursor:progress!important}
|
|
868
|
+
.dsm-trash{margin-top:18px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:14px}
|
|
869
|
+
.dsm-trash-h{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
|
|
870
|
+
.dsm-trash-h h3{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
|
|
871
|
+
.dsm-trash-count{font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
872
|
+
.dsm-trash-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto}
|
|
873
|
+
.dsm-trash-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated)}
|
|
874
|
+
.dsm-trash-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-primary)}
|
|
875
|
+
.dsm-trash-date{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}
|
|
876
|
+
.dsm-trash-actions{display:flex;gap:6px;flex:none}.dsm-trash-actions .archv-btn{min-width:72px}
|
|
877
|
+
.dsm-trash-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:6px 2px}
|
|
878
|
+
`
|
|
879
|
+
|
|
880
|
+
// Shared status-dot state so the ⋯-menu "标记未读" action can toggle the
|
|
881
|
+
// manual flag and trigger a repaint without coupling the two installers.
|
|
882
|
+
const DSM_KEY_MANUAL = 'dsm-manual-unread-v1'
|
|
883
|
+
let dsmManualUnread = null
|
|
884
|
+
let dsmRepaintDots = null
|
|
885
|
+
function dsmLoadManual() {
|
|
886
|
+
if (dsmManualUnread) return dsmManualUnread
|
|
887
|
+
try { dsmManualUnread = new Set(JSON.parse(localStorage.getItem(DSM_KEY_MANUAL) || '[]')) } catch (e) { dsmManualUnread = new Set() }
|
|
888
|
+
return dsmManualUnread
|
|
889
|
+
}
|
|
890
|
+
function dsmSaveManual() { try { localStorage.setItem(DSM_KEY_MANUAL, JSON.stringify([...dsmLoadManual()])) } catch (e) {} }
|
|
891
|
+
function dsmToggleManual(id) {
|
|
892
|
+
const s = dsmLoadManual()
|
|
893
|
+
if (s.has(id)) s.delete(id); else s.add(id)
|
|
894
|
+
dsmSaveManual()
|
|
895
|
+
if (dsmRepaintDots) dsmRepaintDots()
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Recycle-bin membership (session ids currently in 回收站). The sidebar
|
|
899
|
+
// status-dot installer uses this to hide trashed sessions so they don't show
|
|
900
|
+
// up under DSH's "未分组" group. Loaded from the backend on a throttle and
|
|
901
|
+
// updated optimistically when our own ⋯-menu deletes a session.
|
|
902
|
+
let dsmTrashIds = null
|
|
903
|
+
let dsmServerPurgedIds = new Set()
|
|
904
|
+
let dsmAuthoritativeTitles = new Map()
|
|
905
|
+
let dsmTrashTick = 0
|
|
906
|
+
async function dsmLoadTrashIds() {
|
|
907
|
+
try {
|
|
908
|
+
const r = await postJSON('/archived-sessions/sidebar-state', {})
|
|
909
|
+
dsmTrashIds = new Set(((r && r.trashedSessionIds) || []).map(String))
|
|
910
|
+
dsmServerPurgedIds = new Set(((r && r.purgedSessionIds) || []).map(String))
|
|
911
|
+
dsmAuthoritativeTitles = new Map(Object.entries((r && r.titles) || {}).map(([id, title]) => [String(id), String(title)]))
|
|
912
|
+
if (dsmRepaintDots) dsmRepaintDots()
|
|
913
|
+
} catch (e) { /* keep last known set */ }
|
|
914
|
+
return dsmTrashIds
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Permanently-hidden set: sessions the user hard-purged from the 回收站. Unlike
|
|
918
|
+
// dsmTrashIds (which the backend poll refreshes and can drop), these are GONE for
|
|
919
|
+
// good, so we keep them hidden forever — otherwise DSH re-surfaces the orphan
|
|
920
|
+
// under a "未分组" group after the trash poll drops it from dsmTrashIds.
|
|
921
|
+
const DSM_KEY_PURGED = 'dsm-purged-v1'
|
|
922
|
+
let dsmPurgedIds = null
|
|
923
|
+
function dsmLoadPurged() {
|
|
924
|
+
if (!dsmPurgedIds) {
|
|
925
|
+
try { dsmPurgedIds = new Set(JSON.parse(localStorage.getItem(DSM_KEY_PURGED) || '[]')) } catch (e) { dsmPurgedIds = new Set() }
|
|
926
|
+
}
|
|
927
|
+
dsmServerPurgedIds.forEach((id) => dsmPurgedIds.add(id))
|
|
928
|
+
return dsmPurgedIds
|
|
929
|
+
}
|
|
930
|
+
function dsmSavePurged() { try { localStorage.setItem(DSM_KEY_PURGED, JSON.stringify([...dsmLoadPurged()])) } catch (e) {} }
|
|
931
|
+
function dsmMarkPurged(ids) {
|
|
932
|
+
const s = dsmLoadPurged()
|
|
933
|
+
ids.forEach((id) => s.add(String(id)))
|
|
934
|
+
dsmSavePurged()
|
|
935
|
+
if (dsmRepaintDots) dsmRepaintDots()
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function installSidebarSessionMenuAug() {
|
|
939
|
+
if (typeof document === 'undefined') return
|
|
940
|
+
const AUG = 'data-dsm-aug'
|
|
941
|
+
let styleInjected = false
|
|
942
|
+
let activeSubClose = null
|
|
943
|
+
let hoverTimer = null
|
|
944
|
+
|
|
945
|
+
const findFiber = (el) => {
|
|
946
|
+
const k = Object.keys(el).find((kk) => kk.startsWith('__reactFiber') || kk.startsWith('__reactInternalInstance'))
|
|
947
|
+
return k ? el[k] : null
|
|
948
|
+
}
|
|
949
|
+
// Walk the React fiber chain from the portalled menu element up to the
|
|
950
|
+
// SessionNodeItem component, which carries `node.id` (the session id).
|
|
951
|
+
const sessionInfoFromMenu = (menuEl) => {
|
|
952
|
+
let f = findFiber(menuEl)
|
|
953
|
+
let guard = 0
|
|
954
|
+
while (f && guard++ < 300) {
|
|
955
|
+
const p = f.memoizedProps
|
|
956
|
+
if (p && p.node && typeof p.node.id === 'string' && p.node.id) {
|
|
957
|
+
return { id: p.node.id, cwd: p.node.cwd || p.node.workspacePath || null }
|
|
958
|
+
}
|
|
959
|
+
f = f.return
|
|
960
|
+
}
|
|
961
|
+
return null
|
|
962
|
+
}
|
|
963
|
+
const closeMenu = () => {
|
|
964
|
+
try { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) } catch (e) {}
|
|
965
|
+
try { document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) } catch (e) {}
|
|
966
|
+
}
|
|
967
|
+
const escapeHtml = (s) => {
|
|
968
|
+
const d = document.createElement('div')
|
|
969
|
+
d.textContent = s == null ? '' : String(s)
|
|
970
|
+
return d.innerHTML
|
|
971
|
+
}
|
|
972
|
+
const toast = (msg) => {
|
|
973
|
+
const t = document.createElement('div')
|
|
974
|
+
t.className = 'dsm-toast'
|
|
975
|
+
t.textContent = msg
|
|
976
|
+
document.body.appendChild(t)
|
|
977
|
+
setTimeout(() => t.remove(), 2600)
|
|
978
|
+
}
|
|
979
|
+
const ensureStyle = () => {
|
|
980
|
+
if (styleInjected) return
|
|
981
|
+
const s = document.createElement('style')
|
|
982
|
+
s.dataset.dsm = 'aug'
|
|
983
|
+
s.textContent = SIDEBAR_AUG_CSS
|
|
984
|
+
document.head.appendChild(s)
|
|
985
|
+
styleInjected = true
|
|
986
|
+
}
|
|
987
|
+
const closeMoveSubmenu = () => {
|
|
988
|
+
if (activeSubClose) { const f = activeSubClose; activeSubClose = null; f() }
|
|
989
|
+
}
|
|
990
|
+
const openMoveSubmenu = (moveBtn, info) => {
|
|
991
|
+
ensureStyle()
|
|
992
|
+
closeMoveSubmenu()
|
|
993
|
+
const sub = document.createElement('div')
|
|
994
|
+
sub.className = 'dsm-sub'
|
|
995
|
+
sub.setAttribute('role', 'menu')
|
|
996
|
+
sub.setAttribute('data-dsm-sub', '')
|
|
997
|
+
sub.innerHTML = '<div class="dsm-sub-loading">加载工作区…</div>'
|
|
998
|
+
const r = moveBtn.getBoundingClientRect()
|
|
999
|
+
sub.style.top = Math.max(8, Math.round(r.top - 4)) + 'px'
|
|
1000
|
+
sub.style.left = Math.round(r.right + 10) + 'px'
|
|
1001
|
+
document.body.appendChild(sub)
|
|
1002
|
+
const closeSub = () => {
|
|
1003
|
+
activeSubClose = null
|
|
1004
|
+
if (sub.parentNode) sub.remove()
|
|
1005
|
+
document.removeEventListener('mousedown', onDocDown, true)
|
|
1006
|
+
window.removeEventListener('blur', closeSub)
|
|
1007
|
+
}
|
|
1008
|
+
const onDocDown = (e) => {
|
|
1009
|
+
if (sub.contains(e.target) || moveBtn.contains(e.target)) return
|
|
1010
|
+
closeSub()
|
|
1011
|
+
}
|
|
1012
|
+
// Keep DSH's own outside-click handler from closing the parent menu while
|
|
1013
|
+
// the pointer is over our submenu (it lives outside the portalled card).
|
|
1014
|
+
sub.addEventListener('mousedown', (e) => e.stopPropagation())
|
|
1015
|
+
// Hover bridging: moving from the trigger button across the 10px gap to
|
|
1016
|
+
// the submenu must not dismiss it; entering the submenu cancels the
|
|
1017
|
+
// pending close timer set on the button's mouseleave.
|
|
1018
|
+
sub.addEventListener('mouseenter', () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } })
|
|
1019
|
+
sub.addEventListener('mouseleave', () => { hoverTimer = setTimeout(() => closeSub(), 160) })
|
|
1020
|
+
setTimeout(() => document.addEventListener('mousedown', onDocDown, true), 0)
|
|
1021
|
+
window.addEventListener('blur', closeSub)
|
|
1022
|
+
activeSubClose = closeSub
|
|
1023
|
+
Promise.all([
|
|
1024
|
+
postJSON('/archived-sessions/workspaces', {}),
|
|
1025
|
+
postJSON('/archived-sessions/sessions', {}),
|
|
1026
|
+
]).then(([ws, sess]) => {
|
|
1027
|
+
const items = ws.items || []
|
|
1028
|
+
const cur = (sess.items || []).find((x) => x.sessionId === info.id)
|
|
1029
|
+
const curPath = cur ? cur.workspacePath : (info.cwd || null)
|
|
1030
|
+
if (!items.length) { sub.innerHTML = '<div class="dsm-sub-empty">(暂无可用工作区)</div>'; return }
|
|
1031
|
+
sub.innerHTML = ''
|
|
1032
|
+
items.forEach((w) => {
|
|
1033
|
+
const isCur = !!curPath && w.path === curPath
|
|
1034
|
+
const b = document.createElement('button')
|
|
1035
|
+
b.type = 'button'
|
|
1036
|
+
b.className = 'dsm-sub-item'
|
|
1037
|
+
b.setAttribute('role', 'menuitem')
|
|
1038
|
+
b.disabled = isCur
|
|
1039
|
+
const name = document.createElement('span')
|
|
1040
|
+
name.className = 'dsm-sub-name'
|
|
1041
|
+
name.textContent = w.title || pathName(w.path) || w.workspaceId
|
|
1042
|
+
b.appendChild(name)
|
|
1043
|
+
if (isCur) {
|
|
1044
|
+
const c = document.createElement('span')
|
|
1045
|
+
c.className = 'dsm-sub-cur'
|
|
1046
|
+
c.textContent = '当前'
|
|
1047
|
+
b.appendChild(c)
|
|
1048
|
+
}
|
|
1049
|
+
b.addEventListener('click', (e) => {
|
|
1050
|
+
e.stopPropagation()
|
|
1051
|
+
e.preventDefault()
|
|
1052
|
+
closeSub()
|
|
1053
|
+
closeMenu()
|
|
1054
|
+
postJSON('/archived-sessions/move', { sessionId: info.id, targetPath: w.path })
|
|
1055
|
+
.then(() => toast('移动成功'))
|
|
1056
|
+
.catch((ee) => toast('移动失败:' + String((ee && ee.message) || ee)))
|
|
1057
|
+
})
|
|
1058
|
+
sub.appendChild(b)
|
|
1059
|
+
})
|
|
1060
|
+
}).catch((e) => {
|
|
1061
|
+
sub.innerHTML = '<div class="dsm-sub-err">' + escapeHtml(String((e && e.message) || e)) + '</div>'
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
const openDeleteConfirm = (id) => {
|
|
1065
|
+
ensureStyle()
|
|
1066
|
+
const backdrop = document.createElement('div')
|
|
1067
|
+
backdrop.className = 'dsm-backdrop'
|
|
1068
|
+
const dlg = document.createElement('div')
|
|
1069
|
+
dlg.className = 'dsm-dlg'
|
|
1070
|
+
dlg.innerHTML = '<h3 class="dsm-title">删除会话</h3><p class="dsm-text">确认将该会话移入回收站?可在「设置 → 会话管理 → 回收站」中恢复或彻底删除。</p><div class="dsm-actions"><button type="button" class="dsm-btn" data-role="cancel">取消</button><button type="button" class="dsm-btn dsm-del" data-role="ok">移入回收站</button></div>'
|
|
1071
|
+
backdrop.appendChild(dlg)
|
|
1072
|
+
document.body.appendChild(backdrop)
|
|
1073
|
+
const close = () => backdrop.remove()
|
|
1074
|
+
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
|
|
1075
|
+
dlg.querySelector('[data-role=cancel]').addEventListener('click', close)
|
|
1076
|
+
dlg.querySelector('[data-role=ok]').addEventListener('click', () => {
|
|
1077
|
+
close()
|
|
1078
|
+
postJSON('/archived-sessions/delete', { sessionId: id })
|
|
1079
|
+
.then(() => {
|
|
1080
|
+
if (dsmTrashIds) dsmTrashIds.add(String(id))
|
|
1081
|
+
if (dsmRepaintDots) dsmRepaintDots()
|
|
1082
|
+
toast('已移入回收站')
|
|
1083
|
+
})
|
|
1084
|
+
.catch((e) => toast('删除失败:' + String((e && e.message) || e)))
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
1087
|
+
const augmentMenu = (menuEl, info) => {
|
|
1088
|
+
if (menuEl.querySelector('[' + AUG + ']')) return
|
|
1089
|
+
const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
|
|
1090
|
+
if (!viewport) return
|
|
1091
|
+
const proto = menuEl.querySelector('[role="menuitem"]')
|
|
1092
|
+
if (!proto) return
|
|
1093
|
+
const protoWrap = proto.parentElement
|
|
1094
|
+
const protoCls = proto.className
|
|
1095
|
+
const protoWrapCls = protoWrap ? protoWrap.className : ''
|
|
1096
|
+
const ICON_MOVE = '<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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 11v4"/><path d="M10 13l2 2 2-2"/></svg>'
|
|
1097
|
+
const ICON_DEL = '<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>'
|
|
1098
|
+
const ICON_UNREAD = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>'
|
|
1099
|
+
const mk = (label, svg, danger) => {
|
|
1100
|
+
const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
|
|
1101
|
+
if (protoWrapCls) wrap.className = protoWrapCls
|
|
1102
|
+
const btn = document.createElement('button')
|
|
1103
|
+
btn.type = 'button'
|
|
1104
|
+
btn.setAttribute('role', 'menuitem')
|
|
1105
|
+
btn.className = protoCls // same `.item` class DSH uses → identical layout/alignment
|
|
1106
|
+
const icon = document.createElement('span')
|
|
1107
|
+
icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center;color:' + (danger ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-tertiary)')
|
|
1108
|
+
icon.innerHTML = svg
|
|
1109
|
+
const lab = document.createElement('span')
|
|
1110
|
+
lab.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
1111
|
+
lab.textContent = label
|
|
1112
|
+
btn.appendChild(icon)
|
|
1113
|
+
btn.appendChild(lab)
|
|
1114
|
+
if (danger) btn.style.color = 'var(--dsw-alias-state-error-primary)'
|
|
1115
|
+
btn.setAttribute(AUG, '')
|
|
1116
|
+
wrap.appendChild(btn)
|
|
1117
|
+
return { wrap, btn }
|
|
1118
|
+
}
|
|
1119
|
+
const move = mk('移动会话', ICON_MOVE, false)
|
|
1120
|
+
const del = mk('删除会话', ICON_DEL, true)
|
|
1121
|
+
const mark = mk(dsmLoadManual().has(info.id) ? '标记已读' : '标记未读', ICON_UNREAD, false)
|
|
1122
|
+
if (mark.btn.firstChild) mark.btn.firstChild.style.color = 'var(--dsw-alias-state-business-primary)'
|
|
1123
|
+
const chev = document.createElement('span')
|
|
1124
|
+
chev.style.cssText = 'margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1'
|
|
1125
|
+
chev.textContent = '›'
|
|
1126
|
+
move.btn.appendChild(chev)
|
|
1127
|
+
move.btn.addEventListener('mouseenter', () => {
|
|
1128
|
+
if (!document.querySelector('[data-dsm-sub]')) openMoveSubmenu(move.btn, info)
|
|
1129
|
+
})
|
|
1130
|
+
move.btn.addEventListener('mouseleave', () => {
|
|
1131
|
+
hoverTimer = setTimeout(() => closeMoveSubmenu(), 160)
|
|
1132
|
+
})
|
|
1133
|
+
move.btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault() })
|
|
1134
|
+
del.btn.addEventListener('click', (e) => {
|
|
1135
|
+
e.stopPropagation()
|
|
1136
|
+
e.preventDefault()
|
|
1137
|
+
closeMoveSubmenu()
|
|
1138
|
+
openDeleteConfirm(info.id)
|
|
1139
|
+
})
|
|
1140
|
+
mark.btn.addEventListener('click', (e) => {
|
|
1141
|
+
e.stopPropagation()
|
|
1142
|
+
e.preventDefault()
|
|
1143
|
+
closeMoveSubmenu()
|
|
1144
|
+
dsmToggleManual(info.id)
|
|
1145
|
+
closeMenu()
|
|
1146
|
+
})
|
|
1147
|
+
viewport.appendChild(move.wrap)
|
|
1148
|
+
viewport.appendChild(del.wrap)
|
|
1149
|
+
// 标记未读 goes to the very top of the menu (above DSH's native items).
|
|
1150
|
+
viewport.insertBefore(mark.wrap, viewport.firstElementChild)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const seen = new WeakSet()
|
|
1154
|
+
const obs = new MutationObserver(() => {
|
|
1155
|
+
const menus = document.querySelectorAll('body > [role="menu"]')
|
|
1156
|
+
menus.forEach((menuEl) => {
|
|
1157
|
+
if (seen.has(menuEl)) return
|
|
1158
|
+
const info = sessionInfoFromMenu(menuEl)
|
|
1159
|
+
if (!info) return // not a session menu (e.g. settings / header dropdown)
|
|
1160
|
+
seen.add(menuEl)
|
|
1161
|
+
try { augmentMenu(menuEl, info) } catch (e) { /* best-effort DOM shim */ }
|
|
1162
|
+
})
|
|
1163
|
+
})
|
|
1164
|
+
obs.observe(document.body, { childList: true, subtree: false })
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Left-side per-session status dot (DOM shim). The color is driven by DSH's
|
|
1168
|
+
// REAL session status, read from the row's own StateDot ([data-state]):
|
|
1169
|
+
// 手动标记未读(蓝) = 用户在 ⋯ 菜单或点击圆点手动标记,localStorage 持久化(最高优先级)
|
|
1170
|
+
// 工作中(黄) = DSH state 'running'
|
|
1171
|
+
// 需用户反馈(琥珀) = DSH state 'warning'(有追问需用户反馈)
|
|
1172
|
+
// 完成后未读(绿) = DSH state 'done' 且用户尚未读过(read 集合)
|
|
1173
|
+
// 完成已读(不显示) = DSH state 'done' 且已读过(read 集合持久化)
|
|
1174
|
+
// 手动未读集合 + read 集合均持久化于 localStorage;打开过即记入 read,绿点不再出现。
|
|
1175
|
+
// 另外:进入回收站的会话会整行隐藏(不出现在「未分组」里)。
|
|
1176
|
+
function installSidebarStatusDots() {
|
|
1177
|
+
if (typeof document === 'undefined') return
|
|
1178
|
+
const DOT = 'data-dsm-dot'
|
|
1179
|
+
if (!document.querySelector('style[data-dsm=aug]')) {
|
|
1180
|
+
const s = document.createElement('style')
|
|
1181
|
+
s.dataset.dsm = 'aug'
|
|
1182
|
+
s.textContent = SIDEBAR_AUG_CSS
|
|
1183
|
+
document.head.appendChild(s)
|
|
1184
|
+
}
|
|
1185
|
+
const findFiber = (el) => {
|
|
1186
|
+
const k = Object.keys(el).find((kk) => kk.startsWith('__reactFiber') || kk.startsWith('__reactInternalInstance'))
|
|
1187
|
+
return k ? el[k] : null
|
|
1188
|
+
}
|
|
1189
|
+
const fiberProp = (el, pred) => {
|
|
1190
|
+
let f = findFiber(el)
|
|
1191
|
+
let g = 0
|
|
1192
|
+
while (f && g++ < 300) {
|
|
1193
|
+
const p = f.memoizedProps
|
|
1194
|
+
if (p && pred(p)) return pred(p)
|
|
1195
|
+
f = f.return
|
|
1196
|
+
}
|
|
1197
|
+
return null
|
|
1198
|
+
}
|
|
1199
|
+
const rowNode = (row) => fiberProp(row, (p) => (p.node && typeof p.node.id === 'string' && p.node.id ? p.node : null))
|
|
1200
|
+
const rowId = (row) => { const node = rowNode(row); return node ? node.id : null }
|
|
1201
|
+
let curActive = null
|
|
1202
|
+
const activeRowId = () => {
|
|
1203
|
+
const sel = document.querySelector('[role="treeitem"][aria-selected="true"]')
|
|
1204
|
+
return sel ? rowId(sel) : null
|
|
1205
|
+
}
|
|
1206
|
+
// Dot colors per the user's scheme. We read DSH's REAL session status from the
|
|
1207
|
+
// row's own StateDot (its [data-state] attr) and recolor it:
|
|
1208
|
+
// running → 黄 工作中
|
|
1209
|
+
// warning → 琥珀 有追问需用户反馈
|
|
1210
|
+
// done → 绿 完成后未读(读过则不再显示)
|
|
1211
|
+
// error → 红 出错/需关注(与 DSH 自带 StateDot 一致)
|
|
1212
|
+
// (manual → 蓝 手动标记未读,最高优先级)
|
|
1213
|
+
const COLOR = {
|
|
1214
|
+
manual: 'var(--dsw-alias-state-business-primary)', // 蓝 手动标记未读
|
|
1215
|
+
running: '#EAB308', // 黄 工作中 (DSH running)
|
|
1216
|
+
feedback: 'var(--dsw-alias-state-warning-primary)', // 琥珀 需用户反馈 (DSH warning)
|
|
1217
|
+
done: 'var(--dsw-alias-state-success-primary)', // 绿 完成后未读 (DSH done)
|
|
1218
|
+
error: 'var(--dsw-alias-state-error-primary)', // 红 出错/需关注 (DSH error)
|
|
1219
|
+
}
|
|
1220
|
+
const manualUnread = dsmLoadManual()
|
|
1221
|
+
const paint = () => {
|
|
1222
|
+
// Recompute the active session on every paint so the active row is never
|
|
1223
|
+
// shown from a stale `curActive` (the click that changes aria-selected
|
|
1224
|
+
// mutates the DOM and triggers paint immediately, before the 1.2s tick()
|
|
1225
|
+
// would have run — without this, the clicked session flashed green).
|
|
1226
|
+
const activeId = activeRowId()
|
|
1227
|
+
// Clicking into a manually-marked session auto-clears the manual unread
|
|
1228
|
+
// flag (it becomes read on open) — only on the active transition, so a
|
|
1229
|
+
// flag set while already viewing it isn't wiped until you re-enter it.
|
|
1230
|
+
if (activeId !== curActive && activeId && manualUnread.has(activeId)) {
|
|
1231
|
+
manualUnread.delete(activeId)
|
|
1232
|
+
dsmSaveManual()
|
|
1233
|
+
}
|
|
1234
|
+
curActive = activeId
|
|
1235
|
+
const rows = document.querySelectorAll('[role="treeitem"]')
|
|
1236
|
+
rows.forEach((row) => {
|
|
1237
|
+
const id = rowId(row)
|
|
1238
|
+
if (!id) return
|
|
1239
|
+
// Trashed sessions belong in 回收站, not the sidebar (DSH would group
|
|
1240
|
+
// them under "未分组"). Hide the row entirely and skip its dot. So do
|
|
1241
|
+
// hard-purged sessions — they're gone for good and must never resurface
|
|
1242
|
+
// (otherwise DSH re-renders the orphan under a "未分组" group).
|
|
1243
|
+
const purged = dsmLoadPurged()
|
|
1244
|
+
if ((dsmTrashIds && dsmTrashIds.has(id)) || purged.has(id)) {
|
|
1245
|
+
if (row.style.display !== 'none') row.style.display = 'none'
|
|
1246
|
+
const d = row.querySelector('[' + DOT + ']'); if (d) d.remove()
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
if (row.style.display === 'none') row.style.display = ''
|
|
1250
|
+
// DSH's cold list baseline can expose a stale header title until the
|
|
1251
|
+
// Session is opened. Paint the latest log-folded title from the host
|
|
1252
|
+
// authority without materializing the Session or changing its log.
|
|
1253
|
+
const authoritativeTitle = dsmAuthoritativeTitles.get(id)
|
|
1254
|
+
if (authoritativeTitle) {
|
|
1255
|
+
const node = rowNode(row) || {}
|
|
1256
|
+
const expected = new Set([node.title, node.displayTitle, node.name].filter((value) => typeof value === 'string'))
|
|
1257
|
+
const spans = [...row.children].filter((el) => el.tagName === 'SPAN' && !el.querySelector('[data-state]') && (el.textContent || '').trim())
|
|
1258
|
+
const titleEl = spans.find((el) => expected.has((el.textContent || '').trim())) || spans[0]
|
|
1259
|
+
if (titleEl && titleEl.textContent !== authoritativeTitle) titleEl.textContent = authoritativeTitle
|
|
1260
|
+
}
|
|
1261
|
+
// Read DSH's REAL session status from the row's own StateDot and recolor
|
|
1262
|
+
// it with the user's scheme (we also hide DSH's dot so only ours shows).
|
|
1263
|
+
const sd = row.querySelector('[data-state]')
|
|
1264
|
+
if (sd) sd.style.display = 'none'
|
|
1265
|
+
let dot = row.querySelector('[' + DOT + ']')
|
|
1266
|
+
let color = null
|
|
1267
|
+
if (manualUnread.has(id)) color = COLOR.manual
|
|
1268
|
+
else if (sd) {
|
|
1269
|
+
const st = sd.getAttribute('data-state')
|
|
1270
|
+
if (st === 'running') color = COLOR.running
|
|
1271
|
+
else if (st === 'warning') color = COLOR.feedback
|
|
1272
|
+
else if (st === 'error') color = COLOR.error
|
|
1273
|
+
else if (st === 'done') color = (activeId === id) ? null : COLOR.done
|
|
1274
|
+
}
|
|
1275
|
+
if (!color) { if (dot) dot.remove(); return }
|
|
1276
|
+
if (!dot) {
|
|
1277
|
+
dot = document.createElement('span')
|
|
1278
|
+
dot.setAttribute(DOT, '')
|
|
1279
|
+
dot.className = 'dsm-dot'
|
|
1280
|
+
if (!row.hasAttribute('data-dsm-pos')) {
|
|
1281
|
+
if (getComputedStyle(row).position === 'static') row.style.position = 'relative'
|
|
1282
|
+
row.setAttribute('data-dsm-pos', '')
|
|
1283
|
+
}
|
|
1284
|
+
dot.addEventListener('click', (e) => {
|
|
1285
|
+
e.stopPropagation(); e.preventDefault()
|
|
1286
|
+
dsmToggleManual(id)
|
|
1287
|
+
})
|
|
1288
|
+
row.insertBefore(dot, row.firstChild)
|
|
1289
|
+
}
|
|
1290
|
+
dot.style.background = color
|
|
1291
|
+
})
|
|
1292
|
+
}
|
|
1293
|
+
dsmRepaintDots = paint
|
|
1294
|
+
const tick = () => { if (++dsmTrashTick % 8 === 0) dsmLoadTrashIds(); paint() }
|
|
1295
|
+
let raf = 0
|
|
1296
|
+
const schedulePaint = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; paint() }) }
|
|
1297
|
+
tick()
|
|
1298
|
+
dsmLoadTrashIds()
|
|
1299
|
+
paint()
|
|
1300
|
+
const obs = new MutationObserver(schedulePaint)
|
|
1301
|
+
obs.observe(document.body, { childList: true, subtree: true })
|
|
1302
|
+
setInterval(tick, 1200)
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Cross-workspace drag/drop for the native DSH sidebar. DSH does not expose a
|
|
1306
|
+
// sidebar-row plugin slot, so the adapter discovers session/workspace rows from
|
|
1307
|
+
// their React fiber nodes, while the actual move remains entirely host-owned.
|
|
1308
|
+
// The existing “移动会话” menu is also the keyboard-accessible fallback.
|
|
1309
|
+
function installSidebarWorkspaceDrag() {
|
|
1310
|
+
if (typeof document === 'undefined') return
|
|
1311
|
+
let sessions = new Map()
|
|
1312
|
+
let dragging = null
|
|
1313
|
+
let moving = false
|
|
1314
|
+
|
|
1315
|
+
const findFiber = (el) => {
|
|
1316
|
+
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
|
|
1317
|
+
return key ? el[key] : null
|
|
1318
|
+
}
|
|
1319
|
+
const fiberNodes = (el) => {
|
|
1320
|
+
const out = []
|
|
1321
|
+
let fiber = findFiber(el)
|
|
1322
|
+
let guard = 0
|
|
1323
|
+
while (fiber && guard++ < 300) {
|
|
1324
|
+
const props = fiber.memoizedProps
|
|
1325
|
+
if (props && props.node && typeof props.node === 'object') out.push(props.node)
|
|
1326
|
+
// DSH's SessionNodeItem receives `node`, while ProjectRowItem (the
|
|
1327
|
+
// workspace heading) receives `group`. Without the latter every session
|
|
1328
|
+
// can start dragging but no workspace can ever become a valid drop zone.
|
|
1329
|
+
if (props && props.group && typeof props.group === 'object') out.push(props.group)
|
|
1330
|
+
fiber = fiber.return
|
|
1331
|
+
}
|
|
1332
|
+
return out
|
|
1333
|
+
}
|
|
1334
|
+
const sessionForRow = (row) => {
|
|
1335
|
+
for (const node of fiberNodes(row)) {
|
|
1336
|
+
const id = node && node.id != null ? String(node.id) : ''
|
|
1337
|
+
if (sessions.has(id)) return sessions.get(id)
|
|
1338
|
+
// The live DSH row is authoritative even before the async sidebar-state
|
|
1339
|
+
// refresh finishes. Workspace groups use `workspaceId`, never `id`.
|
|
1340
|
+
if (id && node.workspaceId == null && (node.title != null || node.updatedAt != null || node.blank != null)) {
|
|
1341
|
+
return { sessionId: id, title: node.title || '', workspacePath: null }
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return null
|
|
1345
|
+
}
|
|
1346
|
+
const workspaceForRow = (row) => {
|
|
1347
|
+
// A session row's fiber chain also contains its parent workspace node.
|
|
1348
|
+
// Reject it explicitly so only the visible workspace header is a drop zone.
|
|
1349
|
+
if (sessionForRow(row)) return null
|
|
1350
|
+
for (const group of fiberNodes(row)) {
|
|
1351
|
+
if (group.workspaceId != null && typeof group.cwd === 'string' && group.cwd) {
|
|
1352
|
+
return { workspaceId: String(group.workspaceId), path: group.cwd, title: group.label || group.cwd }
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return null
|
|
1356
|
+
}
|
|
1357
|
+
const eventRow = (event) => {
|
|
1358
|
+
for (const item of event.composedPath ? event.composedPath() : []) {
|
|
1359
|
+
if (item instanceof Element && item.getAttribute('role') === 'treeitem') return item
|
|
1360
|
+
}
|
|
1361
|
+
return event.target instanceof Element ? event.target.closest('[role="treeitem"]') : null
|
|
1362
|
+
}
|
|
1363
|
+
const toast = (message) => {
|
|
1364
|
+
const el = document.createElement('div')
|
|
1365
|
+
el.className = 'dsm-toast'
|
|
1366
|
+
el.setAttribute('role', 'status')
|
|
1367
|
+
el.textContent = message
|
|
1368
|
+
document.body.appendChild(el)
|
|
1369
|
+
setTimeout(() => el.remove(), 2600)
|
|
1370
|
+
}
|
|
1371
|
+
const clearVisuals = () => {
|
|
1372
|
+
document.querySelectorAll('.dsm-drag-source,.dsm-drop-target').forEach((el) => el.classList.remove('dsm-drag-source', 'dsm-drop-target'))
|
|
1373
|
+
}
|
|
1374
|
+
const decorateRows = () => {
|
|
1375
|
+
document.querySelectorAll('[role="treeitem"]').forEach((row) => {
|
|
1376
|
+
const session = sessionForRow(row)
|
|
1377
|
+
const ws = workspaceForRow(row)
|
|
1378
|
+
if (session) {
|
|
1379
|
+
row.setAttribute('aria-description', '可拖动到其他工作区;键盘用户可通过更多菜单中的移动会话操作')
|
|
1380
|
+
}
|
|
1381
|
+
if (ws) row.setAttribute('data-dsm-workspace-drop', '')
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
const refresh = async () => {
|
|
1385
|
+
try {
|
|
1386
|
+
const sessionResult = await postJSON('/archived-sessions/sessions', {})
|
|
1387
|
+
sessions = new Map((sessionResult.items || []).map((item) => [String(item.sessionId), item]))
|
|
1388
|
+
decorateRows()
|
|
1389
|
+
} catch (e) { /* sidebar enhancement remains optional */ }
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Capture before DSH's delegated React handlers. DSH reserves the same
|
|
1393
|
+
// native drag gesture for reordering sessions inside one group; only a real
|
|
1394
|
+
// workspace-heading target is intercepted here, so same-group sorting keeps
|
|
1395
|
+
// its official behavior.
|
|
1396
|
+
document.addEventListener('dragstart', (event) => {
|
|
1397
|
+
const row = eventRow(event)
|
|
1398
|
+
const item = row && sessionForRow(row)
|
|
1399
|
+
if (!item || moving) return
|
|
1400
|
+
dragging = item
|
|
1401
|
+
row.classList.add('dsm-drag-source')
|
|
1402
|
+
}, true)
|
|
1403
|
+
document.addEventListener('dragover', (event) => {
|
|
1404
|
+
const row = eventRow(event)
|
|
1405
|
+
const target = row && workspaceForRow(row)
|
|
1406
|
+
if (!dragging || !target || moving) return
|
|
1407
|
+
if (dragging.workspacePath && target.path === dragging.workspacePath) return
|
|
1408
|
+
event.preventDefault()
|
|
1409
|
+
event.stopPropagation()
|
|
1410
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
1411
|
+
document.querySelectorAll('.dsm-drop-target').forEach((el) => { if (el !== row) el.classList.remove('dsm-drop-target') })
|
|
1412
|
+
row.classList.add('dsm-drop-target')
|
|
1413
|
+
}, true)
|
|
1414
|
+
document.addEventListener('drop', async (event) => {
|
|
1415
|
+
const row = eventRow(event)
|
|
1416
|
+
const item = dragging
|
|
1417
|
+
const target = row && workspaceForRow(row)
|
|
1418
|
+
if (!item || !target || moving || (item.workspacePath && target.path === item.workspacePath)) return
|
|
1419
|
+
event.preventDefault()
|
|
1420
|
+
event.stopPropagation()
|
|
1421
|
+
moving = true
|
|
1422
|
+
clearVisuals()
|
|
1423
|
+
row.classList.add('dsm-drag-busy')
|
|
1424
|
+
try {
|
|
1425
|
+
const result = await postJSON('/archived-sessions/move', { sessionId: item.sessionId, targetPath: target.path })
|
|
1426
|
+
sessions.set(item.sessionId, { ...item, workspacePath: result.workspacePath || target.path, workspaceTitle: result.workspaceTitle || target.title })
|
|
1427
|
+
await dsmLoadTrashIds()
|
|
1428
|
+
toast(`已移到「${result.workspaceTitle || target.title}」`)
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
toast('移动失败:' + String((error && error.message) || error))
|
|
1431
|
+
} finally {
|
|
1432
|
+
moving = false
|
|
1433
|
+
dragging = null
|
|
1434
|
+
row.classList.remove('dsm-drag-busy')
|
|
1435
|
+
decorateRows()
|
|
1436
|
+
}
|
|
1437
|
+
}, true)
|
|
1438
|
+
document.addEventListener('dragend', () => {
|
|
1439
|
+
dragging = null
|
|
1440
|
+
clearVisuals()
|
|
1441
|
+
}, true)
|
|
1442
|
+
|
|
1443
|
+
let decorateTimer = null
|
|
1444
|
+
const scheduleDecorate = () => {
|
|
1445
|
+
if (decorateTimer) return
|
|
1446
|
+
decorateTimer = requestAnimationFrame(() => { decorateTimer = null; decorateRows() })
|
|
1447
|
+
}
|
|
1448
|
+
refresh()
|
|
1449
|
+
const observer = new MutationObserver(scheduleDecorate)
|
|
1450
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
1451
|
+
setInterval(refresh, 5000)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
export function apply(ctx) {
|
|
1455
|
+
installSettingsNavIcons(ctx)
|
|
1456
|
+
installSidebarSessionMenuAug()
|
|
1457
|
+
installSidebarStatusDots()
|
|
1458
|
+
installSidebarWorkspaceDrag()
|
|
1459
|
+
const workspacesSvc = ctx.get('workspaces')
|
|
1460
|
+
ctx.slots.inject('settings.section', () =>
|
|
1461
|
+
ctx.slots.register(
|
|
1462
|
+
{ name: 'settings.section', id: 'session-manager', order: 90, label: '会话管理' },
|
|
1463
|
+
(props) => <SessionPanel {...props} workspacesSvc={workspacesSvc} />,
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
}
|