@mzzsfy/dsh-session-manager 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -1,811 +1,811 @@
1
- // dsh-session-manager Client 半区:settings.section 归档面板 + 归档/操作反馈通知。
2
- // 归档快照来自官方 workspace.follow 客户端模型(ctx.get('workspaces')),会话行来自
3
- // ctx.get('sessions');面板数据 = 会话行 ∩ 归档集合(纯投影),通知由 archived
4
- // 增量帧的集合差分驱动,经公共依赖 @mzzsfy/dsh-toast 展示。
5
- // 浏览器半区经 webServer 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
6
- // require;与 src/core.mjs 镜像的纯函数(projectRows / archiveToastStep /
7
- // archiveToastText / projectDeletedRows / pageArchiveRows /
8
- // groupArchiveRowsByWorkspace / filterArchiveRows)修改需两处同步。
9
-
10
- window.__ModuleLoader__.load({
11
- id: '@mzzsfy/dsh-session-manager',
12
- factory(require) {
13
- const React = require('react')
14
- const { useState, useEffect, useRef, useSyncExternalStore } = React
15
-
16
- // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
17
- // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
18
- const NAV_ICON = { '会话归档': 'archive', 'dsh-session-manager': 'archive' }
19
- if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
20
- else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
21
- else window.__navicIconQueue = [NAV_ICON]
22
-
23
- // 通知出口:公共依赖 @mzzsfy/dsh-toast(external require,宿主占位条目由
24
- // 本插件 cordis.patch.yml 代挂)
25
- const { show: toast } = require('@mzzsfy/dsh-toast/client')
26
-
27
- // 视觉方案:归档台账——全部取宿主 alias 令牌(双主题自适应,不造色),数据列
28
- // 用宿主代码字体(会话即文件的档案词汇),签名元素为托盘内的归档轨
29
- const CSS = [
30
- '.sm-panel { display:flex; flex-direction:column; gap:8px; min-width:0; color:var(--dsw-alias-label-primary); font:var(--dsw-font-s-14); }',
31
- '.sm-head { display:flex; align-items:baseline; gap:8px; }',
32
- '.sm-head__title { font:var(--dsw-font-m-18); }',
33
- '.sm-head__count { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
34
- '.sm-head__hint { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption); }',
35
- '.sm-tray { position:relative; background:var(--dsw-alias-bg-module-platform); border-radius:10px; padding:4px 0; }',
36
- '.sm-tray::before { content:""; position:absolute; left:15px; top:12px; bottom:12px; width:1px; background:var(--dsw-alias-border-l3); }',
37
- '.sm-row { position:relative; display:grid; grid-template-columns:88px minmax(0, 1fr) auto; align-items:center; gap:8px;',
38
- ' padding:7px 10px 7px 28px; border-radius:8px; }',
39
- '.sm-row::before { content:""; position:absolute; left:13px; top:50%; width:5px; height:5px; margin:-2.5px 0 0;',
40
- ' border-radius:50%; background:var(--dsw-alias-border-l4); }',
41
- '.sm-row:hover, .sm-row:focus-within { background:var(--dsw-alias-interactive-bg-hover); }',
42
- '.sm-row:hover::before, .sm-row:focus-within::before { background:var(--dsw-alias-state-business-primary); }',
43
- '.sm-row--armed { background:var(--dsw-alias-interactive-bg-hover-danger); }',
44
- '.sm-row--armed::before { background:var(--dsw-alias-state-error-primary); }',
45
- '.sm-row--busy { opacity:.45; pointer-events:none; }',
46
- '.sm-row__time, .sm-row__size { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }',
47
- '.sm-row__size { color:var(--dsw-alias-state-error-primary); }',
48
- '.sm-row__title-wrap { display:flex; align-items:center; gap:6px; min-width:0; }',
49
- '.sm-row__ws { flex:none; max-width:40%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;',
50
- ' padding:0 6px; border-radius:999px; font:11px/18px var(--ds-font-family-code, monospace);',
51
- ' color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }',
52
- '.sm-row__ws--none { background:transparent; box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3); }',
53
- '.sm-row__title { font:var(--dsw-font-s-strong-14); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
54
- '.sm-row__error { grid-column:2 / -1; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-state-error-primary); }',
55
- '.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption);',
56
- ' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
57
- '.sm-deleted { display:flex; flex-direction:column; gap:8px; min-width:0; }',
58
- // 归档行:恢复常显;已删除区保持整组操作悬停显现(删除按钮显隐规则在 .sm-btn 块后)
59
- '.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; }',
60
- '.sm-deleted .sm-row__actions { opacity:0; transition:opacity 0.12s ease; }',
61
- '.sm-deleted .sm-row:hover .sm-row__actions, .sm-deleted .sm-row:focus-within .sm-row__actions { opacity:1; }',
62
- '.sm-btn { border:0; background:transparent; cursor:pointer; padding:2px 8px; border-radius:6px;',
63
- ' font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-tertiary); }',
64
- '.sm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
65
- '.sm-btn--restore:hover { color:var(--dsw-alias-state-business-primary); }',
66
- '.sm-btn--danger:hover { color:var(--dsw-alias-state-error-primary); }',
67
- '.sm-btn--confirm { color:var(--dsw-alias-state-error-primary); border:1px solid var(--dsw-alias-state-error-primary); }',
68
- '.sm-btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); }',
69
- '.sm-btn:disabled { opacity:.45; cursor:default; background:transparent; color:var(--dsw-alias-label-tertiary); }',
70
- '.sm-btn--confirm:disabled { color:var(--dsw-alias-state-error-primary); }',
71
- '.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
72
- // 删除按钮显隐:隐藏规则必须位于 .sm-btn:disabled 之后——同特异度后出者胜,
73
- // 否则 busy 禁用态被禁用半透明规则压过而幽灵显形;显现规则特异度更高,不受顺序约束
74
- '.sm-btn--danger, .sm-btn--danger:disabled { opacity:0; transition:opacity 0.12s ease; }',
75
- '.sm-row:hover .sm-btn--danger, .sm-row:focus-within .sm-btn--danger { opacity:1; }',
76
- // 删除确认弹窗:遮罩 + 居中卡片;高 z-index 压过设置面板层,点击遮罩不关闭(强制显式选择)
77
- '.sm-dialog { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center;',
78
- ' background:rgba(0,0,0,.45); }',
79
- '.sm-dialog__card { width:min(420px, calc(100vw - 48px)); border-radius:12px; padding:20px;',
80
- ' background:var(--dsw-alias-surface-primary, light-dark(#fff, #1f2126)); color:inherit;',
81
- ' box-shadow:0 12px 40px rgba(0,0,0,.3); }',
82
- '.sm-dialog__title { font:var(--dsw-font-m-strong-16, 600 16px/24px sans-serif);',
83
- ' color:var(--dsw-alias-state-error-primary); margin:0 0 10px; }',
84
- '.sm-dialog__body { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-label-secondary); margin:0 0 8px; }',
85
- '.sm-dialog__warn { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-state-error-primary); margin:0 0 16px; }',
86
- '.sm-dialog__actions { display:flex; justify-content:flex-end; gap:8px; }',
87
- '.sm-dialog__btn { border:1px solid var(--dsw-alias-border-l3); background:transparent; cursor:pointer;',
88
- ' padding:6px 16px; border-radius:8px; font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-secondary); }',
89
- '.sm-dialog__btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
90
- '.sm-dialog__btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
91
- '.sm-dialog__btn--confirm { border-color:var(--dsw-alias-state-error-primary); color:var(--dsw-alias-state-error-primary); }',
92
- '.sm-dialog__btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); color:var(--dsw-alias-state-error-primary); }',
93
- '.sm-empty { padding:24px 12px; text-align:center; color:var(--dsw-alias-label-caption); }',
94
- '.sm-empty__hint { font:var(--dsw-font-xxs-12); margin-top:2px; }',
95
- // 归档库二级视图:工具行(搜索)与工作区分组、分页展开按钮
96
- '.sm-lib { display:flex; flex-direction:column; gap:8px; }',
97
- '.sm-libbar { display:flex; align-items:center; gap:8px; }',
98
- '.sm-libbar__search { flex:1; min-width:0; padding:4px 10px; border-radius:8px; color-scheme:light dark;',
99
- ' border:1px solid var(--dsw-alias-border-l3); background:transparent; color:inherit; font:var(--dsw-font-s-14); }',
100
- '.sm-libbar__search:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
101
- '.sm-libbar__count { flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
102
- '.sm-group { display:flex; flex-direction:column; }',
103
- '.sm-group__head { display:flex; align-items:center; gap:8px; width:100%; border:0; background:transparent; cursor:pointer;',
104
- ' padding:8px 10px; border-radius:8px; text-align:left; color:inherit; font:var(--dsw-font-s-strong-14); }',
105
- '.sm-group__head:hover { background:var(--dsw-alias-interactive-bg-hover); }',
106
- '.sm-group__head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
107
- '.sm-group__chev { flex:none; color:var(--dsw-alias-label-caption); transition:transform .15s ease; }',
108
- '.sm-group__head--on .sm-group__chev { transform:rotate(90deg); }',
109
- '.sm-group__name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
110
- '.sm-group__count { margin-left:auto; flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
111
- '.sm-group__body { padding-bottom:4px; }',
112
- '.sm-more { display:block; width:100%; border:0; background:transparent; cursor:pointer; padding:8px;',
113
- ' text-align:center; border-radius:8px; font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-caption); }',
114
- '.sm-more:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
115
- '.sm-more:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
116
- '@media (prefers-reduced-motion: reduce) { .sm-group__chev { transition:none; } }',
117
- '@media (prefers-reduced-motion: reduce) { .sm-btn--danger, .sm-deleted .sm-row__actions { transition:none; } }',
118
- // 自动归档配置行:数值输入内联呈现,窄面板可换行
119
- '.sm-cfg { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:10px;',
120
- ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
121
- '.sm-cfg__field { display:inline-flex; align-items:center; gap:4px; }',
122
- '.sm-cfg__field input { width:56px; padding:2px 6px; border-radius:6px; color-scheme:light dark;',
123
- ' border:1px solid light-dark(rgba(15,17,21,.18), rgba(255,255,255,.22));',
124
- ' background:transparent; color:inherit; font:inherit; }',
125
- '.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #1677ff); outline-offset:0; }',
126
- ].join('\n')
127
-
128
- const UNARCHIVE_URL = '/api/session-manager/unarchive'
129
- const DELETE_URL = '/api/session-manager/delete'
130
- const INFO_URL = '/api/session-manager/info'
131
- const DELETED_URL = '/api/session-manager/deleted'
132
- const REMOUNT_URL = '/api/session-manager/remount'
133
- const FORGET_URL = '/api/session-manager/forget'
134
- const STATUS_URL = '/api/session-manager/status'
135
-
136
- // 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
137
- // 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
138
- const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
139
- const DELETE_DIALOG_TITLE = '确认删除该会话?'
140
- const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
141
- const DELETE_DIALOG_BODY_SUFFIX = '」。'
142
- const DELETE_DIALOG_WARN = '删除会影响其他插件的工作,若无特殊需求,请不要删除;删除后请到「已删除」区查看找回方式。'
143
- const DELETE_TOAST_OS = '已移入系统回收站;还原后可在「已删除」区重新挂载'
144
- const DELETE_TOAST_QUARANTINE = '环境无系统回收站,已移入插件回收区;可在「已删除」区直接重新挂载找回'
145
-
146
- // 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
147
- // 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
148
- // 轮询链继续,host 恢复后自动跟上
149
- const API_TIMEOUT_MS = 8 * 1000
150
-
151
- async function api(url, options) {
152
- const controller = new AbortController()
153
- const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
154
- try {
155
- const response = await fetch(url, {
156
- headers: { 'content-type': 'application/json' },
157
- signal: controller.signal,
158
- ...options,
159
- })
160
- const payload = await response.json().catch(() => ({}))
161
- if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
162
- return payload
163
- } catch (error) {
164
- if (error && error.name === 'AbortError') throw new Error('响应超时(' + Math.round(API_TIMEOUT_MS / 1000) + 's),宿主繁忙中')
165
- throw error
166
- } finally {
167
- clearTimeout(timer)
168
- }
169
- }
170
-
171
- function h(type, props) {
172
- const children = Array.prototype.slice.call(arguments, 2)
173
- return React.createElement.apply(React, [type, props || null].concat(children))
174
- }
175
-
176
- function fmtTime(ms) {
177
- const t = Number(ms)
178
- if (t !== t || !t) return '—'
179
- const d = new Date(t)
180
- const pad = (n) => (n < 10 ? '0' : '') + n
181
- const date = d.getFullYear() === new Date().getFullYear()
182
- ? pad(d.getMonth() + 1) + '/' + pad(d.getDate())
183
- : d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate())
184
- return date + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
185
- }
186
-
187
- function fmtSize(bytes) {
188
- const n = Number(bytes)
189
- if (n !== n || n < 0) return '—'
190
- const UNIT_STEP = 1024
191
- if (n < UNIT_STEP) return n + ' B'
192
- if (n < UNIT_STEP * UNIT_STEP) return (n / UNIT_STEP).toFixed(1) + ' KB'
193
- return (n / (UNIT_STEP * UNIT_STEP)).toFixed(1) + ' MB'
194
- }
195
-
196
- function useSnapshot(source) {
197
- return useSyncExternalStore(
198
- (listener) => source.subscribe(listener),
199
- () => source.getSnapshot(),
200
- )
201
- }
202
-
203
- // 工作区路径末段(官方 workspaceTitleOf 同构,镜像 core.mjs 同名函数)
204
- function workspaceTitleOf(path) {
205
- const trimmed = String(path).replace(/[/\\]+$/, '')
206
- const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
207
- return trimmed.slice(separator + 1)
208
- }
209
-
210
- // 会话所属工作区标题:workspace 账本(sessionIds)一级映射与官方
211
- // dsh-client-ui-workspace workspaceBySession 同构;cwd 回退为本插件收紧决策
212
- // (官方回退 cwd basename 无需命中工作区,此处要求命中登记路径,防未登记目录名
213
- // 冒充工作区),未命中 null(未分组)(镜像 core.mjs workspaceTitleForSession)
214
- function workspaceTitleForSession(workspaces, sessionId, cwd) {
215
- const items = Array.isArray(workspaces) ? workspaces : []
216
- for (const workspace of items) {
217
- if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
218
- return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
219
- }
220
- }
221
- const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
222
- if (!normalizedCwd) return null
223
- for (const workspace of items) {
224
- if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
225
- return workspace.title || workspaceTitleOf(workspace.path)
226
- }
227
- }
228
- return null
229
- }
230
-
231
- // 已归档会话面板行:官方会话行 ∩ 归档集合,按更新时间倒序;workspace 为所属
232
- // 工作区标题或 null(镜像 core.mjs projectArchiveRows)
233
- function projectRows(listState, archivedIds, workspaceState) {
234
- const archived = new Set(archivedIds)
235
- const byId = (listState && listState.byId) || {}
236
- const workspaces = (workspaceState && workspaceState.items) || []
237
- return Object.keys(byId)
238
- .filter((id) => archived.has(id))
239
- .map((id) => ({
240
- id,
241
- title: byId[id].displayTitle || id,
242
- updatedAt: byId[id].updatedAt,
243
- workspace: workspaceTitleForSession(workspaces, id, byId[id].cwd),
244
- }))
245
- .sort((left, right) => right.updatedAt - left.updatedAt)
246
- }
247
-
248
- // 已删除面板行:标题回退会话 id,按删除时间倒序(镜像 core.mjs projectDeletedRows)
249
- function projectDeletedRows(deleted, listState) {
250
- const byId = (listState && listState.byId) || {}
251
- return [...(deleted || [])]
252
- .sort((left, right) => right.deletedAt - left.deletedAt)
253
- .map((item) => ({
254
- sessionId: item.sessionId,
255
- path: item.path,
256
- deletedAt: item.deletedAt,
257
- title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
258
- }))
259
- }
260
-
261
-
262
- // Toast 差分守卫:连续两个 ready 快照才计新增(镜像 core.mjs archiveToastStep)。
263
- // 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
264
- // 归档事件,启动与重连首装不误报。差分 Set 每帧构建一次,与 core.diffArchived 同构
265
- function archiveToastStep(previous, snapshot) {
266
- const ready = Boolean(snapshot && snapshot.phase === 'ready')
267
- const ids = (snapshot && snapshot.archivedSessionIds) || []
268
- const baseline = previous !== undefined && previous.ready && ready ? new Set(previous.ids) : null
269
- const added = baseline ? ids.filter((id) => !baseline.has(id)) : []
270
- return { state: { ready, ids }, added }
271
- }
272
-
273
- // 归档通知标题列举上限:超过该数只列前段并以「 等」收尾(镜像 core.mjs 同名常量)
274
- const TOAST_MAX_TITLES = 3
275
-
276
- // 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
277
- // 「 等」收尾,计数恒为真实总数;标题缺失、空白或行不存在回退会话 id
278
- // (镜像 core.mjs archiveToastText,修改需两处同步)
279
- function archiveToastText(addedIds, rows) {
280
- const added = Array.isArray(addedIds) ? addedIds : []
281
- if (added.length === 0) return ''
282
- const safeRows = Array.isArray(rows) ? rows : []
283
- const titleById = new Map()
284
- for (const row of safeRows) {
285
- if (!row || row.id === undefined || row.id === null) continue
286
- const trimmed = String(row.title ?? '').trim()
287
- if (trimmed !== '') titleById.set(String(row.id), trimmed)
288
- }
289
- const names = added.map((id) => titleById.get(String(id)) || String(id))
290
- if (names.length === 1) return '会话「' + names[0] + '」已归档'
291
- const listed = names.slice(0, TOAST_MAX_TITLES)
292
- return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
293
- }
294
-
295
- // 归档库主列表默认展开行数与手动展开步长(镜像 core.mjs 同名常量)
296
- const ARCHIVE_PAGE_SIZE = 100
297
-
298
- // 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理
299
- // (镜像 core.mjs pageArchiveRows,修改需两处同步)
300
- function pageArchiveRows(rows, visibleCount) {
301
- const list = Array.isArray(rows) ? rows : []
302
- const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
303
- const visible = list.slice(0, count)
304
- return { visible, remaining: list.length - visible.length }
305
- }
306
-
307
- // 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序,
308
- // 组间按组内最新 updatedAt 倒序(镜像 core.mjs groupArchiveRowsByWorkspace,修改需两处同步)
309
- function groupArchiveRowsByWorkspace(rows) {
310
- const list = Array.isArray(rows) ? rows : []
311
- const byKey = new Map()
312
- for (const row of list) {
313
- const title = row.workspace ? String(row.workspace) : ''
314
- if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
315
- const group = byKey.get(title)
316
- group.rows.push(row)
317
- if (row.updatedAt > group.latest) group.latest = row.updatedAt
318
- }
319
- return [...byKey.values()]
320
- .sort((left, right) => right.latest - left.latest)
321
- .map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
322
- }
323
-
324
- // 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,空查询原样
325
- // 返回全量(镜像 core.mjs filterArchiveRows,修改需两处同步)
326
- function filterArchiveRows(rows, query) {
327
- const list = Array.isArray(rows) ? rows : []
328
- const needle = String(query ?? '').trim().toLowerCase()
329
- if (needle === '') return list
330
- return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
331
- }
332
-
333
- function ArchiveRow(props) {
334
- const row = props.row
335
- const armed = props.armed
336
- const busy = props.busy
337
- const confirm = props.confirm
338
- const actions = armed
339
- ? [
340
- h('button', {
341
- key: 'confirm',
342
- className: 'sm-btn sm-btn--confirm',
343
- disabled: busy || !confirm || Boolean(confirm.error),
344
- onClick: props.onConfirm,
345
- }, '移入回收站'),
346
- h('button', { key: 'cancel', className: 'sm-btn', disabled: busy, onClick: props.onDisarm }, '取消'),
347
- ]
348
- : [
349
- h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
350
- h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
351
- ]
352
- return h('div', {
353
- className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
354
- },
355
- armed && confirm && !confirm.error
356
- ? h('span', { key: 'meta', className: 'sm-row__size' }, confirm.missing ? '产物已丢失' : fmtSize(confirm.sizeBytes))
357
- : h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.updatedAt).toLocaleString() }, fmtTime(row.updatedAt)),
358
- h('span', { className: 'sm-row__title-wrap' },
359
- row.workspace
360
- ? h('span', { className: 'sm-row__ws', title: row.workspace }, row.workspace)
361
- : h('span', { className: 'sm-row__ws sm-row__ws--none', title: '该会话不属于任何已登记工作区' }, '未分组'),
362
- h('span', { className: 'sm-row__title', title: row.title }, row.title),
363
- ),
364
- h('div', { className: 'sm-row__actions' }, actions),
365
- confirm && confirm.error ? h('span', { className: 'sm-row__error' }, confirm.error) : null,
366
- )
367
- }
368
-
369
- // 删除确认弹窗:确认链最后一环,行内两击确认之后仍需在此显式确认才发请求。
370
- // 无确认前不发任何请求;遮罩不响应点击,取消与确认按钮是仅有的出口。
371
- // 初始焦点落「取消」(安全默认出口),Escape 等价取消;不做焦点圈闭
372
- // (tab 可离开弹窗但不会误触发删除,圈闭实现成本不成比例)
373
- function DeleteDialog(props) {
374
- const row = props.row
375
- const busy = props.busy
376
- const cancelRef = useRef(null)
377
- useEffect(() => {
378
- if (cancelRef.current) cancelRef.current.focus()
379
- const onKey = (event) => {
380
- if (event.key === 'Escape' && !busy) props.onCancel()
381
- }
382
- window.addEventListener('keydown', onKey)
383
- return () => window.removeEventListener('keydown', onKey)
384
- }, [])
385
- return h('div', { className: 'sm-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'sm-dialog-title', 'aria-describedby': 'sm-dialog-body' },
386
- h('div', { className: 'sm-dialog__card' },
387
- h('p', { id: 'sm-dialog-title', className: 'sm-dialog__title' }, DELETE_DIALOG_TITLE),
388
- h('p', { id: 'sm-dialog-body', className: 'sm-dialog__body' }, DELETE_DIALOG_BODY_PREFIX + (row.title || row.id) + DELETE_DIALOG_BODY_SUFFIX),
389
- h('p', { className: 'sm-dialog__warn' }, DELETE_DIALOG_WARN),
390
- h('div', { className: 'sm-dialog__actions' },
391
- h('button', { ref: cancelRef, className: 'sm-dialog__btn', disabled: busy, onClick: props.onCancel }, '取消'),
392
- h('button', { className: 'sm-dialog__btn sm-dialog__btn--confirm', disabled: busy, onClick: props.onConfirm }, '确认删除'),
393
- ),
394
- ),
395
- )
396
- }
397
-
398
- function DeletedRow(props) {
399
- const row = props.row
400
- const busy = props.busy
401
- return h('div', { className: 'sm-row' + (busy ? ' sm-row--busy' : '') },
402
- h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
403
- h('span', { className: 'sm-row__title', title: row.title }, row.title),
404
- h('div', { className: 'sm-row__actions' },
405
- h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
406
- h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
407
- ),
408
- h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
409
- )
410
- }
411
-
412
- // 已删除分区:仅非空渲染;还原指引在分区头部,行内提供重挂载与移除记录
413
- function DeletedSection(props) {
414
- const rows = props.rows
415
- if (rows.length === 0) return null
416
- return h('div', { className: 'sm-deleted' },
417
- h('div', { className: 'sm-head' },
418
- h('span', { className: 'sm-head__title' }, '已删除'),
419
- h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
420
- ),
421
- h('div', { className: 'sm-head__hint' },
422
- '到系统回收站将会话文件夹还原到原位置,再点「重新挂载」找回;回收区暂存的会话直接点「重新挂载」即可找回;清空回收站后无法找回。'),
423
- h('div', { className: 'sm-tray' },
424
- rows.map((row) => h(DeletedRow, {
425
- key: row.sessionId,
426
- row,
427
- busy: props.busyId === row.sessionId,
428
- onRemount: () => props.onRemount(row),
429
- onForget: () => props.onForget(row),
430
- }))),
431
- )
432
- }
433
-
434
- function SessionManagerApp(props) {
435
- const rows = props.rows
436
- const listState = props.listState
437
- const [busyId, setBusyId] = useState(null)
438
- const [armedId, setArmedId] = useState(null)
439
- const [confirms, setConfirms] = useState({})
440
- // 删除弹窗受控状态:非 null 即弹窗展示中,确认后才真正发删除请求
441
- const [pendingDelete, setPendingDelete] = useState(null)
442
- const [deleted, setDeleted] = useState([])
443
- const [periodic, setPeriodic] = useState(null)
444
- // 归档库视图状态:recent 为主列表固定首页,library 为工作区分组浏览 + 标题
445
- // 搜索;分组默认收起,展开即见首页,组内沿用同一分页机制
446
- const [view, setView] = useState('recent')
447
- const [query, setQuery] = useState('')
448
- const [libraryCount, setLibraryCount] = useState(ARCHIVE_PAGE_SIZE)
449
- const [openGroups, setOpenGroups] = useState(() => new Set())
450
- const [groupCounts, setGroupCounts] = useState({})
451
-
452
- function toggleGroup(key) {
453
- setOpenGroups((prev) => {
454
- const next = new Set(prev)
455
- if (next.has(key)) next.delete(key)
456
- else next.add(key)
457
- return next
458
- })
459
- }
460
-
461
- function expandGroup(key) {
462
- setGroupCounts((prev) => ({ ...prev, [key]: (prev[key] || ARCHIVE_PAGE_SIZE) + ARCHIVE_PAGE_SIZE }))
463
- }
464
-
465
- function refreshDeleted() {
466
- api(DELETED_URL)
467
- .then((payload) => setDeleted((payload && payload.deleted) || []))
468
- .catch((error) => {
469
- // 加载失败保留旧数据:清空会让用户误读为台账已清空(数据丢失假象)
470
- console.warn('[session-manager] 已删除列表加载失败', error)
471
- })
472
- }
473
-
474
- useEffect(() => { refreshDeleted() }, [])
475
-
476
- // 周期评估状态仅用于降级提示,加载失败按无提示处理
477
- useEffect(() => {
478
- api(STATUS_URL)
479
- .then((payload) => setPeriodic(payload && payload.periodic))
480
- .catch(() => {})
481
- }, [])
482
-
483
- function run(sessionId, action, successText) {
484
- setBusyId(sessionId)
485
- return action()
486
- .then((result) => {
487
- if (result && result.partial) toast(result.message, { kind: 'ok' })
488
- else if (result && result.message) toast(result.message, { kind: 'ok' })
489
- else toast(successText || '操作完成', { kind: 'ok' })
490
- setArmedId(null)
491
- setConfirms({})
492
- })
493
- .catch((error) => toast(error && error.message ? error.message : String(error), { kind: 'error', sticky: true }))
494
- .then(() => setBusyId(null))
495
- }
496
-
497
- // 删除确认链:击删除(拉体积)→ 行内确认 → 弹窗确认,弹窗内确认才发请求
498
- function onDelete(row) {
499
- if (armedId !== row.id) {
500
- setArmedId(row.id)
501
- // 首段拉取标题 / 时间 / 体积;产物已缺失(missing)仍可确认,删除仅清理列表
502
- if (confirms[row.id] === undefined) {
503
- api(INFO_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
504
- .then((info) => setConfirms((prev) => ({ ...prev, [row.id]: info.supported
505
- ? { sizeBytes: info.sizeBytes, missing: Boolean(info.missing) }
506
- : { error: '当前存储后端不支持按会话删除' } })))
507
- .catch((error) => setConfirms((prev) => ({ ...prev, [row.id]: { error: String(error.message || error) } })))
508
- }
509
- return
510
- }
511
- setPendingDelete(row)
512
- }
513
-
514
- // 弹窗确认:处置模式区分成功文案——两种模式找回路径不同
515
- function onDialogConfirm() {
516
- const row = pendingDelete
517
- if (row === null) return
518
- void run(row.id, async () => {
519
- const body = await api(DELETE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
520
- if (body && body.ok && !body.message) {
521
- return { ...body, message: body.mode === 'quarantine' ? DELETE_TOAST_QUARANTINE : DELETE_TOAST_OS }
522
- }
523
- return body
524
- }, DELETE_TOAST_OS).then(refreshDeleted)
525
- setPendingDelete(null)
526
- }
527
-
528
- function onRemount(row) {
529
- void run(row.sessionId, () => api(REMOUNT_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }),
530
- '已重新挂载,会话回到工作区列表')
531
- .then(refreshDeleted)
532
- }
533
-
534
- function onForget(row) {
535
- void run(row.sessionId, () => api(FORGET_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }))
536
- .then(refreshDeleted)
537
- }
538
-
539
- const renderArchiveRow = (row) => h(ArchiveRow, {
540
- key: row.id,
541
- row,
542
- armed: armedId === row.id,
543
- busy: busyId === row.id,
544
- confirm: confirms[row.id],
545
- onUnarchive: () => {
546
- void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
547
- },
548
- onDelete: () => onDelete(row),
549
- onConfirm: () => onDelete(row),
550
- onDisarm: () => { setArmedId(null); setConfirms({}) },
551
- })
552
-
553
- // 分页展开按钮:无余量不渲染(React 忽略 null 子节点);key 恒定,
554
- // 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
555
- const expandMore = (remaining, onMore) => remaining <= 0
556
- ? null
557
- : h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
558
-
559
- // 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
560
- const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
561
- const recentTray = h('div', { className: 'sm-tray' },
562
- rows.length === 0
563
- ? h('div', { className: 'sm-empty' },
564
- h('div', null, '还没有归档的会话'),
565
- h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
566
- : [...recentPage.visible.map(renderArchiveRow),
567
- recentPage.remaining > 0
568
- ? h('button', { key: 'more', className: 'sm-more', onClick: () => setView('library') }, '按工作区浏览')
569
- : null],
570
- )
571
-
572
- // 归档库区域:有搜索词时命中平铺(分页),无搜索词按工作区分组(默认收起);
573
- // 仅二级视图构建,recent 视图不做分组与搜索投影,防大集合重渲染放大
574
- function renderLibrary() {
575
- const searching = query.trim() !== ''
576
- const matchedRows = filterArchiveRows(rows, query)
577
- const searchPage = pageArchiveRows(matchedRows, libraryCount)
578
- const tray = rows.length === 0
579
- ? h('div', { className: 'sm-empty' }, '还没有归档的会话')
580
- : searching
581
- ? (matchedRows.length === 0
582
- ? h('div', { className: 'sm-empty' }, '没有匹配的归档会话')
583
- : h('div', { className: 'sm-tray' },
584
- [...searchPage.visible.map(renderArchiveRow),
585
- expandMore(searchPage.remaining, () => setLibraryCount((count) => count + ARCHIVE_PAGE_SIZE))]))
586
- : h('div', { className: 'sm-tray' },
587
- groupArchiveRowsByWorkspace(rows).map((group) => {
588
- // 未分组桶键为空串,空串本身是合法 React key,直接用桶键防与用户标题撞车
589
- const key = group.workspace || ''
590
- const open = openGroups.has(key)
591
- const page = pageArchiveRows(group.rows, groupCounts[key] || ARCHIVE_PAGE_SIZE)
592
- return h('div', { className: 'sm-group', key },
593
- h('button', {
594
- className: 'sm-group__head' + (open ? ' sm-group__head--on' : ''),
595
- 'aria-expanded': open,
596
- onClick: () => toggleGroup(key),
597
- },
598
- h('span', { className: 'sm-group__chev', 'aria-hidden': 'true' }, '▸'),
599
- h('span', { className: 'sm-group__name' }, group.workspace || '未分组'),
600
- h('span', { className: 'sm-group__count' }, group.rows.length + ' 条'),
601
- ),
602
- open
603
- ? h('div', { className: 'sm-group__body' },
604
- page.visible.map(renderArchiveRow),
605
- expandMore(page.remaining, () => expandGroup(key)))
606
- : null,
607
- )
608
- }))
609
- return h('div', { className: 'sm-lib' },
610
- h('div', { className: 'sm-libbar' },
611
- h('input', {
612
- className: 'sm-libbar__search',
613
- type: 'search',
614
- placeholder: '搜索会话标题',
615
- 'aria-label': '搜索会话标题',
616
- value: query,
617
- // 搜索词变化即重置平铺分页:命中集变小后旧可见数无意义
618
- onChange: (event) => { setQuery(event.target.value); setLibraryCount(ARCHIVE_PAGE_SIZE) },
619
- }),
620
- searching ? h('span', { className: 'sm-libbar__count' }, matchedRows.length + ' 条匹配') : null,
621
- ),
622
- tray,
623
- )
624
- }
625
-
626
- const archiveRegion = view === 'recent' ? recentTray : renderLibrary()
627
-
628
- return h('div', { className: 'sm-panel' },
629
- view === 'recent'
630
- ? h('div', { className: 'sm-head' },
631
- h('span', { className: 'sm-head__title' }, '会话归档'),
632
- rows.length > 0 ? h('span', { className: 'sm-head__count' }, rows.length + ' 条') : null,
633
- rows.length > 0
634
- ? h('button', { className: 'sm-btn', onClick: () => setView('library') }, '按工作区浏览 ›')
635
- : null,
636
- )
637
- : h('div', { className: 'sm-head' },
638
- h('button', { className: 'sm-btn', onClick: () => setView('recent') }, '‹ 返回'),
639
- h('span', { className: 'sm-head__title' }, '按工作区浏览'),
640
- h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
641
- ),
642
- h('div', { className: 'sm-head__hint' }, '恢复放回会话列表;删除移入系统回收站(无回收站环境移入插件回收区),可经「已删除」区找回。'),
643
- periodic && periodic.running === false
644
- ? h('div', { className: 'sm-head__hint' },
645
- '周期评估未运行(' + (periodic.reason || '宿主定时服务不可用') + ');自动归档仍在新会话创建与启动时生效。')
646
- : null,
647
- archiveRegion,
648
- h(DeletedSection, {
649
- rows: projectDeletedRows(deleted, listState),
650
- busyId,
651
- onRemount,
652
- onForget,
653
- }),
654
- h(AutoArchiveConfig, null),
655
- pendingDelete !== null
656
- ? h(DeleteDialog, {
657
- row: pendingDelete,
658
- busy: busyId === pendingDelete.id,
659
- onCancel: () => setPendingDelete(null),
660
- onConfirm: onDialogConfirm,
661
- })
662
- : null,
663
- )
664
- }
665
-
666
- // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
667
- // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
668
- const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除。'
669
- const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
670
- const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
671
-
672
- // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
673
- // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
674
- // 服务端失败后回读生效值回滚显示
675
- const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
676
-
677
- // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
678
- // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
679
- // 必须对照已提交值而非显示值
680
- function classifyAutoArchiveInput(committedText, text) {
681
- const trimmed = text.trim()
682
- if (trimmed === committedText) return { action: 'noop' }
683
- const value = Number(trimmed)
684
- if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
685
- return { action: 'post', value }
686
- }
687
-
688
- function AutoArchiveConfig() {
689
- const [state, setState] = useState(null)
690
- // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
691
- const committedRef = React.useRef(null)
692
- useEffect(() => {
693
- api(AUTO_ARCHIVE_URL)
694
- .then((payload) => {
695
- if (!payload) return
696
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
697
- committedRef.current = next
698
- setState(next)
699
- })
700
- .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
701
- }, [])
702
- function refresh() {
703
- return api(AUTO_ARCHIVE_URL)
704
- .then((payload) => {
705
- if (!payload) return
706
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
707
- committedRef.current = next
708
- setState(next)
709
- })
710
- .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
711
- }
712
- function commit(field, text) {
713
- const committed = committedRef.current
714
- if (committed === null) return
715
- // 提交判定与 core.mjs classifyAutoArchiveInput 镜像同规:受控输入下显示值已随
716
- // 击键同步,守卫必须对已提交基准比较,与显示值比较恒相等
717
- const verdict = classifyAutoArchiveInput(committed[field], text)
718
- if (verdict.action === 'noop') return
719
- if (verdict.action === 'invalid') {
720
- setState({ ...committed })
721
- toast('自动归档配置须为非负整数', { kind: 'error' })
722
- return
723
- }
724
- const value = verdict.value
725
- api(AUTO_ARCHIVE_URL, { method: 'POST', body: JSON.stringify({ [field]: value }) })
726
- .then((payload) => {
727
- // 仅合回已提交字段,不清掉另一字段的未提交编辑
728
- const saved = String(payload[field])
729
- committedRef.current = { ...committedRef.current, [field]: saved }
730
- setState((prev) => (prev === null ? prev : { ...prev, [field]: saved }))
731
- toast('自动归档配置已保存')
732
- })
733
- .catch(() => {
734
- toast('自动归档配置保存失败', { kind: 'error' })
735
- void refresh()
736
- })
737
- }
738
- if (state === null) return null
739
- const numberInput = (field) => h('input', {
740
- type: 'number', min: 0, step: 1, value: state[field],
741
- onChange: (event) => setState((prev) => (prev === null ? prev : { ...prev, [field]: event.target.value })),
742
- // Enter 经 blur 走同一提交路径,避免与 onBlur 双触发重复提交
743
- onKeyDown: (event) => { if (event.key === 'Enter') event.target.blur() },
744
- onBlur: (event) => commit(field, event.target.value),
745
- })
746
- return h('div', { className: 'sm-cfg' },
747
- h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
748
- h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
749
- h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
750
- )
751
- }
752
-
753
- return {
754
- inject: ['slots', 'sessions', 'workspaces'],
755
- apply(ctx) {
756
- const sessions = ctx.get('sessions')
757
- const workspaces = ctx.get('workspaces')
758
-
759
- // 样式挂载在宿主文档级:设置页未打开时面板不存在,
760
- // 样式若随面板注入则面板裸样式渲染
761
- ctx.effect(() => {
762
- const style = document.createElement('style')
763
- // 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
764
- style.setAttribute('data-plugin', '@mzzsfy/dsh-session-manager')
765
- style.textContent = CSS
766
- document.head.appendChild(style)
767
- return () => style.remove()
768
- }, 'session-manager styles')
769
-
770
- // 归档快照差分:新增驱动通知,文案携带会话标题
771
- let previous
772
- const unsubscribe = workspaces.list.subscribe(() => {
773
- const step = archiveToastStep(previous, workspaces.list.getSnapshot())
774
- previous = step.state
775
- if (step.added.length > 0) {
776
- // 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
777
- const listState = sessions.list.getSnapshot()
778
- const byId = (listState && listState.byId) || {}
779
- const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
780
- toast(archiveToastText(step.added, rows))
781
- }
782
- })
783
-
784
- // 工作区文件夹运行标记:服务状态驱动的侧边栏组头标注——官方折叠组不渲染
785
- // 会话行(deriveGroups 的 sessions 为空数组),DOM 扫描对折叠态失效,
786
- // 归属与运行态一律取服务快照;DOM 仅按下标对位官方组容器(渲染序 = 快照
787
- // items 序,未分组桶恒在末位)。开关拉取失败按启用兜底;拉取未决期间仅清除
788
- // 不标记(停用用户刷新不闪现),缺 MutationObserver 或 querySelectorAll 的
789
- // 环境(异常宿主)整体跳过,不阻塞其余能力
790
-
791
- ctx.slots.inject('settings.section', () =>
792
- ctx.slots.register(
793
- { name: 'settings.section', id: 'session-manager', order: 46, label: '会话归档' },
794
- () => React.createElement(SessionManagerPanel, { sessions, workspaces }),
795
- ))
796
-
797
-
798
- ctx.effect(() => unsubscribe, 'session-manager archived diff')
799
-
800
- function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
801
- const listState = useSnapshot(sessionSvc.list)
802
- const workspaceState = useSnapshot(workspaceSvc.list)
803
- return React.createElement(SessionManagerApp, {
804
- rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || [], workspaceState),
805
- listState,
806
- })
807
- }
808
- },
809
- }
810
- },
811
- })
1
+ // dsh-session-manager Client 半区:settings.section 归档面板 + 归档/操作反馈通知。
2
+ // 归档快照来自官方 workspace.follow 客户端模型(ctx.get('workspaces')),会话行来自
3
+ // ctx.get('sessions');面板数据 = 会话行 ∩ 归档集合(纯投影),通知由 archived
4
+ // 增量帧的集合差分驱动,经公共依赖 @mzzsfy/dsh-common-tools 展示。
5
+ // 浏览器半区经 webServer 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
6
+ // require;与 src/core.mjs 镜像的纯函数(projectRows / archiveToastStep /
7
+ // archiveToastText / projectDeletedRows / pageArchiveRows /
8
+ // groupArchiveRowsByWorkspace / filterArchiveRows)修改需两处同步。
9
+
10
+ window.__ModuleLoader__.load({
11
+ id: '@mzzsfy/dsh-session-manager',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const { useState, useEffect, useRef, useSyncExternalStore } = React
15
+
16
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
17
+ // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
18
+ const NAV_ICON = { '会话归档': 'archive', 'dsh-session-manager': 'archive' }
19
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
20
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
21
+ else window.__navicIconQueue = [NAV_ICON]
22
+
23
+ // 通知出口:公共依赖 @mzzsfy/dsh-common-tools(external require,宿主占位条目由
24
+ // 本插件 cordis.patch.yml 代挂)
25
+ const { show: toast } = require('@mzzsfy/dsh-common-tools/client').toast
26
+
27
+ // 视觉方案:归档台账——全部取宿主 alias 令牌(双主题自适应,不造色),数据列
28
+ // 用宿主代码字体(会话即文件的档案词汇),签名元素为托盘内的归档轨
29
+ const CSS = [
30
+ '.sm-panel { display:flex; flex-direction:column; gap:8px; min-width:0; color:var(--dsw-alias-label-primary); font:var(--dsw-font-s-14); }',
31
+ '.sm-head { display:flex; align-items:baseline; gap:8px; }',
32
+ '.sm-head__title { font:var(--dsw-font-m-18); }',
33
+ '.sm-head__count { font:12px/18px var(--ds-font-family-code); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
34
+ '.sm-head__hint { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption); }',
35
+ '.sm-tray { position:relative; background:var(--dsw-alias-bg-module-platform); border-radius:10px; padding:4px 0; }',
36
+ '.sm-tray::before { content:""; position:absolute; left:15px; top:12px; bottom:12px; width:1px; background:var(--dsw-alias-border-l3); }',
37
+ '.sm-row { position:relative; display:grid; grid-template-columns:88px minmax(0, 1fr) auto; align-items:center; gap:8px;',
38
+ ' padding:7px 10px 7px 28px; border-radius:8px; }',
39
+ '.sm-row::before { content:""; position:absolute; left:13px; top:50%; width:5px; height:5px; margin:-2.5px 0 0;',
40
+ ' border-radius:50%; background:var(--dsw-alias-border-l4); }',
41
+ '.sm-row:hover, .sm-row:focus-within { background:var(--dsw-alias-interactive-bg-hover); }',
42
+ '.sm-row:hover::before, .sm-row:focus-within::before { background:var(--dsw-alias-state-business-primary); }',
43
+ '.sm-row--armed { background:var(--dsw-alias-interactive-bg-hover-danger); }',
44
+ '.sm-row--armed::before { background:var(--dsw-alias-state-error-primary); }',
45
+ '.sm-row--busy { opacity:.45; pointer-events:none; }',
46
+ '.sm-row__time, .sm-row__size { font:12px/18px var(--ds-font-family-code); color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }',
47
+ '.sm-row__size { color:var(--dsw-alias-state-error-primary); }',
48
+ '.sm-row__title-wrap { display:flex; align-items:center; gap:6px; min-width:0; }',
49
+ '.sm-row__ws { flex:none; max-width:40%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;',
50
+ ' padding:0 6px; border-radius:999px; font:11px/18px var(--ds-font-family-code);',
51
+ ' color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }',
52
+ '.sm-row__ws--none { background:transparent; box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3); }',
53
+ '.sm-row__title { font:var(--dsw-font-s-strong-14); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
54
+ '.sm-row__error { grid-column:2 / -1; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-state-error-primary); }',
55
+ '.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code); color:var(--dsw-alias-label-caption);',
56
+ ' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
57
+ '.sm-deleted { display:flex; flex-direction:column; gap:8px; min-width:0; }',
58
+ // 归档行:恢复常显;已删除区保持整组操作悬停显现(删除按钮显隐规则在 .sm-btn 块后)
59
+ '.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; }',
60
+ '.sm-deleted .sm-row__actions { opacity:0; transition:opacity 0.12s ease; }',
61
+ '.sm-deleted .sm-row:hover .sm-row__actions, .sm-deleted .sm-row:focus-within .sm-row__actions { opacity:1; }',
62
+ '.sm-btn { border:0; background:transparent; cursor:pointer; padding:2px 8px; border-radius:6px;',
63
+ ' font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-tertiary); }',
64
+ '.sm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
65
+ '.sm-btn--restore:hover { color:var(--dsw-alias-state-business-primary); }',
66
+ '.sm-btn--danger:hover { color:var(--dsw-alias-state-error-primary); }',
67
+ '.sm-btn--confirm { color:var(--dsw-alias-state-error-primary); border:1px solid var(--dsw-alias-state-error-primary); }',
68
+ '.sm-btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); }',
69
+ '.sm-btn:disabled { opacity:.45; cursor:default; background:transparent; color:var(--dsw-alias-label-tertiary); }',
70
+ '.sm-btn--confirm:disabled { color:var(--dsw-alias-state-error-primary); }',
71
+ '.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
72
+ // 删除按钮显隐:隐藏规则必须位于 .sm-btn:disabled 之后——同特异度后出者胜,
73
+ // 否则 busy 禁用态被禁用半透明规则压过而幽灵显形;显现规则特异度更高,不受顺序约束
74
+ '.sm-btn--danger, .sm-btn--danger:disabled { opacity:0; transition:opacity 0.12s ease; }',
75
+ '.sm-row:hover .sm-btn--danger, .sm-row:focus-within .sm-btn--danger { opacity:1; }',
76
+ // 删除确认弹窗:遮罩 + 居中卡片;高 z-index 压过设置面板层,点击遮罩不关闭(强制显式选择)
77
+ '.sm-dialog { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center;',
78
+ ' background:color-mix(in srgb, var(--dsw-alias-label-primary) 45%, transparent); }',
79
+ '.sm-dialog__card { width:min(420px, calc(100vw - 48px)); border-radius:12px; padding:20px;',
80
+ ' background:var(--dsw-alias-bg-layer-1); color:inherit;',
81
+ ' box-shadow:var(--dsw-shadow-lv3); }',
82
+ '.sm-dialog__title { font:var(--dsw-font-m-strong-16, 600 16px/24px sans-serif);',
83
+ ' color:var(--dsw-alias-state-error-primary); margin:0 0 10px; }',
84
+ '.sm-dialog__body { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-label-secondary); margin:0 0 8px; }',
85
+ '.sm-dialog__warn { font:var(--dsw-font-s-14, 14px/22px sans-serif); color:var(--dsw-alias-state-error-primary); margin:0 0 16px; }',
86
+ '.sm-dialog__actions { display:flex; justify-content:flex-end; gap:8px; }',
87
+ '.sm-dialog__btn { border:1px solid var(--dsw-alias-border-l3); background:transparent; cursor:pointer;',
88
+ ' padding:6px 16px; border-radius:8px; font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-secondary); }',
89
+ '.sm-dialog__btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
90
+ '.sm-dialog__btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
91
+ '.sm-dialog__btn--confirm { border-color:var(--dsw-alias-state-error-primary); color:var(--dsw-alias-state-error-primary); }',
92
+ '.sm-dialog__btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); color:var(--dsw-alias-state-error-primary); }',
93
+ '.sm-empty { padding:24px 12px; text-align:center; color:var(--dsw-alias-label-caption); }',
94
+ '.sm-empty__hint { font:var(--dsw-font-xxs-12); margin-top:2px; }',
95
+ // 归档库二级视图:工具行(搜索)与工作区分组、分页展开按钮
96
+ '.sm-lib { display:flex; flex-direction:column; gap:8px; }',
97
+ '.sm-libbar { display:flex; align-items:center; gap:8px; }',
98
+ '.sm-libbar__search { flex:1; min-width:0; padding:4px 10px; border-radius:8px; color-scheme:light dark;',
99
+ ' border:1px solid var(--dsw-alias-border-l3); background:transparent; color:inherit; font:var(--dsw-font-s-14); }',
100
+ '.sm-libbar__search:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
101
+ '.sm-libbar__count { flex:none; font:12px/18px var(--ds-font-family-code); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
102
+ '.sm-group { display:flex; flex-direction:column; }',
103
+ '.sm-group__head { display:flex; align-items:center; gap:8px; width:100%; border:0; background:transparent; cursor:pointer;',
104
+ ' padding:8px 10px; border-radius:8px; text-align:left; color:inherit; font:var(--dsw-font-s-strong-14); }',
105
+ '.sm-group__head:hover { background:var(--dsw-alias-interactive-bg-hover); }',
106
+ '.sm-group__head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
107
+ '.sm-group__chev { flex:none; color:var(--dsw-alias-label-caption); transition:transform .15s ease; }',
108
+ '.sm-group__head--on .sm-group__chev { transform:rotate(90deg); }',
109
+ '.sm-group__name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
110
+ '.sm-group__count { margin-left:auto; flex:none; font:12px/18px var(--ds-font-family-code); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
111
+ '.sm-group__body { padding-bottom:4px; }',
112
+ '.sm-more { display:block; width:100%; border:0; background:transparent; cursor:pointer; padding:8px;',
113
+ ' text-align:center; border-radius:8px; font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-caption); }',
114
+ '.sm-more:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
115
+ '.sm-more:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
116
+ '@media (prefers-reduced-motion: reduce) { .sm-group__chev { transition:none; } }',
117
+ '@media (prefers-reduced-motion: reduce) { .sm-btn--danger, .sm-deleted .sm-row__actions { transition:none; } }',
118
+ // 自动归档配置行:数值输入内联呈现,窄面板可换行
119
+ '.sm-cfg { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:10px;',
120
+ ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption); }',
121
+ '.sm-cfg__field { display:inline-flex; align-items:center; gap:4px; }',
122
+ '.sm-cfg__field input { width:56px; padding:2px 6px; border-radius:6px; color-scheme:light dark;',
123
+ ' border:1px solid var(--dsw-alias-border-l2);',
124
+ ' background:transparent; color:inherit; font:inherit; }',
125
+ '.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
126
+ ].join('\n')
127
+
128
+ const UNARCHIVE_URL = '/api/session-manager/unarchive'
129
+ const DELETE_URL = '/api/session-manager/delete'
130
+ const INFO_URL = '/api/session-manager/info'
131
+ const DELETED_URL = '/api/session-manager/deleted'
132
+ const REMOUNT_URL = '/api/session-manager/remount'
133
+ const FORGET_URL = '/api/session-manager/forget'
134
+ const STATUS_URL = '/api/session-manager/status'
135
+
136
+ // 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
137
+ // 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
138
+ const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
139
+ const DELETE_DIALOG_TITLE = '确认删除该会话?'
140
+ const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
141
+ const DELETE_DIALOG_BODY_SUFFIX = '」。'
142
+ const DELETE_DIALOG_WARN = '删除会影响其他插件的工作,若无特殊需求,请不要删除;删除后请到「已删除」区查看找回方式。'
143
+ const DELETE_TOAST_OS = '已移入系统回收站;还原后可在「已删除」区重新挂载'
144
+ const DELETE_TOAST_QUARANTINE = '环境无系统回收站,已移入插件回收区;可在「已删除」区直接重新挂载找回'
145
+
146
+ // 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
147
+ // 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
148
+ // 轮询链继续,host 恢复后自动跟上
149
+ const API_TIMEOUT_MS = 8 * 1000
150
+
151
+ async function api(url, options) {
152
+ const controller = new AbortController()
153
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
154
+ try {
155
+ const response = await fetch(url, {
156
+ headers: { 'content-type': 'application/json' },
157
+ signal: controller.signal,
158
+ ...options,
159
+ })
160
+ const payload = await response.json().catch(() => ({}))
161
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
162
+ return payload
163
+ } catch (error) {
164
+ if (error && error.name === 'AbortError') throw new Error('响应超时(' + Math.round(API_TIMEOUT_MS / 1000) + 's),宿主繁忙中')
165
+ throw error
166
+ } finally {
167
+ clearTimeout(timer)
168
+ }
169
+ }
170
+
171
+ function h(type, props) {
172
+ const children = Array.prototype.slice.call(arguments, 2)
173
+ return React.createElement.apply(React, [type, props || null].concat(children))
174
+ }
175
+
176
+ function fmtTime(ms) {
177
+ const t = Number(ms)
178
+ if (t !== t || !t) return '—'
179
+ const d = new Date(t)
180
+ const pad = (n) => (n < 10 ? '0' : '') + n
181
+ const date = d.getFullYear() === new Date().getFullYear()
182
+ ? pad(d.getMonth() + 1) + '/' + pad(d.getDate())
183
+ : d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate())
184
+ return date + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
185
+ }
186
+
187
+ function fmtSize(bytes) {
188
+ const n = Number(bytes)
189
+ if (n !== n || n < 0) return '—'
190
+ const UNIT_STEP = 1024
191
+ if (n < UNIT_STEP) return n + ' B'
192
+ if (n < UNIT_STEP * UNIT_STEP) return (n / UNIT_STEP).toFixed(1) + ' KB'
193
+ return (n / (UNIT_STEP * UNIT_STEP)).toFixed(1) + ' MB'
194
+ }
195
+
196
+ function useSnapshot(source) {
197
+ return useSyncExternalStore(
198
+ (listener) => source.subscribe(listener),
199
+ () => source.getSnapshot(),
200
+ )
201
+ }
202
+
203
+ // 工作区路径末段(官方 workspaceTitleOf 同构,镜像 core.mjs 同名函数)
204
+ function workspaceTitleOf(path) {
205
+ const trimmed = String(path).replace(/[/\\]+$/, '')
206
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
207
+ return trimmed.slice(separator + 1)
208
+ }
209
+
210
+ // 会话所属工作区标题:workspace 账本(sessionIds)一级映射与官方
211
+ // dsh-client-ui-workspace workspaceBySession 同构;cwd 回退为本插件收紧决策
212
+ // (官方回退 cwd basename 无需命中工作区,此处要求命中登记路径,防未登记目录名
213
+ // 冒充工作区),未命中 null(未分组)(镜像 core.mjs workspaceTitleForSession)
214
+ function workspaceTitleForSession(workspaces, sessionId, cwd) {
215
+ const items = Array.isArray(workspaces) ? workspaces : []
216
+ for (const workspace of items) {
217
+ if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
218
+ return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
219
+ }
220
+ }
221
+ const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
222
+ if (!normalizedCwd) return null
223
+ for (const workspace of items) {
224
+ if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
225
+ return workspace.title || workspaceTitleOf(workspace.path)
226
+ }
227
+ }
228
+ return null
229
+ }
230
+
231
+ // 已归档会话面板行:官方会话行 ∩ 归档集合,按更新时间倒序;workspace 为所属
232
+ // 工作区标题或 null(镜像 core.mjs projectArchiveRows)
233
+ function projectRows(listState, archivedIds, workspaceState) {
234
+ const archived = new Set(archivedIds)
235
+ const byId = (listState && listState.byId) || {}
236
+ const workspaces = (workspaceState && workspaceState.items) || []
237
+ return Object.keys(byId)
238
+ .filter((id) => archived.has(id))
239
+ .map((id) => ({
240
+ id,
241
+ title: byId[id].displayTitle || id,
242
+ updatedAt: byId[id].updatedAt,
243
+ workspace: workspaceTitleForSession(workspaces, id, byId[id].cwd),
244
+ }))
245
+ .sort((left, right) => right.updatedAt - left.updatedAt)
246
+ }
247
+
248
+ // 已删除面板行:标题回退会话 id,按删除时间倒序(镜像 core.mjs projectDeletedRows)
249
+ function projectDeletedRows(deleted, listState) {
250
+ const byId = (listState && listState.byId) || {}
251
+ return [...(deleted || [])]
252
+ .sort((left, right) => right.deletedAt - left.deletedAt)
253
+ .map((item) => ({
254
+ sessionId: item.sessionId,
255
+ path: item.path,
256
+ deletedAt: item.deletedAt,
257
+ title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
258
+ }))
259
+ }
260
+
261
+
262
+ // Toast 差分守卫:连续两个 ready 快照才计新增(镜像 core.mjs archiveToastStep)。
263
+ // 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
264
+ // 归档事件,启动与重连首装不误报。差分 Set 每帧构建一次,与 core.diffArchived 同构
265
+ function archiveToastStep(previous, snapshot) {
266
+ const ready = Boolean(snapshot && snapshot.phase === 'ready')
267
+ const ids = (snapshot && snapshot.archivedSessionIds) || []
268
+ const baseline = previous !== undefined && previous.ready && ready ? new Set(previous.ids) : null
269
+ const added = baseline ? ids.filter((id) => !baseline.has(id)) : []
270
+ return { state: { ready, ids }, added }
271
+ }
272
+
273
+ // 归档通知标题列举上限:超过该数只列前段并以「 等」收尾(镜像 core.mjs 同名常量)
274
+ const TOAST_MAX_TITLES = 3
275
+
276
+ // 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
277
+ // 「 等」收尾,计数恒为真实总数;标题缺失、空白或行不存在回退会话 id
278
+ // (镜像 core.mjs archiveToastText,修改需两处同步)
279
+ function archiveToastText(addedIds, rows) {
280
+ const added = Array.isArray(addedIds) ? addedIds : []
281
+ if (added.length === 0) return ''
282
+ const safeRows = Array.isArray(rows) ? rows : []
283
+ const titleById = new Map()
284
+ for (const row of safeRows) {
285
+ if (!row || row.id === undefined || row.id === null) continue
286
+ const trimmed = String(row.title ?? '').trim()
287
+ if (trimmed !== '') titleById.set(String(row.id), trimmed)
288
+ }
289
+ const names = added.map((id) => titleById.get(String(id)) || String(id))
290
+ if (names.length === 1) return '会话「' + names[0] + '」已归档'
291
+ const listed = names.slice(0, TOAST_MAX_TITLES)
292
+ return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
293
+ }
294
+
295
+ // 归档库主列表默认展开行数与手动展开步长(镜像 core.mjs 同名常量)
296
+ const ARCHIVE_PAGE_SIZE = 100
297
+
298
+ // 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理
299
+ // (镜像 core.mjs pageArchiveRows,修改需两处同步)
300
+ function pageArchiveRows(rows, visibleCount) {
301
+ const list = Array.isArray(rows) ? rows : []
302
+ const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
303
+ const visible = list.slice(0, count)
304
+ return { visible, remaining: list.length - visible.length }
305
+ }
306
+
307
+ // 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序,
308
+ // 组间按组内最新 updatedAt 倒序(镜像 core.mjs groupArchiveRowsByWorkspace,修改需两处同步)
309
+ function groupArchiveRowsByWorkspace(rows) {
310
+ const list = Array.isArray(rows) ? rows : []
311
+ const byKey = new Map()
312
+ for (const row of list) {
313
+ const title = row.workspace ? String(row.workspace) : ''
314
+ if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
315
+ const group = byKey.get(title)
316
+ group.rows.push(row)
317
+ if (row.updatedAt > group.latest) group.latest = row.updatedAt
318
+ }
319
+ return [...byKey.values()]
320
+ .sort((left, right) => right.latest - left.latest)
321
+ .map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
322
+ }
323
+
324
+ // 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,空查询原样
325
+ // 返回全量(镜像 core.mjs filterArchiveRows,修改需两处同步)
326
+ function filterArchiveRows(rows, query) {
327
+ const list = Array.isArray(rows) ? rows : []
328
+ const needle = String(query ?? '').trim().toLowerCase()
329
+ if (needle === '') return list
330
+ return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
331
+ }
332
+
333
+ function ArchiveRow(props) {
334
+ const row = props.row
335
+ const armed = props.armed
336
+ const busy = props.busy
337
+ const confirm = props.confirm
338
+ const actions = armed
339
+ ? [
340
+ h('button', {
341
+ key: 'confirm',
342
+ className: 'sm-btn sm-btn--confirm',
343
+ disabled: busy || !confirm || Boolean(confirm.error),
344
+ onClick: props.onConfirm,
345
+ }, '移入回收站'),
346
+ h('button', { key: 'cancel', className: 'sm-btn', disabled: busy, onClick: props.onDisarm }, '取消'),
347
+ ]
348
+ : [
349
+ h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
350
+ h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
351
+ ]
352
+ return h('div', {
353
+ className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
354
+ },
355
+ armed && confirm && !confirm.error
356
+ ? h('span', { key: 'meta', className: 'sm-row__size' }, confirm.missing ? '产物已丢失' : fmtSize(confirm.sizeBytes))
357
+ : h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.updatedAt).toLocaleString() }, fmtTime(row.updatedAt)),
358
+ h('span', { className: 'sm-row__title-wrap' },
359
+ row.workspace
360
+ ? h('span', { className: 'sm-row__ws', title: row.workspace }, row.workspace)
361
+ : h('span', { className: 'sm-row__ws sm-row__ws--none', title: '该会话不属于任何已登记工作区' }, '未分组'),
362
+ h('span', { className: 'sm-row__title', title: row.title }, row.title),
363
+ ),
364
+ h('div', { className: 'sm-row__actions' }, actions),
365
+ confirm && confirm.error ? h('span', { className: 'sm-row__error' }, confirm.error) : null,
366
+ )
367
+ }
368
+
369
+ // 删除确认弹窗:确认链最后一环,行内两击确认之后仍需在此显式确认才发请求。
370
+ // 无确认前不发任何请求;遮罩不响应点击,取消与确认按钮是仅有的出口。
371
+ // 初始焦点落「取消」(安全默认出口),Escape 等价取消;不做焦点圈闭
372
+ // (tab 可离开弹窗但不会误触发删除,圈闭实现成本不成比例)
373
+ function DeleteDialog(props) {
374
+ const row = props.row
375
+ const busy = props.busy
376
+ const cancelRef = useRef(null)
377
+ useEffect(() => {
378
+ if (cancelRef.current) cancelRef.current.focus()
379
+ const onKey = (event) => {
380
+ if (event.key === 'Escape' && !busy) props.onCancel()
381
+ }
382
+ window.addEventListener('keydown', onKey)
383
+ return () => window.removeEventListener('keydown', onKey)
384
+ }, [])
385
+ return h('div', { className: 'sm-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'sm-dialog-title', 'aria-describedby': 'sm-dialog-body' },
386
+ h('div', { className: 'sm-dialog__card' },
387
+ h('p', { id: 'sm-dialog-title', className: 'sm-dialog__title' }, DELETE_DIALOG_TITLE),
388
+ h('p', { id: 'sm-dialog-body', className: 'sm-dialog__body' }, DELETE_DIALOG_BODY_PREFIX + (row.title || row.id) + DELETE_DIALOG_BODY_SUFFIX),
389
+ h('p', { className: 'sm-dialog__warn' }, DELETE_DIALOG_WARN),
390
+ h('div', { className: 'sm-dialog__actions' },
391
+ h('button', { ref: cancelRef, className: 'sm-dialog__btn', disabled: busy, onClick: props.onCancel }, '取消'),
392
+ h('button', { className: 'sm-dialog__btn sm-dialog__btn--confirm', disabled: busy, onClick: props.onConfirm }, '确认删除'),
393
+ ),
394
+ ),
395
+ )
396
+ }
397
+
398
+ function DeletedRow(props) {
399
+ const row = props.row
400
+ const busy = props.busy
401
+ return h('div', { className: 'sm-row' + (busy ? ' sm-row--busy' : '') },
402
+ h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
403
+ h('span', { className: 'sm-row__title', title: row.title }, row.title),
404
+ h('div', { className: 'sm-row__actions' },
405
+ h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
406
+ h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
407
+ ),
408
+ h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
409
+ )
410
+ }
411
+
412
+ // 已删除分区:仅非空渲染;还原指引在分区头部,行内提供重挂载与移除记录
413
+ function DeletedSection(props) {
414
+ const rows = props.rows
415
+ if (rows.length === 0) return null
416
+ return h('div', { className: 'sm-deleted' },
417
+ h('div', { className: 'sm-head' },
418
+ h('span', { className: 'sm-head__title' }, '已删除'),
419
+ h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
420
+ ),
421
+ h('div', { className: 'sm-head__hint' },
422
+ '到系统回收站将会话文件夹还原到原位置,再点「重新挂载」找回;回收区暂存的会话直接点「重新挂载」即可找回;清空回收站后无法找回。'),
423
+ h('div', { className: 'sm-tray' },
424
+ rows.map((row) => h(DeletedRow, {
425
+ key: row.sessionId,
426
+ row,
427
+ busy: props.busyId === row.sessionId,
428
+ onRemount: () => props.onRemount(row),
429
+ onForget: () => props.onForget(row),
430
+ }))),
431
+ )
432
+ }
433
+
434
+ function SessionManagerApp(props) {
435
+ const rows = props.rows
436
+ const listState = props.listState
437
+ const [busyId, setBusyId] = useState(null)
438
+ const [armedId, setArmedId] = useState(null)
439
+ const [confirms, setConfirms] = useState({})
440
+ // 删除弹窗受控状态:非 null 即弹窗展示中,确认后才真正发删除请求
441
+ const [pendingDelete, setPendingDelete] = useState(null)
442
+ const [deleted, setDeleted] = useState([])
443
+ const [periodic, setPeriodic] = useState(null)
444
+ // 归档库视图状态:recent 为主列表固定首页,library 为工作区分组浏览 + 标题
445
+ // 搜索;分组默认收起,展开即见首页,组内沿用同一分页机制
446
+ const [view, setView] = useState('recent')
447
+ const [query, setQuery] = useState('')
448
+ const [libraryCount, setLibraryCount] = useState(ARCHIVE_PAGE_SIZE)
449
+ const [openGroups, setOpenGroups] = useState(() => new Set())
450
+ const [groupCounts, setGroupCounts] = useState({})
451
+
452
+ function toggleGroup(key) {
453
+ setOpenGroups((prev) => {
454
+ const next = new Set(prev)
455
+ if (next.has(key)) next.delete(key)
456
+ else next.add(key)
457
+ return next
458
+ })
459
+ }
460
+
461
+ function expandGroup(key) {
462
+ setGroupCounts((prev) => ({ ...prev, [key]: (prev[key] || ARCHIVE_PAGE_SIZE) + ARCHIVE_PAGE_SIZE }))
463
+ }
464
+
465
+ function refreshDeleted() {
466
+ api(DELETED_URL)
467
+ .then((payload) => setDeleted((payload && payload.deleted) || []))
468
+ .catch((error) => {
469
+ // 加载失败保留旧数据:清空会让用户误读为台账已清空(数据丢失假象)
470
+ console.warn('[session-manager] 已删除列表加载失败', error)
471
+ })
472
+ }
473
+
474
+ useEffect(() => { refreshDeleted() }, [])
475
+
476
+ // 周期评估状态仅用于降级提示,加载失败按无提示处理
477
+ useEffect(() => {
478
+ api(STATUS_URL)
479
+ .then((payload) => setPeriodic(payload && payload.periodic))
480
+ .catch(() => {})
481
+ }, [])
482
+
483
+ function run(sessionId, action, successText) {
484
+ setBusyId(sessionId)
485
+ return action()
486
+ .then((result) => {
487
+ if (result && result.partial) toast(result.message, { kind: 'ok' })
488
+ else if (result && result.message) toast(result.message, { kind: 'ok' })
489
+ else toast(successText || '操作完成', { kind: 'ok' })
490
+ setArmedId(null)
491
+ setConfirms({})
492
+ })
493
+ .catch((error) => toast(error && error.message ? error.message : String(error), { kind: 'error', sticky: true }))
494
+ .then(() => setBusyId(null))
495
+ }
496
+
497
+ // 删除确认链:击删除(拉体积)→ 行内确认 → 弹窗确认,弹窗内确认才发请求
498
+ function onDelete(row) {
499
+ if (armedId !== row.id) {
500
+ setArmedId(row.id)
501
+ // 首段拉取标题 / 时间 / 体积;产物已缺失(missing)仍可确认,删除仅清理列表
502
+ if (confirms[row.id] === undefined) {
503
+ api(INFO_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
504
+ .then((info) => setConfirms((prev) => ({ ...prev, [row.id]: info.supported
505
+ ? { sizeBytes: info.sizeBytes, missing: Boolean(info.missing) }
506
+ : { error: '当前存储后端不支持按会话删除' } })))
507
+ .catch((error) => setConfirms((prev) => ({ ...prev, [row.id]: { error: String(error.message || error) } })))
508
+ }
509
+ return
510
+ }
511
+ setPendingDelete(row)
512
+ }
513
+
514
+ // 弹窗确认:处置模式区分成功文案——两种模式找回路径不同
515
+ function onDialogConfirm() {
516
+ const row = pendingDelete
517
+ if (row === null) return
518
+ void run(row.id, async () => {
519
+ const body = await api(DELETE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
520
+ if (body && body.ok && !body.message) {
521
+ return { ...body, message: body.mode === 'quarantine' ? DELETE_TOAST_QUARANTINE : DELETE_TOAST_OS }
522
+ }
523
+ return body
524
+ }, DELETE_TOAST_OS).then(refreshDeleted)
525
+ setPendingDelete(null)
526
+ }
527
+
528
+ function onRemount(row) {
529
+ void run(row.sessionId, () => api(REMOUNT_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }),
530
+ '已重新挂载,会话回到工作区列表')
531
+ .then(refreshDeleted)
532
+ }
533
+
534
+ function onForget(row) {
535
+ void run(row.sessionId, () => api(FORGET_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }))
536
+ .then(refreshDeleted)
537
+ }
538
+
539
+ const renderArchiveRow = (row) => h(ArchiveRow, {
540
+ key: row.id,
541
+ row,
542
+ armed: armedId === row.id,
543
+ busy: busyId === row.id,
544
+ confirm: confirms[row.id],
545
+ onUnarchive: () => {
546
+ void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
547
+ },
548
+ onDelete: () => onDelete(row),
549
+ onConfirm: () => onDelete(row),
550
+ onDisarm: () => { setArmedId(null); setConfirms({}) },
551
+ })
552
+
553
+ // 分页展开按钮:无余量不渲染(React 忽略 null 子节点);key 恒定,
554
+ // 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
555
+ const expandMore = (remaining, onMore) => remaining <= 0
556
+ ? null
557
+ : h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
558
+
559
+ // 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
560
+ const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
561
+ const recentTray = h('div', { className: 'sm-tray' },
562
+ rows.length === 0
563
+ ? h('div', { className: 'sm-empty' },
564
+ h('div', null, '还没有归档的会话'),
565
+ h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
566
+ : [...recentPage.visible.map(renderArchiveRow),
567
+ recentPage.remaining > 0
568
+ ? h('button', { key: 'more', className: 'sm-more', onClick: () => setView('library') }, '按工作区浏览')
569
+ : null],
570
+ )
571
+
572
+ // 归档库区域:有搜索词时命中平铺(分页),无搜索词按工作区分组(默认收起);
573
+ // 仅二级视图构建,recent 视图不做分组与搜索投影,防大集合重渲染放大
574
+ function renderLibrary() {
575
+ const searching = query.trim() !== ''
576
+ const matchedRows = filterArchiveRows(rows, query)
577
+ const searchPage = pageArchiveRows(matchedRows, libraryCount)
578
+ const tray = rows.length === 0
579
+ ? h('div', { className: 'sm-empty' }, '还没有归档的会话')
580
+ : searching
581
+ ? (matchedRows.length === 0
582
+ ? h('div', { className: 'sm-empty' }, '没有匹配的归档会话')
583
+ : h('div', { className: 'sm-tray' },
584
+ [...searchPage.visible.map(renderArchiveRow),
585
+ expandMore(searchPage.remaining, () => setLibraryCount((count) => count + ARCHIVE_PAGE_SIZE))]))
586
+ : h('div', { className: 'sm-tray' },
587
+ groupArchiveRowsByWorkspace(rows).map((group) => {
588
+ // 未分组桶键为空串,空串本身是合法 React key,直接用桶键防与用户标题撞车
589
+ const key = group.workspace || ''
590
+ const open = openGroups.has(key)
591
+ const page = pageArchiveRows(group.rows, groupCounts[key] || ARCHIVE_PAGE_SIZE)
592
+ return h('div', { className: 'sm-group', key },
593
+ h('button', {
594
+ className: 'sm-group__head' + (open ? ' sm-group__head--on' : ''),
595
+ 'aria-expanded': open,
596
+ onClick: () => toggleGroup(key),
597
+ },
598
+ h('span', { className: 'sm-group__chev', 'aria-hidden': 'true' }, '▸'),
599
+ h('span', { className: 'sm-group__name' }, group.workspace || '未分组'),
600
+ h('span', { className: 'sm-group__count' }, group.rows.length + ' 条'),
601
+ ),
602
+ open
603
+ ? h('div', { className: 'sm-group__body' },
604
+ page.visible.map(renderArchiveRow),
605
+ expandMore(page.remaining, () => expandGroup(key)))
606
+ : null,
607
+ )
608
+ }))
609
+ return h('div', { className: 'sm-lib' },
610
+ h('div', { className: 'sm-libbar' },
611
+ h('input', {
612
+ className: 'sm-libbar__search',
613
+ type: 'search',
614
+ placeholder: '搜索会话标题',
615
+ 'aria-label': '搜索会话标题',
616
+ value: query,
617
+ // 搜索词变化即重置平铺分页:命中集变小后旧可见数无意义
618
+ onChange: (event) => { setQuery(event.target.value); setLibraryCount(ARCHIVE_PAGE_SIZE) },
619
+ }),
620
+ searching ? h('span', { className: 'sm-libbar__count' }, matchedRows.length + ' 条匹配') : null,
621
+ ),
622
+ tray,
623
+ )
624
+ }
625
+
626
+ const archiveRegion = view === 'recent' ? recentTray : renderLibrary()
627
+
628
+ return h('div', { className: 'sm-panel' },
629
+ view === 'recent'
630
+ ? h('div', { className: 'sm-head' },
631
+ h('span', { className: 'sm-head__title' }, '会话归档'),
632
+ rows.length > 0 ? h('span', { className: 'sm-head__count' }, rows.length + ' 条') : null,
633
+ rows.length > 0
634
+ ? h('button', { className: 'sm-btn', onClick: () => setView('library') }, '按工作区浏览 ›')
635
+ : null,
636
+ )
637
+ : h('div', { className: 'sm-head' },
638
+ h('button', { className: 'sm-btn', onClick: () => setView('recent') }, '‹ 返回'),
639
+ h('span', { className: 'sm-head__title' }, '按工作区浏览'),
640
+ h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
641
+ ),
642
+ h('div', { className: 'sm-head__hint' }, '恢复放回会话列表;删除移入系统回收站(无回收站环境移入插件回收区),可经「已删除」区找回。'),
643
+ periodic && periodic.running === false
644
+ ? h('div', { className: 'sm-head__hint' },
645
+ '周期评估未运行(' + (periodic.reason || '宿主定时服务不可用') + ');自动归档仍在新会话创建与启动时生效。')
646
+ : null,
647
+ archiveRegion,
648
+ h(DeletedSection, {
649
+ rows: projectDeletedRows(deleted, listState),
650
+ busyId,
651
+ onRemount,
652
+ onForget,
653
+ }),
654
+ h(AutoArchiveConfig, null),
655
+ pendingDelete !== null
656
+ ? h(DeleteDialog, {
657
+ row: pendingDelete,
658
+ busy: busyId === pendingDelete.id,
659
+ onCancel: () => setPendingDelete(null),
660
+ onConfirm: onDialogConfirm,
661
+ })
662
+ : null,
663
+ )
664
+ }
665
+
666
+ // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
667
+ // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
668
+ const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
669
+ const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
670
+ const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
671
+
672
+ // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
673
+ // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
674
+ // 服务端失败后回读生效值回滚显示
675
+ const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
676
+
677
+ // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
678
+ // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
679
+ // 必须对照已提交值而非显示值
680
+ function classifyAutoArchiveInput(committedText, text) {
681
+ const trimmed = text.trim()
682
+ if (trimmed === committedText) return { action: 'noop' }
683
+ const value = Number(trimmed)
684
+ if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
685
+ return { action: 'post', value }
686
+ }
687
+
688
+ function AutoArchiveConfig() {
689
+ const [state, setState] = useState(null)
690
+ // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
691
+ const committedRef = React.useRef(null)
692
+ useEffect(() => {
693
+ api(AUTO_ARCHIVE_URL)
694
+ .then((payload) => {
695
+ if (!payload) return
696
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
697
+ committedRef.current = next
698
+ setState(next)
699
+ })
700
+ .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
701
+ }, [])
702
+ function refresh() {
703
+ return api(AUTO_ARCHIVE_URL)
704
+ .then((payload) => {
705
+ if (!payload) return
706
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
707
+ committedRef.current = next
708
+ setState(next)
709
+ })
710
+ .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
711
+ }
712
+ function commit(field, text) {
713
+ const committed = committedRef.current
714
+ if (committed === null) return
715
+ // 提交判定与 core.mjs classifyAutoArchiveInput 镜像同规:受控输入下显示值已随
716
+ // 击键同步,守卫必须对已提交基准比较,与显示值比较恒相等
717
+ const verdict = classifyAutoArchiveInput(committed[field], text)
718
+ if (verdict.action === 'noop') return
719
+ if (verdict.action === 'invalid') {
720
+ setState({ ...committed })
721
+ toast('自动归档配置须为非负整数', { kind: 'error' })
722
+ return
723
+ }
724
+ const value = verdict.value
725
+ api(AUTO_ARCHIVE_URL, { method: 'POST', body: JSON.stringify({ [field]: value }) })
726
+ .then((payload) => {
727
+ // 仅合回已提交字段,不清掉另一字段的未提交编辑
728
+ const saved = String(payload[field])
729
+ committedRef.current = { ...committedRef.current, [field]: saved }
730
+ setState((prev) => (prev === null ? prev : { ...prev, [field]: saved }))
731
+ toast('自动归档配置已保存')
732
+ })
733
+ .catch(() => {
734
+ toast('自动归档配置保存失败', { kind: 'error' })
735
+ void refresh()
736
+ })
737
+ }
738
+ if (state === null) return null
739
+ const numberInput = (field) => h('input', {
740
+ type: 'number', min: 0, step: 1, value: state[field],
741
+ onChange: (event) => setState((prev) => (prev === null ? prev : { ...prev, [field]: event.target.value })),
742
+ // Enter 经 blur 走同一提交路径,避免与 onBlur 双触发重复提交
743
+ onKeyDown: (event) => { if (event.key === 'Enter') event.target.blur() },
744
+ onBlur: (event) => commit(field, event.target.value),
745
+ })
746
+ return h('div', { className: 'sm-cfg' },
747
+ h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
748
+ h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
749
+ h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
750
+ )
751
+ }
752
+
753
+ return {
754
+ inject: ['slots', 'sessions', 'workspaces'],
755
+ apply(ctx) {
756
+ const sessions = ctx.get('sessions')
757
+ const workspaces = ctx.get('workspaces')
758
+
759
+ // 样式挂载在宿主文档级:设置页未打开时面板不存在,
760
+ // 样式若随面板注入则面板裸样式渲染
761
+ ctx.effect(() => {
762
+ const style = document.createElement('style')
763
+ // 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
764
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-session-manager')
765
+ style.textContent = CSS
766
+ document.head.appendChild(style)
767
+ return () => style.remove()
768
+ }, 'session-manager styles')
769
+
770
+ // 归档快照差分:新增驱动通知,文案携带会话标题
771
+ let previous
772
+ const unsubscribe = workspaces.list.subscribe(() => {
773
+ const step = archiveToastStep(previous, workspaces.list.getSnapshot())
774
+ previous = step.state
775
+ if (step.added.length > 0) {
776
+ // 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
777
+ const listState = sessions.list.getSnapshot()
778
+ const byId = (listState && listState.byId) || {}
779
+ const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
780
+ toast(archiveToastText(step.added, rows))
781
+ }
782
+ })
783
+
784
+ // 工作区文件夹运行标记:服务状态驱动的侧边栏组头标注——官方折叠组不渲染
785
+ // 会话行(deriveGroups 的 sessions 为空数组),DOM 扫描对折叠态失效,
786
+ // 归属与运行态一律取服务快照;DOM 仅按下标对位官方组容器(渲染序 = 快照
787
+ // items 序,未分组桶恒在末位)。开关拉取失败按启用兜底;拉取未决期间仅清除
788
+ // 不标记(停用用户刷新不闪现),缺 MutationObserver 或 querySelectorAll 的
789
+ // 环境(异常宿主)整体跳过,不阻塞其余能力
790
+
791
+ ctx.slots.inject('settings.section', () =>
792
+ ctx.slots.register(
793
+ { name: 'settings.section', id: 'session-manager', order: 46, label: '会话归档' },
794
+ () => React.createElement(SessionManagerPanel, { sessions, workspaces }),
795
+ ))
796
+
797
+
798
+ ctx.effect(() => unsubscribe, 'session-manager archived diff')
799
+
800
+ function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
801
+ const listState = useSnapshot(sessionSvc.list)
802
+ const workspaceState = useSnapshot(workspaceSvc.list)
803
+ return React.createElement(SessionManagerApp, {
804
+ rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || [], workspaceState),
805
+ listState,
806
+ })
807
+ }
808
+ },
809
+ }
810
+ },
811
+ })