dsh-session-ops 0.0.0-stage → 0.1.4
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.md +117 -2
- package/README.zh.md +111 -0
- package/cordis.patch.yml +38 -0
- package/docs/DETAIL.md +24 -0
- package/docs/DETAIL.zh.md +23 -0
- package/icon.svg +1 -0
- package/lib/admin.js +955 -0
- package/lib/client.js +2167 -0
- package/lib/engine.js +237 -0
- package/lib/index.js +289 -0
- package/lib/memory.js +205 -0
- package/lib/move.js +844 -0
- package/lib/sessionlog.js +30 -0
- package/lib/store/frames.js +261 -0
- package/lib/store/host.js +105 -0
- package/lib/store/http.js +128 -0
- package/lib/store/index.js +31 -0
- package/lib/store/paths.js +163 -0
- package/lib/store/registry.js +223 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +79 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,2167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-session-ops` 浏览器半边。
|
|
3
|
+
*
|
|
4
|
+
* 作为包的 `./client` 产物发布:DSH 客户端模块系统把它当普通经典脚本加载
|
|
5
|
+
* (`window.__ModuleLoader__.load({ id, factory })`),React 由外壳预置
|
|
6
|
+
* (`factory(require)` 里 `require('react')`)。刻意写成无打包器的普通脚本。
|
|
7
|
+
*
|
|
8
|
+
* 这个文件是**两个原插件浏览器半边的合并**:
|
|
9
|
+
* - 删除半边:`dsh-session-admin/lib/client.js`(右栏「会话管理」页签 + 会话标题栏
|
|
10
|
+
* 垃圾桶图标 + 设置页 section + 那 4 个删除旋钮);
|
|
11
|
+
* - 搬迁半边:`dsh-session-move/lib/client.js`(右栏「会话归位」页签 +
|
|
12
|
+
* 那 3 个搬迁旋钮)。左栏底部那个 ⇄ 入口已按 2026-10-26 的要求去掉。
|
|
13
|
+
*
|
|
14
|
+
* 合并方式(为了可逐字核对):两半各自原封不动地待在一个**块作用域**里
|
|
15
|
+
* (`{ ... }`),块里的常量/组件名互不冲突,所以一行都不用重命名;块尾把该半边的
|
|
16
|
+
* `{ inject, apply }` 交给外层,外层合成一个插件对象。两个半边互不知道对方。
|
|
17
|
+
*
|
|
18
|
+
* ## 对外契约(与合并前逐字一致)
|
|
19
|
+
*
|
|
20
|
+
* - 页签:kind/id 仍是 `dsh-session-admin:manager`(+`…:manager-via-better-sidebar`)
|
|
21
|
+
* 与 `dsh-session-move:panel`(+`…:panel-via-better-sidebar`),标题分别是
|
|
22
|
+
* 「会话管理」「会话归位」,角标语义不变(删除半边:未分组条数;搬迁半边:无);
|
|
23
|
+
* - 会话标题栏垃圾桶的 id `dsh-session-admin:delete-current`(仍然是**图标**,
|
|
24
|
+
* 开关由 Host 的 `headerDeleteButton` 决定,默认开启,删除要两步确认);
|
|
25
|
+
* - 数据面仍然只打各自的前缀:`/session-admin/api` 与 `/session-move/api`。
|
|
26
|
+
*
|
|
27
|
+
* ## 统一右键菜单(DSH 0.2.2-rc.1 起)
|
|
28
|
+
*
|
|
29
|
+
* 会话行右键 / Shift+F10 的菜单里有两项本插件的动作,两半各注册一个 `target:'session'`
|
|
30
|
+
* 的贡献(各自用自己的 HTTP 客户端,互不依赖),合起来是两项:
|
|
31
|
+
*
|
|
32
|
+
* - 「移动会话到工作区…」(搬迁半边,order 500):菜单里只判断能不能搬并写出理由,
|
|
33
|
+
* 点下去时重新读一次 Host 状态,然后打开既有右栏「会话归位」面板并选中这条会话;
|
|
34
|
+
* - 「删除会话…」(删除半边,order 620,`afterContent` 与原生「归档」同区):与标题栏
|
|
35
|
+
* 垃圾桶**共用** `deleteSessionFlow`(预演 → 二次确认 → 删除),`run` 里会再预演
|
|
36
|
+
* 一次,所以菜单打开到点下去之间状态变了也不会绕过守卫。
|
|
37
|
+
*
|
|
38
|
+
* 两项的图标注册在 `contextMenu.icon` 的 keyed slot 上;没有 `contextMenus` 服务
|
|
39
|
+
* (0.2.1 及更早的外壳)时这两项整体缺席,其它注册照旧——服务是**可选**注入的。
|
|
40
|
+
*
|
|
41
|
+
* ## 合并的那一处:配置卡片
|
|
42
|
+
*
|
|
43
|
+
* 合并前是两张卡片(`dsh-session-admin#session-admin` / `dsh-session-move#session-move`),
|
|
44
|
+
* 现在只有**一张**,7 个 volatile 旋钮全在(删除 4 个 + 搬迁 3 个)。搬迁半边的卡片
|
|
45
|
+
* 代码就此删除,它的 3 个字段与 `enum` 控件形态并入删除半边那张卡片。
|
|
46
|
+
*
|
|
47
|
+
* 2026-09-25 起这张卡片挂在**插件页**:槽 `plugins.bundle.config`、key = 包名
|
|
48
|
+
* `dsh-session-ops`,渲染在本组合包页面的「描述与行之间」。原来那处
|
|
49
|
+
* `plugins.row.config`(key `dsh-session-ops#session-toolbox`)随之摘掉——同一份
|
|
50
|
+
* 表单只留一处。设置页的 `settings.section`(标签「会话工具」)**保留**:那是清理面板
|
|
51
|
+
* 页,不是配置菜单。卡片体一个字符都没改:这个槽不传 `form`,所以注册项的 `inject`
|
|
52
|
+
* 把 `ctx.configForms.get('session-toolbox')` 的 `{ state, mutate }` 喂给它。
|
|
53
|
+
*
|
|
54
|
+
* @module dsh-session-ops/client
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
;(function () {
|
|
58
|
+
const PACKAGE = 'dsh-session-ops'
|
|
59
|
+
|
|
60
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
61
|
+
// 删除半边(原 dsh-session-admin/lib/client.js,逐字搬入)
|
|
62
|
+
//
|
|
63
|
+
// 块作用域:两半的常量/组件同名(S、API、createApi、createConfigCard…)也不会撞车,
|
|
64
|
+
// 因此**半边的每一行都是原文件的原字节**(只改了行尾:原 client.js 是 CRLF,这里是 LF)。
|
|
65
|
+
// 唯一被改的是 `factory: (require) => {` → `sessionAdminHalf = (require) => {`。
|
|
66
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** 删除半边的插件对象,块尾交给外层合成(见文件末尾的 `target.load`)。 */
|
|
69
|
+
let sessionAdminHalf
|
|
70
|
+
{
|
|
71
|
+
const API = '/session-admin/api'
|
|
72
|
+
/**
|
|
73
|
+
* 页签标识。两条注册路径**必须用不同的字符串**,这是实测出来的硬约束:
|
|
74
|
+
*
|
|
75
|
+
* betterSidebar 把描述符的 `id` 直接当成原生 `kind` 注册
|
|
76
|
+
* (dsh-better-sidebar/lib/client.js:17034 `kind: descriptor.id`)。如果我们
|
|
77
|
+
* 原生那条也用同一个 kind,那么「先撤原生、再注册 betterSidebar」的做法挡不住
|
|
78
|
+
* 冲突——实测里原生的 dispose 不是同步生效的,两条会短暂并存,于是:
|
|
79
|
+
* `sidebarRight: tab kind "…" is already registered (extension)`
|
|
80
|
+
* 换个 kind 就从根本上不可能撞,也不必去赌 dispose 的时序。
|
|
81
|
+
*
|
|
82
|
+
* 代价是打开页签时不知道当前是哪条在生效,所以 `openManagerTab` 两个都试。
|
|
83
|
+
*/
|
|
84
|
+
const TAB_ID = 'dsh-session-admin:manager'
|
|
85
|
+
/** 走 betterSidebar 时用的描述符 id(不透明,但必须与 TAB_ID 不同)。 */
|
|
86
|
+
const BETTER_TAB_ID = 'dsh-session-admin:manager-via-better-sidebar'
|
|
87
|
+
|
|
88
|
+
/** 跨入口传递的预设筛选(设置页/头部按钮 → 管理面板)。 */
|
|
89
|
+
const pending = { cwdPrefix: undefined, scope: undefined }
|
|
90
|
+
|
|
91
|
+
const S = {
|
|
92
|
+
root: { padding: '4px 2px', fontSize: 13, color: 'inherit', display: 'flex', flexDirection: 'column', gap: 10 },
|
|
93
|
+
bar: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
|
|
94
|
+
select: { padding: '4px 8px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)', background: 'transparent', color: 'inherit' },
|
|
95
|
+
input: { padding: '4px 8px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)', background: 'transparent', color: 'inherit', flex: '1 1 140px', minWidth: 120 },
|
|
96
|
+
button: { padding: '5px 10px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)', background: 'transparent', color: 'inherit', cursor: 'pointer' },
|
|
97
|
+
danger: { padding: '5px 10px', fontSize: 12.5, borderRadius: 6, border: '1px solid transparent', background: 'var(--dsh-danger, #d92d20)', color: '#fff', cursor: 'pointer' },
|
|
98
|
+
primary: { padding: '5px 10px', fontSize: 12.5, borderRadius: 6, border: '1px solid transparent', background: 'var(--dsh-accent, #4d6bfe)', color: '#fff', cursor: 'pointer' },
|
|
99
|
+
muted: { fontSize: 12, opacity: 0.62 },
|
|
100
|
+
error: { fontSize: 12.5, color: 'var(--dsh-danger, #d92d20)' },
|
|
101
|
+
list: { border: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))', borderRadius: 8, maxHeight: 420, overflow: 'auto' },
|
|
102
|
+
row: { display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 8px', overflow: 'hidden', borderTop: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.16))' },
|
|
103
|
+
rowFirst: { borderTop: 'none' },
|
|
104
|
+
title: { fontSize: 12.5, lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' },
|
|
105
|
+
path: { fontSize: 11.5, opacity: 0.6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
106
|
+
badge: { fontSize: 10.5, padding: '0 5px', borderRadius: 999, border: '1px solid var(--dsh-border, #d0d5dd)', opacity: 0.75, whiteSpace: 'nowrap' },
|
|
107
|
+
result: { border: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))', borderRadius: 8, padding: '8px 10px', fontSize: 12 },
|
|
108
|
+
headerIconButton: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, padding: 6, border: 'none', borderRadius: 28, background: 'transparent', color: 'inherit', cursor: 'pointer', flex: 'none', opacity: 0.78 },
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 面板的两种排版。
|
|
113
|
+
*
|
|
114
|
+
* 同一个组件在两个容器里渲染,宿主的尺寸约束完全不同:
|
|
115
|
+
* - 右侧面板页签:容器有确定高度,列表应当**撑满**它,否则下面的空间全空着
|
|
116
|
+
* (这正是把 420px 上限直接搬进页签的后果)。
|
|
117
|
+
* - 设置弹窗:容器是普通文档流,没有确定高度,给一个上限反而更好——列表自己
|
|
118
|
+
* 滚动,而不是把弹窗无限拉长。
|
|
119
|
+
* 所以 `fill` 由调用方给:页签传 true,设置页不传。
|
|
120
|
+
*/
|
|
121
|
+
const S_FILL = {
|
|
122
|
+
root: { height: '100%', minHeight: 0, boxSizing: 'border-box' },
|
|
123
|
+
list: { maxHeight: 'none', flex: '1 1 auto', minHeight: 0 },
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 人可读字节数。 */
|
|
127
|
+
function human(bytes) {
|
|
128
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
129
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
130
|
+
let value = bytes
|
|
131
|
+
let unit = 0
|
|
132
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
133
|
+
value /= 1024
|
|
134
|
+
unit += 1
|
|
135
|
+
}
|
|
136
|
+
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 删除的二次确认。
|
|
141
|
+
*
|
|
142
|
+
* 两步,且第二步必须**动手输入**:
|
|
143
|
+
* 1. `confirm` 说明到底要删什么(条数、体积、会话 id);
|
|
144
|
+
* 2. `prompt` 要求输入确认词。
|
|
145
|
+
*
|
|
146
|
+
* 只弹一个 confirm 不够——那个对话框可以被回车或连点顺手确认掉,而这正是上次
|
|
147
|
+
* 「防止误删」担心的事。输入确认词要求的是一次有意的动作,手滑触发不了。
|
|
148
|
+
* @param summary - 这次删除的对象描述。
|
|
149
|
+
* @returns 用户是否确认删除。
|
|
150
|
+
*/
|
|
151
|
+
function confirmDeletion(summary) {
|
|
152
|
+
if (
|
|
153
|
+
!window.confirm(
|
|
154
|
+
`${summary}\n\n这会移除会话日志、投影缓存与回退快照,没有撤销、没有回收站。继续?`,
|
|
155
|
+
)
|
|
156
|
+
) {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
const typed = window.prompt(`最后一步:输入「删除」以确认\n\n${summary}`, '')
|
|
160
|
+
if (typed === null) return false
|
|
161
|
+
return typed.trim() === '删除'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 时间戳 → `MM-DD HH:mm`。 */
|
|
165
|
+
function stamp(ms) {
|
|
166
|
+
if (!Number.isFinite(ms) || ms <= 0) return ''
|
|
167
|
+
const d = new Date(ms)
|
|
168
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
169
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Host 侧 API 客户端。
|
|
174
|
+
*
|
|
175
|
+
* `signal` 是给右键菜单的 `resolve` 用的:菜单一关,Host 还没答完的那次查询就该
|
|
176
|
+
* 作废(否则除了 prepare 被丢弃,还会白扫一遍目录)。
|
|
177
|
+
*/
|
|
178
|
+
function createApi() {
|
|
179
|
+
async function call(method, payload, signal) {
|
|
180
|
+
const response = await fetch(`${API}/${method}`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'content-type': 'application/json' },
|
|
183
|
+
credentials: 'same-origin',
|
|
184
|
+
body: JSON.stringify(payload ?? {}),
|
|
185
|
+
...(signal === undefined ? null : { signal }),
|
|
186
|
+
})
|
|
187
|
+
let body
|
|
188
|
+
try {
|
|
189
|
+
body = await response.json()
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(`HTTP ${response.status}`)
|
|
192
|
+
}
|
|
193
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
194
|
+
throw new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
195
|
+
}
|
|
196
|
+
return body.value
|
|
197
|
+
}
|
|
198
|
+
return { call }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 「未分组有多少条」的角标数据源。
|
|
203
|
+
*
|
|
204
|
+
* 角标函数必须是同步且极便宜的——betterSidebar 每次渲染页签栏都会调它一次,
|
|
205
|
+
* 所以这里只读一个缓存值,真正的取值走轻量的 `count` 接口(Host 侧跳过体积
|
|
206
|
+
* 统计)。刷新节奏取两分钟:它只是个提示,不值得为此常驻轮询。
|
|
207
|
+
*/
|
|
208
|
+
function createBadge(api) {
|
|
209
|
+
const state = { value: null, timer: undefined }
|
|
210
|
+
const refresh = async () => {
|
|
211
|
+
try {
|
|
212
|
+
const value = await api.call('count', { scope: 'ungrouped' })
|
|
213
|
+
const matched = value?.matched
|
|
214
|
+
state.value = Number.isFinite(matched) && matched > 0 ? matched : null
|
|
215
|
+
} catch {
|
|
216
|
+
/* 取不到就沿用上一次的值,不影响其它功能 */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
get value() {
|
|
221
|
+
return state.value
|
|
222
|
+
},
|
|
223
|
+
refresh,
|
|
224
|
+
start() {
|
|
225
|
+
void refresh()
|
|
226
|
+
if (state.timer === undefined) state.timer = setInterval(() => void refresh(), 120_000)
|
|
227
|
+
},
|
|
228
|
+
stop() {
|
|
229
|
+
if (state.timer !== undefined) {
|
|
230
|
+
clearInterval(state.timer)
|
|
231
|
+
state.timer = undefined
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 拒绝理由 → 中文说明。
|
|
239
|
+
*
|
|
240
|
+
* 每条都要回答「那我该怎么办」,而不是只宣告「不行」:`running-session` 是用户最常
|
|
241
|
+
* 撞上的一条(他正开着的会话通常就是 Host 持有的那个),只写「不允许」会看起来
|
|
242
|
+
* 像功能坏了。它其实是有条件的:会话空闲后(一轮跑完)就不再被持有,那时可以删;
|
|
243
|
+
* 换句话说这不是永久拒绝,而是「现在不行」。
|
|
244
|
+
*/
|
|
245
|
+
const REASONS = {
|
|
246
|
+
'running-session': '会话正在运行中(这一轮还没结束)——它跑完就能删;不能边跑边删,那会让这一轮正在进行的写入失败',
|
|
247
|
+
'workspace-session': '属于某个工作区(在面板里显式勾选可放行)',
|
|
248
|
+
'archived-session': '已归档(在面板里选「已归档」范围可放行)',
|
|
249
|
+
'subagent-session': '是子代理子会话(在面板里选「子代理会话」范围可放行)',
|
|
250
|
+
'caller-session': '是当前这个会话本身',
|
|
251
|
+
'batch-limit': '超出单次批量上限',
|
|
252
|
+
'outside-sessions-root': '目录不在 sessions 根下(已拒绝)',
|
|
253
|
+
'not-found': '已经不存在',
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 同一批理由的**短句版**:右键菜单里一行字放不下上面那些完整解释。
|
|
258
|
+
*
|
|
259
|
+
* 菜单不是面板,没有第二行可以展开说;禁用项只给一句「现在为什么不行」,完整
|
|
260
|
+
* 解释仍留给面板里的预演结果。
|
|
261
|
+
*/
|
|
262
|
+
const REASONS_SHORT = {
|
|
263
|
+
'running-session': '会话运行中,这一轮跑完才能删',
|
|
264
|
+
'workspace-session': '属于某个工作区,需要显式放行',
|
|
265
|
+
'archived-session': '已归档,需要显式放行',
|
|
266
|
+
'subagent-session': '子代理子会话,需要显式放行',
|
|
267
|
+
'caller-session': '不能删当前这个会话',
|
|
268
|
+
'batch-limit': '超出单次批量上限',
|
|
269
|
+
'outside-sessions-root': '目录不在 sessions 根下',
|
|
270
|
+
'not-found': '已经不存在',
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 「会话管理」组件:列表 + 范围筛选 + 多选 + 预演/删除。
|
|
275
|
+
* @param React - 外壳预置的 React。
|
|
276
|
+
* @param api - Host API 客户端。
|
|
277
|
+
* @returns React 组件。
|
|
278
|
+
*/
|
|
279
|
+
function createManager(React, api, badge) {
|
|
280
|
+
const h = React.createElement
|
|
281
|
+
|
|
282
|
+
return function SessionManager(props) {
|
|
283
|
+
const fill = props?.fill === true
|
|
284
|
+
const [scope, setScope] = React.useState(pending.scope ?? 'ungrouped')
|
|
285
|
+
const [search, setSearch] = React.useState('')
|
|
286
|
+
const [cwdPrefix, setCwdPrefix] = React.useState(pending.cwdPrefix ?? '')
|
|
287
|
+
const [data, setData] = React.useState(undefined)
|
|
288
|
+
const [selected, setSelected] = React.useState(() => new Set())
|
|
289
|
+
const [busy, setBusy] = React.useState(false)
|
|
290
|
+
const [failure, setFailure] = React.useState(undefined)
|
|
291
|
+
const [result, setResult] = React.useState(undefined)
|
|
292
|
+
const [showAll, setShowAll] = React.useState(false)
|
|
293
|
+
|
|
294
|
+
const load = React.useCallback(async () => {
|
|
295
|
+
setBusy(true)
|
|
296
|
+
setFailure(undefined)
|
|
297
|
+
try {
|
|
298
|
+
const value = await api.call('list', {
|
|
299
|
+
scope,
|
|
300
|
+
search: search === '' ? undefined : search,
|
|
301
|
+
cwdPrefix: cwdPrefix === '' ? undefined : cwdPrefix,
|
|
302
|
+
limit: 2000,
|
|
303
|
+
})
|
|
304
|
+
setData(value)
|
|
305
|
+
setSelected(new Set())
|
|
306
|
+
} catch (error) {
|
|
307
|
+
setFailure(String(error?.message ?? error))
|
|
308
|
+
} finally {
|
|
309
|
+
setBusy(false)
|
|
310
|
+
}
|
|
311
|
+
}, [scope, search, cwdPrefix])
|
|
312
|
+
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
void load()
|
|
315
|
+
}, [load])
|
|
316
|
+
|
|
317
|
+
const rows = data?.sessions ?? []
|
|
318
|
+
const visible = showAll ? rows : rows.slice(0, 200)
|
|
319
|
+
|
|
320
|
+
function toggle(id) {
|
|
321
|
+
setSelected((current) => {
|
|
322
|
+
const next = new Set(current)
|
|
323
|
+
if (next.has(id)) next.delete(id)
|
|
324
|
+
else next.add(id)
|
|
325
|
+
return next
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function run(dryRun) {
|
|
330
|
+
const ids = [...selected]
|
|
331
|
+
if (ids.length === 0) return
|
|
332
|
+
const bytes = rows.filter((r) => selected.has(r.id)).reduce((sum, r) => sum + (r.bytes ?? 0), 0)
|
|
333
|
+
if (dryRun !== true) {
|
|
334
|
+
if (!confirmDeletion(`将永久删除 ${ids.length} 个会话(约 ${human(bytes)})。`)) return
|
|
335
|
+
}
|
|
336
|
+
setBusy(true)
|
|
337
|
+
setFailure(undefined)
|
|
338
|
+
setResult(undefined)
|
|
339
|
+
try {
|
|
340
|
+
const value = await api.call('delete', {
|
|
341
|
+
sessionIds: ids,
|
|
342
|
+
dryRun: dryRun === true,
|
|
343
|
+
// 显式勾选即显式同意:管理面板放行工作区/归档/子代理三类
|
|
344
|
+
// (这三类只在「按范围批量」的模型侧路径上默认被拦)。
|
|
345
|
+
includeWorkspace: true,
|
|
346
|
+
includeArchived: true,
|
|
347
|
+
includeSubagent: true,
|
|
348
|
+
})
|
|
349
|
+
setResult(value)
|
|
350
|
+
if (dryRun !== true) {
|
|
351
|
+
await load()
|
|
352
|
+
// 删完立刻更新角标,不让它挂着一个旧数字。
|
|
353
|
+
void badge?.refresh?.()
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
setFailure(String(error?.message ?? error))
|
|
357
|
+
} finally {
|
|
358
|
+
setBusy(false)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const refusalRows =
|
|
363
|
+
result === undefined
|
|
364
|
+
? []
|
|
365
|
+
: result.results.filter((r) => r.status === 'skipped' || r.status === 'failed')
|
|
366
|
+
|
|
367
|
+
const head = h(
|
|
368
|
+
'div',
|
|
369
|
+
{ style: S.bar },
|
|
370
|
+
h(
|
|
371
|
+
'select',
|
|
372
|
+
{
|
|
373
|
+
style: S.select,
|
|
374
|
+
value: scope,
|
|
375
|
+
onChange: (event) => setScope(event.target.value),
|
|
376
|
+
},
|
|
377
|
+
h('option', { value: 'ungrouped' }, '未分组'),
|
|
378
|
+
h('option', { value: 'temp' }, '临时/失效目录'),
|
|
379
|
+
h('option', { value: 'archived' }, '已归档'),
|
|
380
|
+
h('option', { value: 'subagent' }, '子代理会话'),
|
|
381
|
+
h('option', { value: 'all' }, '全部会话'),
|
|
382
|
+
),
|
|
383
|
+
h('input', {
|
|
384
|
+
style: S.input,
|
|
385
|
+
placeholder: '搜索标题 / 目录 / id',
|
|
386
|
+
value: search,
|
|
387
|
+
onChange: (event) => setSearch(event.target.value),
|
|
388
|
+
}),
|
|
389
|
+
h('button', { type: 'button', style: S.button, disabled: busy, onClick: () => void load() }, busy ? '读取中…' : '刷新'),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
const pathBar =
|
|
393
|
+
cwdPrefix === ''
|
|
394
|
+
? null
|
|
395
|
+
: h(
|
|
396
|
+
'div',
|
|
397
|
+
{ style: S.bar },
|
|
398
|
+
h('span', { style: S.badge }, `目录筛选:${cwdPrefix}`),
|
|
399
|
+
h(
|
|
400
|
+
'button',
|
|
401
|
+
{ type: 'button', style: S.button, onClick: () => setCwdPrefix('') },
|
|
402
|
+
'清除',
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
const toolbar = h(
|
|
407
|
+
'div',
|
|
408
|
+
{ style: S.bar },
|
|
409
|
+
h(
|
|
410
|
+
'button',
|
|
411
|
+
{
|
|
412
|
+
type: 'button',
|
|
413
|
+
style: S.button,
|
|
414
|
+
disabled: rows.length === 0,
|
|
415
|
+
onClick: () =>
|
|
416
|
+
setSelected((current) =>
|
|
417
|
+
current.size === rows.length ? new Set() : new Set(rows.map((r) => r.id)),
|
|
418
|
+
),
|
|
419
|
+
},
|
|
420
|
+
selected.size === rows.length && rows.length > 0 ? '取消全选' : `全选 ${rows.length} 条`,
|
|
421
|
+
),
|
|
422
|
+
h('span', { style: S.muted }, `已选 ${selected.size} 条`),
|
|
423
|
+
h(
|
|
424
|
+
'button',
|
|
425
|
+
{ type: 'button', style: S.button, disabled: busy || selected.size === 0, onClick: () => void run(true) },
|
|
426
|
+
'预演',
|
|
427
|
+
),
|
|
428
|
+
h(
|
|
429
|
+
'button',
|
|
430
|
+
{ type: 'button', style: S.danger, disabled: busy || selected.size === 0, onClick: () => void run(false) },
|
|
431
|
+
'永久删除',
|
|
432
|
+
),
|
|
433
|
+
data === undefined
|
|
434
|
+
? null
|
|
435
|
+
: h(
|
|
436
|
+
'span',
|
|
437
|
+
{ style: S.muted },
|
|
438
|
+
`共 ${data.total} 个会话,命中 ${data.matched} 个${data.truncated ? '(已截断)' : ''}`,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
const list =
|
|
443
|
+
rows.length === 0
|
|
444
|
+
? h('div', { style: S.muted }, busy ? '读取中…' : '这个范围下没有会话。')
|
|
445
|
+
: h(
|
|
446
|
+
'div',
|
|
447
|
+
{ style: fill ? { ...S.list, ...S_FILL.list } : S.list },
|
|
448
|
+
visible.map((row, index) =>
|
|
449
|
+
h(
|
|
450
|
+
'div',
|
|
451
|
+
{ key: row.id, style: index === 0 ? { ...S.row, ...S.rowFirst } : S.row },
|
|
452
|
+
h('input', {
|
|
453
|
+
type: 'checkbox',
|
|
454
|
+
checked: selected.has(row.id),
|
|
455
|
+
onChange: () => toggle(row.id),
|
|
456
|
+
}),
|
|
457
|
+
h(
|
|
458
|
+
'div',
|
|
459
|
+
{ style: { flex: 1, minWidth: 0 } },
|
|
460
|
+
h('div', { style: S.title }, row.title ?? '(无标题)'),
|
|
461
|
+
h('div', { style: S.path, title: row.cwd ?? '' }, row.cwd ?? '(目录未知)'),
|
|
462
|
+
h(
|
|
463
|
+
'div',
|
|
464
|
+
{ style: { ...S.bar, marginTop: 3, gap: 5 } },
|
|
465
|
+
row.live ? h('span', { style: { ...S.badge, color: 'var(--dsh-accent, #4d6bfe)' } }, '使用中') : null,
|
|
466
|
+
row.ungrouped ? h('span', { style: S.badge }, '未分组') : h('span', { style: S.badge }, row.workspaceTitle ?? '工作区'),
|
|
467
|
+
row.archived ? h('span', { style: S.badge }, '已归档') : null,
|
|
468
|
+
row.subagent ? h('span', { style: S.badge }, '子代理') : null,
|
|
469
|
+
row.temp ? h('span', { style: S.badge }, '临时/失效') : null,
|
|
470
|
+
h('span', { style: S.badge }, human(row.bytes)),
|
|
471
|
+
h('span', { style: S.badge }, stamp(row.updatedAt)),
|
|
472
|
+
),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
rows.length > visible.length
|
|
477
|
+
? h(
|
|
478
|
+
'div',
|
|
479
|
+
{ style: { padding: 8 } },
|
|
480
|
+
h(
|
|
481
|
+
'button',
|
|
482
|
+
{ type: 'button', style: S.button, onClick: () => setShowAll(true) },
|
|
483
|
+
`还有 ${rows.length - visible.length} 条,显示全部`,
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
: null,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
const resultBox =
|
|
490
|
+
result === undefined
|
|
491
|
+
? null
|
|
492
|
+
: h(
|
|
493
|
+
'div',
|
|
494
|
+
{ style: S.result },
|
|
495
|
+
h(
|
|
496
|
+
'div',
|
|
497
|
+
null,
|
|
498
|
+
result.dryRun === true
|
|
499
|
+
? `预演:命中 ${result.results.length} 条,可删 ${result.removed} 条,可释放 ${human(result.reclaimed)}。`
|
|
500
|
+
: `已删除 ${result.removed} 个会话,释放 ${human(result.reclaimed)}。` +
|
|
501
|
+
(result.pruned?.length > 0 ? ` 清理了 ${result.pruned.length} 个空项目目录。` : ''),
|
|
502
|
+
),
|
|
503
|
+
refusalRows.length > 0
|
|
504
|
+
? h(
|
|
505
|
+
'div',
|
|
506
|
+
{ style: { marginTop: 6 } },
|
|
507
|
+
h('div', { style: S.muted }, `未处理 ${refusalRows.length} 条:`),
|
|
508
|
+
refusalRows.slice(0, 12).map((row) =>
|
|
509
|
+
h(
|
|
510
|
+
'div',
|
|
511
|
+
{ key: row.id, style: S.muted },
|
|
512
|
+
`${row.id} — ${REASONS[row.reason] ?? row.reason}`,
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
refusalRows.length > 12 ? h('div', { style: S.muted }, `…以及另外 ${refusalRows.length - 12} 条`) : null,
|
|
516
|
+
)
|
|
517
|
+
: null,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
// 待删除队列:条目、状态、撤销、立即重试。
|
|
521
|
+
return h(
|
|
522
|
+
'div',
|
|
523
|
+
{ style: fill ? { ...S.root, ...S_FILL.root } : S.root },
|
|
524
|
+
h(
|
|
525
|
+
'div',
|
|
526
|
+
{ style: S.muted },
|
|
527
|
+
'DSH 只能「归档」(隐藏)会话;这里做的是把会话的持久化数据真正从磁盘移除。删除需要二次确认,不可恢复。',
|
|
528
|
+
),
|
|
529
|
+
head,
|
|
530
|
+
pathBar,
|
|
531
|
+
toolbar,
|
|
532
|
+
failure !== undefined ? h('div', { style: S.error }, `失败:${failure}`) : null,
|
|
533
|
+
list,
|
|
534
|
+
resultBox,
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** 右侧页签的标题条。 */
|
|
540
|
+
function createTabTitle(React) {
|
|
541
|
+
const h = React.createElement
|
|
542
|
+
return function SessionAdminTabTitle() {
|
|
543
|
+
return h('span', { style: { fontSize: 12.5 } }, '会话管理')
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 右键菜单里「删除会话」的图标 key(同时是 `contextMenu.icon` 槽的 key)。 */
|
|
548
|
+
const DELETE_MENU_ICON = 'session-toolbox.delete-session'
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 垃圾桶字形,内联 SVG。
|
|
552
|
+
*
|
|
553
|
+
* 描边风格(Lucide 的 `trash-2`),不是实心填充:相邻的头部图标都是细描边,
|
|
554
|
+
* 实心字形在 16px 下会显得又黑又笨重。viewBox 用 24 是为了沿用 Lucide 的坐标,
|
|
555
|
+
* 实际渲染缩到 16。标题栏图标与右键菜单图标共用这一份字形。
|
|
556
|
+
*
|
|
557
|
+
* 刻意不 require `@deepseek-ai/dsh-client-ui-primitives`:那个模块表在版本间
|
|
558
|
+
* 漂过(本仓库踩过第三方 UI 插件因它整体不可用的坑),一个 16×16 的图形不值得
|
|
559
|
+
* 为它引一条跨版本依赖。
|
|
560
|
+
*/
|
|
561
|
+
const TRASH_GLYPHS = [
|
|
562
|
+
'M3 6h18',
|
|
563
|
+
'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6',
|
|
564
|
+
'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
|
|
565
|
+
'M10 11v6',
|
|
566
|
+
'M14 11v6',
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 垃圾桶图标组件(16×16),标题栏与右键菜单共用。
|
|
571
|
+
* @param React - 外壳预置的 React。
|
|
572
|
+
* @returns 图标组件。
|
|
573
|
+
*/
|
|
574
|
+
function createTrashIcon(React) {
|
|
575
|
+
return function TrashIcon() {
|
|
576
|
+
return React.createElement(
|
|
577
|
+
'svg',
|
|
578
|
+
{
|
|
579
|
+
width: 16,
|
|
580
|
+
height: 16,
|
|
581
|
+
viewBox: '0 0 24 24',
|
|
582
|
+
fill: 'none',
|
|
583
|
+
stroke: 'currentColor',
|
|
584
|
+
strokeWidth: 1.7,
|
|
585
|
+
strokeLinecap: 'round',
|
|
586
|
+
strokeLinejoin: 'round',
|
|
587
|
+
'aria-hidden': 'true',
|
|
588
|
+
focusable: 'false',
|
|
589
|
+
},
|
|
590
|
+
TRASH_GLYPHS.map((d, index) => React.createElement('path', { key: index, d })),
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 当前选中的会话 id;拿不到(没有会话服务 / 老外壳)时为 undefined。 */
|
|
596
|
+
function currentSessionId(ctx) {
|
|
597
|
+
try {
|
|
598
|
+
return ctx.sessions.list.getSnapshot().current
|
|
599
|
+
} catch {
|
|
600
|
+
return undefined
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* 会话被真删掉之后离开它。
|
|
606
|
+
*
|
|
607
|
+
* 数据已经没了,界面不能停在它上面:继续在这个会话里发消息,Host 会往已删除的
|
|
608
|
+
* 目录重新写日志——那正是「无头日志」的由来,而删除守卫正是为了避免它。
|
|
609
|
+
*/
|
|
610
|
+
function leaveDeletedSession(ctx) {
|
|
611
|
+
try {
|
|
612
|
+
ctx.sessions.create()
|
|
613
|
+
return
|
|
614
|
+
} catch {
|
|
615
|
+
/* 建不出新会话就退回整页刷新 */
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
window.location.reload()
|
|
619
|
+
} catch {
|
|
620
|
+
/* 已经无可挽回,界面刷新失败也不影响数据层 */
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 删除一个会话的整条流程:预演 → 二次确认 → 删除。
|
|
626
|
+
*
|
|
627
|
+
* 标题栏垃圾桶与右键菜单共用这一段:两处删的是同一个东西,只是入口不同,守卫与
|
|
628
|
+
* 确认必须逐字一致,否则「从菜单删」就等于绕开「从图标删」的那些检查。
|
|
629
|
+
*
|
|
630
|
+
* 预演不只是让用户先看一眼:删除守卫的逐条拒绝理由要扫过目录、过完所有守卫才拿
|
|
631
|
+
* 得到,所以它同时是「现在到底能不能删」的唯一权威答案。
|
|
632
|
+
*
|
|
633
|
+
* @param ctx - 插件上下文(删的是当前会话时需要它离开)。
|
|
634
|
+
* @param api - Host API 客户端。
|
|
635
|
+
* @param sessionId - 目标会话 id。
|
|
636
|
+
* @returns `{ ok: true }` 删掉了;`{ ok: false, note? }` 没删,note 是给用户看的一句话
|
|
637
|
+
* (用户在确认框里取消时没有 note,不该冒出一条消息)。
|
|
638
|
+
*/
|
|
639
|
+
async function deleteSessionFlow(ctx, api, sessionId) {
|
|
640
|
+
// 与标题栏同一个口径:显式入口即显式同意,放行工作区 / 归档 / 子代理三类。
|
|
641
|
+
const target = {
|
|
642
|
+
sessionIds: [sessionId],
|
|
643
|
+
includeWorkspace: true,
|
|
644
|
+
includeArchived: true,
|
|
645
|
+
includeSubagent: true,
|
|
646
|
+
}
|
|
647
|
+
const probe = await api.call('delete', { ...target, dryRun: true })
|
|
648
|
+
const entry = probe.results[0]
|
|
649
|
+
if (probe.removed === 0) {
|
|
650
|
+
return { ok: false, note: REASONS[entry?.reason] ?? entry?.reason ?? '无法删除' }
|
|
651
|
+
}
|
|
652
|
+
if (!confirmDeletion(`永久删除会话 ${sessionId}?`)) return { ok: false }
|
|
653
|
+
const value = await api.call('delete', target)
|
|
654
|
+
const outcome = value.results[0]
|
|
655
|
+
if (outcome?.status !== 'removed') {
|
|
656
|
+
return { ok: false, note: REASONS[outcome?.reason] ?? outcome?.reason ?? '删除失败' }
|
|
657
|
+
}
|
|
658
|
+
return { ok: true }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* 会话标题栏的垃圾桶图标:删除当前会话。
|
|
663
|
+
*
|
|
664
|
+
* 形态是「图标 + 二次确认」而不是带字面标签的按钮:标签会招来点击,而这里删的
|
|
665
|
+
* 正是你正在用的会话。图标不占字面位置,误点最多弹出一个确认框。
|
|
666
|
+
*/
|
|
667
|
+
function createHeaderButton(React, ctx, api) {
|
|
668
|
+
const h = React.createElement
|
|
669
|
+
const TrashIcon = createTrashIcon(React)
|
|
670
|
+
|
|
671
|
+
return function SessionAdminHeaderButton() {
|
|
672
|
+
const [busy, setBusy] = React.useState(false)
|
|
673
|
+
const [note, setNote] = React.useState(undefined)
|
|
674
|
+
|
|
675
|
+
async function onClick() {
|
|
676
|
+
const id = currentSessionId(ctx)
|
|
677
|
+
if (id === undefined) {
|
|
678
|
+
setNote('没有选中会话')
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
setBusy(true)
|
|
682
|
+
setNote(undefined)
|
|
683
|
+
try {
|
|
684
|
+
const result = await deleteSessionFlow(ctx, api, id)
|
|
685
|
+
if (result.ok === true) {
|
|
686
|
+
// 删掉的正是当前会话:先离开它,再让这条界面自然消失。
|
|
687
|
+
leaveDeletedSession(ctx)
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
setNote(result.note)
|
|
691
|
+
} catch (error) {
|
|
692
|
+
setNote(String(error?.message ?? error))
|
|
693
|
+
} finally {
|
|
694
|
+
setBusy(false)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return h(
|
|
699
|
+
'span',
|
|
700
|
+
{ style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
|
|
701
|
+
h(
|
|
702
|
+
'button',
|
|
703
|
+
{
|
|
704
|
+
type: 'button',
|
|
705
|
+
style: S.headerIconButton,
|
|
706
|
+
disabled: busy,
|
|
707
|
+
title: '删除当前会话(需确认,不可恢复)',
|
|
708
|
+
'aria-label': '删除当前会话',
|
|
709
|
+
'aria-busy': busy ? 'true' : undefined,
|
|
710
|
+
onClick: () => void onClick(),
|
|
711
|
+
},
|
|
712
|
+
h(TrashIcon, null),
|
|
713
|
+
),
|
|
714
|
+
note !== undefined ? h('span', { style: { ...S.muted, maxWidth: 220 } }, note) : null,
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 构造右键菜单「删除会话…」的 resolve。
|
|
721
|
+
*
|
|
722
|
+
* 菜单打开时先预演一次:能删就给出可点的一项;不能删就把它标成 disabled,并把
|
|
723
|
+
* **一句人话理由**写进 label——菜单里没有第二行,理由只能跟在标题后面。
|
|
724
|
+
*
|
|
725
|
+
* 真正的删除在 `run` 里重跑 `deleteSessionFlow`(它自己会再预演一遍):从菜单打开
|
|
726
|
+
* 到点下去之间,会话可能已经跑起来、被归档,或者被别人删掉,预演结果不能当作执行
|
|
727
|
+
* 许可。这就是「run 里重新校验」的那一步。
|
|
728
|
+
*
|
|
729
|
+
* @param ctx - 插件上下文(`ctx.sessions` 用来判断删的是不是当前会话)。
|
|
730
|
+
* @returns `(menus, target, signal) => Promise<MenuAction[]>`。
|
|
731
|
+
*/
|
|
732
|
+
function createResolveDeleteSessionMenu(ctx) {
|
|
733
|
+
return async function resolveDeleteSessionMenu(menus, target, signal) {
|
|
734
|
+
const sessionId = target?.sessionId
|
|
735
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return []
|
|
736
|
+
const api = createApi()
|
|
737
|
+
const base = {
|
|
738
|
+
id: 'delete-session',
|
|
739
|
+
icon: { key: DELETE_MENU_ICON },
|
|
740
|
+
danger: true,
|
|
741
|
+
// 与原生「归档」同区(footer):两者都是对整条会话的收尾动作,排最后不易误点。
|
|
742
|
+
afterContent: true,
|
|
743
|
+
failureLabel: '删除失败,请稍后再试',
|
|
744
|
+
}
|
|
745
|
+
const run = async () => {
|
|
746
|
+
// 先记下删的是不是当前会话:删完再问就未必问得到(列表/索引可能已经更新)。
|
|
747
|
+
const wasCurrent = currentSessionId(ctx) === sessionId
|
|
748
|
+
try {
|
|
749
|
+
const result = await deleteSessionFlow(ctx, api, sessionId)
|
|
750
|
+
if (result.ok === true) {
|
|
751
|
+
if (wasCurrent) leaveDeletedSession(ctx)
|
|
752
|
+
menus.notify('已删除会话', true)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (result.note !== undefined) menus.notify(result.note, false)
|
|
756
|
+
} catch (error) {
|
|
757
|
+
menus.notify(`删除失败:${String(error?.message ?? error)}`, false)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
let probe
|
|
761
|
+
try {
|
|
762
|
+
probe = await api.call(
|
|
763
|
+
'delete',
|
|
764
|
+
{
|
|
765
|
+
sessionIds: [sessionId],
|
|
766
|
+
dryRun: true,
|
|
767
|
+
includeWorkspace: true,
|
|
768
|
+
includeArchived: true,
|
|
769
|
+
includeSubagent: true,
|
|
770
|
+
},
|
|
771
|
+
signal,
|
|
772
|
+
)
|
|
773
|
+
} catch (error) {
|
|
774
|
+
// 菜单在请求途中被关掉了:静默作废(registry 会丢弃这份结果)。
|
|
775
|
+
if (signal?.aborted === true) return []
|
|
776
|
+
return [
|
|
777
|
+
{
|
|
778
|
+
...base,
|
|
779
|
+
label: `删除会话…(现在读不到 Host 状态:${String(error?.message ?? error)})`,
|
|
780
|
+
disabled: true,
|
|
781
|
+
},
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
const entry = probe?.results?.[0]
|
|
785
|
+
if (!(probe?.removed > 0)) {
|
|
786
|
+
const why = REASONS_SHORT[entry?.reason] ?? entry?.reason ?? '现在不能删'
|
|
787
|
+
return [{ ...base, label: `删除会话…(${why})`, disabled: true }]
|
|
788
|
+
}
|
|
789
|
+
return [{ ...base, label: '删除会话…', run }]
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** 设置页里的清理面板:复用管理组件,另给一个「在右侧面板打开」。 */
|
|
794
|
+
function createSettingsSection(React, openManagerTab, SessionAdminManager) {
|
|
795
|
+
const h = React.createElement
|
|
796
|
+
return function SessionAdminSettingsSection() {
|
|
797
|
+
return h(
|
|
798
|
+
'div',
|
|
799
|
+
null,
|
|
800
|
+
h('p', { style: { ...S.muted, marginTop: 0 } }, '批量清理会话数据。右侧面板里也有同一份界面。'),
|
|
801
|
+
h(
|
|
802
|
+
'div',
|
|
803
|
+
{ style: { ...S.bar, marginBottom: 8 } },
|
|
804
|
+
h(
|
|
805
|
+
'button',
|
|
806
|
+
{ type: 'button', style: S.button, onClick: () => openManagerTab() },
|
|
807
|
+
'在右侧面板打开',
|
|
808
|
+
),
|
|
809
|
+
),
|
|
810
|
+
h(SessionAdminManager, {}),
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 本插件在 profile 里的 entry id(`- id: session-toolbox`,由 cordis.patch.yml 声明)。
|
|
816
|
+
// 它同时是 Host 设置命名空间的 key 与 `ctx.configForms.get(...)` 的 key。合并后只有
|
|
817
|
+
// 这一张卡片,键也就只有一个:`session-toolbox`。
|
|
818
|
+
const CONFIG_ENTRY_ID = 'session-toolbox'
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 插件页上本组合包页面的**配置卡片**。
|
|
822
|
+
*
|
|
823
|
+
* 注册进 `plugins.bundle.config`(key 是**包的 package name**),渲染在该组合包页面的
|
|
824
|
+
* 「描述与行之间」——官方插件放配置的位置。这个槽**不**把共享表单传进来(不像
|
|
825
|
+
* `plugins.row.config` 传 `{ state, mutate }`),所以注册项的 `inject` 自己把
|
|
826
|
+
* `ctx.configForms.get(CONFIG_ENTRY_ID)` 的同一份 `{ state, mutate }` 作为 `form`
|
|
827
|
+
* 喂给这张卡片;卡片体与当年挂 `plugins.row.config` 时逐字相同。
|
|
828
|
+
*
|
|
829
|
+
* **只有宿主 `Config` 里标了 `.volatile()` 的字段在这里可改**:命名空间正是由
|
|
830
|
+
* volatile 字段撑起来的,只有它被服务时快照才可写。
|
|
831
|
+
* `enabled` / `root` / `adminAllowHosts` / `moveAllowHosts` 是组合事实(挂载期决定
|
|
832
|
+
* 注册什么、会话根与两条信任栅栏),刻意不标 volatile,因此不在这张表里——要改请改
|
|
833
|
+
* profile 的 patch。两半的旋钮合并成这一张卡片,卡片注册只有一处。
|
|
834
|
+
*/
|
|
835
|
+
function createConfigCard(React) {
|
|
836
|
+
const h = React.createElement
|
|
837
|
+
|
|
838
|
+
/** 字段表:key 必须与宿主 `Config` 的 volatile 字段一一对应(合并后一共 7 个)。 */
|
|
839
|
+
const FIELDS = [
|
|
840
|
+
{
|
|
841
|
+
key: 'headerDeleteButton',
|
|
842
|
+
kind: 'boolean',
|
|
843
|
+
label: '标题栏删除图标',
|
|
844
|
+
hint: '在会话标题栏显示垃圾桶图标(默认开启)。它是图标而不是按钮,且删除要两步确认。改完刷新页面生效。',
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
key: 'allowLiveSessions',
|
|
848
|
+
kind: 'boolean',
|
|
849
|
+
label: '允许删除正在运行的会话',
|
|
850
|
+
hint: '默认否。打开后可以删掉仍然挂在 Host 进程里的会话(正在浏览或正在跑)。',
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
key: 'allowWorkspaceSessions',
|
|
854
|
+
kind: 'boolean',
|
|
855
|
+
label: '允许删除工作区会话',
|
|
856
|
+
hint: '默认否。打开后可以删掉「属于某个工作区」的会话。',
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
key: 'maxBatch',
|
|
860
|
+
kind: 'number',
|
|
861
|
+
label: '单次最多删除',
|
|
862
|
+
hint: '一次调用最多删多少个会话(默认 200),防手滑。',
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
key: 'refuseRunning',
|
|
866
|
+
kind: 'boolean',
|
|
867
|
+
label: '拒绝搬迁正在跑的会话',
|
|
868
|
+
hint: '默认开启。搬运期间该会话的写入句柄可能落盘,关掉它要自己承担风险。',
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
key: 'maxSessions',
|
|
872
|
+
kind: 'number',
|
|
873
|
+
label: '归位列表一次最多返回',
|
|
874
|
+
hint: '`/session-move/api/list` 一次最多返回多少条会话(浏览器半边还会再筛选)。默认 2000。',
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
key: 'memoryFollow',
|
|
878
|
+
kind: 'enum',
|
|
879
|
+
label: '项目记忆跟随',
|
|
880
|
+
hint: 'off(默认)只在报告里给只读预览;copy 把本会话产出的条目复制到目标工作区;move 复制后从源移除——不可逆。',
|
|
881
|
+
options: [
|
|
882
|
+
['off', 'off(只给预览,推荐)'],
|
|
883
|
+
['copy', 'copy(复制到目标工作区)'],
|
|
884
|
+
['move', 'move(复制后从源移除,不可逆)'],
|
|
885
|
+
],
|
|
886
|
+
},
|
|
887
|
+
]
|
|
888
|
+
|
|
889
|
+
const S = {
|
|
890
|
+
card: { border: '1px solid var(--dsh-border, #d0d5dd)', borderRadius: 10, padding: '16px 18px', margin: '0 0 14px' },
|
|
891
|
+
title: { margin: '0 0 6px', fontSize: 15, fontWeight: 600 },
|
|
892
|
+
intro: { margin: '0 0 14px', fontSize: 12.5, lineHeight: 1.6, opacity: 0.75 },
|
|
893
|
+
row: { padding: '10px 0', borderTop: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))' },
|
|
894
|
+
rowHead: { display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' },
|
|
895
|
+
label: { fontSize: 13, fontWeight: 500 },
|
|
896
|
+
badge: { fontSize: 11, padding: '1px 6px', borderRadius: 999, border: '1px solid var(--dsh-border, #d0d5dd)', opacity: 0.7 },
|
|
897
|
+
hint: { margin: '4px 0 0', fontSize: 12, lineHeight: 1.6, opacity: 0.66 },
|
|
898
|
+
control: { marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 },
|
|
899
|
+
input: { padding: '5px 8px', fontSize: 13, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)', background: 'transparent', color: 'inherit', minWidth: 140 },
|
|
900
|
+
actions: { marginTop: 16, display: 'flex', gap: 8, alignItems: 'center' },
|
|
901
|
+
button: { padding: '6px 14px', fontSize: 13, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)', background: 'transparent', color: 'inherit', cursor: 'pointer' },
|
|
902
|
+
buttonPrimary: { padding: '6px 14px', fontSize: 13, borderRadius: 6, border: '1px solid transparent', background: 'var(--dsh-accent, #4d6bfe)', color: '#fff', cursor: 'pointer' },
|
|
903
|
+
error: { fontSize: 12.5, color: 'var(--dsh-danger, #d92d20)' },
|
|
904
|
+
muted: { fontSize: 12, opacity: 0.6 },
|
|
905
|
+
link: { fontSize: 12, background: 'none', border: 'none', padding: 0, color: 'inherit', opacity: 0.66, cursor: 'pointer', textDecoration: 'underline' },
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const SUMMARY = '会话工具箱:真正删除持久化会话,或把会话搬到另一个工作区。'
|
|
909
|
+
|
|
910
|
+
/** 已保存值 → 控件草稿。 */
|
|
911
|
+
function toDraft(field, value) {
|
|
912
|
+
if (field.kind === 'boolean') return value === true
|
|
913
|
+
if (field.kind === 'enum') return typeof value === 'string' && value.length > 0 ? value : field.options[0][0]
|
|
914
|
+
return value === undefined || value === null ? '' : String(value)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** 草稿是否非法(只有数字会非法)。 */
|
|
918
|
+
function isInvalid(field, draft) {
|
|
919
|
+
if (field.kind !== 'number') return false
|
|
920
|
+
if (draft === '') return true
|
|
921
|
+
const n = Number(draft)
|
|
922
|
+
return !Number.isInteger(n) || n < 1
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** 草稿 → 写回本行 entry 配置的值。 */
|
|
926
|
+
function toValue(field, draft) {
|
|
927
|
+
if (field.kind === 'boolean') return draft === true
|
|
928
|
+
if (field.kind === 'enum') return String(draft)
|
|
929
|
+
return Number(draft)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** 该字段是否「已被用户层覆盖」。 */
|
|
933
|
+
function isOverridden(user, key) {
|
|
934
|
+
return user !== null && typeof user === 'object' && Object.hasOwn(user, key)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return function SessionToolboxConfigCard(props) {
|
|
938
|
+
const [drafts, setDrafts] = React.useState({})
|
|
939
|
+
const [resets, setResets] = React.useState({})
|
|
940
|
+
const [fence, setFence] = React.useState(undefined)
|
|
941
|
+
const [saving, setSaving] = React.useState(false)
|
|
942
|
+
const [failure, setFailure] = React.useState(undefined)
|
|
943
|
+
|
|
944
|
+
// 注册项的 `inject` 把共享表单的 `{ state, mutate }` 作为 `form` 传进来;命名空间
|
|
945
|
+
// 不是「Host 在服务的设置命名空间」时快照是 undefined(见 dsh-client-ui-settings
|
|
946
|
+
// 的 ConfigFormController 与 dsh-client-ui-plugin-manager 的 formFor)。
|
|
947
|
+
const form = props.form
|
|
948
|
+
const snapshot = form !== undefined && form !== null ? form.state : undefined
|
|
949
|
+
const writable = snapshot !== undefined && snapshot.writable !== false
|
|
950
|
+
|
|
951
|
+
if (props.view === 'summary') return h('span', null, SUMMARY)
|
|
952
|
+
if (snapshot === undefined) {
|
|
953
|
+
return h(
|
|
954
|
+
'p',
|
|
955
|
+
{ style: S.muted },
|
|
956
|
+
'Host 没有把这一行服务成可写的设置命名空间——只有标了 volatile 的字段才会出现在这里。',
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const value = snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
961
|
+
const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
|
|
962
|
+
const dirty = Object.keys(drafts).length > 0 || Object.keys(resets).length > 0
|
|
963
|
+
|
|
964
|
+
/** 某字段当前该显示什么:优先本地草稿,否则回落到已保存值。 */
|
|
965
|
+
function draftOf(field) {
|
|
966
|
+
if (Object.hasOwn(drafts, field.key)) return drafts[field.key]
|
|
967
|
+
return toDraft(field, value[field.key])
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function edit(field, next) {
|
|
971
|
+
if (fence === undefined) setFence(snapshot.revision)
|
|
972
|
+
setResets((current) => {
|
|
973
|
+
if (!Object.hasOwn(current, field.key)) return current
|
|
974
|
+
const copy = { ...current }
|
|
975
|
+
delete copy[field.key]
|
|
976
|
+
return copy
|
|
977
|
+
})
|
|
978
|
+
setDrafts((current) => ({ ...current, [field.key]: next }))
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** 标记该字段恢复默认:写回时发 unset,让它重新继承部署层与 schema 默认值。 */
|
|
982
|
+
function restore(field) {
|
|
983
|
+
if (fence === undefined) setFence(snapshot.revision)
|
|
984
|
+
setDrafts((current) => {
|
|
985
|
+
if (!Object.hasOwn(current, field.key)) return current
|
|
986
|
+
const copy = { ...current }
|
|
987
|
+
delete copy[field.key]
|
|
988
|
+
return copy
|
|
989
|
+
})
|
|
990
|
+
setResets((current) => ({ ...current, [field.key]: true }))
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function discard() {
|
|
994
|
+
setDrafts({})
|
|
995
|
+
setResets({})
|
|
996
|
+
setFence(undefined)
|
|
997
|
+
setFailure(undefined)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function save() {
|
|
1001
|
+
const ops = []
|
|
1002
|
+
for (const key of Object.keys(resets)) ops.push({ op: 'unset', path: [key] })
|
|
1003
|
+
for (const key of Object.keys(drafts)) {
|
|
1004
|
+
const field = FIELDS.find((f) => f.key === key)
|
|
1005
|
+
if (field === undefined) continue
|
|
1006
|
+
ops.push({ op: 'set', path: [key], value: toValue(field, drafts[key]) })
|
|
1007
|
+
}
|
|
1008
|
+
if (ops.length === 0) {
|
|
1009
|
+
discard()
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
setSaving(true)
|
|
1013
|
+
setFailure(undefined)
|
|
1014
|
+
try {
|
|
1015
|
+
await form.mutate(ops, fence)
|
|
1016
|
+
setDrafts({})
|
|
1017
|
+
setResets({})
|
|
1018
|
+
setFence(undefined)
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
setFailure(
|
|
1021
|
+
error !== null && typeof error === 'object' && typeof error.message === 'string'
|
|
1022
|
+
? error.message
|
|
1023
|
+
: String(error),
|
|
1024
|
+
)
|
|
1025
|
+
} finally {
|
|
1026
|
+
setSaving(false)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const blocked =
|
|
1031
|
+
!writable || saving || FIELDS.some((field) => Object.hasOwn(drafts, field.key) && isInvalid(field, drafts[field.key]))
|
|
1032
|
+
|
|
1033
|
+
const rows = FIELDS.map((field) => {
|
|
1034
|
+
const draft = draftOf(field)
|
|
1035
|
+
const overridden = isOverridden(user, field.key)
|
|
1036
|
+
let control
|
|
1037
|
+
if (field.kind === 'boolean') {
|
|
1038
|
+
control = h('input', {
|
|
1039
|
+
type: 'checkbox',
|
|
1040
|
+
checked: draft === true,
|
|
1041
|
+
disabled: !writable || saving,
|
|
1042
|
+
onChange: (event) => edit(field, event.target.checked),
|
|
1043
|
+
})
|
|
1044
|
+
} else if (field.kind === 'enum') {
|
|
1045
|
+
control = h(
|
|
1046
|
+
'select',
|
|
1047
|
+
{
|
|
1048
|
+
style: S.input,
|
|
1049
|
+
value: draft,
|
|
1050
|
+
disabled: !writable || saving,
|
|
1051
|
+
onChange: (event) => edit(field, event.target.value),
|
|
1052
|
+
},
|
|
1053
|
+
field.options.map(([optionValue, optionLabel]) => h('option', { key: optionValue, value: optionValue }, optionLabel)),
|
|
1054
|
+
)
|
|
1055
|
+
} else {
|
|
1056
|
+
control = h('input', {
|
|
1057
|
+
type: 'number',
|
|
1058
|
+
style: S.input,
|
|
1059
|
+
value: draft,
|
|
1060
|
+
disabled: !writable || saving,
|
|
1061
|
+
onChange: (event) => edit(field, event.target.value),
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return h(
|
|
1066
|
+
'div',
|
|
1067
|
+
{ key: field.key, style: S.row },
|
|
1068
|
+
h(
|
|
1069
|
+
'div',
|
|
1070
|
+
{ style: S.rowHead },
|
|
1071
|
+
h('span', { style: S.label }, field.label),
|
|
1072
|
+
overridden
|
|
1073
|
+
? h(
|
|
1074
|
+
'span',
|
|
1075
|
+
null,
|
|
1076
|
+
h('span', { style: S.badge }, '已覆盖'),
|
|
1077
|
+
' ',
|
|
1078
|
+
h('button', { type: 'button', style: S.link, onClick: () => restore(field) }, '恢复默认'),
|
|
1079
|
+
)
|
|
1080
|
+
: null,
|
|
1081
|
+
),
|
|
1082
|
+
h('p', { style: S.hint }, field.hint),
|
|
1083
|
+
h(
|
|
1084
|
+
'div',
|
|
1085
|
+
{ style: S.control },
|
|
1086
|
+
control,
|
|
1087
|
+
field.kind === 'number' && isInvalid(field, draft) ? h('span', { style: S.error }, '需要 ≥ 1 的整数') : null,
|
|
1088
|
+
),
|
|
1089
|
+
)
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
return h(
|
|
1093
|
+
'div',
|
|
1094
|
+
{ style: S.card },
|
|
1095
|
+
h('h3', { style: S.title }, '会话工具箱配置'),
|
|
1096
|
+
h(
|
|
1097
|
+
'p',
|
|
1098
|
+
{ style: S.intro },
|
|
1099
|
+
'这些是本行(dsh-session-ops)的 volatile 旋钮——删除半边 4 个 + 搬迁半边 3 个:保存即写回 profile 的 cordis.patch.yml,Loader 原地换引用,不用重启、也不用重挂。',
|
|
1100
|
+
),
|
|
1101
|
+
rows,
|
|
1102
|
+
h(
|
|
1103
|
+
'div',
|
|
1104
|
+
{ style: S.actions },
|
|
1105
|
+
h(
|
|
1106
|
+
'button',
|
|
1107
|
+
{ type: 'button', style: blocked ? S.button : S.buttonPrimary, disabled: blocked, onClick: save },
|
|
1108
|
+
saving ? '保存中…' : '保存',
|
|
1109
|
+
),
|
|
1110
|
+
h('button', { type: 'button', style: S.button, disabled: !dirty || saving, onClick: discard }, '放弃更改'),
|
|
1111
|
+
failure !== undefined ? h('span', { style: S.error }, `保存失败:${failure}`) : null,
|
|
1112
|
+
!writable ? h('span', { style: S.muted }, '当前部署的设置不可写') : null,
|
|
1113
|
+
),
|
|
1114
|
+
)
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
sessionAdminHalf = (require) => {
|
|
1119
|
+
const React = require('react')
|
|
1120
|
+
|
|
1121
|
+
/** 在调用方插件的生命周期上挂载三张脸。 */
|
|
1122
|
+
/**
|
|
1123
|
+
* 在调用方插件的生命周期上挂载删除半边的三张脸(设置页 section、标题栏垃圾桶、
|
|
1124
|
+
* 右栏页签)+ 插件页配置卡片。**函数体逐字来自 dsh-session-admin/lib/client.js**,
|
|
1125
|
+
* 只是外面多了一层块作用域与 `sessionAdminHalf` 箭头函数(合并需要,见文件头)。
|
|
1126
|
+
*/
|
|
1127
|
+
function apply(ctx) {
|
|
1128
|
+
const api = createApi()
|
|
1129
|
+
const badge = createBadge(api)
|
|
1130
|
+
badge.start()
|
|
1131
|
+
ctx.effect(() => () => badge.stop(), 'dsh-session-admin: badge refresh timer')
|
|
1132
|
+
const SessionAdminManager = createManager(React, api, badge)
|
|
1133
|
+
|
|
1134
|
+
// 右侧面板的控制器是可选依赖:`ctx.get` 对没 inject 过的服务不保证拿得到,
|
|
1135
|
+
// 所以走 inject 回调把实例存下来,服务缺失时按钮静默无效。
|
|
1136
|
+
let sidebarRight
|
|
1137
|
+
ctx.inject(['sidebarRight'], (injected) => {
|
|
1138
|
+
sidebarRight = injected.get('sidebarRight')
|
|
1139
|
+
return () => {
|
|
1140
|
+
sidebarRight = undefined
|
|
1141
|
+
}
|
|
1142
|
+
})
|
|
1143
|
+
const openManagerTab = () => {
|
|
1144
|
+
if (sidebarRight === undefined || typeof sidebarRight.openTab !== 'function') return
|
|
1145
|
+
// 不知道当前是哪条注册在生效,两个 kind 都试一遍。
|
|
1146
|
+
for (const kind of [TAB_ID, BETTER_TAB_ID]) {
|
|
1147
|
+
try {
|
|
1148
|
+
sidebarRight.openTab(kind)
|
|
1149
|
+
return
|
|
1150
|
+
} catch {
|
|
1151
|
+
/* 换下一个 */
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// 1) 设置页:一个独立的 section(合并后只有这一个 section)。
|
|
1157
|
+
//
|
|
1158
|
+
// 它是**清理面板页**(复用管理组件 + 一个「在右侧面板打开」的快捷入口),不是配置
|
|
1159
|
+
// 菜单——配置那张卡片从来不在这一页,2026-09-25 起它挂在插件页的
|
|
1160
|
+
// `plugins.bundle.config`(见下面第 4 条)。所以这一项与 dsh-cross-memory 的「记忆页」
|
|
1161
|
+
// 同类,保留。
|
|
1162
|
+
ctx.slots.inject('settings.section', () =>
|
|
1163
|
+
ctx.slots.register(
|
|
1164
|
+
{
|
|
1165
|
+
name: 'settings.section',
|
|
1166
|
+
id: 'session-toolbox',
|
|
1167
|
+
order: 120,
|
|
1168
|
+
label: () => '会话工具',
|
|
1169
|
+
inject: () => ({}),
|
|
1170
|
+
},
|
|
1171
|
+
createSettingsSection(React, openManagerTab, SessionAdminManager),
|
|
1172
|
+
),
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
// 2) 会话标题栏:删除当前会话。
|
|
1176
|
+
//
|
|
1177
|
+
// 这个入口的开关由 Host 配置 `headerDeleteButton`(默认 true)决定:
|
|
1178
|
+
// 它就在你正在用的那个会话的右上角,删的也正是它,误点即不可恢复。
|
|
1179
|
+
// 清理由右侧面板 / 设置页承担——那是显式挑选动作,不存在误触。
|
|
1180
|
+
// 因为要等一次请求才知道开关,所以注册是异步的;请求失败时按「不渲染」处理
|
|
1181
|
+
// (安全的那一侧),而不是把按钮露出来。
|
|
1182
|
+
let headerButtonDispose
|
|
1183
|
+
ctx.effect(
|
|
1184
|
+
() => () => {
|
|
1185
|
+
try {
|
|
1186
|
+
headerButtonDispose?.()
|
|
1187
|
+
} catch {
|
|
1188
|
+
/* 卸载失败不影响其它入口 */
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
'dsh-session-admin: header button teardown',
|
|
1192
|
+
)
|
|
1193
|
+
void api
|
|
1194
|
+
.call('config', {})
|
|
1195
|
+
.then((config) => {
|
|
1196
|
+
if (config?.headerDeleteButton !== true) return
|
|
1197
|
+
headerButtonDispose = ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
1198
|
+
ctx.slots.register(
|
|
1199
|
+
{
|
|
1200
|
+
name: 'conversation.session.header.utilities',
|
|
1201
|
+
id: 'dsh-session-admin:delete-current',
|
|
1202
|
+
order: 20,
|
|
1203
|
+
registrant: PACKAGE,
|
|
1204
|
+
},
|
|
1205
|
+
createHeaderButton(React, ctx, api),
|
|
1206
|
+
),
|
|
1207
|
+
)
|
|
1208
|
+
})
|
|
1209
|
+
.catch(() => {
|
|
1210
|
+
/* Host 不可达时保持不渲染 */
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
// 3) 右侧面板页签。两条路,自动二选一:
|
|
1214
|
+
//
|
|
1215
|
+
// a) 装了 dsh-better-sidebar → 走它的 `ctx.betterSidebar.registerTab`。
|
|
1216
|
+
// 它把描述符**镜像到 DSH 原生 sidebarRightTabs**(client.js:17093
|
|
1217
|
+
// 的 sync → registerDescriptor),所以落点是同一个,多出来的是角标、
|
|
1218
|
+
// 新建列表里的描述、Side card 里的启用开关和生命周期回调。
|
|
1219
|
+
// b) 没装 / 它半边没起来 → 直接注册原生 sidebarRightTabs,也就是原行为。
|
|
1220
|
+
//
|
|
1221
|
+
// 两边不能并存:betterSidebar 的注册表对 id 唯一(重复即 throw),原生
|
|
1222
|
+
// tabs.register 对 kind 也冲突。所以是「先挂原生,betterSidebar 一出现就
|
|
1223
|
+
// 换成它、并撤掉原生」,它消失时再换回来。
|
|
1224
|
+
const registerNativeTab = () => {
|
|
1225
|
+
try {
|
|
1226
|
+
return ctx.inject(['sidebarRightTabs'], (injected) => {
|
|
1227
|
+
const tabs = injected.get('sidebarRightTabs')
|
|
1228
|
+
if (tabs === undefined) return undefined
|
|
1229
|
+
const disposeDefinition = tabs.register({
|
|
1230
|
+
id: TAB_ID,
|
|
1231
|
+
kind: TAB_ID,
|
|
1232
|
+
priority: 'extension',
|
|
1233
|
+
title: () => '会话管理',
|
|
1234
|
+
guide: [{ order: 100, title: () => '会话管理' }],
|
|
1235
|
+
})
|
|
1236
|
+
const disposeBody = ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
1237
|
+
ctx.slots.register(
|
|
1238
|
+
// fill: 页签容器有确定高度,让列表撑满它而不是停在 420px。
|
|
1239
|
+
{ name: 'sidebar.right.pane.tab', key: TAB_ID, inject: () => ({ fill: true }) },
|
|
1240
|
+
SessionAdminManager,
|
|
1241
|
+
),
|
|
1242
|
+
)
|
|
1243
|
+
const disposeTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
1244
|
+
ctx.slots.register(
|
|
1245
|
+
{ name: 'sidebar.right.pane.tab.title', key: TAB_ID, inject: () => ({}) },
|
|
1246
|
+
createTabTitle(React),
|
|
1247
|
+
),
|
|
1248
|
+
)
|
|
1249
|
+
return () => {
|
|
1250
|
+
try {
|
|
1251
|
+
disposeTitle?.()
|
|
1252
|
+
disposeBody?.()
|
|
1253
|
+
disposeDefinition?.()
|
|
1254
|
+
} catch {
|
|
1255
|
+
/* 卸载失败不影响其它入口 */
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
})
|
|
1259
|
+
} catch {
|
|
1260
|
+
/* 没有右侧面板服务时静默降级 */
|
|
1261
|
+
return undefined
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
let nativeTabDispose = registerNativeTab()
|
|
1266
|
+
try {
|
|
1267
|
+
ctx.inject(['betterSidebar'], (injected) => {
|
|
1268
|
+
const service = injected.get('betterSidebar')
|
|
1269
|
+
if (service === undefined || typeof service.registerTab !== 'function') return undefined
|
|
1270
|
+
// 顺序是硬要求:`registerTab` 会**同步**把描述符镜像到原生
|
|
1271
|
+
// `sidebarRightTabs`(client.js:17093 的 sync → registerDescriptor),
|
|
1272
|
+
// 所以必须先把原生那条撤掉。反过来写——先注册再撤——会让 betterSidebar
|
|
1273
|
+
// 的注册正好撞上我们自己的原生注册:
|
|
1274
|
+
// sidebarRight: tab kind "dsh-session-admin:manager" is already registered
|
|
1275
|
+
try {
|
|
1276
|
+
nativeTabDispose?.dispose?.()
|
|
1277
|
+
} catch {
|
|
1278
|
+
/* 撤不掉也要往下走,下面的 catch 会把原生接回来 */
|
|
1279
|
+
}
|
|
1280
|
+
nativeTabDispose = undefined
|
|
1281
|
+
let disposeBetter
|
|
1282
|
+
try {
|
|
1283
|
+
disposeBetter = service.registerTab({
|
|
1284
|
+
id: BETTER_TAB_ID,
|
|
1285
|
+
title: () => '会话管理',
|
|
1286
|
+
description: () => '列出并删除会话:按未分组 / 临时目录 / 归档筛选,先预演再永久删除。',
|
|
1287
|
+
order: 120,
|
|
1288
|
+
/*
|
|
1289
|
+
* 角标:未分组的条数。
|
|
1290
|
+
*
|
|
1291
|
+
* ⚠️ 实测(2026-09-14,better-sidebar 0.19.1):**在原生右栏看不到它**。
|
|
1292
|
+
* 角标只由 better-sidebar 自绘的页签条渲染(lib/client.js:16416 的
|
|
1293
|
+
* `sidebar_module_css_default.tabBadge`),而 v0.19 起右列已交给 DSH
|
|
1294
|
+
* 原生侧边栏。它并非死代码:本机存在「展开底部面板」开关,底部工作台
|
|
1295
|
+
* 展开后那个页签条就会出现(我最初查 DOM 时它处于收起状态,所以漏判了)。
|
|
1296
|
+
* 简言之:原生右栏没有数字,底部工作台里会有。
|
|
1297
|
+
*/
|
|
1298
|
+
badge: () => badge.value,
|
|
1299
|
+
// 单实例:再点一次只是聚焦已有页签,不会堆出一排。
|
|
1300
|
+
single: true,
|
|
1301
|
+
component: () => React.createElement(SessionAdminManager, { fill: true }),
|
|
1302
|
+
})
|
|
1303
|
+
} catch {
|
|
1304
|
+
// 注册失败就把原生接回去,页签不至于整个消失。
|
|
1305
|
+
nativeTabDispose = registerNativeTab()
|
|
1306
|
+
return undefined
|
|
1307
|
+
}
|
|
1308
|
+
return () => {
|
|
1309
|
+
try {
|
|
1310
|
+
disposeBetter?.()
|
|
1311
|
+
} catch {
|
|
1312
|
+
/* 卸载失败不影响其它入口 */
|
|
1313
|
+
}
|
|
1314
|
+
// betterSidebar 走了,把原生那条接回来。
|
|
1315
|
+
if (nativeTabDispose === undefined) nativeTabDispose = registerNativeTab()
|
|
1316
|
+
}
|
|
1317
|
+
})
|
|
1318
|
+
} catch {
|
|
1319
|
+
/* 没有 betterSidebar 时留在原生路径 */
|
|
1320
|
+
}
|
|
1321
|
+
ctx.effect(
|
|
1322
|
+
() => () => {
|
|
1323
|
+
try {
|
|
1324
|
+
nativeTabDispose?.dispose?.()
|
|
1325
|
+
} catch {
|
|
1326
|
+
/* 卸载失败不影响其它入口 */
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
'dsh-session-admin: pane tab teardown',
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
// 4) 插件页:本组合包页面上的**配置卡片**(本插件配置的唯一入口)。
|
|
1333
|
+
//
|
|
1334
|
+
// key 是**包的 package name**:插件页把 `plugins.bundle.config` 渲染在该组合包页面
|
|
1335
|
+
// 的「描述与行之间」(官方插件放配置的位置),认领 key 的正是这个包。
|
|
1336
|
+
//
|
|
1337
|
+
// 这个槽**不**把共享表单传进来(不像 `plugins.row.config` 传 `{ state, mutate }`),
|
|
1338
|
+
// 所以注册项的 `inject` 自己把 `ctx.configForms.get(CONFIG_ENTRY_ID)` 的同一份
|
|
1339
|
+
// `{ state, mutate }` 作为 `form` 喂给卡片——卡片体逐字未改。能不能写仍由 Host 决定:
|
|
1340
|
+
// 只有这一行有 volatile 字段、命名空间被服务时快照才可写;否则卡片只显示一句说明,
|
|
1341
|
+
// 不会假装能改。
|
|
1342
|
+
//
|
|
1343
|
+
// 2026-09-25 起这是配置的**唯一**注册处:原来的 `plugins.row.config`
|
|
1344
|
+
// (key `dsh-session-ops#session-toolbox`)摘掉了——同一份表单在插件页出现两次
|
|
1345
|
+
// 只会让人怀疑哪个才算数(合并前的取舍也是这个理由)。
|
|
1346
|
+
const configForm = ctx.configForms.get(CONFIG_ENTRY_ID)
|
|
1347
|
+
ctx.slots.inject('plugins.bundle.config', () =>
|
|
1348
|
+
ctx.slots.register(
|
|
1349
|
+
{
|
|
1350
|
+
name: 'plugins.bundle.config',
|
|
1351
|
+
key: PACKAGE,
|
|
1352
|
+
inject: () => ({
|
|
1353
|
+
form: {
|
|
1354
|
+
state: configForm.getSnapshot(),
|
|
1355
|
+
mutate: (ops, revision) => configForm.mutate(ops, revision),
|
|
1356
|
+
},
|
|
1357
|
+
}),
|
|
1358
|
+
},
|
|
1359
|
+
createConfigCard(React),
|
|
1360
|
+
),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
// 5) 统一右键菜单(0.2.2 起):会话行右键 → 「删除会话…」。
|
|
1364
|
+
//
|
|
1365
|
+
// 与标题栏垃圾桶是**同一条流程**(`deleteSessionFlow`),只是入口不同。这里的
|
|
1366
|
+
// `resolve` 只做「菜单打开时能不能删、为什么不能删」的预演,真正的删除在
|
|
1367
|
+
// `run` 里重新预演一遍(见 `createResolveDeleteSessionMenu`)。
|
|
1368
|
+
//
|
|
1369
|
+
// 服务用可选注入而不是写进插件 inject:没有这个服务(0.2.1 及更早的外壳)时
|
|
1370
|
+
// 只少一个菜单入口,右栏页签 / 标题栏 / 设置页照旧工作。
|
|
1371
|
+
ctx.slots.inject('contextMenu.icon', () =>
|
|
1372
|
+
ctx.slots.register({ name: 'contextMenu.icon', key: DELETE_MENU_ICON }, createTrashIcon(React)),
|
|
1373
|
+
)
|
|
1374
|
+
ctx.inject(['contextMenus'], (injected) => {
|
|
1375
|
+
const menus = injected.get('contextMenus')
|
|
1376
|
+
if (menus === undefined || typeof menus.register !== 'function') return undefined
|
|
1377
|
+
const resolve = createResolveDeleteSessionMenu(ctx)
|
|
1378
|
+
const dispose = ctx.effect(
|
|
1379
|
+
() =>
|
|
1380
|
+
menus.register({
|
|
1381
|
+
id: 'session-toolbox.delete-session',
|
|
1382
|
+
target: 'session',
|
|
1383
|
+
// 排在原生「归档」(600)之后:两者都是对整条会话的收尾动作。
|
|
1384
|
+
order: 620,
|
|
1385
|
+
// 会话列表变了(跑起来 / 跑完 / 归档)就重算一次,禁用理由跟着变。
|
|
1386
|
+
observe: () => {
|
|
1387
|
+
try {
|
|
1388
|
+
return ctx.sessions?.list === undefined ? [] : [ctx.sessions.list]
|
|
1389
|
+
} catch {
|
|
1390
|
+
return []
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
resolve: (target, signal) => resolve(menus, target, signal),
|
|
1394
|
+
}),
|
|
1395
|
+
'dsh-session-ops: session delete menu',
|
|
1396
|
+
)
|
|
1397
|
+
return () => {
|
|
1398
|
+
try {
|
|
1399
|
+
dispose?.()
|
|
1400
|
+
} catch {
|
|
1401
|
+
/* 卸载失败不影响其它入口 */
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
})
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// `configForms`:配置卡片挂的是插件页的 `plugins.bundle.config`,那个槽不传共享
|
|
1408
|
+
// 表单,卡片要自己从 `ctx.configForms.get(CONFIG_ENTRY_ID)` 取(见上面第 4 条)。
|
|
1409
|
+
return { inject: ['slots', 'sessions', 'configForms'], apply }
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
1414
|
+
// 搬迁半边(原 dsh-session-move/lib/client.js,逐字搬入)
|
|
1415
|
+
//
|
|
1416
|
+
// 与删除半边同样处理;它原来那张 `plugins.row.config` 卡片被摘掉(合并成一张,
|
|
1417
|
+
// 字段并进了删除半边那张卡片,2026-09-25 起统一挂到插件页的 `plugins.bundle.config`),
|
|
1418
|
+
// `factory:` 同样改名。
|
|
1419
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
1420
|
+
|
|
1421
|
+
/** 搬迁半边的插件对象(同上)。 */
|
|
1422
|
+
let sessionMoveHalf
|
|
1423
|
+
{
|
|
1424
|
+
const API = '/session-move/api'
|
|
1425
|
+
/** 原生页签 kind。 */
|
|
1426
|
+
const TAB_ID = 'dsh-session-move:panel'
|
|
1427
|
+
/**
|
|
1428
|
+
* 走 better-sidebar 时用的描述符 id。**必须与 TAB_ID 不同**:better-sidebar 会把
|
|
1429
|
+
* 描述符 id 直接当原生 kind 注册,两条路若同名,撤原生/再注册之间会短暂撞车
|
|
1430
|
+
* (dsh-session-admin 实测得到同样的结论)。
|
|
1431
|
+
*/
|
|
1432
|
+
const BETTER_TAB_ID = 'dsh-session-move:panel-via-better-sidebar'
|
|
1433
|
+
|
|
1434
|
+
const S = {
|
|
1435
|
+
root: { padding: '4px 2px', fontSize: 13, color: 'inherit', display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0 },
|
|
1436
|
+
title: { fontSize: 13, fontWeight: 600 },
|
|
1437
|
+
muted: { fontSize: 12, opacity: 0.62, lineHeight: 1.5 },
|
|
1438
|
+
tiny: { fontSize: 11.5, opacity: 0.55 },
|
|
1439
|
+
bar: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' },
|
|
1440
|
+
input: {
|
|
1441
|
+
padding: '4px 8px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)',
|
|
1442
|
+
background: 'transparent', color: 'inherit', flex: '1 1 140px', minWidth: 110,
|
|
1443
|
+
},
|
|
1444
|
+
select: {
|
|
1445
|
+
padding: '4px 6px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)',
|
|
1446
|
+
background: 'transparent', color: 'inherit', maxWidth: '100%',
|
|
1447
|
+
},
|
|
1448
|
+
button: {
|
|
1449
|
+
padding: '4px 9px', fontSize: 12.5, borderRadius: 6, border: '1px solid var(--dsh-border, #d0d5dd)',
|
|
1450
|
+
background: 'transparent', color: 'inherit', cursor: 'pointer',
|
|
1451
|
+
},
|
|
1452
|
+
primary: {
|
|
1453
|
+
padding: '4px 9px', fontSize: 12.5, borderRadius: 6, border: '1px solid transparent',
|
|
1454
|
+
background: 'var(--dsh-accent, #4d6bfe)', color: '#fff', cursor: 'pointer',
|
|
1455
|
+
},
|
|
1456
|
+
disabled: { opacity: 0.45, cursor: 'default' },
|
|
1457
|
+
label: { fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.04em', opacity: 0.55 },
|
|
1458
|
+
cards: {
|
|
1459
|
+
border: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))', borderRadius: 8,
|
|
1460
|
+
overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 120, flex: '1 1 auto',
|
|
1461
|
+
},
|
|
1462
|
+
card: {
|
|
1463
|
+
display: 'flex', flexDirection: 'column', gap: 2, padding: '6px 8px', cursor: 'grab',
|
|
1464
|
+
borderTop: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.14))',
|
|
1465
|
+
},
|
|
1466
|
+
cardFirst: { borderTop: 'none' },
|
|
1467
|
+
cardSelected: { background: 'var(--dsh-surface-hover, rgba(77,107,254,0.10))' },
|
|
1468
|
+
cardDragging: { opacity: 0.45 },
|
|
1469
|
+
cardTitle: { fontSize: 12.5, lineHeight: 1.35, wordBreak: 'break-word', overflowWrap: 'anywhere' },
|
|
1470
|
+
meta: { fontSize: 11.5, opacity: 0.6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
1471
|
+
badges: { display: 'flex', gap: 4, flexWrap: 'wrap' },
|
|
1472
|
+
badge: {
|
|
1473
|
+
fontSize: 10.5, padding: '0 5px', borderRadius: 999,
|
|
1474
|
+
border: '1px solid var(--dsh-border, #d0d5dd)', opacity: 0.8, whiteSpace: 'nowrap',
|
|
1475
|
+
},
|
|
1476
|
+
badgeWarn: { fontSize: 10.5, padding: '0 5px', borderRadius: 999, border: '1px solid var(--dsh-danger, #d92d20)', color: 'var(--dsh-danger, #d92d20)', whiteSpace: 'nowrap' },
|
|
1477
|
+
wsList: {
|
|
1478
|
+
border: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))', borderRadius: 8,
|
|
1479
|
+
overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 100, flex: '1 1 auto',
|
|
1480
|
+
},
|
|
1481
|
+
ws: {
|
|
1482
|
+
display: 'flex', flexDirection: 'column', gap: 2, padding: '6px 8px',
|
|
1483
|
+
borderTop: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.14))',
|
|
1484
|
+
},
|
|
1485
|
+
wsHover: { background: 'var(--dsh-surface-hover, rgba(77,107,254,0.16))', outline: '1px solid var(--dsh-accent, #4d6bfe)' },
|
|
1486
|
+
wsTitle: { fontSize: 12.5, display: 'flex', gap: 6, alignItems: 'baseline' },
|
|
1487
|
+
wsCount: { fontSize: 11, opacity: 0.55 },
|
|
1488
|
+
wsPath: { fontSize: 11, opacity: 0.55, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
1489
|
+
result: { border: '1px solid var(--dsh-border-subtle, rgba(128,128,128,0.22))', borderRadius: 8, padding: '8px 10px', fontSize: 12 },
|
|
1490
|
+
good: { color: 'var(--dsh-success, #12b76a)' },
|
|
1491
|
+
bad: { color: 'var(--dsh-danger, #d92d20)' },
|
|
1492
|
+
pre: {
|
|
1493
|
+
maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
1494
|
+
background: 'rgba(128,128,128,0.1)', borderRadius: 6, padding: 8, margin: '6px 0 0',
|
|
1495
|
+
fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 11,
|
|
1496
|
+
},
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const S_FILL = {
|
|
1500
|
+
root: { height: '100%', minHeight: 0, boxSizing: 'border-box' },
|
|
1501
|
+
cards: { maxHeight: 'none', flex: '1 1 45%' },
|
|
1502
|
+
wsList: { maxHeight: 'none', flex: '1 1 45%' },
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** 右键菜单里「移动会话」的图标 key(同时是 `contextMenu.icon` 槽的 key)。 */
|
|
1506
|
+
const MOVE_MENU_ICON = 'session-toolbox.move-session'
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* 搬迁箭头图标(Lucide `arrow-right` 的描边路径),菜单里表示「搬到别处」。
|
|
1510
|
+
* 与垃圾桶图标一样刻意不引 primitives 包,理由见删除半边的 `createTrashIcon`。
|
|
1511
|
+
* @param React - 外壳预置的 React。
|
|
1512
|
+
* @returns 图标组件。
|
|
1513
|
+
*/
|
|
1514
|
+
function createMoveIcon(React) {
|
|
1515
|
+
const GLYPHS = ['M5 12h14', 'M12 5l7 7-7 7']
|
|
1516
|
+
return function MoveIcon() {
|
|
1517
|
+
return React.createElement(
|
|
1518
|
+
'svg',
|
|
1519
|
+
{
|
|
1520
|
+
width: 16,
|
|
1521
|
+
height: 16,
|
|
1522
|
+
viewBox: '0 0 24 24',
|
|
1523
|
+
fill: 'none',
|
|
1524
|
+
stroke: 'currentColor',
|
|
1525
|
+
strokeWidth: 1.7,
|
|
1526
|
+
strokeLinecap: 'round',
|
|
1527
|
+
strokeLinejoin: 'round',
|
|
1528
|
+
'aria-hidden': 'true',
|
|
1529
|
+
focusable: 'false',
|
|
1530
|
+
},
|
|
1531
|
+
GLYPHS.map((d, index) => React.createElement('path', { key: index, d })),
|
|
1532
|
+
)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* 「右键菜单 → 右栏面板」的一次性交接:菜单选中的那个会话。
|
|
1538
|
+
*
|
|
1539
|
+
* 面板可能早就挂载了(页签一直开着),也可能这次才被打开,所以不能只在初始化时读
|
|
1540
|
+
* 一次初值:挂一个最小订阅,把请求推给已经挂载的面板;没挂载时请求留在槽里,面板
|
|
1541
|
+
* 首次挂载的 effect 会把它取走。
|
|
1542
|
+
*/
|
|
1543
|
+
const moveRequest = { sessionId: undefined, version: 0, listeners: new Set() }
|
|
1544
|
+
|
|
1545
|
+
/** 请求面板把某个会话选中(目标工作区仍由用户在面板里挑)。 */
|
|
1546
|
+
function requestMoveSession(sessionId) {
|
|
1547
|
+
moveRequest.sessionId = sessionId
|
|
1548
|
+
moveRequest.version += 1
|
|
1549
|
+
for (const listener of moveRequest.listeners) {
|
|
1550
|
+
try {
|
|
1551
|
+
listener()
|
|
1552
|
+
} catch {
|
|
1553
|
+
/* 单个面板的监听失败不影响其它入口 */
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* 构造右键菜单「移动会话到工作区…」的 resolve。
|
|
1560
|
+
*
|
|
1561
|
+
* 菜单里只判断「现在能不能搬」并给出理由(运行中 / 没有 cwd / Host 拒绝),真正的
|
|
1562
|
+
* 搬迁仍在既有面板里做——菜单只负责把面板打开并选中这一条。`run` 会重新读一次
|
|
1563
|
+
* Host 状态再打开面板:从菜单打开到点下去之间,会话可能已经跑起来。
|
|
1564
|
+
*
|
|
1565
|
+
* @param openPanel - 打开搬迁面板并选中一条会话(见 apply 里)。
|
|
1566
|
+
* @returns `(menus, target, signal) => Promise<MenuAction[]>`。
|
|
1567
|
+
*/
|
|
1568
|
+
function createResolveMoveSessionMenu(openPanel) {
|
|
1569
|
+
return async function resolveMoveSessionMenu(menus, target, signal) {
|
|
1570
|
+
const sessionId = target?.sessionId
|
|
1571
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return []
|
|
1572
|
+
const api = createApi()
|
|
1573
|
+
const base = { id: 'move-session', icon: { key: MOVE_MENU_ICON }, failureLabel: '移动失败,请稍后再试' }
|
|
1574
|
+
const blocked = (why) => [{ ...base, label: `移动会话到工作区…(${why})`, disabled: true }]
|
|
1575
|
+
const rowOf = (snapshot) => (snapshot?.sessions ?? []).find((entry) => entry.id === sessionId)
|
|
1576
|
+
let snapshot
|
|
1577
|
+
try {
|
|
1578
|
+
snapshot = await api.call('list', {}, signal)
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
// 菜单在请求途中被关掉了:静默作废(registry 会丢弃这份结果)。
|
|
1581
|
+
if (signal?.aborted === true) return []
|
|
1582
|
+
return blocked(`现在读不到 Host 状态:${String(error?.message ?? error)}`)
|
|
1583
|
+
}
|
|
1584
|
+
const row = rowOf(snapshot)
|
|
1585
|
+
if (row === undefined) return blocked('搬迁列表里没有这个会话(可能超过 maxSessions 上限)')
|
|
1586
|
+
if (row.running === 'running') return blocked('会话正在运行中,这一轮跑完才能搬')
|
|
1587
|
+
if (row.movable !== true) return blocked('没有 cwd,或 Host 的配置拒绝了搬迁')
|
|
1588
|
+
return [
|
|
1589
|
+
{
|
|
1590
|
+
...base,
|
|
1591
|
+
label: '移动会话到工作区…',
|
|
1592
|
+
run: async () => {
|
|
1593
|
+
let fresh
|
|
1594
|
+
try {
|
|
1595
|
+
fresh = await api.call('list', {})
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
menus.notify(`搬迁前读取状态失败:${String(error?.message ?? error)}`, false)
|
|
1598
|
+
return
|
|
1599
|
+
}
|
|
1600
|
+
const current = rowOf(fresh)
|
|
1601
|
+
if (current === undefined) {
|
|
1602
|
+
menus.notify('这个会话已经不在列表里(可能刚被删除,或超出列表上限)', false)
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1605
|
+
if (current.running === 'running') {
|
|
1606
|
+
menus.notify('会话正在运行中,这一轮跑完才能搬', false)
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
if (current.movable !== true) {
|
|
1610
|
+
menus.notify('没有 cwd,或 Host 的配置拒绝了搬迁', false)
|
|
1611
|
+
return
|
|
1612
|
+
}
|
|
1613
|
+
if (!openPanel(sessionId)) menus.notify('右侧面板没能打开,无法继续搬迁', false)
|
|
1614
|
+
},
|
|
1615
|
+
},
|
|
1616
|
+
]
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Host 侧 API 客户端。
|
|
1622
|
+
*
|
|
1623
|
+
* `signal` 是给右键菜单的 `resolve` 用的:菜单一关,Host 还没答完的那次查询就该
|
|
1624
|
+
* 作废。
|
|
1625
|
+
*/
|
|
1626
|
+
function createApi() {
|
|
1627
|
+
async function call(method, payload, signal) {
|
|
1628
|
+
const response = await fetch(`${API}/${method}`, {
|
|
1629
|
+
method: 'POST',
|
|
1630
|
+
headers: { 'content-type': 'application/json' },
|
|
1631
|
+
credentials: 'same-origin',
|
|
1632
|
+
body: JSON.stringify(payload ?? {}),
|
|
1633
|
+
...(signal === undefined ? null : { signal }),
|
|
1634
|
+
})
|
|
1635
|
+
let body
|
|
1636
|
+
try {
|
|
1637
|
+
body = await response.json()
|
|
1638
|
+
} catch {
|
|
1639
|
+
throw new Error(`HTTP ${response.status}`)
|
|
1640
|
+
}
|
|
1641
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
1642
|
+
throw new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
1643
|
+
}
|
|
1644
|
+
return body.value
|
|
1645
|
+
}
|
|
1646
|
+
return { call }
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** 路径末段。 */
|
|
1650
|
+
function baseName(value) {
|
|
1651
|
+
if (typeof value !== 'string' || value.length === 0) return ''
|
|
1652
|
+
const parts = value.replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
1653
|
+
return parts.length === 0 ? value : parts[parts.length - 1]
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** 会话 id 的短形式。 */
|
|
1657
|
+
function shortId(id) {
|
|
1658
|
+
const text = String(id)
|
|
1659
|
+
const stripped = text.startsWith('session-') ? text.slice(8) : text
|
|
1660
|
+
return stripped.length > 8 ? stripped.slice(0, 8) : stripped
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/** `MM-DD HH:mm`。 */
|
|
1664
|
+
function stamp(ms) {
|
|
1665
|
+
if (!Number.isFinite(ms) || ms <= 0) return ''
|
|
1666
|
+
const d = new Date(ms)
|
|
1667
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
1668
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 拖放面板。
|
|
1673
|
+
* @param React - 外壳预置的 React。
|
|
1674
|
+
* @param api - Host API 客户端。
|
|
1675
|
+
* @returns 面板组件。
|
|
1676
|
+
*/
|
|
1677
|
+
function createPanel(React, api) {
|
|
1678
|
+
const h = React.createElement
|
|
1679
|
+
return function SessionMovePanel(props) {
|
|
1680
|
+
const fill = props.fill === true
|
|
1681
|
+
const useSessions = props.useSessions
|
|
1682
|
+
const store = typeof useSessions === 'function' ? useSessions((snapshot) => snapshot) : undefined
|
|
1683
|
+
const byId = store !== undefined && store !== null ? store.byId : undefined
|
|
1684
|
+
|
|
1685
|
+
const [snapshot, setSnapshot] = React.useState(null)
|
|
1686
|
+
const [failure, setFailure] = React.useState(null)
|
|
1687
|
+
const [busy, setBusy] = React.useState(true)
|
|
1688
|
+
const [filter, setFilter] = React.useState('')
|
|
1689
|
+
const [selected, setSelected] = React.useState('')
|
|
1690
|
+
const [targetPick, setTargetPick] = React.useState('')
|
|
1691
|
+
const [dragging, setDragging] = React.useState('')
|
|
1692
|
+
const [hover, setHover] = React.useState('')
|
|
1693
|
+
const [outcome, setOutcome] = React.useState(null)
|
|
1694
|
+
const [token, setToken] = React.useState(0)
|
|
1695
|
+
// 右键菜单的一次性交接:面板一直开着时靠订阅推过来;这次才挂载时,初始版本号
|
|
1696
|
+
// 会让下面的 effect 在挂载后立刻把槽里的请求取走。
|
|
1697
|
+
const [requestVersion, setRequestVersion] = React.useState(moveRequest.version)
|
|
1698
|
+
React.useEffect(() => {
|
|
1699
|
+
const listener = () => setRequestVersion(moveRequest.version)
|
|
1700
|
+
moveRequest.listeners.add(listener)
|
|
1701
|
+
return () => {
|
|
1702
|
+
moveRequest.listeners.delete(listener)
|
|
1703
|
+
}
|
|
1704
|
+
}, [])
|
|
1705
|
+
React.useEffect(() => {
|
|
1706
|
+
if (moveRequest.sessionId === undefined) return
|
|
1707
|
+
setSelected(moveRequest.sessionId)
|
|
1708
|
+
setFilter('')
|
|
1709
|
+
moveRequest.sessionId = undefined
|
|
1710
|
+
}, [requestVersion])
|
|
1711
|
+
|
|
1712
|
+
React.useEffect(() => {
|
|
1713
|
+
let alive = true
|
|
1714
|
+
setBusy(true)
|
|
1715
|
+
api
|
|
1716
|
+
.call('list', {})
|
|
1717
|
+
.then(
|
|
1718
|
+
(value) => {
|
|
1719
|
+
if (!alive) return
|
|
1720
|
+
setSnapshot(value)
|
|
1721
|
+
setFailure(null)
|
|
1722
|
+
},
|
|
1723
|
+
(error) => {
|
|
1724
|
+
if (alive) setFailure(String(error?.message ?? error))
|
|
1725
|
+
},
|
|
1726
|
+
)
|
|
1727
|
+
.then(() => {
|
|
1728
|
+
if (alive) setBusy(false)
|
|
1729
|
+
})
|
|
1730
|
+
return () => {
|
|
1731
|
+
alive = false
|
|
1732
|
+
}
|
|
1733
|
+
}, [token])
|
|
1734
|
+
|
|
1735
|
+
const workspaces = snapshot !== null && snapshot !== undefined && Array.isArray(snapshot.workspaces) ? snapshot.workspaces : []
|
|
1736
|
+
const sessions = snapshot !== null && snapshot !== undefined && Array.isArray(snapshot.sessions) ? snapshot.sessions : []
|
|
1737
|
+
|
|
1738
|
+
const titleOf = (row) => {
|
|
1739
|
+
const record = byId !== undefined ? byId[row.id] : undefined
|
|
1740
|
+
if (record === undefined || record === null) return ''
|
|
1741
|
+
if (typeof record.displayTitle === 'string' && record.displayTitle.length > 0) return record.displayTitle
|
|
1742
|
+
if (typeof record.title === 'string' && record.title.length > 0) return record.title
|
|
1743
|
+
return ''
|
|
1744
|
+
}
|
|
1745
|
+
const needle = filter.trim().toLowerCase()
|
|
1746
|
+
const rows = sessions.filter((row) => {
|
|
1747
|
+
if (needle.length === 0) return true
|
|
1748
|
+
if (row.id.toLowerCase().includes(needle)) return true
|
|
1749
|
+
if ((row.cwd ?? '').toLowerCase().includes(needle)) return true
|
|
1750
|
+
if ((row.workspaceTitle ?? '').toLowerCase().includes(needle)) return true
|
|
1751
|
+
return titleOf(row).toLowerCase().includes(needle)
|
|
1752
|
+
})
|
|
1753
|
+
// 未分组排最前(最需要归位的),跑着的沉底。
|
|
1754
|
+
const rank = (row) => (row.workspaceId === null ? 0 : 1) * 2 + (row.running === 'running' ? 1 : 0)
|
|
1755
|
+
rows.sort((left, right) => rank(left) - rank(right) || (right.createdAt ?? 0) - (left.createdAt ?? 0))
|
|
1756
|
+
|
|
1757
|
+
const selectedRow = rows.find((row) => row.id === selected) ?? sessions.find((row) => row.id === selected)
|
|
1758
|
+
|
|
1759
|
+
const runMove = (sessionId, workspaceId, dryRun) => {
|
|
1760
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
1761
|
+
if (typeof workspaceId !== 'string' || workspaceId.length === 0) return
|
|
1762
|
+
setBusy(true)
|
|
1763
|
+
setOutcome(null)
|
|
1764
|
+
api
|
|
1765
|
+
.call('move', { sessionId, targetWorkspace: workspaceId, dryRun: dryRun === true })
|
|
1766
|
+
.then(
|
|
1767
|
+
(value) => {
|
|
1768
|
+
setOutcome({ ok: true, report: value, dryRun: dryRun === true })
|
|
1769
|
+
if (dryRun !== true) setToken((current) => current + 1)
|
|
1770
|
+
},
|
|
1771
|
+
(error) => setOutcome({ ok: false, error: String(error?.message ?? error) }),
|
|
1772
|
+
)
|
|
1773
|
+
.then(() => setBusy(false))
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const children = []
|
|
1777
|
+
children.push(h('div', { key: 'title', style: S.title }, '会话归位'))
|
|
1778
|
+
children.push(
|
|
1779
|
+
h('div', { key: 'hint', style: S.muted },
|
|
1780
|
+
'把会话卡片拖到下面的工作区即可搬迁(真搬迁:日志与 header.cwd 一起走)。也可以点卡片选中,再用上面的下拉框精确选择。' +
|
|
1781
|
+
(busy ? ' 处理中…' : '')),
|
|
1782
|
+
)
|
|
1783
|
+
if (failure !== null) children.push(h('div', { key: 'fail', style: S.bad }, `读取失败:${failure}`))
|
|
1784
|
+
|
|
1785
|
+
children.push(
|
|
1786
|
+
h('div', { key: 'bar', style: S.bar }, [
|
|
1787
|
+
h('input', {
|
|
1788
|
+
key: 'q',
|
|
1789
|
+
style: S.input,
|
|
1790
|
+
type: 'text',
|
|
1791
|
+
placeholder: '筛选:标题 / 路径 / 工作区 / id',
|
|
1792
|
+
value: filter,
|
|
1793
|
+
onChange: (event) => setFilter(event.target.value),
|
|
1794
|
+
}),
|
|
1795
|
+
h('button', { key: 'r', type: 'button', style: busy ? { ...S.button, ...S.disabled } : S.button, disabled: busy, onClick: () => setToken((current) => current + 1) }, '刷新'),
|
|
1796
|
+
]),
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
const sessionOptions = [h('option', { key: 'none', value: '' }, '(未选中)')]
|
|
1800
|
+
for (const row of rows) {
|
|
1801
|
+
sessionOptions.push(
|
|
1802
|
+
h('option', { key: row.id, value: row.id },
|
|
1803
|
+
`${titleOf(row) || '(无标题)'} — ${row.workspaceTitle ?? '未分组'} · ${shortId(row.id)}`),
|
|
1804
|
+
)
|
|
1805
|
+
}
|
|
1806
|
+
const workspaceOptions = [h('option', { key: 'none', value: '' }, '(未选择)')]
|
|
1807
|
+
for (const workspace of workspaces) {
|
|
1808
|
+
workspaceOptions.push(h('option', { key: workspace.id, value: workspace.id }, `${workspace.title} — ${workspace.path}`))
|
|
1809
|
+
}
|
|
1810
|
+
const blocked = busy || selectedRow === undefined || targetPick.length === 0 || selectedRow.running === 'running'
|
|
1811
|
+
children.push(
|
|
1812
|
+
h('div', { key: 'manual', style: S.bar }, [
|
|
1813
|
+
h('select', {
|
|
1814
|
+
key: 's',
|
|
1815
|
+
style: { ...S.select, flex: '1 1 150px' },
|
|
1816
|
+
value: selectedRow === undefined ? '' : selectedRow.id,
|
|
1817
|
+
onChange: (event) => setSelected(event.target.value),
|
|
1818
|
+
}, sessionOptions),
|
|
1819
|
+
h('select', {
|
|
1820
|
+
key: 'w',
|
|
1821
|
+
style: { ...S.select, flex: '1 1 130px' },
|
|
1822
|
+
value: targetPick,
|
|
1823
|
+
onChange: (event) => setTargetPick(event.target.value),
|
|
1824
|
+
}, workspaceOptions),
|
|
1825
|
+
h('button', {
|
|
1826
|
+
key: 'dry',
|
|
1827
|
+
type: 'button',
|
|
1828
|
+
style: blocked ? { ...S.button, ...S.disabled } : S.button,
|
|
1829
|
+
disabled: blocked,
|
|
1830
|
+
onClick: () => runMove(selectedRow.id, targetPick, true),
|
|
1831
|
+
}, '预演'),
|
|
1832
|
+
h('button', {
|
|
1833
|
+
key: 'go',
|
|
1834
|
+
type: 'button',
|
|
1835
|
+
style: blocked ? { ...S.primary, ...S.disabled } : S.primary,
|
|
1836
|
+
disabled: blocked,
|
|
1837
|
+
onClick: () => runMove(selectedRow.id, targetPick, false),
|
|
1838
|
+
}, '移动'),
|
|
1839
|
+
]),
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
children.push(h('div', { key: 'labelSessions', style: S.label }, `会话 ${rows.length} / ${sessions.length}`))
|
|
1843
|
+
const cards = []
|
|
1844
|
+
rows.forEach((row, index) => {
|
|
1845
|
+
const draggable = row.running !== 'running'
|
|
1846
|
+
const badges = []
|
|
1847
|
+
if (row.running === 'running') badges.push(h('span', { key: 'run', style: S.badgeWarn }, '● 运行中'))
|
|
1848
|
+
else if (row.resident) badges.push(h('span', { key: 'live', style: S.badge }, '○ 已加载'))
|
|
1849
|
+
else badges.push(h('span', { key: 'cold', style: S.badge }, '冷会话'))
|
|
1850
|
+
if (row.archived) badges.push(h('span', { key: 'arch', style: S.badge }, '已归档'))
|
|
1851
|
+
if (row.workspaceId === null) badges.push(h('span', { key: 'ung', style: S.badge }, '未分组'))
|
|
1852
|
+
if (row.hasWriteHandle) badges.push(h('span', { key: 'wh', style: S.badge }, '写入句柄'))
|
|
1853
|
+
// cwd 已经等于某个工作区的路径、却不在它名下(导入 / 同步回来 / 建工作区之前就有的会话):
|
|
1854
|
+
// 这种"归位"只补注册表成员,不动日志。
|
|
1855
|
+
const samePath = workspaces.find((workspace) => workspace.path === row.cwd)
|
|
1856
|
+
if (samePath !== undefined && row.workspaceId !== samePath.id) {
|
|
1857
|
+
badges.push(h('span', { key: 'filed', style: S.badge }, `⟲ 可直接归位:${samePath.title}`))
|
|
1858
|
+
}
|
|
1859
|
+
cards.push(
|
|
1860
|
+
h('div', {
|
|
1861
|
+
key: row.id,
|
|
1862
|
+
draggable,
|
|
1863
|
+
onDragStart: draggable
|
|
1864
|
+
? (event) => {
|
|
1865
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
1866
|
+
event.dataTransfer.setData('text/plain', row.id)
|
|
1867
|
+
setDragging(row.id)
|
|
1868
|
+
if (selected !== row.id) setSelected(row.id)
|
|
1869
|
+
}
|
|
1870
|
+
: undefined,
|
|
1871
|
+
onDragEnd: () => {
|
|
1872
|
+
setDragging('')
|
|
1873
|
+
setHover('')
|
|
1874
|
+
},
|
|
1875
|
+
onClick: () => setSelected(row.id),
|
|
1876
|
+
style: {
|
|
1877
|
+
...S.card,
|
|
1878
|
+
...(index === 0 ? S.cardFirst : null),
|
|
1879
|
+
...(selected === row.id ? S.cardSelected : null),
|
|
1880
|
+
...(dragging === row.id ? S.cardDragging : null),
|
|
1881
|
+
...(draggable ? null : { cursor: 'default' }),
|
|
1882
|
+
},
|
|
1883
|
+
title: row.cwd ?? '',
|
|
1884
|
+
}, [
|
|
1885
|
+
h('div', { key: 't', style: S.cardTitle }, titleOf(row) || '(无标题)'),
|
|
1886
|
+
h('div', { key: 'm', style: S.meta },
|
|
1887
|
+
`${row.workspaceTitle ?? '未分组'} · ${baseName(row.cwd) || '无 cwd'} · ${shortId(row.id)}${row.createdAt ? ` · ${stamp(row.createdAt)}` : ''}`),
|
|
1888
|
+
h('div', { key: 'b', style: S.badges }, badges),
|
|
1889
|
+
]),
|
|
1890
|
+
)
|
|
1891
|
+
})
|
|
1892
|
+
if (cards.length === 0) cards.push(h('div', { key: 'empty', style: { ...S.muted, padding: '8px 10px' } }, '没有匹配的会话。'))
|
|
1893
|
+
children.push(h('div', { key: 'cards', style: fill ? { ...S.cards, ...S_FILL.cards } : S.cards }, cards))
|
|
1894
|
+
|
|
1895
|
+
children.push(h('div', { key: 'labelWs', style: S.label }, '工作区(拖到这里)'))
|
|
1896
|
+
const wsRows = []
|
|
1897
|
+
workspaces.forEach((workspace, index) => {
|
|
1898
|
+
const isCurrent = selectedRow !== undefined && selectedRow !== null && selectedRow.workspaceId === workspace.id
|
|
1899
|
+
const active = hover === workspace.id
|
|
1900
|
+
wsRows.push(
|
|
1901
|
+
h('div', {
|
|
1902
|
+
key: workspace.id,
|
|
1903
|
+
onDragOver: (event) => {
|
|
1904
|
+
if (dragging.length === 0) return
|
|
1905
|
+
event.preventDefault()
|
|
1906
|
+
event.dataTransfer.dropEffect = 'move'
|
|
1907
|
+
if (hover !== workspace.id) setHover(workspace.id)
|
|
1908
|
+
},
|
|
1909
|
+
onDragLeave: () => {
|
|
1910
|
+
if (hover === workspace.id) setHover('')
|
|
1911
|
+
},
|
|
1912
|
+
onDrop: (event) => {
|
|
1913
|
+
event.preventDefault()
|
|
1914
|
+
const dropped = dragging.length > 0 ? dragging : event.dataTransfer.getData('text/plain')
|
|
1915
|
+
setDragging('')
|
|
1916
|
+
setHover('')
|
|
1917
|
+
runMove(dropped, workspace.id, false)
|
|
1918
|
+
},
|
|
1919
|
+
onClick: () => setTargetPick(workspace.id),
|
|
1920
|
+
style: {
|
|
1921
|
+
...S.ws,
|
|
1922
|
+
...(index === 0 ? { borderTop: 'none' } : null),
|
|
1923
|
+
...(active ? S.wsHover : null),
|
|
1924
|
+
cursor: dragging.length > 0 ? 'copy' : 'pointer',
|
|
1925
|
+
},
|
|
1926
|
+
}, [
|
|
1927
|
+
h('div', { key: 't', style: S.wsTitle }, [
|
|
1928
|
+
h('span', { key: 'n' }, workspace.title),
|
|
1929
|
+
h('span', { key: 'c', style: S.wsCount }, `${workspace.visible}${workspace.accounted !== workspace.visible ? `/${workspace.accounted}` : ''}`),
|
|
1930
|
+
isCurrent ? h('span', { key: 'cur', style: S.badge }, '当前') : null,
|
|
1931
|
+
]),
|
|
1932
|
+
h('div', { key: 'p', style: S.wsPath }, workspace.path),
|
|
1933
|
+
]),
|
|
1934
|
+
)
|
|
1935
|
+
})
|
|
1936
|
+
children.push(h('div', { key: 'ws', style: fill ? { ...S.wsList, ...S_FILL.wsList } : S.wsList }, wsRows))
|
|
1937
|
+
|
|
1938
|
+
if (outcome !== null && outcome !== undefined) {
|
|
1939
|
+
const ok = outcome.ok === true
|
|
1940
|
+
children.push(
|
|
1941
|
+
h('div', { key: 'out', style: S.result }, [
|
|
1942
|
+
h('div', { key: 's', style: ok ? S.good : S.bad },
|
|
1943
|
+
ok
|
|
1944
|
+
? `${outcome.dryRun === true ? '✔ 预演完成' : '✔ 已搬迁'}${outcome.report?.noop === true ? '(无需搬迁)' : ''}`
|
|
1945
|
+
: `✘ ${String(outcome.error)}`),
|
|
1946
|
+
h('pre', { key: 'd', style: S.pre }, JSON.stringify(outcome.report ?? outcome, null, 2)),
|
|
1947
|
+
]),
|
|
1948
|
+
)
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
children.push(
|
|
1952
|
+
h('div', { key: 'foot', style: S.tiny },
|
|
1953
|
+
snapshot !== null && snapshot !== undefined
|
|
1954
|
+
? `根目录 ${snapshot.root} · zstd=${String(snapshot.zstd)} · 工作区 ${snapshot.counts?.workspaces ?? workspaces.length} · 会话 ${snapshot.counts?.stored ?? sessions.length}(Host 内已加载 ${snapshot.counts?.resident ?? 0})`
|
|
1955
|
+
: '正在读取 Host 状态…'),
|
|
1956
|
+
)
|
|
1957
|
+
return h('div', { style: fill ? { ...S.root, ...S_FILL.root } : S.root }, children)
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/** 右栏页签标题。 */
|
|
1962
|
+
function createTabTitle(React) {
|
|
1963
|
+
return function SessionMoveTabTitle() {
|
|
1964
|
+
return React.createElement('span', null, '会话归位')
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
sessionMoveHalf = (require) => {
|
|
1970
|
+
const React = require('react')
|
|
1971
|
+
|
|
1972
|
+
function apply(ctx) {
|
|
1973
|
+
const api = createApi()
|
|
1974
|
+
const Panel = createPanel(React, api)
|
|
1975
|
+
|
|
1976
|
+
// 右侧面板服务:右键菜单要借它把搬迁页签打开(与删除半边同一套可选注入)。
|
|
1977
|
+
let sidebarRight
|
|
1978
|
+
ctx.inject(['sidebarRight'], (injected) => {
|
|
1979
|
+
sidebarRight = injected.get('sidebarRight')
|
|
1980
|
+
return () => {
|
|
1981
|
+
sidebarRight = undefined
|
|
1982
|
+
}
|
|
1983
|
+
})
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* 打开搬迁面板并选中这个会话(目标工作区由用户在面板里挑)。
|
|
1987
|
+
* @returns 面板服务不在 / 两个 kind 都打不开时 false。
|
|
1988
|
+
*/
|
|
1989
|
+
const openPanel = (sessionId) => {
|
|
1990
|
+
if (sidebarRight === undefined || typeof sidebarRight.openTab !== 'function') return false
|
|
1991
|
+
// 先登记请求再开页签:页签这次才创建时,面板挂载后会立刻把它取走。
|
|
1992
|
+
requestMoveSession(sessionId)
|
|
1993
|
+
for (const kind of [TAB_ID, BETTER_TAB_ID]) {
|
|
1994
|
+
try {
|
|
1995
|
+
sidebarRight.openTab(kind)
|
|
1996
|
+
return true
|
|
1997
|
+
} catch {
|
|
1998
|
+
/* 换下一个 */
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// 页签打不开就把交接撤掉,免得下次面板自己打开时莫名选中一条旧会话。
|
|
2002
|
+
requestMoveSession(undefined)
|
|
2003
|
+
return false
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// 右栏页签:优先 better-sidebar,回落到原生。
|
|
2007
|
+
// 左栏底部那个 ⇄ 入口已去掉(2026-10-26):会话归位只挂侧边栏页签,不再往左栏
|
|
2008
|
+
// footer 塞按钮。
|
|
2009
|
+
const registerNativeTab = () => {
|
|
2010
|
+
try {
|
|
2011
|
+
return ctx.inject(['sidebarRightTabs'], (injected) => {
|
|
2012
|
+
const tabs = injected.get('sidebarRightTabs')
|
|
2013
|
+
if (tabs === undefined) return undefined
|
|
2014
|
+
const disposeDefinition = tabs.register({
|
|
2015
|
+
id: TAB_ID,
|
|
2016
|
+
kind: TAB_ID,
|
|
2017
|
+
priority: 'extension',
|
|
2018
|
+
title: () => '会话归位',
|
|
2019
|
+
guide: [{ order: 140, title: () => '会话归位' }],
|
|
2020
|
+
})
|
|
2021
|
+
const disposeBody = ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
2022
|
+
ctx.slots.register(
|
|
2023
|
+
{ name: 'sidebar.right.pane.tab', key: TAB_ID, inject: () => ({ fill: true }) },
|
|
2024
|
+
Panel,
|
|
2025
|
+
),
|
|
2026
|
+
)
|
|
2027
|
+
const disposeTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
2028
|
+
ctx.slots.register(
|
|
2029
|
+
{ name: 'sidebar.right.pane.tab.title', key: TAB_ID, inject: () => ({}) },
|
|
2030
|
+
createTabTitle(React),
|
|
2031
|
+
),
|
|
2032
|
+
)
|
|
2033
|
+
return () => {
|
|
2034
|
+
try {
|
|
2035
|
+
disposeTitle?.()
|
|
2036
|
+
disposeBody?.()
|
|
2037
|
+
disposeDefinition?.()
|
|
2038
|
+
} catch {
|
|
2039
|
+
/* 卸载失败不影响其它入口 */
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
})
|
|
2043
|
+
} catch {
|
|
2044
|
+
return undefined
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
let nativeTabDispose = registerNativeTab()
|
|
2049
|
+
try {
|
|
2050
|
+
ctx.inject(['betterSidebar'], (injected) => {
|
|
2051
|
+
const service = injected.get('betterSidebar')
|
|
2052
|
+
if (service === undefined || typeof service.registerTab !== 'function') return undefined
|
|
2053
|
+
// 顺序是硬要求:registerTab 会同步把描述符镜像到原生 sidebarRightTabs,
|
|
2054
|
+
// 所以必须先把原生那条撤掉,否则撞 kind。
|
|
2055
|
+
try {
|
|
2056
|
+
nativeTabDispose?.dispose?.()
|
|
2057
|
+
} catch {
|
|
2058
|
+
/* 撤不掉也要往下走,catch 会把原生接回来 */
|
|
2059
|
+
}
|
|
2060
|
+
nativeTabDispose = undefined
|
|
2061
|
+
let disposeBetter
|
|
2062
|
+
try {
|
|
2063
|
+
disposeBetter = service.registerTab({
|
|
2064
|
+
id: BETTER_TAB_ID,
|
|
2065
|
+
title: () => '会话归位',
|
|
2066
|
+
description: () => '把会话拖到另一个工作区:真搬迁(日志与 header.cwd 一起走),可预演。',
|
|
2067
|
+
order: 140,
|
|
2068
|
+
single: true,
|
|
2069
|
+
component: () => React.createElement(Panel, { fill: true }),
|
|
2070
|
+
})
|
|
2071
|
+
} catch {
|
|
2072
|
+
nativeTabDispose = registerNativeTab()
|
|
2073
|
+
return undefined
|
|
2074
|
+
}
|
|
2075
|
+
return () => {
|
|
2076
|
+
try {
|
|
2077
|
+
disposeBetter?.()
|
|
2078
|
+
} catch {
|
|
2079
|
+
/* 卸载失败不影响其它入口 */
|
|
2080
|
+
}
|
|
2081
|
+
if (nativeTabDispose === undefined) nativeTabDispose = registerNativeTab()
|
|
2082
|
+
}
|
|
2083
|
+
})
|
|
2084
|
+
} catch {
|
|
2085
|
+
/* 没有 betterSidebar 时留在原生路径 */
|
|
2086
|
+
}
|
|
2087
|
+
ctx.effect(
|
|
2088
|
+
() => () => {
|
|
2089
|
+
try {
|
|
2090
|
+
nativeTabDispose?.dispose?.()
|
|
2091
|
+
} catch {
|
|
2092
|
+
/* 卸载失败不影响其它入口 */
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
'dsh-session-move: pane tab teardown',
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
// 右键菜单(0.2.2 起):会话行右键 → 「移动会话到工作区…」。
|
|
2099
|
+
//
|
|
2100
|
+
// 菜单里不给「目标工作区」子菜单:搬迁要挑目标、能预演、会写日志和改
|
|
2101
|
+
// header.cwd,这些面板里都有;菜单只负责把面板打开并选中这一条。
|
|
2102
|
+
//
|
|
2103
|
+
// 服务用可选注入(与删除半边一致):没有这个服务时只少一个菜单入口,
|
|
2104
|
+
// 右栏页签照旧工作。
|
|
2105
|
+
ctx.slots.inject('contextMenu.icon', () =>
|
|
2106
|
+
ctx.slots.register({ name: 'contextMenu.icon', key: MOVE_MENU_ICON }, createMoveIcon(React)),
|
|
2107
|
+
)
|
|
2108
|
+
ctx.inject(['contextMenus'], (injected) => {
|
|
2109
|
+
const menus = injected.get('contextMenus')
|
|
2110
|
+
if (menus === undefined || typeof menus.register !== 'function') return undefined
|
|
2111
|
+
const resolve = createResolveMoveSessionMenu(openPanel)
|
|
2112
|
+
const dispose = ctx.effect(
|
|
2113
|
+
() =>
|
|
2114
|
+
menus.register({
|
|
2115
|
+
id: 'session-toolbox.move-session',
|
|
2116
|
+
target: 'session',
|
|
2117
|
+
// 排在原生「复制会话 id」(450)与「归档」(600)之间:会话级整理动作。
|
|
2118
|
+
order: 500,
|
|
2119
|
+
resolve: (target, signal) => resolve(menus, target, signal),
|
|
2120
|
+
}),
|
|
2121
|
+
'dsh-session-ops: session move menu',
|
|
2122
|
+
)
|
|
2123
|
+
return () => {
|
|
2124
|
+
try {
|
|
2125
|
+
dispose?.()
|
|
2126
|
+
} catch {
|
|
2127
|
+
/* 卸载失败不影响其它入口 */
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
})
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
return { inject: ['slots'], apply }
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2138
|
+
// 模块加载:一个包、一个 loader id、一个插件对象
|
|
2139
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2140
|
+
|
|
2141
|
+
const target = globalThis.window === undefined ? undefined : globalThis.window.__ModuleLoader__
|
|
2142
|
+
if (target === undefined) {
|
|
2143
|
+
throw new Error(
|
|
2144
|
+
`${PACKAGE}: window.__ModuleLoader__ is missing — this bundle is served by the DSH web shell and cannot be loaded directly`,
|
|
2145
|
+
)
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
target.load({
|
|
2149
|
+
id: PACKAGE,
|
|
2150
|
+
factory: (require) => {
|
|
2151
|
+
const adminHalf = sessionAdminHalf(require)
|
|
2152
|
+
const moveHalf = sessionMoveHalf(require)
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* 两半各自 apply 一次:都只碰自己的槽位与自己的 HTTP 前缀,顺序无关紧要。
|
|
2156
|
+
* `inject` 取并集——删除半边要 `sessions`(会话标题)与 `configForms`(插件页配置
|
|
2157
|
+
* 卡片自己取共享表单),搬迁半边只用 `slots`。
|
|
2158
|
+
*/
|
|
2159
|
+
function apply(ctx) {
|
|
2160
|
+
adminHalf.apply(ctx)
|
|
2161
|
+
moveHalf.apply(ctx)
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
return { inject: ['slots', 'sessions', 'configForms'], apply }
|
|
2165
|
+
},
|
|
2166
|
+
})
|
|
2167
|
+
})()
|