@xrn1997/dsh-session-delete 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +130 -3
- package/cordis.patch.yml +3 -0
- package/icon.svg +18 -0
- package/lib/client.js +2083 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +65 -0
- package/lib/index.js +876 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -6
package/lib/client.js
ADDED
|
@@ -0,0 +1,2083 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@xrn1997/dsh-session-delete",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
let react_dom = require("react-dom");
|
|
10
|
+
//#region src/client/copy.ts
|
|
11
|
+
/** 本插件的文案命名空间(第三方必须自带前缀;官方占用的是 `common` / `settings` / `sidebar` 这些)。 */
|
|
12
|
+
const COPY_NS = "xrn1997-session-delete";
|
|
13
|
+
/** zh 是真源;`en` 必须逐键齐全(宿主的对象形式注册会整体校验)。 */
|
|
14
|
+
const ZH = {
|
|
15
|
+
"common.untitled": "(无标题会话)",
|
|
16
|
+
"common.cancel": "取消",
|
|
17
|
+
"common.close": "关闭",
|
|
18
|
+
"common.refresh": "刷新",
|
|
19
|
+
"common.retry": "重试",
|
|
20
|
+
"trash.title": "回收站",
|
|
21
|
+
"trash.loading": "正在读回收站…",
|
|
22
|
+
"trash.readFailed": "回收站读不到",
|
|
23
|
+
"trash.empty.title": "回收站是空的",
|
|
24
|
+
"trash.empty.hint": "在会话条目的「…」菜单里选「删除」,会话会先进到这里,随时可以恢复。",
|
|
25
|
+
"trash.summary": "{count} 个会话 · 共 {size}",
|
|
26
|
+
"trash.group.count": "{count} 个会话",
|
|
27
|
+
"trash.project.unknown": "未记录项目",
|
|
28
|
+
"trash.row.deletedNow": "刚刚删除",
|
|
29
|
+
"trash.row.deletedAgo": "删除于 {n}{unit}前",
|
|
30
|
+
"trash.unit.minutes": "分钟",
|
|
31
|
+
"trash.unit.hours": "小时",
|
|
32
|
+
"trash.unit.days": "天",
|
|
33
|
+
"trash.unit.months": "个月",
|
|
34
|
+
"trash.unit.years": "年",
|
|
35
|
+
"trash.row.restore": "恢复",
|
|
36
|
+
"trash.row.purge": "彻底删除…",
|
|
37
|
+
"trash.footer.note": "回收站本体在宿主数据根下的 {dir} 里,不自动过期;清空后不可恢复。",
|
|
38
|
+
"trash.emptyAll": "清空回收站…",
|
|
39
|
+
"trash.confirm.all": "清空回收站?",
|
|
40
|
+
"trash.confirm.one": "彻底删除 1 个会话?",
|
|
41
|
+
"trash.confirm.one.meta": "{project} · 删除于 {at} · {size}",
|
|
42
|
+
"trash.confirm.body": "这会从回收站移除文件,{strong}。",
|
|
43
|
+
"trash.confirm.body.strong": "之后没有任何副本可以恢复",
|
|
44
|
+
"trash.confirm.purge": "彻底删除",
|
|
45
|
+
"menu.delete": "删除",
|
|
46
|
+
"menu.live": "会话正在运行,先停止再删除",
|
|
47
|
+
"dialog.title": "删除这个会话?",
|
|
48
|
+
"dialog.hint": "会话会移入回收站,之后可以随时恢复。彻底删除是回收站里的另一个动作。",
|
|
49
|
+
"dialog.confirm": "移入回收站",
|
|
50
|
+
"undo.moved": "{title} 已移入回收站",
|
|
51
|
+
"undo.session": "会话",
|
|
52
|
+
"undo.undo": "撤销"
|
|
53
|
+
};
|
|
54
|
+
/** en:与 `ZH` 逐键镜像(键集合由 `Record<CopyKey, string>` 盯住,漏一个就编译不过)。
|
|
55
|
+
* 单位词与宿主 en 侧 `relativeTime` 的写法对齐(宿主 en 的桶就是 `5min` / `3h` / `2d` / `4mo` / `1y`)。 */
|
|
56
|
+
const EN = {
|
|
57
|
+
"common.untitled": "(Untitled session)",
|
|
58
|
+
"common.cancel": "Cancel",
|
|
59
|
+
"common.close": "Close",
|
|
60
|
+
"common.refresh": "Refresh",
|
|
61
|
+
"common.retry": "Retry",
|
|
62
|
+
"trash.title": "Trash",
|
|
63
|
+
"trash.loading": "Reading the trash…",
|
|
64
|
+
"trash.readFailed": "Could not read the trash",
|
|
65
|
+
"trash.empty.title": "The trash is empty",
|
|
66
|
+
"trash.empty.hint": "Pick Delete in a session’s “…” menu; the session lands here first and can be restored at any time.",
|
|
67
|
+
"trash.summary": "{count} sessions · {size} total",
|
|
68
|
+
"trash.group.count": "{count} sessions",
|
|
69
|
+
"trash.project.unknown": "Unrecorded project",
|
|
70
|
+
"trash.row.deletedNow": "Deleted just now",
|
|
71
|
+
"trash.row.deletedAgo": "Deleted {n}{unit} ago",
|
|
72
|
+
"trash.unit.minutes": "min",
|
|
73
|
+
"trash.unit.hours": "h",
|
|
74
|
+
"trash.unit.days": "d",
|
|
75
|
+
"trash.unit.months": "mo",
|
|
76
|
+
"trash.unit.years": "y",
|
|
77
|
+
"trash.row.restore": "Restore",
|
|
78
|
+
"trash.row.purge": "Delete permanently…",
|
|
79
|
+
"trash.footer.note": "The trash itself lives under the host data root in {dir}; it never expires on its own, and emptying it cannot be undone.",
|
|
80
|
+
"trash.emptyAll": "Empty the trash…",
|
|
81
|
+
"trash.confirm.all": "Empty the trash?",
|
|
82
|
+
"trash.confirm.one": "Delete this session permanently?",
|
|
83
|
+
"trash.confirm.one.meta": "{project} · deleted {at} · {size}",
|
|
84
|
+
"trash.confirm.body": "This removes the files from the trash; {strong}.",
|
|
85
|
+
"trash.confirm.body.strong": "no copy remains to restore it",
|
|
86
|
+
"trash.confirm.purge": "Delete permanently",
|
|
87
|
+
"menu.delete": "Delete",
|
|
88
|
+
"menu.live": "This session is running. Stop it first.",
|
|
89
|
+
"dialog.title": "Delete this session?",
|
|
90
|
+
"dialog.hint": "The session moves to the trash and can be restored at any time. Permanent deletion is a separate action in the trash.",
|
|
91
|
+
"dialog.confirm": "Move to trash",
|
|
92
|
+
"undo.moved": "{title} was moved to the trash",
|
|
93
|
+
"undo.session": "the session",
|
|
94
|
+
"undo.undo": "Undo"
|
|
95
|
+
};
|
|
96
|
+
/** 与宿主 `translate` 同一条替换规则(`{name}`,参数缺席时原样留下占位符)。 */
|
|
97
|
+
function interpolate(template, params) {
|
|
98
|
+
if (params === void 0) return template;
|
|
99
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
100
|
+
}
|
|
101
|
+
/** 服务缺席时的缺省:字典里那套 zh。**也是组件 prop 的缺省值**——它表达的是"这次没有 locale 服务",
|
|
102
|
+
* 与运行期 `createTranslate` 的降级分支是同一件事,不是"忘了传"。 */
|
|
103
|
+
const zhTranslate = (key, params) => interpolate(ZH[key], params);
|
|
104
|
+
/**
|
|
105
|
+
* 注册字典并交回本插件的 `t`。服务不在时返回 `zhTranslate`(不抛、不 pending)。
|
|
106
|
+
*
|
|
107
|
+
* 注册必须挂在 `ctx.effect` 上:`register` 返回的 disposer 是**官方契约**(卸载时要把字典摘掉),
|
|
108
|
+
* 而且同一命名空间的同一语言**只能注册一次**——effect 没挂,HMR 重挂就撞 "already has locale"。
|
|
109
|
+
*/
|
|
110
|
+
function createTranslate(ctx) {
|
|
111
|
+
const service = ctx.get?.("locale");
|
|
112
|
+
if (service === void 0) return zhTranslate;
|
|
113
|
+
ctx.effect(() => service.register(COPY_NS, {
|
|
114
|
+
zh: ZH,
|
|
115
|
+
en: EN
|
|
116
|
+
}), "session-delete: copy dictionaries");
|
|
117
|
+
const bound = service.bind(COPY_NS);
|
|
118
|
+
return (key, params) => bound(key, params);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 把一句话里内嵌的那一段拆出来:`'…文件,之后没有任何副本可以恢复。'` 交给调用面用 `<b>` 包中间那段。
|
|
122
|
+
*
|
|
123
|
+
* 为什么不留成"前段 / 内嵌段 / 后段"三个键:那等于把英文和中文的语序各钉一次,两段翻译各自
|
|
124
|
+
* 成立、拼起来未必成立。整句一个键 + 一个占位符,语言自己决定那一段落在哪里。
|
|
125
|
+
*/
|
|
126
|
+
function splitOn(sentence, inner) {
|
|
127
|
+
const at = sentence.indexOf(inner);
|
|
128
|
+
if (at < 0) return [
|
|
129
|
+
sentence,
|
|
130
|
+
"",
|
|
131
|
+
""
|
|
132
|
+
];
|
|
133
|
+
return [
|
|
134
|
+
sentence.slice(0, at),
|
|
135
|
+
inner,
|
|
136
|
+
sentence.slice(at + inner.length)
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/shared/wire.ts
|
|
141
|
+
/**
|
|
142
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、四个端点段、请求字段名**——
|
|
143
|
+
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
144
|
+
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
145
|
+
*
|
|
146
|
+
* 两条构建期硬约束(不是偏好):
|
|
147
|
+
* ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
|
|
148
|
+
* 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
|
|
149
|
+
* ② 跨插件一律 `import type`——本文件干脆没有 import。
|
|
150
|
+
*
|
|
151
|
+
* 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
|
|
152
|
+
* Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那两件),浏览器半按它判别
|
|
153
|
+
* (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
|
|
154
|
+
* 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
|
|
155
|
+
* 不是对宿主模块的 import。
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* 插件专属前缀。
|
|
159
|
+
*
|
|
160
|
+
* 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
|
|
161
|
+
* 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
|
|
162
|
+
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
163
|
+
*/
|
|
164
|
+
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
165
|
+
/** 四个端点的段名(相对前缀;`GET list` 读、其余三个 `POST` 写)。 */
|
|
166
|
+
const ROUTES = {
|
|
167
|
+
list: "/list",
|
|
168
|
+
delete: "/delete",
|
|
169
|
+
restore: "/restore",
|
|
170
|
+
purge: "/purge"
|
|
171
|
+
};
|
|
172
|
+
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
|
|
173
|
+
const PARAMS = {
|
|
174
|
+
sessionId: "sessionId",
|
|
175
|
+
entryId: "entryId"
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
|
|
179
|
+
* 位名与判法取自宿主网关客户端面的原文:
|
|
180
|
+
* `if (typeof value === "object" && value !== null && value.isDSHRemoteError === true && typeof value.code === "string") return value`。
|
|
181
|
+
*
|
|
182
|
+
* 线上递过来的是**一段 JSON**(标记位不跟着过线)⇒ 落到哪一端,由那端的构造处就地补上标记:
|
|
183
|
+
* 浏览器半是 `src/client/remote.ts` 的 `throwRemoteFailure`。
|
|
184
|
+
*/
|
|
185
|
+
function isDSHRemoteError(value) {
|
|
186
|
+
if (typeof value !== "object" || value === null) return false;
|
|
187
|
+
const candidate = value;
|
|
188
|
+
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 宿主 `projectKey` 的 **`~XXXX` 转义还原**。
|
|
192
|
+
*
|
|
193
|
+
* 编码那一半在 Node 侧(`src/ports/host-port.ts` 的 `projectKey`,逐字镜像宿主实现);这里放与它配对的
|
|
194
|
+
* 解码,客户端渲染回收站组头用它——**字母表只有这一份**,两边不会各自漂移(配对关系由
|
|
195
|
+
* `tests/assembly.test.ts` 的往返用例钉住)。
|
|
196
|
+
*
|
|
197
|
+
* **只还原可还原的那部分**:不在 `[A-Za-z0-9._-]` 里、又不是 `~` 的字符编成 `~` + 4 位大写十六进制码元,
|
|
198
|
+
* 字面 `~` 自己编成 `~007E` ⇒ `~XXXX` 不含歧义。而**分隔符(`/` `\` `:`)被折成一个 `-`,那一段是有损的**,
|
|
199
|
+
* 这里还原不回来——调用方按"尽力显示"用它,别拿它反推真实路径。
|
|
200
|
+
*/
|
|
201
|
+
function unescapeProjectKey(encoded) {
|
|
202
|
+
return encoded.replace(/~([0-9A-F]{4})/g, (_whole, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/client/remote.ts
|
|
206
|
+
/**
|
|
207
|
+
* 浏览器半的**取数通道**:同源 fetch 宿主 web server 上的 `/dsh-session-delete-api`,
|
|
208
|
+
* 加上信封判别与注入面那一处「展开成裸值」的适配。
|
|
209
|
+
*
|
|
210
|
+
* 通道形状(裁决):真相/账本/判据全在宿主那边,Node 半用宿主**自己的 web server** 开一条
|
|
211
|
+
* 插件专属前缀路由把四个动词搬出来(`src/api/dispatch.ts`),浏览器半同源调用。**不是重造后端**:
|
|
212
|
+
* 路由层只做搬运与信封。为什么不用官方的 `remote.<namespace>`:那条路要**生成的 Typert 远程描述符**
|
|
213
|
+
* 加一次 `$mount`,而盒内装配清单写死在 `dsh-api-remotes` 里、全宿主没有动态发现,第三方只能自挂自,
|
|
214
|
+
* 对一个薄插件不划算。
|
|
215
|
+
*
|
|
216
|
+
* 三条约束决定了这个文件长这样:
|
|
217
|
+
*
|
|
218
|
+
* 1. **信封刻意与官方 unary 面同形**:`{ ok: true, value }` / `{ ok: false, error }`。这不是"还留着
|
|
219
|
+
* 官方通道"——它现在是**我们两半之间**的 wire 契约(Node 半按它产出,见 `src/api/dispatch.ts`),
|
|
220
|
+
* 选它是为了让组件面、`unwrapRemoteResult` 与既有用例一行都不用改。失败是**返回**而不是
|
|
221
|
+
* reject(同宿主网关原文:"Every unary call resolves to `RemoteResult<T>` … and never rejects for a
|
|
222
|
+
* carrier problem");只有往返本身失败(打不通、回的不是信封)才 reject,那是**通道缺席**。
|
|
223
|
+
* 2. **不许值 import 跨插件包**。`error` 在线上就是一段 JSON(`{code, message}`),不是 `RemoteError`
|
|
224
|
+
* 实例——所以判别**所依据的那一个标记位**只镜一份,家在两半共用的 `src/shared/wire.ts`
|
|
225
|
+
* (`isDSHRemoteError`)。浏览器半落到本地时用 `throwRemoteFailure` 就地补上 Error 本体与标记位;
|
|
226
|
+
* Node 半(`src/remote/map-error.ts`)只用它做「这已经是一次远程失败」的判别,产出的是线上那两件、
|
|
227
|
+
* 不造任何 Error,更不 import 宿主那个类。
|
|
228
|
+
* 3. **失败原文必须能上屏**。调用方(`callRemote`)只把**抛出来的失败**收成判别式状态——
|
|
229
|
+
* `{ ok: false, error }` 不是第三种落地:它在注入面那一处已经被 `unwrapRemoteResult` 抛成了异常,
|
|
230
|
+
* 所以到了组件面前只剩「裸值 / throw」两支,调用方只写 `if (!outcome.ok)`,正常流里没有 try/catch;
|
|
231
|
+
* 非本信封形状的异常(网络层、TypeError…)也不丢原文(见 `failureOf`)。
|
|
232
|
+
*
|
|
233
|
+
* 面(`list`/`delete`/`restore`/`purge`)与 Node 半路由的四个段名一一对应(段名本身的家是
|
|
234
|
+
* `src/shared/wire.ts`);`list/delete/restore` 回 `TrashEntry`,`purge` 回 `PurgeResult`。
|
|
235
|
+
*/
|
|
236
|
+
/** 任意异常 → 失败面。非带标记的失败(网络层、TypeError…)也保留原文,绝不吞。 */
|
|
237
|
+
function failureOf(reason) {
|
|
238
|
+
if (isDSHRemoteError(reason)) return {
|
|
239
|
+
code: reason.code,
|
|
240
|
+
message: String(reason.message)
|
|
241
|
+
};
|
|
242
|
+
if (reason instanceof Error) return {
|
|
243
|
+
code: "client/unknown",
|
|
244
|
+
message: reason.message
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
code: "client/unknown",
|
|
248
|
+
message: String(reason)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 信封的失败支 → 一个**抛出来的**失败。
|
|
253
|
+
*
|
|
254
|
+
* 后半句与网关客户端面同形:那边 `error` 是活的 `RemoteError`、`throw result.error` 保持 throw 语义,
|
|
255
|
+
* 网关自己的重建函数 `rebuiltFailure(error) { return new RemoteError(error.code, error.message,
|
|
256
|
+
* error.details) }` 就干这件事。这里多一层:线上递过来的失败必然是**普通对象**(JSON)而不是本 bundle
|
|
257
|
+
* 的 Error 实例,所以就地造一个并打上同一个标记位——**code 与 message 都不丢**
|
|
258
|
+
* (`code` 是判别依据,`message` 是上屏的原文;本插件的线上形状没有 `details`,见 `shared/wire.ts`)。
|
|
259
|
+
*/
|
|
260
|
+
function throwRemoteFailure(error) {
|
|
261
|
+
const candidate = error;
|
|
262
|
+
if (isDSHRemoteError(candidate)) throw candidate;
|
|
263
|
+
throw Object.assign(new Error(String(error.message)), {
|
|
264
|
+
code: error.code,
|
|
265
|
+
isDSHRemoteError: true
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** 注入面用的那一处展开:`ok ? value : throw Object.assign(new Error(error.message), { code })`。 */
|
|
269
|
+
function unwrapRemoteResult(result) {
|
|
270
|
+
if (result.ok) return result.value;
|
|
271
|
+
throwRemoteFailure(result.error);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 跑一次远程调用,把两种落地(值 / 抛出的失败)收成一个状态。
|
|
275
|
+
*
|
|
276
|
+
* 「返回的失败」在这里不再是一种落地:`{ ok: false, error }` 已在注入面被 `unwrapRemoteResult`
|
|
277
|
+
* 抛成了异常,所以到这一层的只有裸值或 throw——判别留在 `failureOf` 里(带标记位的失败带出它自己的
|
|
278
|
+
* code,其余按原文兜底)。
|
|
279
|
+
*/
|
|
280
|
+
async function callRemote(run) {
|
|
281
|
+
try {
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
value: await run()
|
|
285
|
+
};
|
|
286
|
+
} catch (reason) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
failure: failureOf(reason)
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* 一次 HTTP 往返 → 信封。**`ok:false` 也是落地**(返回,不抛);只有往返本身失败才抛:
|
|
295
|
+
* 打不通(宿主没起来 / 路由没挂)或回的不是信封(前缀路由缺席时请求会落到 SPA 兜底处理器上,
|
|
296
|
+
* 回的是 index.html)。两种抛都带原文,组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
|
|
297
|
+
*/
|
|
298
|
+
async function requestEnvelope(path, init) {
|
|
299
|
+
let response;
|
|
300
|
+
try {
|
|
301
|
+
response = await fetch(path, init);
|
|
302
|
+
} catch (reason) {
|
|
303
|
+
throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return await response.json();
|
|
307
|
+
} catch {
|
|
308
|
+
throw new Error(`取数通道回的不是信封(HTTP ${String(response.status)})`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 前缀路由的 fetch 实现。**相对路径**:不拼 base、不认 origin——页面本身就是从宿主 web server
|
|
313
|
+
* 加载的,同源是这条薄线的全部前提(也是它敢不带凭据的原因:宿主那行 URL 上的 browser-trust token
|
|
314
|
+
* 不是这条通道的凭据,栅栏在 Node 半那一侧按 Origin/Referer 判)。
|
|
315
|
+
*/
|
|
316
|
+
function createHttpRemote(prefix = SESSION_DELETE_API_PREFIX) {
|
|
317
|
+
const post = (body) => ({
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: { "content-type": "application/json" },
|
|
320
|
+
body: JSON.stringify(body)
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
list: () => requestEnvelope(prefix + ROUTES.list, { method: "GET" }),
|
|
324
|
+
delete: (sessionId) => requestEnvelope(prefix + ROUTES.delete, post({ [PARAMS.sessionId]: sessionId })),
|
|
325
|
+
restore: (entryId) => requestEnvelope(prefix + ROUTES.restore, post({ [PARAMS.entryId]: entryId })),
|
|
326
|
+
purge: (entryId) => requestEnvelope(prefix + ROUTES.purge, post(entryId === void 0 ? {} : { [PARAMS.entryId]: entryId }))
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* 取数通道探测:一次 `list` 走通(或走到)就是"通道在"。
|
|
331
|
+
*
|
|
332
|
+
* 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
|
|
333
|
+
* 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
|
|
334
|
+
* 一个槽位贡献都不注册(设计稿 §7「未接通态」)。
|
|
335
|
+
*/
|
|
336
|
+
async function probeChannel(raw) {
|
|
337
|
+
try {
|
|
338
|
+
await raw.list();
|
|
339
|
+
return true;
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
|
|
346
|
+
*
|
|
347
|
+
* **为什么必须有这一步**:通道那头的调用 resolve 成 `RemoteResult<T>`(`{ ok: true, value }` /
|
|
348
|
+
* `{ ok: false, error }`),失败是**返回**而不是 reject。组件面要的是「拿到值 / 抛异常」,
|
|
349
|
+
* 所以在这里展开一次:`ok ? value : throw`。放在这一处而不是组件里,是为了让三个组件的 props 契约
|
|
350
|
+
* (以及钉住它们的用例)保持裸值形状不变。
|
|
351
|
+
*/
|
|
352
|
+
function buildDeps(raw) {
|
|
353
|
+
return {
|
|
354
|
+
list: () => raw.list().then((result) => unwrapRemoteResult(result)),
|
|
355
|
+
delete: (sessionId) => raw.delete(sessionId).then((result) => unwrapRemoteResult(result)),
|
|
356
|
+
restore: (entryId) => raw.restore(entryId).then((result) => unwrapRemoteResult(result)),
|
|
357
|
+
purge: (entryId) => raw.purge(entryId).then((result) => unwrapRemoteResult(result))
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/client/trash-store.ts
|
|
362
|
+
/**
|
|
363
|
+
* 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
|
|
364
|
+
*
|
|
365
|
+
* 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
|
|
366
|
+
* **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
|
|
367
|
+
* 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
|
|
368
|
+
* 同理。
|
|
369
|
+
*
|
|
370
|
+
* 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场 +
|
|
371
|
+
* `useSyncExternalStore`**,不为此引状态库、也不引入上下文——三样东西都挂在 `shell.overlay`/`main`
|
|
372
|
+
* 这些各不相邻的座位上,模块级是它们唯一的共同点。
|
|
373
|
+
*
|
|
374
|
+
* 语义只有"变过"这一件事:一个单调递增的修订号。面板把它写进 effect 的依赖里,戳一次重读一次;
|
|
375
|
+
* 不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
|
|
376
|
+
*/
|
|
377
|
+
let revision = 0;
|
|
378
|
+
const listeners$2 = /* @__PURE__ */ new Set();
|
|
379
|
+
/** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
|
|
380
|
+
function invalidateTrash() {
|
|
381
|
+
revision += 1;
|
|
382
|
+
for (const listener of listeners$2) listener();
|
|
383
|
+
}
|
|
384
|
+
function getTrashRevision() {
|
|
385
|
+
return revision;
|
|
386
|
+
}
|
|
387
|
+
function subscribeTrash(listener) {
|
|
388
|
+
listeners$2.add(listener);
|
|
389
|
+
return () => {
|
|
390
|
+
listeners$2.delete(listener);
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** 供面板订阅。 */
|
|
394
|
+
function useTrashRevision() {
|
|
395
|
+
return (0, react.useSyncExternalStore)(subscribeTrash, getTrashRevision, getTrashRevision);
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/client/ui/ui.css?dsh-css-text
|
|
399
|
+
var ui_default = "/*\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\n *\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\n * `Toast.module.css`、`Menu.module.css`)。这是官方给第三方 UI 插件的处方:\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\n *\n * 三条改写规则:\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\n *\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)。**没抄进来的就是本插件不用的**,\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\n */\n\n/* ────────────────────────── Button ────────────────────────── */\n\n.sd-button {\n box-sizing: border-box;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n border: none;\n border-radius: var(--dsw-radius-md);\n cursor: pointer;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n padding: 0 14px;\n}\n\n.sd-button:disabled {\n cursor: not-allowed;\n opacity: 0.4;\n}\n\n.sd-button-md {\n height: 36px;\n}\n\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\n.sd-button-sm {\n height: 28px;\n font-size: 12px;\n line-height: 18px;\n padding: 0 10px;\n border-radius: var(--dsw-radius-sm);\n}\n\n.sd-button-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.sd-button-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n.sd-button-ghost:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-button-ghost:active:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.sd-button-outline {\n border: 0.5px solid var(--dsw-alias-border-l3);\n background: transparent;\n}\n\n.sd-button-outline:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-button-icon {\n display: inline-flex;\n width: 16px;\n height: 16px;\n align-items: center;\n justify-content: center;\n}\n\n/* ────────────────────────── Tag ────────────────────────── */\n\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\n.sd-tag {\n display: inline-flex;\n align-items: center;\n border-radius: 999px;\n corner-shape: round;\n padding: 1px 8px;\n font-size: 11px;\n line-height: 17px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\n.sd-tag-danger {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\n color: var(--dsw-alias-state-error-primary);\n}\n\n/* ────────────────────────── Modal ────────────────────────── */\n\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\n.sd-modal-root {\n pointer-events: auto;\n position: fixed;\n inset: 0;\n z-index: 1000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\n}\n\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\n.sd-modal-mask {\n position: absolute;\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\n backdrop-filter: var(--dsw-mask-blur);\n}\n\n.sd-modal-mask::after {\n content: '';\n position: absolute;\n inset: 0;\n background: var(--dsw-alias-bg-mask-1);\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\n}\n\n.sd-modal-dialog {\n box-sizing: border-box;\n position: relative;\n z-index: 1;\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: min(380px, 100%);\n padding: 0 0 24px;\n overflow: hidden;\n border: 0;\n border-radius: var(--dsw-radius-panel);\n background: var(--dsw-alias-bg-layer-2);\n box-shadow: var(--dsw-elevation-prominent);\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\n}\n\n@keyframes sd-modal-enter {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sd-modal-mask::after,\n .sd-modal-dialog { animation: none; }\n}\n\n.sd-modal-dialog:focus {\n outline: none;\n}\n\n.sd-modal-content {\n display: flex;\n flex-direction: column;\n width: 100%;\n}\n\n/* 头行:标题左、关闭钮右。 */\n.sd-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 22px 14px 12px 24px;\n}\n\n.sd-modal-title {\n margin: 0;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary);\n}\n\n.sd-modal-close {\n flex: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n border-radius: var(--dsw-radius-sm);\n background: transparent;\n cursor: pointer;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sd-modal-close:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-modal-body {\n display: flex;\n flex-direction: column;\n min-width: 0;\n margin-top: 20px;\n padding: 0 24px;\n}\n\n.sd-modal-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 0 24px;\n}\n\n/* ────────────────────────── Tooltip ────────────────────────── */\n\n.sd-tooltip-bubble {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n position: fixed;\n z-index: 100;\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\n `max-content` 只按文案定宽,再用半个视口收口。 */\n width: max-content;\n max-width: 50vw;\n padding: 3px 7px;\n border-radius: var(--dsw-radius-sm);\n background: var(--dsw-alias-tooltip-bg);\n color: var(--dsw-static-neutral-bluish-00);\n font-size: 13px;\n line-height: 20px;\n white-space: pre-line;\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\n overflow-wrap: break-word;\n pointer-events: none;\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\n}\n\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\n.sd-tooltip-label { min-width: 0; }\n\n.sd-tooltip-bubble[data-side='right'] {\n transform: translateY(-50%);\n}\n\n.sd-tooltip-bubble[data-side='bottom'] {\n transform: translateX(-50%);\n}\n\n.sd-tooltip-bubble[data-side='top'] {\n transform: translate(-50%, -100%);\n}\n\n@keyframes sd-tooltip-in {\n from { opacity: 0; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sd-tooltip-bubble {\n animation: none;\n }\n}\n\n/* ────────────────────────── Toast ────────────────────────── */\n\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\n.sd-toast {\n position: fixed;\n top: 40px;\n left: 50%;\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\n z-index: 1100;\n /* 通告不截点击。 */\n pointer-events: none;\n display: flex;\n align-items: center;\n gap: 10px;\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\n width: max-content;\n max-width: min(640px, calc(100vw - 48px));\n padding: 12px 16px;\n border-radius: var(--dsw-radius-lg);\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\n background: var(--dsw-alias-toast-bg);\n color: var(--dsw-alias-toast-label);\n font-size: 14px;\n line-height: 22px;\n box-shadow: var(--dsw-shadow-lv3);\n transform: translateX(-50%);\n animation:\n sd-toast-in 160ms ease-out,\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\n}\n\n.sd-toast-icon {\n display: grid;\n place-items: center;\n flex: none;\n color: var(--dsw-alias-state-warn-label);\n}\n\n.sd-toast-icon-success {\n color: var(--dsw-alias-state-success-primary);\n}\n\n.sd-toast-text {\n min-width: 0;\n}\n\n.sd-toast-action {\n display: inline;\n padding: 0;\n border: 0;\n background: none;\n color: var(--dsw-static-deepseek-400);\n font: inherit;\n cursor: pointer;\n /* 动作文字与周围文案之间的呼吸位。 */\n margin-inline: 3px;\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\n pointer-events: auto;\n}\n\n.sd-toast-action:hover {\n opacity: 0.8;\n}\n\n@keyframes sd-toast-in {\n from {\n opacity: 0;\n transform: translate(-50%, -6px);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, 0);\n }\n}\n\n@keyframes sd-toast-fade {\n to {\n opacity: 0;\n visibility: hidden;\n }\n}\n\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\n@media (prefers-reduced-motion: reduce) {\n .sd-toast {\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\n }\n}\n\n/* ────────────────────────── 菜单行 ────────────────────────── */\n\n.sd-menu-item-wrap {\n position: relative;\n}\n\n/* 菜单格:紧凑文字、图标与选中间距。 */\n.sd-menu-item {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 34px;\n padding: 6px 8px;\n border: none;\n border-radius: var(--dsw-radius-md);\n background: transparent;\n cursor: pointer;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n}\n\n.sd-menu-item:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\n 浏览器默认的 ring 再叠一层就重了。 */\n.sd-menu-item:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n\n.sd-menu-item:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n.sd-menu-item-icon {\n display: inline-flex;\n flex: none;\n width: 14px;\n height: 14px;\n align-items: center;\n justify-content: center;\n color: var(--dsw-alias-menu-icon);\n}\n\n.sd-menu-item-icon svg {\n width: 14px;\n height: 14px;\n}\n\n.sd-menu-item-label {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\n.sd-menu-item-danger {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sd-menu-item-danger .sd-menu-item-icon {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sd-menu-item-danger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n}\n\n.sd-menu-item-danger:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n outline: none;\n}\n\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\n.sd-menu-separator {\n height: 0.5px;\n margin: 3px 2px;\n background: var(--dsw-alias-border-l2);\n}\n";
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/client/ui/styles.ts
|
|
402
|
+
/**
|
|
403
|
+
* 本插件控件的样式装载:把 `ui.css` 的文本挂成一个 `<style>`,**每个 ui 组件 import 本模块**
|
|
404
|
+
* (副作用导入),所以「组件在屏幕上」必然等于「样式在场」。
|
|
405
|
+
*
|
|
406
|
+
* 为什么是往 DOM 里注 `<style>`、而不是构建期抽成 `.css` 资产:
|
|
407
|
+
* 宿主对客户端插件的样式**本来就有这条一等公民通道**——浏览器半的模块表在**工厂物化**
|
|
408
|
+
* (factory materialization)时会把插件自己注入的 `<style>` 认领归档(真产物原文:*"Claim and
|
|
409
|
+
* inventory the `<style>` tags a factory injected during materialization: preset-emitted tags arrive
|
|
410
|
+
* pre-tagged with `data-plugin`; any untagged tag is claimed for the materializing plugin (HMR
|
|
411
|
+
* bookkeeping)"*),卸载与换代时由它统一摘掉。这就是官方给插件 UI 的处方
|
|
412
|
+
* (`references/ui-plugin.md`:「Component-local styles can render as React elements so unmounting
|
|
413
|
+
* removes them」)。
|
|
414
|
+
*
|
|
415
|
+
* 因此装载点选在**模块作用域**而不是 `apply()` 里:模块作用域正好落在物化那一刻,能进宿主的认领表;
|
|
416
|
+
* 挂在 `apply` 里就晚了一拍。反复装载由 `data-dsh-session-delete` 标记挡住(同一插件在同一文档里
|
|
417
|
+
* 是单例,模块也只物化一次,这一层是给 HMR 换代留下的保险)。
|
|
418
|
+
*
|
|
419
|
+
* **不发布任何全局状态**:官方的 `pointerModality` 会往 `document.documentElement` 写
|
|
420
|
+
* `data-input-modality` 属性,那是宿主自己那份 primitives 的职责(它已经在跑),我们只在本文件里
|
|
421
|
+
* 记一个本地标志位(见 `Tooltip.tsx`)。
|
|
422
|
+
*/
|
|
423
|
+
/** 认领标记:既是宿主 HMR 的归档线索,也是「已经装过」的幂等判据。 */
|
|
424
|
+
const STYLE_MARKER = "dsh-session-delete";
|
|
425
|
+
function install() {
|
|
426
|
+
if (typeof document === "undefined") return;
|
|
427
|
+
const head = document.head;
|
|
428
|
+
if (head === null) return;
|
|
429
|
+
if (head.querySelector(`style[data-dsh-session-delete="${STYLE_MARKER}"]`) !== null) return;
|
|
430
|
+
const element = document.createElement("style");
|
|
431
|
+
element.setAttribute("data-dsh-session-delete", STYLE_MARKER);
|
|
432
|
+
element.textContent = ui_default;
|
|
433
|
+
head.appendChild(element);
|
|
434
|
+
}
|
|
435
|
+
install();
|
|
436
|
+
/**
|
|
437
|
+
* 输入模态:最近一次输入是**指针**还是**键盘**。
|
|
438
|
+
*
|
|
439
|
+
* `Tooltip` 用它挡掉「指针交互之后紧接着到来的 focus」——菜单收起时宿主会把焦点还给触发按钮,
|
|
440
|
+
* 那一拍不该把我们的提示顶出来(官方 `pointerModality()` 的同一条契约,实现也一样:
|
|
441
|
+
* `pointerdown` 置真、`keydown` 置假)。官方还会据此发布 `data-input-modality` 去控制焦点环的
|
|
442
|
+
* 显隐,**那件事不复制**:宿主自己那份 primitives 已经在同一份文档上发布了它,我们再来一份就是
|
|
443
|
+
* 两个写入者抢同一个属性。
|
|
444
|
+
*/
|
|
445
|
+
let pointerModality = false;
|
|
446
|
+
if (typeof window !== "undefined") {
|
|
447
|
+
window.addEventListener("pointerdown", () => {
|
|
448
|
+
pointerModality = true;
|
|
449
|
+
}, true);
|
|
450
|
+
window.addEventListener("keydown", () => {
|
|
451
|
+
pointerModality = false;
|
|
452
|
+
}, true);
|
|
453
|
+
}
|
|
454
|
+
/** 最近一次输入是否来自指针(仅本插件自用)。 */
|
|
455
|
+
function isPointerModality() {
|
|
456
|
+
return pointerModality;
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/client/ui/Button.tsx
|
|
460
|
+
/**
|
|
461
|
+
* `Button`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Button,出处与三条改写规则
|
|
462
|
+
* 见 `ui.css` 头注):`<button type="button">`,`variant` × `size` 两个正交档,可带前导图标。
|
|
463
|
+
*
|
|
464
|
+
* 只收本插件用到的面:`primary` / `ghost` / `outline` 三档与 `md`(36px) / `sm`(28px) 两号;
|
|
465
|
+
* 官方还有 `toolbar` 变体与 `className` 之外的转发面,这里不铺开。
|
|
466
|
+
*
|
|
467
|
+
* **转发 `ref`**:官方把 `ref` 交给原生按钮("for focus management and overlay anchors"),
|
|
468
|
+
* `Modal` 的初始焦点与外壳的对齐测量都吃这个。React 18 的 `forwardRef` 与官方实现同形。
|
|
469
|
+
*/
|
|
470
|
+
const Button = (0, react.forwardRef)(function Button({ variant = "ghost", size = "md", icon, className, children, ...rest }, ref) {
|
|
471
|
+
const classes = [
|
|
472
|
+
"sd-button",
|
|
473
|
+
`sd-button-${variant}`,
|
|
474
|
+
`sd-button-${size}`,
|
|
475
|
+
className
|
|
476
|
+
].filter((name) => name !== void 0 && name !== "").join(" ");
|
|
477
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
478
|
+
ref,
|
|
479
|
+
type: "button",
|
|
480
|
+
className: classes,
|
|
481
|
+
...rest,
|
|
482
|
+
children: [icon === void 0 || icon === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
483
|
+
className: "sd-button-icon",
|
|
484
|
+
children: icon
|
|
485
|
+
}), children]
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/client/ui/icons.tsx
|
|
490
|
+
/** 六个图标共用的外框(官方每个 artwork 函数里逐字重复的那一段)。 */
|
|
491
|
+
function IconFrame({ size = 16, children }) {
|
|
492
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
493
|
+
width: size,
|
|
494
|
+
height: size,
|
|
495
|
+
viewBox: "0 0 16 16",
|
|
496
|
+
fill: "none",
|
|
497
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
498
|
+
"aria-hidden": "true",
|
|
499
|
+
strokeWidth: 1,
|
|
500
|
+
children
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
function IconTrashOutlineRegular({ size }) {
|
|
504
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
505
|
+
size,
|
|
506
|
+
children: [
|
|
507
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
508
|
+
d: "M1.28149 3.88831H14.7187",
|
|
509
|
+
stroke: "currentColor"
|
|
510
|
+
}),
|
|
511
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
512
|
+
d: "M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833",
|
|
513
|
+
stroke: "currentColor"
|
|
514
|
+
}),
|
|
515
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
516
|
+
d: "M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831",
|
|
517
|
+
stroke: "currentColor"
|
|
518
|
+
}),
|
|
519
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
520
|
+
d: "M6.44946 6.98926V11.1238",
|
|
521
|
+
stroke: "currentColor"
|
|
522
|
+
}),
|
|
523
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
524
|
+
d: "M9.55054 6.98926V11.1238",
|
|
525
|
+
stroke: "currentColor"
|
|
526
|
+
})
|
|
527
|
+
]
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function IconWarningOutlineRegular({ size }) {
|
|
531
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
532
|
+
size,
|
|
533
|
+
children: [
|
|
534
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
535
|
+
d: "M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z",
|
|
536
|
+
stroke: "currentColor"
|
|
537
|
+
}),
|
|
538
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
539
|
+
d: "M8 4.29199V9.79199",
|
|
540
|
+
stroke: "currentColor"
|
|
541
|
+
}),
|
|
542
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
543
|
+
d: "M8 10.708V11.708",
|
|
544
|
+
stroke: "currentColor"
|
|
545
|
+
})
|
|
546
|
+
]
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
function IconRefreshOutlineRegular({ size }) {
|
|
550
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
551
|
+
size,
|
|
552
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
553
|
+
d: "M14.5001 8C14.5 9.28552 14.1188 10.5422 13.4045 11.611C12.6903 12.6799 11.6752 13.5129 10.4875 14.0049C9.29982 14.4968 7.99295 14.6255 6.73212 14.3747C5.4713 14.124 4.31314 13.505 3.4041 12.596C2.49514 11.687 1.87614 10.5288 1.62537 9.26798C1.37459 8.00716 1.50331 6.70028 1.99525 5.51261C2.48719 4.32494 3.32025 3.30981 4.3891 2.59557C5.45795 1.88134 6.71458 1.50008 8.0001 1.5C9.9001 1.5 11.7001 2.3 13.0001 3.6L14.5001 5.1",
|
|
554
|
+
stroke: "currentColor"
|
|
555
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
556
|
+
d: "M14.4999 1.5V5.1H10.8999",
|
|
557
|
+
stroke: "currentColor"
|
|
558
|
+
})]
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function IconFolderOpenOutlineRegular({ size }) {
|
|
562
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
563
|
+
size,
|
|
564
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
565
|
+
d: "M12.3994 13.5986H2.04956C1.49728 13.5986 1.04956 13.1509 1.04956 12.5986V3.40137C1.04956 2.84908 1.49728 2.40137 2.04956 2.40137H4.76632C5.01016 2.40137 5.24561 2.49046 5.42836 2.6519L6.94088 3.98799C7.12364 4.14943 7.35908 4.23852 7.60293 4.23852H12.3994C12.9517 4.23852 13.3994 4.68624 13.3994 5.23852V7.16991",
|
|
566
|
+
stroke: "currentColor"
|
|
567
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
568
|
+
d: "M2.55911 7.93683C2.67584 7.49906 3.07229 7.19446 3.52536 7.19446H13.6491C14.3061 7.19446 14.7846 7.81725 14.6153 8.45209L13.4411 12.856C13.3244 13.2938 12.9279 13.5984 12.4748 13.5984H2.35113C1.69411 13.5984 1.21562 12.9756 1.38489 12.3407L2.55911 7.93683Z",
|
|
569
|
+
stroke: "currentColor"
|
|
570
|
+
})]
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function IconCloseOutlineRegular({ size }) {
|
|
574
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
575
|
+
size,
|
|
576
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
577
|
+
d: "M2.5 2.5L13.5 13.5",
|
|
578
|
+
stroke: "currentColor"
|
|
579
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
580
|
+
d: "M13.5 2.5L2.5 13.5",
|
|
581
|
+
stroke: "currentColor"
|
|
582
|
+
})]
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/** 撤销提示 success 档的前导 glyph(官方 `Toast` 在 `tone="success"` 时自带的那一枚)。 */
|
|
586
|
+
function IconCheckCircleOutlineRegular({ size }) {
|
|
587
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(IconFrame, {
|
|
588
|
+
size,
|
|
589
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
590
|
+
d: "M12.5303 6.53027L8.80273 10.2578C8.54967 10.5109 8.31796 10.7439 8.10645 10.9141C7.88375 11.0932 7.616 11.2602 7.27344 11.3145C7.09229 11.3431 6.90771 11.3431 6.72656 11.3145C6.384 11.2602 6.11625 11.0932 5.89355 10.9141C5.68204 10.7439 5.45033 10.5109 5.19727 10.2578L3.46973 8.53027L4.53027 7.46973L6.25781 9.19727C6.53457 9.47402 6.70036 9.63859 6.83398 9.74609C6.95637 9.84453 6.98241 9.83644 6.96094 9.83301C6.98679 9.83709 7.01321 9.83709 7.03906 9.83301C7.01759 9.83644 7.04363 9.84453 7.16602 9.74609C7.29964 9.63859 7.46543 9.47402 7.74219 9.19727L11.4697 5.46973L12.5303 6.53027Z",
|
|
591
|
+
fill: "currentColor"
|
|
592
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
593
|
+
d: "M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z",
|
|
594
|
+
fill: "currentColor"
|
|
595
|
+
})]
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/client/ui/Modal.tsx
|
|
600
|
+
/**
|
|
601
|
+
* `Modal`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Modal 与 useModalLayer,
|
|
602
|
+
* 出处与三条改写规则见 `ui.css` 头注):整视口蒙层 + 居中卡片,portal 到 `document.body`。
|
|
603
|
+
*
|
|
604
|
+
* **保留下来的行为**(官方"用户真的依赖"的那几条,`references/practices.md` 点名了 Modal 的焦点与
|
|
605
|
+
* Escape):`role="dialog"` + `aria-modal` + `aria-label`(标题即是可及名)、蒙层点击关闭、
|
|
606
|
+
* Escape 关闭、Tab 在框内循环、打开时把焦点送进框(首个 `[data-modal-autofocus]`,没有就首个可聚焦
|
|
607
|
+
* 元素,再没有就框本体)、关闭后把焦点还给打开它的那个元素、自动焦点不留焦点环
|
|
608
|
+
* (`data-dsh-automatic-focus`,宿主主题读它来抑制 outline——键盘导航或 blur 即恢复)。
|
|
609
|
+
*
|
|
610
|
+
* **砍掉的**(本插件用不到,砍掉是为了不摊开官方整个 Modal API):
|
|
611
|
+
* - `description` / `headless` / `className` / `contentClassName` / `onKeyDownCapture` /
|
|
612
|
+
* `backdropBlur` / `shortcutModal`:本插件两个确认框都是"标题 + 正文 + 两个按钮"的同一形状。
|
|
613
|
+
* - **官方的多层栈**(`layers` WeakMap + `closeTopModal` / `isBehindModal`):那是给"对话框里还能再开
|
|
614
|
+
* 对话框、且宿主菜单也在抢 Escape"的场景准备的。本插件同一时刻只会有一个确认框,所以这里只保留它
|
|
615
|
+
* 的**判据本身**——Escape 只在"自己就是文档里最后一个 `[role="dialog"][aria-modal="true"]`
|
|
616
|
+
* 或 `[role="menu"]`"时生效(`isTopLayer`)。这一条不能省:菜单/对话框先后打开时,
|
|
617
|
+
* "谁在最上面谁收 Escape"是用户能看见的规矩。
|
|
618
|
+
* - **输入法合成守卫**(`observeComposition`):本插件的对话框里没有文本输入框,不可能处在合成态。
|
|
619
|
+
*/
|
|
620
|
+
const FOCUSABLE = "button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex=\"0\"]";
|
|
621
|
+
/** 文档序里排在前面的层决定谁拿快捷键:对话框与菜单同一张名单(官方 `modalSelector` 逐字)。 */
|
|
622
|
+
const TOP_LAYER = "[role=\"dialog\"][aria-modal=\"true\"], [role=\"menu\"]";
|
|
623
|
+
/** 这些键一旦按下就不再是"自动焦点",焦点环恢复正常。 */
|
|
624
|
+
const NAVIGATION_KEYS = /* @__PURE__ */ new Set([
|
|
625
|
+
"Tab",
|
|
626
|
+
"ArrowUp",
|
|
627
|
+
"ArrowDown",
|
|
628
|
+
"ArrowLeft",
|
|
629
|
+
"ArrowRight",
|
|
630
|
+
"Home",
|
|
631
|
+
"End"
|
|
632
|
+
]);
|
|
633
|
+
/** 自己是不是文档里最上面的那一层。 */
|
|
634
|
+
function isTopLayer(element) {
|
|
635
|
+
const layers = element.ownerDocument.querySelectorAll(TOP_LAYER);
|
|
636
|
+
return layers.length > 0 && layers[layers.length - 1] === element;
|
|
637
|
+
}
|
|
638
|
+
/** 自动聚焦不留环:宿主主题按 `data-dsh-automatic-focus` 抑制 outline,键盘导航或失焦即摘掉。 */
|
|
639
|
+
function focusWithoutRing(element) {
|
|
640
|
+
const release = () => {
|
|
641
|
+
element.removeAttribute("data-dsh-automatic-focus");
|
|
642
|
+
element.removeEventListener("blur", release);
|
|
643
|
+
element.removeEventListener("keydown", navigate, true);
|
|
644
|
+
};
|
|
645
|
+
const navigate = (event) => {
|
|
646
|
+
if (!event.isComposing && !event.ctrlKey && !event.altKey && !event.metaKey && NAVIGATION_KEYS.has(event.key)) release();
|
|
647
|
+
};
|
|
648
|
+
element.setAttribute("data-dsh-automatic-focus", "");
|
|
649
|
+
element.addEventListener("blur", release);
|
|
650
|
+
element.addEventListener("keydown", navigate, true);
|
|
651
|
+
element.focus();
|
|
652
|
+
if (!element.matches(":focus")) release();
|
|
653
|
+
}
|
|
654
|
+
/** Escape(最上层才生效)与 Tab 环绕;关闭时把焦点还给打开它的元素。 */
|
|
655
|
+
function useModalLayer(dialog, open, onClose) {
|
|
656
|
+
const close = (0, react.useRef)(onClose);
|
|
657
|
+
close.current = onClose;
|
|
658
|
+
(0, react.useLayoutEffect)(() => {
|
|
659
|
+
const element = dialog.current;
|
|
660
|
+
if (!open || element === null) return;
|
|
661
|
+
const doc = element.ownerDocument;
|
|
662
|
+
const previous = doc.activeElement;
|
|
663
|
+
const initial = element.querySelector("[data-modal-autofocus]") ?? element.querySelector(FOCUSABLE) ?? element;
|
|
664
|
+
if (!element.contains(doc.activeElement)) focusWithoutRing(initial);
|
|
665
|
+
const keydown = (event) => {
|
|
666
|
+
if (!isTopLayer(element) || event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return;
|
|
667
|
+
if (event.key === "Escape" && !event.shiftKey) {
|
|
668
|
+
event.preventDefault();
|
|
669
|
+
if (!event.repeat) close.current();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (event.key !== "Tab") return;
|
|
673
|
+
if (doc.activeElement?.closest("[role=\"menu\"]") != null) return;
|
|
674
|
+
const items = [...element.querySelectorAll(FOCUSABLE)].filter((item) => item.closest("[inert], [hidden]") === null);
|
|
675
|
+
const first = items[0] ?? element;
|
|
676
|
+
const last = items[items.length - 1] ?? element;
|
|
677
|
+
const atEdge = event.shiftKey ? doc.activeElement === first : doc.activeElement === last;
|
|
678
|
+
if (doc.activeElement === element || !element.contains(doc.activeElement) || atEdge) {
|
|
679
|
+
event.preventDefault();
|
|
680
|
+
(event.shiftKey ? last : first).focus();
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
doc.addEventListener("keydown", keydown);
|
|
684
|
+
return () => {
|
|
685
|
+
doc.removeEventListener("keydown", keydown);
|
|
686
|
+
if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
|
|
687
|
+
};
|
|
688
|
+
}, [dialog, open]);
|
|
689
|
+
}
|
|
690
|
+
function Modal({ open, onClose, title, closeLabel, footer, children }) {
|
|
691
|
+
const dialog = (0, react.useRef)(null);
|
|
692
|
+
useModalLayer(dialog, open, onClose);
|
|
693
|
+
if (!open) return null;
|
|
694
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
695
|
+
className: "sd-modal-root",
|
|
696
|
+
role: "presentation",
|
|
697
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
698
|
+
className: "sd-modal-mask",
|
|
699
|
+
"aria-hidden": "true",
|
|
700
|
+
onClick: onClose
|
|
701
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
702
|
+
ref: dialog,
|
|
703
|
+
tabIndex: -1,
|
|
704
|
+
className: "sd-modal-dialog",
|
|
705
|
+
role: "dialog",
|
|
706
|
+
"aria-modal": "true",
|
|
707
|
+
"aria-label": title,
|
|
708
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
709
|
+
className: "sd-modal-content",
|
|
710
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
711
|
+
className: "sd-modal-header",
|
|
712
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
713
|
+
className: "sd-modal-title",
|
|
714
|
+
children: title
|
|
715
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
716
|
+
type: "button",
|
|
717
|
+
className: "sd-modal-close",
|
|
718
|
+
"aria-label": closeLabel,
|
|
719
|
+
onClick: onClose,
|
|
720
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCloseOutlineRegular, { size: 14 })
|
|
721
|
+
})]
|
|
722
|
+
}), children === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
723
|
+
className: "sd-modal-body",
|
|
724
|
+
children
|
|
725
|
+
})]
|
|
726
|
+
}), footer === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
727
|
+
className: "sd-modal-footer",
|
|
728
|
+
children: footer
|
|
729
|
+
})]
|
|
730
|
+
})]
|
|
731
|
+
}), document.body);
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/client/ui/Tag.tsx
|
|
735
|
+
const TONE_CLASS = { danger: "sd-tag-danger" };
|
|
736
|
+
function Tag({ tone, children }) {
|
|
737
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
738
|
+
className: `sd-tag ${TONE_CLASS[tone]}`,
|
|
739
|
+
children
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/client/ui/Toast.tsx
|
|
744
|
+
/**
|
|
745
|
+
* `Toast`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Toast,出处与三条改写规则
|
|
746
|
+
* 见 `ui.css` 头注):顶部居中的一次性横幅——滑入、全不透明停留、淡出,然后回调 `onDone` 让 owner
|
|
747
|
+
* 卸载它。用 body portal 渲染,免得被有 transform / filter 的祖先困在它的盒子里。
|
|
748
|
+
*
|
|
749
|
+
* **保留下来的行为**:
|
|
750
|
+
* - 时长只有一个来源:`holdMs` 同时驱动卸载计时器与样式表的淡出延迟(后者以 `--dsh-toast-hold`
|
|
751
|
+
* 自定义属性读它)⇒ 两者不可能再对不上、把横幅卡在淡出中途卸载。
|
|
752
|
+
* - 同一个 `holdMs` 下,父组件重渲染**不延长**寿命(`onDone` 走 ref,不进 effect 依赖)。
|
|
753
|
+
* - 完成回调拿到的永远是最新的 `onDone`;已经淡完的动作不再接输入(CSS 里那张表面本就穿透点击)。
|
|
754
|
+
* - `tone="success"` 自带官方那枚圆圈对勾(此时忽略传入的 `icon`)。
|
|
755
|
+
*
|
|
756
|
+
* **砍掉的**:`anchor`(把横幅对齐到某个元素的水平中心——本插件的撤销提示就是窗口居中)、
|
|
757
|
+
* `actions[].prefix`(动作文案前的连接词,本插件只用「撤销」两个字)。**没砍 `text`/`icon`/`tone`/
|
|
758
|
+
* `holdMs`/`actions`/`onDone`**:唯一的调用点这几样都在用。
|
|
759
|
+
*/
|
|
760
|
+
/** 不指定时全不透明的停留时长。 */
|
|
761
|
+
const DEFAULT_HOLD_MS = 3e3;
|
|
762
|
+
/** 淡出时长。必须与样式表里 `sd-toast-fade` 的时长一致。 */
|
|
763
|
+
const FADE_MS = 1e3;
|
|
764
|
+
function Toast({ text, icon, tone, holdMs = DEFAULT_HOLD_MS, actions, onDone }) {
|
|
765
|
+
const latestOnDone = (0, react.useRef)(onDone);
|
|
766
|
+
(0, react.useLayoutEffect)(() => {
|
|
767
|
+
latestOnDone.current = onDone;
|
|
768
|
+
}, [onDone]);
|
|
769
|
+
(0, react.useEffect)(() => {
|
|
770
|
+
const timer = setTimeout(() => {
|
|
771
|
+
latestOnDone.current();
|
|
772
|
+
}, holdMs + FADE_MS);
|
|
773
|
+
return () => {
|
|
774
|
+
clearTimeout(timer);
|
|
775
|
+
};
|
|
776
|
+
}, [holdMs]);
|
|
777
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
778
|
+
className: "sd-toast",
|
|
779
|
+
role: "alert",
|
|
780
|
+
style: { "--dsh-toast-hold": `${String(holdMs)}ms` },
|
|
781
|
+
children: [tone === "success" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
782
|
+
className: "sd-toast-icon sd-toast-icon-success",
|
|
783
|
+
"aria-hidden": "true",
|
|
784
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckCircleOutlineRegular, {})
|
|
785
|
+
}) : icon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
786
|
+
className: "sd-toast-icon",
|
|
787
|
+
"aria-hidden": "true",
|
|
788
|
+
children: icon
|
|
789
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
790
|
+
className: "sd-toast-text",
|
|
791
|
+
children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
792
|
+
type: "button",
|
|
793
|
+
className: "sd-toast-action",
|
|
794
|
+
onClick: action.onClick,
|
|
795
|
+
children: action.label
|
|
796
|
+
}, action.label))]
|
|
797
|
+
})]
|
|
798
|
+
}), document.body);
|
|
799
|
+
}
|
|
800
|
+
//#endregion
|
|
801
|
+
//#region src/client/undo-toast.tsx
|
|
802
|
+
/**
|
|
803
|
+
* 撤销提示(设计稿 §7 状态 ③):删除成功后一条可撤销的横幅。
|
|
804
|
+
*
|
|
805
|
+
* 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
|
|
806
|
+
* (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
|
|
807
|
+
* 宿主的官方配方同形——会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的
|
|
808
|
+
* `workspace.row-toast` 读它(asar 原文:`inject("shell.overlay", … id: "workspace.row-toast" …
|
|
809
|
+
* inject: rowToastInjected … }, RowActionToast)`,而
|
|
810
|
+
* `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
|
|
811
|
+
* 本文件就是那份 store + 那个常驻组件。
|
|
812
|
+
*
|
|
813
|
+
* 状态源用 React 自带的 `useSyncExternalStore`——不引 `@deepseek-ai/dsh-client-store`
|
|
814
|
+
* (它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
|
|
815
|
+
*/
|
|
816
|
+
let notice = null;
|
|
817
|
+
let nextSeq$1 = 0;
|
|
818
|
+
const listeners$1 = /* @__PURE__ */ new Set();
|
|
819
|
+
const emit$1 = () => {
|
|
820
|
+
for (const listener of listeners$1) listener();
|
|
821
|
+
};
|
|
822
|
+
/** 删除成功后发布一条撤销提示。 */
|
|
823
|
+
function publishUndoNotice(input) {
|
|
824
|
+
notice = {
|
|
825
|
+
seq: nextSeq$1 += 1,
|
|
826
|
+
entryId: input.entryId,
|
|
827
|
+
title: input.title
|
|
828
|
+
};
|
|
829
|
+
emit$1();
|
|
830
|
+
}
|
|
831
|
+
/** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
|
|
832
|
+
function clearUndoNotice() {
|
|
833
|
+
if (notice === null) return;
|
|
834
|
+
notice = null;
|
|
835
|
+
emit$1();
|
|
836
|
+
}
|
|
837
|
+
function getUndoNotice() {
|
|
838
|
+
return notice;
|
|
839
|
+
}
|
|
840
|
+
function subscribe$1(listener) {
|
|
841
|
+
listeners$1.add(listener);
|
|
842
|
+
return () => {
|
|
843
|
+
listeners$1.delete(listener);
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/** 供组件订阅。 */
|
|
847
|
+
function useUndoNotice() {
|
|
848
|
+
return (0, react.useSyncExternalStore)(subscribe$1, getUndoNotice, getUndoNotice);
|
|
849
|
+
}
|
|
850
|
+
/** 提示停留时长。设计稿 §7 定死:**6 秒**(Toast 自己的默认是 3 秒)。 */
|
|
851
|
+
const UNDO_HOLD_MS = 6e3;
|
|
852
|
+
/** 撤销:`restore(entryId)`;失败就把原因换到同一条横幅上,不静默。 */
|
|
853
|
+
async function undo(deps, current) {
|
|
854
|
+
if (current.entryId === "") return;
|
|
855
|
+
const outcome = await callRemote(() => deps.restore(current.entryId));
|
|
856
|
+
if (outcome.ok) {
|
|
857
|
+
invalidateTrash();
|
|
858
|
+
clearUndoNotice();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
notice = {
|
|
862
|
+
...current,
|
|
863
|
+
seq: nextSeq$1 += 1,
|
|
864
|
+
failure: outcome.failure.message
|
|
865
|
+
};
|
|
866
|
+
emit$1();
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
|
|
870
|
+
* pill all belong here",见 asar 里 `shell.overlay` 的声明):没有待撤销的事时什么都不渲染。
|
|
871
|
+
*/
|
|
872
|
+
function UndoToast({ deps, t = zhTranslate }) {
|
|
873
|
+
const current = useUndoNotice();
|
|
874
|
+
if (current === null) return null;
|
|
875
|
+
const { seq, entryId, title, failure } = current;
|
|
876
|
+
if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
|
|
877
|
+
text: failure,
|
|
878
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 }),
|
|
879
|
+
holdMs: UNDO_HOLD_MS,
|
|
880
|
+
onDone: clearUndoNotice
|
|
881
|
+
}, seq);
|
|
882
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
|
|
883
|
+
tone: "success",
|
|
884
|
+
text: t("undo.moved", { title: title === "" ? t("undo.session") : title }),
|
|
885
|
+
holdMs: UNDO_HOLD_MS,
|
|
886
|
+
actions: entryId === "" ? void 0 : [{
|
|
887
|
+
label: t("undo.undo"),
|
|
888
|
+
onClick: () => void undo(deps, current)
|
|
889
|
+
}],
|
|
890
|
+
onDone: clearUndoNotice
|
|
891
|
+
}, seq);
|
|
892
|
+
}
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/client/delete-confirm.tsx
|
|
895
|
+
/**
|
|
896
|
+
* 「删除」的确认框——**挂在 `shell.overlay` 上的宿主组件**,加它读的那个模块级现场。
|
|
897
|
+
*
|
|
898
|
+
* 为什么确认框不能住在菜单行里(这是本文件存在的唯一理由):**宿主的菜单行随菜单一起卸载**。
|
|
899
|
+
* 真产物 `Menu` 的渲染是
|
|
900
|
+
* `const list = open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`
|
|
901
|
+
* (asar @22074291 逐字;`children` 就是本插件那一行所在的槽位投影)——`open === false` 时
|
|
902
|
+
* **槽位行根本不渲染**,行连同它的整棵子树卸载。而按官方契约,按下这一行先 `setMenuOpen(false)`
|
|
903
|
+
* 收起菜单正是正解(官方 README 的第三方行示例逐字 `onSelect={() => { setMenuOpen(false); … }}`,
|
|
904
|
+
* 宿主自己的 pin / rename / fork / archive 四行同样这么做)。两条一起成立 ⇒ 确认框若住在行里,
|
|
905
|
+
* 它会在菜单收起的那一帧被**连坐卸载**(`createPortal` 只改 DOM 归属,**不改 React 生命周期**),
|
|
906
|
+
* 真机上「移入回收站」永远点不到。
|
|
907
|
+
*
|
|
908
|
+
* 所以按宿主自己的配方把"请求"与"确认"拆开(同一条路:宿主对会话行的撤销提示就是这么做的,
|
|
909
|
+
* `ctx.slots.inject("shell.overlay", … id: "workspace.row-toast" …, RowActionToast)`):
|
|
910
|
+
*
|
|
911
|
+
* - **菜单行只发请求**:`DeleteMenuItem` 把 `{ sessionId, title }` 写进本文件的模块级现场;
|
|
912
|
+
* - **确认框由本文件的宿主渲染与提交**:`DeleteConfirmHost` 挂在 `shell.overlay` 上,从现场读请求,
|
|
913
|
+
* 自己持有 busy / failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
|
|
914
|
+
* 整条链路都在菜单子树之外,菜单关不关都不影响它。
|
|
915
|
+
*
|
|
916
|
+
* 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
|
|
917
|
+
* 见 `undo-toast.tsx`),状态源用 React 自带的 `useSyncExternalStore`——不为此引新的状态库。
|
|
918
|
+
*/
|
|
919
|
+
/**
|
|
920
|
+
* 被操作对象那张卡。`Modal`(`ui/Modal.tsx`,照官方抄的)的 `.sd-modal-body` 不设字号也不给层级 ⇒ 裸摆一行会话标题会和下面那句
|
|
921
|
+
* 提示**同字同色**,读不出"这才是要被删的东西"。配方与「彻底删除」框里那张摘要卡同一套(宿主设置面
|
|
922
|
+
* 的卡片 token:`bg-layer-2` + `border-l4` + `radius-lg`),两个对话框因此同形。
|
|
923
|
+
* 浅色下 `bg-layer-2` 与对话框自身同色,分隔全靠那根描边——宿主自己的卡片在浅色下也是这样。
|
|
924
|
+
*/
|
|
925
|
+
const TARGET_CARD = {
|
|
926
|
+
padding: "10px 12px",
|
|
927
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
928
|
+
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
929
|
+
borderRadius: "var(--dsw-radius-lg)"
|
|
930
|
+
};
|
|
931
|
+
let request = null;
|
|
932
|
+
let nextSeq = 0;
|
|
933
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
934
|
+
const emit = () => {
|
|
935
|
+
for (const listener of listeners) listener();
|
|
936
|
+
};
|
|
937
|
+
/** 菜单行调用:请求删除这个会话(确认框由 overlay 上的宿主渲染)。 */
|
|
938
|
+
function requestDelete(input) {
|
|
939
|
+
request = {
|
|
940
|
+
seq: nextSeq += 1,
|
|
941
|
+
sessionId: input.sessionId,
|
|
942
|
+
title: input.title
|
|
943
|
+
};
|
|
944
|
+
emit();
|
|
945
|
+
}
|
|
946
|
+
/** 收起确认框(用户点「取消」/「关闭」、或删除成功后收工)。 */
|
|
947
|
+
function clearDeleteRequest() {
|
|
948
|
+
if (request === null) return;
|
|
949
|
+
request = null;
|
|
950
|
+
emit();
|
|
951
|
+
}
|
|
952
|
+
function getDeleteRequest() {
|
|
953
|
+
return request;
|
|
954
|
+
}
|
|
955
|
+
function subscribe(listener) {
|
|
956
|
+
listeners.add(listener);
|
|
957
|
+
return () => {
|
|
958
|
+
listeners.delete(listener);
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/** 供宿主组件订阅。 */
|
|
962
|
+
function useDeleteRequest() {
|
|
963
|
+
return (0, react.useSyncExternalStore)(subscribe, getDeleteRequest, getDeleteRequest);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 常驻的确认框宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
|
|
967
|
+
* pill all belong here"):没有待确认的删除时什么都不渲染。
|
|
968
|
+
*/
|
|
969
|
+
function DeleteConfirmHost({ deps, t = zhTranslate }) {
|
|
970
|
+
const current = useDeleteRequest();
|
|
971
|
+
if (current === null) return null;
|
|
972
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfirmDialog, {
|
|
973
|
+
request: current,
|
|
974
|
+
deps,
|
|
975
|
+
t
|
|
976
|
+
}, current.seq);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* 确认框本体。视图与文案与设计稿 §7 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
|
|
980
|
+
* 那个 primary 在宿主浅色下是近黑、深色下近白,**从来不是蓝**)。
|
|
981
|
+
* 失败面**不吞**:原文以 `Tag tone="danger"` 摆进对话框,且**确认失败后不关框**,
|
|
982
|
+
* 用户可以再点一次(不是一次失败就永久锁死)。
|
|
983
|
+
*/
|
|
984
|
+
function ConfirmDialog({ request: current, deps, t }) {
|
|
985
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
986
|
+
const [failure, setFailure] = (0, react.useState)(null);
|
|
987
|
+
const confirm = async () => {
|
|
988
|
+
setBusy(true);
|
|
989
|
+
setFailure(null);
|
|
990
|
+
const outcome = await callRemote(() => deps.delete(current.sessionId));
|
|
991
|
+
setBusy(false);
|
|
992
|
+
if (!outcome.ok) {
|
|
993
|
+
setFailure(outcome.failure.message);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
clearDeleteRequest();
|
|
997
|
+
invalidateTrash();
|
|
998
|
+
publishUndoNotice({
|
|
999
|
+
entryId: outcome.value.id,
|
|
1000
|
+
title: current.title
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
1004
|
+
open: true,
|
|
1005
|
+
onClose: clearDeleteRequest,
|
|
1006
|
+
title: t("dialog.title"),
|
|
1007
|
+
closeLabel: t("common.close"),
|
|
1008
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1009
|
+
variant: "outline",
|
|
1010
|
+
onClick: clearDeleteRequest,
|
|
1011
|
+
children: t("common.cancel")
|
|
1012
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1013
|
+
variant: "primary",
|
|
1014
|
+
disabled: busy,
|
|
1015
|
+
onClick: () => void confirm(),
|
|
1016
|
+
children: t("dialog.confirm")
|
|
1017
|
+
})] }),
|
|
1018
|
+
children: [
|
|
1019
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1020
|
+
style: TARGET_CARD,
|
|
1021
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1022
|
+
"data-dsh-session-delete": "target",
|
|
1023
|
+
style: {
|
|
1024
|
+
fontSize: 14,
|
|
1025
|
+
fontWeight: 500,
|
|
1026
|
+
lineHeight: "20px",
|
|
1027
|
+
overflow: "hidden",
|
|
1028
|
+
textOverflow: "ellipsis",
|
|
1029
|
+
whiteSpace: "nowrap"
|
|
1030
|
+
},
|
|
1031
|
+
children: current.title === "" ? t("common.untitled") : current.title
|
|
1032
|
+
})
|
|
1033
|
+
}),
|
|
1034
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("dialog.hint") }),
|
|
1035
|
+
failure === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tag, {
|
|
1036
|
+
tone: "danger",
|
|
1037
|
+
children: failure
|
|
1038
|
+
})
|
|
1039
|
+
]
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/client/ui/MenuItemButton.tsx
|
|
1044
|
+
function MenuItemButton({ children, icon, disabled = false, danger = false, separatorBefore = false, onSelect }) {
|
|
1045
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1046
|
+
className: "sd-menu-item-wrap",
|
|
1047
|
+
children: [separatorBefore ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1048
|
+
className: "sd-menu-separator",
|
|
1049
|
+
role: "separator"
|
|
1050
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1051
|
+
type: "button",
|
|
1052
|
+
role: "menuitem",
|
|
1053
|
+
className: danger ? "sd-menu-item sd-menu-item-danger" : "sd-menu-item",
|
|
1054
|
+
disabled,
|
|
1055
|
+
onClick: onSelect,
|
|
1056
|
+
children: [icon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1057
|
+
className: "sd-menu-item-icon",
|
|
1058
|
+
children: icon
|
|
1059
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1060
|
+
className: "sd-menu-item-label",
|
|
1061
|
+
children
|
|
1062
|
+
})]
|
|
1063
|
+
})]
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/client/ui/Tooltip.tsx
|
|
1068
|
+
/**
|
|
1069
|
+
* `Tooltip`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Tooltip,出处与三条改写
|
|
1070
|
+
* 规则见 `ui.css` 头注):把提示挂在锚点元素上,hover / 键盘 focus 才出。
|
|
1071
|
+
*
|
|
1072
|
+
* **保留下来的行为**:`side` 三个方位的定位(`right` 取锚点右缘再让出 10px,`bottom`/`top` 居中并按
|
|
1073
|
+
* `gap` 让位)、视口边界收口(`edgeMargin=12`、`bottom`/`top` 装不下就翻面)、等 `ResizeObserver`
|
|
1074
|
+
* 量到气泡尺寸再显示(所以不会先闪一个错位的框)、指针交互之后的 focus 不弹(见 `styles.ts` 的
|
|
1075
|
+
* `isPointerModality`)、`portal` 时挂到 `document.body`(宿主菜单有裁剪与层叠上下文,锚点在菜单里
|
|
1076
|
+
* 时必须 portal,否则气泡被裁掉)。
|
|
1077
|
+
*
|
|
1078
|
+
* **砍掉的**:`shortcutKeys`(本插件没有快捷键)、`openOnClick`/`pinned` 与随之而来的
|
|
1079
|
+
* `useDismissOnOutsidePointer`、`aria-describedby` 关联(官方只在 pinned 时挂)、
|
|
1080
|
+
* `TooltipSuppression`(那是给 HoverCard 预览用的互相压制)、`delayMs`/`focusDelayMs`/`maxWidth`
|
|
1081
|
+
* (本插件唯一一处用的是零延迟默认值)。**没有砍掉 `side`/`gap`**:定位数学是三行分支,
|
|
1082
|
+
* 留着才与官方同形。
|
|
1083
|
+
*
|
|
1084
|
+
* **与官方的一处差异**:官方把锚点自己的 `ref` 与它的 `ref` 合并后一起挂;这里要求锚点**不带
|
|
1085
|
+
* 自己的 ref**(本插件唯一的调用点是一个裸 `<span>`),所以直接挂。要合并再补。
|
|
1086
|
+
*/
|
|
1087
|
+
function Tooltip({ label, side = "right", gap = 8, disabled = false, portal = false, children }) {
|
|
1088
|
+
const anchor = (0, react.useRef)(null);
|
|
1089
|
+
const bubble = (0, react.useRef)(null);
|
|
1090
|
+
const [rect, setRect] = (0, react.useState)(null);
|
|
1091
|
+
const visible = rect !== null && !disabled;
|
|
1092
|
+
(0, react.useEffect)(() => {
|
|
1093
|
+
const element = bubble.current;
|
|
1094
|
+
if (rect === null || !visible || element === null) return;
|
|
1095
|
+
const EDGE_MARGIN = 12;
|
|
1096
|
+
let size;
|
|
1097
|
+
let placement = side;
|
|
1098
|
+
const fit = () => {
|
|
1099
|
+
if (size === void 0) return;
|
|
1100
|
+
const { inlineSize: width, blockSize: height } = size;
|
|
1101
|
+
const offset = side === "right" ? 0 : width / 2;
|
|
1102
|
+
const left = Math.max(EDGE_MARGIN, Math.min(rect.x - offset, window.innerWidth - EDGE_MARGIN - width));
|
|
1103
|
+
const fitsBelow = rect.bottom + gap + height <= window.innerHeight - EDGE_MARGIN;
|
|
1104
|
+
const fitsAbove = rect.top - gap - height >= EDGE_MARGIN;
|
|
1105
|
+
if (placement === "bottom" && !fitsBelow && fitsAbove) placement = "top";
|
|
1106
|
+
else if (placement === "top" && !fitsAbove && fitsBelow) placement = "bottom";
|
|
1107
|
+
element.style.left = `${left + offset}px`;
|
|
1108
|
+
element.style.top = `${placement === "right" ? (rect.top + rect.bottom) / 2 : placement === "top" ? rect.top - gap : rect.bottom + gap}px`;
|
|
1109
|
+
element.dataset.side = placement;
|
|
1110
|
+
element.style.visibility = "visible";
|
|
1111
|
+
};
|
|
1112
|
+
const observer = new ResizeObserver((entries) => {
|
|
1113
|
+
size = entries[0]?.borderBoxSize[0];
|
|
1114
|
+
fit();
|
|
1115
|
+
});
|
|
1116
|
+
observer.observe(element, { box: "border-box" });
|
|
1117
|
+
window.addEventListener("resize", fit);
|
|
1118
|
+
return () => {
|
|
1119
|
+
observer.disconnect();
|
|
1120
|
+
window.removeEventListener("resize", fit);
|
|
1121
|
+
};
|
|
1122
|
+
}, [
|
|
1123
|
+
gap,
|
|
1124
|
+
rect,
|
|
1125
|
+
side,
|
|
1126
|
+
visible
|
|
1127
|
+
]);
|
|
1128
|
+
const show = (0, react.useCallback)(() => {
|
|
1129
|
+
const element = anchor.current;
|
|
1130
|
+
/* v8 ignore next -- 事件是在克隆出来的锚点上冒泡上来的,触发时 ref 必已挂上 */
|
|
1131
|
+
if (element === null) return;
|
|
1132
|
+
const box = element.getBoundingClientRect();
|
|
1133
|
+
setRect({
|
|
1134
|
+
x: side === "right" ? box.right + 10 : box.left + box.width / 2,
|
|
1135
|
+
top: box.top,
|
|
1136
|
+
bottom: box.bottom
|
|
1137
|
+
});
|
|
1138
|
+
}, [side]);
|
|
1139
|
+
const withdraw = (0, react.useCallback)(() => {
|
|
1140
|
+
setRect(null);
|
|
1141
|
+
}, []);
|
|
1142
|
+
(0, react.useEffect)(() => {
|
|
1143
|
+
if (disabled) setRect(null);
|
|
1144
|
+
}, [disabled]);
|
|
1145
|
+
/** 锚点自己带的处理器要照常跑(我们只加不减)——官方也是先 `children.props.onX?.(e)` 再接管。 */
|
|
1146
|
+
const inner = children.props;
|
|
1147
|
+
const anchorElement = (0, react.cloneElement)(children, {
|
|
1148
|
+
ref: (element) => {
|
|
1149
|
+
anchor.current = element;
|
|
1150
|
+
},
|
|
1151
|
+
onMouseEnter: (event) => {
|
|
1152
|
+
inner.onMouseEnter?.(event);
|
|
1153
|
+
if (!disabled) show();
|
|
1154
|
+
},
|
|
1155
|
+
onMouseLeave: (event) => {
|
|
1156
|
+
inner.onMouseLeave?.(event);
|
|
1157
|
+
withdraw();
|
|
1158
|
+
},
|
|
1159
|
+
onClick: (event) => {
|
|
1160
|
+
inner.onClick?.(event);
|
|
1161
|
+
withdraw();
|
|
1162
|
+
},
|
|
1163
|
+
onFocus: (event) => {
|
|
1164
|
+
inner.onFocus?.(event);
|
|
1165
|
+
if (isPointerModality()) return;
|
|
1166
|
+
if (!disabled) show();
|
|
1167
|
+
},
|
|
1168
|
+
onBlur: (event) => {
|
|
1169
|
+
inner.onBlur?.(event);
|
|
1170
|
+
withdraw();
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
const content = visible ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1174
|
+
ref: bubble,
|
|
1175
|
+
className: "sd-tooltip-bubble",
|
|
1176
|
+
"data-side": side,
|
|
1177
|
+
"data-portal": portal ? "true" : void 0,
|
|
1178
|
+
style: {
|
|
1179
|
+
left: rect.x,
|
|
1180
|
+
top: rect.top,
|
|
1181
|
+
visibility: "hidden"
|
|
1182
|
+
},
|
|
1183
|
+
role: "tooltip",
|
|
1184
|
+
children: label === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1185
|
+
className: "sd-tooltip-label",
|
|
1186
|
+
children: label
|
|
1187
|
+
})
|
|
1188
|
+
}) : null;
|
|
1189
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [anchorElement, content === null ? null : portal ? (0, react_dom.createPortal)(content, document.body) : content] });
|
|
1190
|
+
}
|
|
1191
|
+
//#endregion
|
|
1192
|
+
//#region src/client/delete-menu-item.tsx
|
|
1193
|
+
/**
|
|
1194
|
+
* 会话条目「…」菜单里的「删除」行(设计稿 §7 状态 ①)。
|
|
1195
|
+
*
|
|
1196
|
+
* 这一行**只做两件事**:把「要删这个会话」写进 `delete-confirm.tsx` 的模块级现场,然后让菜单
|
|
1197
|
+
* owner 把菜单收起来(确认框不许叠在收起的菜单上——设计稿 §7 状态 ② 画的就是菜单已经收起之后
|
|
1198
|
+
* 的画面)。**确认框不在这棵子树里**——
|
|
1199
|
+
* 原因见 `delete-confirm.tsx` 的头注:宿主的菜单行随菜单一起卸载(真产物 `Menu` 是
|
|
1200
|
+
* `open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`,`open === false`
|
|
1201
|
+
* 时槽位行根本不渲染),住在行里的确认框会被这次的卸载连坐带走,真机上「移入回收站」永远点不到。
|
|
1202
|
+
* 所以"请求"与"确认"分居两处:请求走模块级现场,确认框住在 `shell.overlay` 上。
|
|
1203
|
+
*
|
|
1204
|
+
* 另外两件事说清:
|
|
1205
|
+
*
|
|
1206
|
+
* 1. **不自建菜单壳**。菜单行由宿主既有菜单容器渲染,我们只贡献一行 `MenuItemButton`
|
|
1207
|
+
* (`ui/MenuItemButton.tsx`,照官方同名组件抄的),由 `index.tsx` 注册进槽位
|
|
1208
|
+
* `sidebar.workspaces.session.menu.item`(该槽位只投影 `sessionId` / `displayTitle` 两个数据
|
|
1209
|
+
* 加 `menuOpenState` / `shortcuts` 两个 hook,见 asar @46391872 的槽位声明原文)。
|
|
1210
|
+
* 菜单的**开合归 owner**:槽位把 `useMenuOpenState` 交给这一行(`index.tsx` 的 `DeleteRow` 接住后
|
|
1211
|
+
* 当 `dismissMenu` 传下来),按下时先发请求、再收菜单——与官方 README 的示例同序
|
|
1212
|
+
* (`onSelect={() => { setMenuOpen(false); … }}`)。
|
|
1213
|
+
* 2. **运行中置灰**(设计稿 §7「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
|
|
1214
|
+
* `MenuItemButton` 只把 `disabled` 落在原生按钮属性上(真产物原文 `<button type="button"
|
|
1215
|
+
* role="menuitem" … disabled={disabled}>`),jest-dom 的 `toBeDisabled()` 也只认这个属性;
|
|
1216
|
+
* `aria-disabled` 在这条链上既渲染不出来也不算数。原因文案用 `ui/Tooltip.tsx`(照官方 Tooltip 抄的,
|
|
1217
|
+
* 行为同款:hover / 键盘 focus 才出)挂在行上
|
|
1218
|
+
* (anchor 用一层 `<span>`:`MenuItemButton` 是普通函数组件、接不住 `Tooltip` 注入的 ref)。
|
|
1219
|
+
*/
|
|
1220
|
+
function DeleteMenuItem({ sessionId, title, live = false, dismissMenu, t = zhTranslate }) {
|
|
1221
|
+
const select = () => {
|
|
1222
|
+
requestDelete({
|
|
1223
|
+
sessionId,
|
|
1224
|
+
title
|
|
1225
|
+
});
|
|
1226
|
+
dismissMenu?.();
|
|
1227
|
+
};
|
|
1228
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
|
|
1229
|
+
label: t("menu.live"),
|
|
1230
|
+
side: "right",
|
|
1231
|
+
portal: true,
|
|
1232
|
+
disabled: !live,
|
|
1233
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItemButton, {
|
|
1234
|
+
separatorBefore: true,
|
|
1235
|
+
danger: true,
|
|
1236
|
+
disabled: live,
|
|
1237
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 14 }),
|
|
1238
|
+
onSelect: select,
|
|
1239
|
+
children: t("menu.delete")
|
|
1240
|
+
}) })
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/client/trash-entry.tsx
|
|
1245
|
+
/**
|
|
1246
|
+
* 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;设计稿 §7 状态 ④ 的入口那一行)。
|
|
1247
|
+
*
|
|
1248
|
+
* **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
|
|
1249
|
+
* onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
|
|
1250
|
+
* active }, { only: id })`)加一个 `.panelTitle`(**名字从注册元数据来**:宿主 `syncPanels` 逐字
|
|
1251
|
+
* `label: resolveSlotLabel(options.label) ?? id`,asar @44795361),外加折叠时的 `Tooltip`、
|
|
1252
|
+
* hover 与选中底色(asar @44783481 逐字)。所以这里**不能**有按钮、文字或角标:多画一样,
|
|
1253
|
+
* 这一行就与「插件 / 任务 / 小说」三行错位。
|
|
1254
|
+
*
|
|
1255
|
+
* `active`(本行是否被中央列选中)由 owner 一并给出,**刻意不用**:选中呈现已经是宿主行自己的事
|
|
1256
|
+
* (`aria-current` + `.panelActive`),我们再加一层就会出现两种选中态。
|
|
1257
|
+
*
|
|
1258
|
+
* 计数角标**不做**(2026-09-30 裁决):这个座位只给一个 16/18px 的 glyph 盒(`.panelGlyph{flex:none}`),
|
|
1259
|
+
* 角标只能溢出到行外(本仓量过:15px 的角标右缘超出 glyph 7px、顶缘高出行顶 5px,宽态就压到上一行;
|
|
1260
|
+
* rail 态那行是 36px 圆、`padding:0`,更糟)。`label` 虽支持 thunk,但宿主只在**槽位条目变化或
|
|
1261
|
+
* 语言变化**时重读(同上 @44795361),拿它当活计数必然 stale。条数与合计占用改在面板头部给
|
|
1262
|
+
* (见 `trash-panel.tsx`)。
|
|
1263
|
+
*/
|
|
1264
|
+
function TrashPanelIcon({ size = 16 }) {
|
|
1265
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size });
|
|
1266
|
+
}
|
|
1267
|
+
//#endregion
|
|
1268
|
+
//#region src/client/ui/format.ts
|
|
1269
|
+
/** 紧凑的字节数文本(`312B`、`4.2KB`、`1.5MB`、`2.4GB`):整单位,选中单位小于十时带一位小数。 */
|
|
1270
|
+
function fileSizeText(bytes) {
|
|
1271
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1272
|
+
const kb = bytes / 1024;
|
|
1273
|
+
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`;
|
|
1274
|
+
const mb = kb / 1024;
|
|
1275
|
+
if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`;
|
|
1276
|
+
const gb = mb / 1024;
|
|
1277
|
+
return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB`;
|
|
1278
|
+
}
|
|
1279
|
+
const MINUTE_MS = 6e4;
|
|
1280
|
+
const HOUR_MS = 36e5;
|
|
1281
|
+
const DAY_MS = 864e5;
|
|
1282
|
+
/** 时间分桶(`now` / 分钟 / 小时 / 天 / 月 / 年);措辞由调用面自己组(见 `trash-panel.tsx`)。 */
|
|
1283
|
+
function relativeTime(at, now) {
|
|
1284
|
+
const diff = Math.max(0, now - at);
|
|
1285
|
+
if (diff < MINUTE_MS) return {
|
|
1286
|
+
unit: "now",
|
|
1287
|
+
n: 0
|
|
1288
|
+
};
|
|
1289
|
+
if (diff < HOUR_MS) return {
|
|
1290
|
+
unit: "minutes",
|
|
1291
|
+
n: Math.floor(diff / MINUTE_MS)
|
|
1292
|
+
};
|
|
1293
|
+
if (diff < DAY_MS) return {
|
|
1294
|
+
unit: "hours",
|
|
1295
|
+
n: Math.floor(diff / HOUR_MS)
|
|
1296
|
+
};
|
|
1297
|
+
if (diff < 30 * DAY_MS) return {
|
|
1298
|
+
unit: "days",
|
|
1299
|
+
n: Math.floor(diff / DAY_MS)
|
|
1300
|
+
};
|
|
1301
|
+
if (diff < 365 * DAY_MS) return {
|
|
1302
|
+
unit: "months",
|
|
1303
|
+
n: Math.floor(diff / (30 * DAY_MS))
|
|
1304
|
+
};
|
|
1305
|
+
return {
|
|
1306
|
+
unit: "years",
|
|
1307
|
+
n: Math.floor(diff / (365 * DAY_MS))
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/client/trash-panel.tsx
|
|
1312
|
+
/**
|
|
1313
|
+
* 回收站清单面板(设计稿 §7 状态 ④⑤)——**中央面板的本体**(`main` 槽的占用者)。
|
|
1314
|
+
*
|
|
1315
|
+
* 面板自带页面骨架(标题 / 条数与合计 / 刷新 / 滚动区 / 页脚),因为宿主给中央列的那个盒子
|
|
1316
|
+
* (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
|
|
1317
|
+
* `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
|
|
1318
|
+
* 如此。回收站可以很长(本机 85 个会话合计 15M),不自己收滚动就会把整列顶长。
|
|
1319
|
+
*
|
|
1320
|
+
* **2026-10-01 视觉改版:四列表格 → 按项目分组的卡片列表。**三条真机读数决定的:
|
|
1321
|
+
* ① 「原项目」那一列摆的是宿主的 `projectKey`(`--D-develop-GitHub-…--`),96px 的列宽只够露出驱动器
|
|
1322
|
+
* 和半截路径,等于没有信息;② 「删除时间」96px 装不下 `toLocaleString()` ⇒ 折成两行,行高在
|
|
1323
|
+
* 40/56px 之间抖;③ 表格没有行分隔,条数一多就读成一面墙。现在项目名做**组头**(一个项目只出现
|
|
1324
|
+
* 一次,整条给得下),时间走 `relativeTime` 的分桶(**函数是从官方 `primitives` 逐字抄进
|
|
1325
|
+
* `ui/format.ts` 的**,与宿主侧栏同一套桶;措辞按该模块的约定留在 `copy.ts`),大小并到第二行
|
|
1326
|
+
* 元信息里,行与行的分隔交给卡片自己的边框。
|
|
1327
|
+
*
|
|
1328
|
+
* 卡片的底色/描边照抄宿主设置面自己的卡片配方(asar @28440420 逐字
|
|
1329
|
+
* `border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);
|
|
1330
|
+
* border-radius:var(--dsw-radius-xl)`,而 `body` 上这两个别名就解析成 `--dsw-alias-bg-layer-2` 与
|
|
1331
|
+
* `--dsw-alias-border-l4`)。这里**直接写这两个底层 token**而不是那两个 settings 别名:别名挂在宿主
|
|
1332
|
+
* 应用的 `body` 上、不是随包发布的组件面的一部分,底层那两个才是官方组件 CSS 自己在用的。圆角取
|
|
1333
|
+
* `--dsw-radius-lg`(16px)而不是卡片面的 `xl`(20px)——行高只有 60px 上下,20px 会把卡片吹成胶囊。
|
|
1334
|
+
*
|
|
1335
|
+
* 三条界面规则没有因为改版让路:
|
|
1336
|
+
* - **空态**:`回收站是空的`,且底部**不出现**「清空回收站…」(没有东西可清)。
|
|
1337
|
+
* - **失败写在行上**:恢复/彻底删除失败的原因留在那一行(卡片第二行下方,error 色 + 警示 glyph),
|
|
1338
|
+
* 不弹全局错误、不静默跳过;行不消失,按钮可以再点。清单本身读不到时不再是一枚胶囊——单独一块
|
|
1339
|
+
* 把原文摆出来并给「重试」;那时不是空态("读不到"与"空的"是两件事)。两块失败都标了
|
|
1340
|
+
* `role="status"`:读屏走到那里会把它当状态消息念,但**插入即播报并不保证**(`aria-live` 的播报看的是
|
|
1341
|
+
* "已在场的区域里发生了变更",而这两块是连着内容一起冒出来的)。要稳得常驻一个只给读屏看的播报台,
|
|
1342
|
+
* 那件事本机没有 AT 可验,所以留在这里当已知边界,不写成已经做到。
|
|
1343
|
+
* - **彻底删除要二次确认**:单条与清空走同一个确认框,正文明写「之后没有任何副本可以恢复」。
|
|
1344
|
+
* **红色没有 danger 按钮变体**(官方那套 `Button` 只有 primary/ghost/outline/toolbar——我们抄来的
|
|
1345
|
+
* `ui.css` 同样只这三档,宿主主题的 `--dsw-alias-button-*` 里也没有 danger 家族),所以两档危险
|
|
1346
|
+
* 各自照抄宿主自己组红色的两条配方:
|
|
1347
|
+
* - **行内 = 安静的红**:只写 `color:--dsw-alias-state-error-primary`、**不写 background**——宿主设置面
|
|
1348
|
+
* 的行内 danger 按钮 @28442232 逐字就是 `color:…-error-primary;background:0 0;border:none`。不写底
|
|
1349
|
+
* 还有一层硬理由:作者态内联 `background` 永远赢过类规则,而 `ui.css` 里 `.sd-button-ghost:hover` /
|
|
1350
|
+
* `.sd-button-ghost:active` 都是普通类、无 `!important` ⇒ 铺了内联底的按钮**在点错之前不给任何视觉回应**。
|
|
1351
|
+
* - **批量与确认框 = 实心红**:`variant="primary"` 并在元素上就地覆盖 `--dsw-alias-button-primary-fill`
|
|
1352
|
+
* 与 `--dsw-alias-button-primary-hover` 为 `--dsw-alias-state-error-primary`(宿主插件管理页的 danger
|
|
1353
|
+
* 按钮 @21658274 逐字就这么干)。`.sd-button-primary:hover` 的类规则照常生效,红也是主题自洽的实心红。
|
|
1354
|
+
* **层级**:整页只有一块实心红(「清空回收站…」,确认框里那块是它的后续),行内是安静的红——一次
|
|
1355
|
+
* 点错只损失一条,清空才是整页级的损失。(`--dsw-alias-interactive-bg-hover-danger` 是**hover 态**的
|
|
1356
|
+
* 透明红(浅色 5% / 深色 15% alpha;宿主拿它当底的是 @21305622 那条 error **块** `wq12jW_error`,
|
|
1357
|
+
* 不是芯片——`Tag[data-tone='danger']` 的底是 `color-mix(… 10%, transparent)`),当常置底既撑不起
|
|
1358
|
+
* "实心"也不是它的用途,所以这里两处都不用它。)
|
|
1359
|
+
*
|
|
1360
|
+
* 布局只用**结构**内联样式(display/flex/gap/padding/overflow/max-width),**配色与圆角一律走 token**;
|
|
1361
|
+
* **字号是字面量 px**、取宿主同类面的读数(它也这么写,如 `fO69Vq_crumb{font-size:12.5px}`)。结构与
|
|
1362
|
+
* 层级归设计稿 §7(本仓不另存像素稿),皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`
|
|
1363
|
+
* (照官方明文 CSS 抄的),由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——**这是宿主给
|
|
1364
|
+
* 客户端插件的一等公民通道**(模块表会认领工厂物化时注入的 `<style>` 并在卸载/HMR 时摘掉),
|
|
1365
|
+
* 详见那里与设计稿 §10。
|
|
1366
|
+
*
|
|
1367
|
+
* **浅色主题已在真机看过(2026-10-01,用户截图:3 条 / 2 组)**:浅色下 `--dsw-alias-bg-base` 与
|
|
1368
|
+
* `--dsw-alias-bg-layer-2` 是同一个值(都解析成 `bluish-00`;深色才分 `950` / `850`)⇒ 卡片确实只剩
|
|
1369
|
+
* `border-l4` 一根描边与页面分隔,分组靠组头 + 间距站住——读得下来,但比深色薄。宿主自己的 `rowCard`
|
|
1370
|
+
* 是同一配方,所以这是宿主的取舍、不是本面板的偏差。**深色下的新版还没看过**(同一批 token,风险低)。
|
|
1371
|
+
*/
|
|
1372
|
+
/**
|
|
1373
|
+
* 行内的危险动作:只有字色,没有底(见头注——铺内联底会把 `.ghost:hover` 打死)。
|
|
1374
|
+
* 配 `variant="ghost"` 用。
|
|
1375
|
+
*/
|
|
1376
|
+
const ROW_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
|
|
1377
|
+
/**
|
|
1378
|
+
* 整页那唯一一块实心红:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
|
|
1379
|
+
* 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
|
|
1380
|
+
*/
|
|
1381
|
+
const BULK_DANGER = {
|
|
1382
|
+
"--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
|
|
1383
|
+
"--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
|
|
1384
|
+
};
|
|
1385
|
+
/** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处
|
|
1386
|
+
* (隔离宿主就是这么起的),写死会让这条页脚文案在那些环境下说谎。也是页脚那句里的 `{dir}`。 */
|
|
1387
|
+
const TRASH_DIR = "session-trash/";
|
|
1388
|
+
/** 中央列在宽屏上就是整个对话区的宽度;清单不限宽会拉成一条横向的空。 */
|
|
1389
|
+
const COLUMN_MAX_WIDTH = 760;
|
|
1390
|
+
/**
|
|
1391
|
+
* 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
|
|
1392
|
+
*
|
|
1393
|
+
* `boxSizing:'border-box'` **不是装饰**:宿主文档没有全局 border-box reset(220 处 `box-sizing`
|
|
1394
|
+
* 声明全是组件自己带的,如 `.P2izSq_card *`),默认是 content-box ⇒ `height:100%` 再加 20px 上边距
|
|
1395
|
+
* 会溢出 20px,把页脚整块推到中央列底边之外(真机浅色截图上页脚那行贴着窗口下缘就是这个)。
|
|
1396
|
+
*/
|
|
1397
|
+
const PAGE = {
|
|
1398
|
+
display: "flex",
|
|
1399
|
+
flexDirection: "column",
|
|
1400
|
+
boxSizing: "border-box",
|
|
1401
|
+
height: "100%",
|
|
1402
|
+
minWidth: 0,
|
|
1403
|
+
padding: "20px 24px 12px"
|
|
1404
|
+
};
|
|
1405
|
+
/** 限宽的正文列(`margin:'0 auto'` 居中,滚动区在它内部)。 */
|
|
1406
|
+
const COLUMN = {
|
|
1407
|
+
display: "flex",
|
|
1408
|
+
flexDirection: "column",
|
|
1409
|
+
flex: "1 1 auto",
|
|
1410
|
+
minHeight: 0,
|
|
1411
|
+
width: "100%",
|
|
1412
|
+
maxWidth: COLUMN_MAX_WIDTH,
|
|
1413
|
+
margin: "0 auto"
|
|
1414
|
+
};
|
|
1415
|
+
/**
|
|
1416
|
+
* 滚动区。
|
|
1417
|
+
*
|
|
1418
|
+
* **滚动条不需要注入任何 CSS 就已经是宿主的**:宿主在 `body` 上给出一组量(`--dsh-scrollbar-width:5px`、
|
|
1419
|
+
* thumb 圆角 999、色走 `--dsh-scrollbar-thumb`,默认 `--dsw-alias-scrollbar-bg-l1`),再用**全局**
|
|
1420
|
+
* `::-webkit-scrollbar*` 规则消费它们(asar @45064180 起,`ui-theme/src/styles/scrollbar.css`)
|
|
1421
|
+
* ⇒ 插件这里一个 `overflow:auto` 拿到的就是 5px 圆角 token 条,不是 Windows 默认的 15px 灰条。
|
|
1422
|
+
* 下面把 thumb 提到 `-l2` 那一档,与宿主自己的长滚动面同档(CodeCard、PopupSelectView 逐字设这两个
|
|
1423
|
+
* 自定义属性)。右内边距 10px 只是余量,不是修 bug。
|
|
1424
|
+
*/
|
|
1425
|
+
const SCROLLER = {
|
|
1426
|
+
display: "flex",
|
|
1427
|
+
flexDirection: "column",
|
|
1428
|
+
gap: 16,
|
|
1429
|
+
flex: "1 1 auto",
|
|
1430
|
+
minHeight: 0,
|
|
1431
|
+
overflow: "auto",
|
|
1432
|
+
paddingRight: 10,
|
|
1433
|
+
paddingBottom: 16,
|
|
1434
|
+
"--dsh-scrollbar-thumb": "var(--dsw-alias-scrollbar-bg-l2)",
|
|
1435
|
+
"--dsh-scrollbar-thumb-hover": "var(--dsw-alias-scrollbar-hover-l2)"
|
|
1436
|
+
};
|
|
1437
|
+
/** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
|
|
1438
|
+
const CARD = {
|
|
1439
|
+
display: "flex",
|
|
1440
|
+
alignItems: "center",
|
|
1441
|
+
gap: 14,
|
|
1442
|
+
minWidth: 0,
|
|
1443
|
+
padding: "9px 14px",
|
|
1444
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1445
|
+
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
1446
|
+
borderRadius: "var(--dsw-radius-lg)"
|
|
1447
|
+
};
|
|
1448
|
+
const ELLIPSIS = {
|
|
1449
|
+
overflow: "hidden",
|
|
1450
|
+
textOverflow: "ellipsis",
|
|
1451
|
+
whiteSpace: "nowrap"
|
|
1452
|
+
};
|
|
1453
|
+
/**
|
|
1454
|
+
* `relativeTime`(`ui/format.ts`,逐字抄自官方)只交回桶与量(`{unit, n}`),措辞按它自己的约定留在本面——现在那份措辞住
|
|
1455
|
+
* `copy.ts` 的字典里(单位词与宿主 zh 字典逐字对齐:`time.minutes="{n}分钟"`…
|
|
1456
|
+
* **数字与单位之间不空格**是它的写法)。`now` 桶不套模板——"删除于 刚刚前"不是话。
|
|
1457
|
+
*/
|
|
1458
|
+
const BUCKET_KEY = {
|
|
1459
|
+
minutes: "trash.unit.minutes",
|
|
1460
|
+
hours: "trash.unit.hours",
|
|
1461
|
+
days: "trash.unit.days",
|
|
1462
|
+
months: "trash.unit.months",
|
|
1463
|
+
years: "trash.unit.years"
|
|
1464
|
+
};
|
|
1465
|
+
function deletedAtText(at) {
|
|
1466
|
+
return new Date(at).toLocaleString();
|
|
1467
|
+
}
|
|
1468
|
+
/** 卡片第二行的时间短语;绝对时间另给 `title`。 */
|
|
1469
|
+
function deletedAgo(at, now, t) {
|
|
1470
|
+
const { unit, n } = relativeTime(at, now);
|
|
1471
|
+
return unit === "now" ? t("trash.row.deletedNow") : t("trash.row.deletedAgo", {
|
|
1472
|
+
n,
|
|
1473
|
+
unit: t(BUCKET_KEY[unit])
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* 宿主的 `projectKey`(逐字镜像见 `src/ports/host-port.ts`)把 cwd 里的 `/`、`\`、`:` 全折成**一个**
|
|
1478
|
+
* `-` ⇒ **分隔符那部分有损**:拆不回真实路径,也没法可靠认出哪几段是项目文件夹
|
|
1479
|
+
* (`dsh-session-delete` 自己就带连字符)。但**非分隔符那部分可逆**:不在 `[A-Za-z0-9._-]` 里、又不是
|
|
1480
|
+
* `~` 的字符编码成 `~` + 4 位大写十六进制码元,而字面 `~` 自己也被编成 `~007E` ⇒ 串里的 `~XXXX` 不含
|
|
1481
|
+
* 歧义(代理对拆成两个码元,逐个还原再拼起来仍是原字符)。所以中文目录能从 `D-develop-~-4E2D-6587`
|
|
1482
|
+
* 还原成 `D-develop-中文`。摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
|
|
1483
|
+
* **字母表只有一份**:还原走 `shared/wire.ts` 的 `unescapeProjectKey`(编码那半在 Node 侧)。
|
|
1484
|
+
*/
|
|
1485
|
+
function projectLabel(projectDir, t) {
|
|
1486
|
+
const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
|
|
1487
|
+
return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
|
|
1488
|
+
}
|
|
1489
|
+
/** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
|
|
1490
|
+
function groupByProject(entries, t) {
|
|
1491
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
1492
|
+
for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
|
|
1493
|
+
const list = byProject.get(entry.projectDir);
|
|
1494
|
+
if (list === void 0) byProject.set(entry.projectDir, [entry]);
|
|
1495
|
+
else list.push(entry);
|
|
1496
|
+
}
|
|
1497
|
+
return [...byProject].map(([projectDir, list]) => ({
|
|
1498
|
+
projectDir,
|
|
1499
|
+
label: projectLabel(projectDir, t),
|
|
1500
|
+
latest: list[0].deletedAt,
|
|
1501
|
+
entries: list
|
|
1502
|
+
})).sort((a, b) => b.latest - a.latest);
|
|
1503
|
+
}
|
|
1504
|
+
function TrashPanel({ deps, t = zhTranslate }) {
|
|
1505
|
+
const [entries, setEntries] = (0, react.useState)(null);
|
|
1506
|
+
const [listFailure, setListFailure] = (0, react.useState)(null);
|
|
1507
|
+
const [rowFailure, setRowFailure] = (0, react.useState)({});
|
|
1508
|
+
const [pending, setPending] = (0, react.useState)(null);
|
|
1509
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
1510
|
+
const revision = useTrashRevision();
|
|
1511
|
+
const reload = (0, react.useCallback)(async () => {
|
|
1512
|
+
const outcome = await callRemote(() => deps.list());
|
|
1513
|
+
if (!outcome.ok) {
|
|
1514
|
+
setListFailure(outcome.failure.message);
|
|
1515
|
+
setEntries(null);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
setListFailure(null);
|
|
1519
|
+
setEntries(outcome.value);
|
|
1520
|
+
}, [deps]);
|
|
1521
|
+
(0, react.useEffect)(() => {
|
|
1522
|
+
reload();
|
|
1523
|
+
}, [reload, revision]);
|
|
1524
|
+
const failRow = (entryId, message) => {
|
|
1525
|
+
setRowFailure((current) => ({
|
|
1526
|
+
...current,
|
|
1527
|
+
[entryId]: message
|
|
1528
|
+
}));
|
|
1529
|
+
};
|
|
1530
|
+
const restore = async (entry) => {
|
|
1531
|
+
setRowFailure((current) => {
|
|
1532
|
+
const next = { ...current };
|
|
1533
|
+
delete next[entry.id];
|
|
1534
|
+
return next;
|
|
1535
|
+
});
|
|
1536
|
+
const outcome = await callRemote(() => deps.restore(entry.id));
|
|
1537
|
+
if (!outcome.ok) {
|
|
1538
|
+
failRow(entry.id, outcome.failure.message);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
await reload();
|
|
1542
|
+
};
|
|
1543
|
+
const confirmPurge = async () => {
|
|
1544
|
+
const target = pending;
|
|
1545
|
+
if (target === null) return;
|
|
1546
|
+
setBusy(true);
|
|
1547
|
+
const outcome = await callRemote(() => target.kind === "entry" ? deps.purge(target.entry.id) : deps.purge(void 0));
|
|
1548
|
+
setBusy(false);
|
|
1549
|
+
if (!outcome.ok) {
|
|
1550
|
+
setPending(null);
|
|
1551
|
+
if (target.kind === "entry") failRow(target.entry.id, outcome.failure.message);
|
|
1552
|
+
else setListFailure(outcome.failure.message);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
setPending(null);
|
|
1556
|
+
await reload();
|
|
1557
|
+
};
|
|
1558
|
+
const groups = (0, react.useMemo)(() => groupByProject(entries ?? [], t), [entries, t]);
|
|
1559
|
+
const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.sizeBytes, 0);
|
|
1560
|
+
const now = Date.now();
|
|
1561
|
+
const hasEntries = entries !== null && entries.length > 0;
|
|
1562
|
+
const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
|
|
1563
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1564
|
+
"data-dsh-session-delete": "trash-panel",
|
|
1565
|
+
style: PAGE,
|
|
1566
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1567
|
+
style: COLUMN,
|
|
1568
|
+
children: [
|
|
1569
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1570
|
+
style: {
|
|
1571
|
+
display: "flex",
|
|
1572
|
+
alignItems: "flex-end",
|
|
1573
|
+
gap: 12,
|
|
1574
|
+
paddingBottom: 14
|
|
1575
|
+
},
|
|
1576
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1577
|
+
style: {
|
|
1578
|
+
...ELLIPSIS,
|
|
1579
|
+
minWidth: 0
|
|
1580
|
+
},
|
|
1581
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1582
|
+
style: {
|
|
1583
|
+
fontSize: 16,
|
|
1584
|
+
fontWeight: 600,
|
|
1585
|
+
lineHeight: "24px"
|
|
1586
|
+
},
|
|
1587
|
+
children: t("trash.title")
|
|
1588
|
+
}), hasEntries ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1589
|
+
style: {
|
|
1590
|
+
fontSize: 12,
|
|
1591
|
+
lineHeight: "17px",
|
|
1592
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1593
|
+
},
|
|
1594
|
+
children: t("trash.summary", {
|
|
1595
|
+
count: entries.length,
|
|
1596
|
+
size: fileSizeText(totalBytes)
|
|
1597
|
+
})
|
|
1598
|
+
}) : null]
|
|
1599
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1600
|
+
style: {
|
|
1601
|
+
marginLeft: "auto",
|
|
1602
|
+
flex: "none"
|
|
1603
|
+
},
|
|
1604
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1605
|
+
variant: "ghost",
|
|
1606
|
+
size: "sm",
|
|
1607
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconRefreshOutlineRegular, { size: 14 }),
|
|
1608
|
+
onClick: () => void reload(),
|
|
1609
|
+
children: t("common.refresh")
|
|
1610
|
+
})
|
|
1611
|
+
})]
|
|
1612
|
+
}),
|
|
1613
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1614
|
+
style: SCROLLER,
|
|
1615
|
+
children: listFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1616
|
+
role: "status",
|
|
1617
|
+
style: {
|
|
1618
|
+
...CARD,
|
|
1619
|
+
alignItems: "flex-start",
|
|
1620
|
+
gap: 12,
|
|
1621
|
+
padding: "14px"
|
|
1622
|
+
},
|
|
1623
|
+
children: [
|
|
1624
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1625
|
+
style: {
|
|
1626
|
+
display: "inline-flex",
|
|
1627
|
+
flex: "none",
|
|
1628
|
+
paddingTop: 2,
|
|
1629
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
1630
|
+
},
|
|
1631
|
+
"aria-hidden": "true",
|
|
1632
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
|
|
1633
|
+
}),
|
|
1634
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1635
|
+
style: {
|
|
1636
|
+
display: "flex",
|
|
1637
|
+
flexDirection: "column",
|
|
1638
|
+
gap: 3,
|
|
1639
|
+
flex: "1 1 auto",
|
|
1640
|
+
minWidth: 0
|
|
1641
|
+
},
|
|
1642
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1643
|
+
style: {
|
|
1644
|
+
fontSize: 13,
|
|
1645
|
+
lineHeight: "19px"
|
|
1646
|
+
},
|
|
1647
|
+
children: t("trash.readFailed")
|
|
1648
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1649
|
+
style: {
|
|
1650
|
+
fontSize: 12,
|
|
1651
|
+
lineHeight: "17px",
|
|
1652
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
1653
|
+
wordBreak: "break-word"
|
|
1654
|
+
},
|
|
1655
|
+
children: listFailure
|
|
1656
|
+
})]
|
|
1657
|
+
}),
|
|
1658
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1659
|
+
variant: "outline",
|
|
1660
|
+
size: "sm",
|
|
1661
|
+
style: { flex: "none" },
|
|
1662
|
+
onClick: () => void reload(),
|
|
1663
|
+
children: t("common.retry")
|
|
1664
|
+
})
|
|
1665
|
+
]
|
|
1666
|
+
}) : entries === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1667
|
+
style: {
|
|
1668
|
+
padding: "20px 2px",
|
|
1669
|
+
fontSize: 12.5,
|
|
1670
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1671
|
+
},
|
|
1672
|
+
children: t("trash.loading")
|
|
1673
|
+
}) : entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1674
|
+
style: {
|
|
1675
|
+
display: "flex",
|
|
1676
|
+
flexDirection: "column",
|
|
1677
|
+
alignItems: "center",
|
|
1678
|
+
justifyContent: "center",
|
|
1679
|
+
gap: 10,
|
|
1680
|
+
flex: "1 1 auto",
|
|
1681
|
+
padding: "24px",
|
|
1682
|
+
textAlign: "center"
|
|
1683
|
+
},
|
|
1684
|
+
children: [
|
|
1685
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1686
|
+
style: {
|
|
1687
|
+
display: "flex",
|
|
1688
|
+
alignItems: "center",
|
|
1689
|
+
justifyContent: "center",
|
|
1690
|
+
width: 46,
|
|
1691
|
+
height: 46,
|
|
1692
|
+
flex: "none",
|
|
1693
|
+
borderRadius: 999,
|
|
1694
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1695
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1696
|
+
},
|
|
1697
|
+
"aria-hidden": "true",
|
|
1698
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 20 })
|
|
1699
|
+
}),
|
|
1700
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1701
|
+
style: {
|
|
1702
|
+
fontSize: 13.5,
|
|
1703
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
1704
|
+
},
|
|
1705
|
+
children: t("trash.empty.title")
|
|
1706
|
+
}),
|
|
1707
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1708
|
+
style: {
|
|
1709
|
+
maxWidth: 340,
|
|
1710
|
+
fontSize: 12,
|
|
1711
|
+
lineHeight: "18px",
|
|
1712
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1713
|
+
},
|
|
1714
|
+
children: t("trash.empty.hint")
|
|
1715
|
+
})
|
|
1716
|
+
]
|
|
1717
|
+
}) : groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1718
|
+
"data-dsh-session-delete": "trash-group",
|
|
1719
|
+
"aria-label": group.label,
|
|
1720
|
+
style: {
|
|
1721
|
+
display: "flex",
|
|
1722
|
+
flexDirection: "column",
|
|
1723
|
+
gap: 8,
|
|
1724
|
+
minWidth: 0
|
|
1725
|
+
},
|
|
1726
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1727
|
+
style: {
|
|
1728
|
+
display: "flex",
|
|
1729
|
+
alignItems: "center",
|
|
1730
|
+
gap: 8,
|
|
1731
|
+
minWidth: 0
|
|
1732
|
+
},
|
|
1733
|
+
children: [
|
|
1734
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1735
|
+
style: {
|
|
1736
|
+
display: "inline-flex",
|
|
1737
|
+
flex: "none",
|
|
1738
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1739
|
+
},
|
|
1740
|
+
"aria-hidden": "true",
|
|
1741
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
|
|
1742
|
+
}),
|
|
1743
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1744
|
+
style: {
|
|
1745
|
+
...ELLIPSIS,
|
|
1746
|
+
fontSize: 12,
|
|
1747
|
+
fontWeight: 500,
|
|
1748
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
1749
|
+
},
|
|
1750
|
+
title: group.projectDir,
|
|
1751
|
+
children: group.label
|
|
1752
|
+
}),
|
|
1753
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1754
|
+
style: {
|
|
1755
|
+
marginLeft: "auto",
|
|
1756
|
+
flex: "none",
|
|
1757
|
+
fontSize: 11.5,
|
|
1758
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1759
|
+
},
|
|
1760
|
+
children: t("trash.group.count", { count: group.entries.length })
|
|
1761
|
+
})
|
|
1762
|
+
]
|
|
1763
|
+
}), group.entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1764
|
+
"data-dsh-session-delete": "trash-row",
|
|
1765
|
+
"data-entry-id": entry.id,
|
|
1766
|
+
style: CARD,
|
|
1767
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1768
|
+
style: {
|
|
1769
|
+
display: "flex",
|
|
1770
|
+
flexDirection: "column",
|
|
1771
|
+
gap: 1,
|
|
1772
|
+
flex: "1 1 auto",
|
|
1773
|
+
minWidth: 0
|
|
1774
|
+
},
|
|
1775
|
+
children: [
|
|
1776
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1777
|
+
style: {
|
|
1778
|
+
...ELLIPSIS,
|
|
1779
|
+
fontSize: 14,
|
|
1780
|
+
lineHeight: "20px"
|
|
1781
|
+
},
|
|
1782
|
+
title: entry.title === "" ? void 0 : entry.title,
|
|
1783
|
+
children: entry.title === "" ? t("common.untitled") : entry.title
|
|
1784
|
+
}),
|
|
1785
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1786
|
+
style: {
|
|
1787
|
+
...ELLIPSIS,
|
|
1788
|
+
fontSize: 12,
|
|
1789
|
+
lineHeight: "16px",
|
|
1790
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1791
|
+
},
|
|
1792
|
+
title: deletedAtText(entry.deletedAt),
|
|
1793
|
+
children: `${deletedAgo(entry.deletedAt, now, t)} · ${fileSizeText(entry.sizeBytes)}`
|
|
1794
|
+
}),
|
|
1795
|
+
rowFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1796
|
+
role: "status",
|
|
1797
|
+
style: {
|
|
1798
|
+
display: "flex",
|
|
1799
|
+
alignItems: "flex-start",
|
|
1800
|
+
gap: 6,
|
|
1801
|
+
paddingTop: 3,
|
|
1802
|
+
fontSize: 12,
|
|
1803
|
+
lineHeight: "16px",
|
|
1804
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
1805
|
+
},
|
|
1806
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1807
|
+
style: {
|
|
1808
|
+
display: "inline-flex",
|
|
1809
|
+
flex: "none"
|
|
1810
|
+
},
|
|
1811
|
+
"aria-hidden": "true",
|
|
1812
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 13 })
|
|
1813
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: rowFailure[entry.id] })]
|
|
1814
|
+
})
|
|
1815
|
+
]
|
|
1816
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1817
|
+
style: {
|
|
1818
|
+
display: "flex",
|
|
1819
|
+
gap: 6,
|
|
1820
|
+
flex: "none"
|
|
1821
|
+
},
|
|
1822
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1823
|
+
variant: "outline",
|
|
1824
|
+
size: "sm",
|
|
1825
|
+
onClick: () => void restore(entry),
|
|
1826
|
+
children: t("trash.row.restore")
|
|
1827
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1828
|
+
variant: "ghost",
|
|
1829
|
+
size: "sm",
|
|
1830
|
+
style: ROW_DANGER,
|
|
1831
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
1832
|
+
onClick: () => setPending({
|
|
1833
|
+
kind: "entry",
|
|
1834
|
+
entry
|
|
1835
|
+
}),
|
|
1836
|
+
children: t("trash.row.purge")
|
|
1837
|
+
})]
|
|
1838
|
+
})]
|
|
1839
|
+
}, entry.id))]
|
|
1840
|
+
}, group.projectDir))
|
|
1841
|
+
}),
|
|
1842
|
+
hasEntries ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1843
|
+
style: {
|
|
1844
|
+
display: "flex",
|
|
1845
|
+
alignItems: "center",
|
|
1846
|
+
gap: 12,
|
|
1847
|
+
padding: "12px 2px",
|
|
1848
|
+
borderTop: "0.5px solid var(--dsw-alias-border-l2)"
|
|
1849
|
+
},
|
|
1850
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1851
|
+
style: {
|
|
1852
|
+
minWidth: 0,
|
|
1853
|
+
fontSize: 11.5,
|
|
1854
|
+
lineHeight: "16px",
|
|
1855
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1856
|
+
},
|
|
1857
|
+
children: [
|
|
1858
|
+
noteParts[0],
|
|
1859
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1860
|
+
style: {
|
|
1861
|
+
fontFamily: "var(--ds-font-family-code)",
|
|
1862
|
+
fontSize: 11
|
|
1863
|
+
},
|
|
1864
|
+
children: noteParts[1]
|
|
1865
|
+
}),
|
|
1866
|
+
noteParts[2]
|
|
1867
|
+
]
|
|
1868
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1869
|
+
style: {
|
|
1870
|
+
marginLeft: "auto",
|
|
1871
|
+
flex: "none"
|
|
1872
|
+
},
|
|
1873
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1874
|
+
variant: "primary",
|
|
1875
|
+
size: "sm",
|
|
1876
|
+
style: BULK_DANGER,
|
|
1877
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
1878
|
+
onClick: () => setPending({ kind: "all" }),
|
|
1879
|
+
children: t("trash.emptyAll")
|
|
1880
|
+
})
|
|
1881
|
+
})]
|
|
1882
|
+
}) : null
|
|
1883
|
+
]
|
|
1884
|
+
}), pending === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashPurgeConfirm, {
|
|
1885
|
+
pending,
|
|
1886
|
+
busy,
|
|
1887
|
+
t,
|
|
1888
|
+
onCancel: () => setPending(null),
|
|
1889
|
+
onConfirm: () => void confirmPurge()
|
|
1890
|
+
})]
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
function TrashPurgeConfirm({ pending, busy, t, onCancel, onConfirm }) {
|
|
1894
|
+
const title = pending.kind === "all" ? t("trash.confirm.all") : t("trash.confirm.one");
|
|
1895
|
+
const strong = t("trash.confirm.body.strong");
|
|
1896
|
+
const body = splitOn(t("trash.confirm.body", { strong }), strong);
|
|
1897
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
1898
|
+
open: true,
|
|
1899
|
+
onClose: onCancel,
|
|
1900
|
+
title,
|
|
1901
|
+
closeLabel: t("common.close"),
|
|
1902
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1903
|
+
variant: "outline",
|
|
1904
|
+
onClick: onCancel,
|
|
1905
|
+
children: t("common.cancel")
|
|
1906
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
1907
|
+
variant: "primary",
|
|
1908
|
+
style: BULK_DANGER,
|
|
1909
|
+
disabled: busy,
|
|
1910
|
+
onClick: onConfirm,
|
|
1911
|
+
children: t("trash.confirm.purge")
|
|
1912
|
+
})] }),
|
|
1913
|
+
children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1914
|
+
style: {
|
|
1915
|
+
...CARD,
|
|
1916
|
+
flexDirection: "column",
|
|
1917
|
+
alignItems: "stretch",
|
|
1918
|
+
gap: 2,
|
|
1919
|
+
padding: "10px 12px",
|
|
1920
|
+
marginBlockEnd: 10
|
|
1921
|
+
},
|
|
1922
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1923
|
+
style: {
|
|
1924
|
+
...ELLIPSIS,
|
|
1925
|
+
fontSize: 13.5,
|
|
1926
|
+
lineHeight: "19px"
|
|
1927
|
+
},
|
|
1928
|
+
children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
|
|
1929
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1930
|
+
style: {
|
|
1931
|
+
...ELLIPSIS,
|
|
1932
|
+
fontSize: 12,
|
|
1933
|
+
lineHeight: "16px",
|
|
1934
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1935
|
+
},
|
|
1936
|
+
children: t("trash.confirm.one.meta", {
|
|
1937
|
+
project: projectLabel(pending.entry.projectDir, t),
|
|
1938
|
+
at: deletedAtText(pending.entry.deletedAt),
|
|
1939
|
+
size: fileSizeText(pending.entry.sizeBytes)
|
|
1940
|
+
})
|
|
1941
|
+
})]
|
|
1942
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
|
|
1943
|
+
body[0],
|
|
1944
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
|
|
1945
|
+
style: { color: "var(--dsw-alias-state-error-primary)" },
|
|
1946
|
+
children: body[1]
|
|
1947
|
+
}),
|
|
1948
|
+
body[2]
|
|
1949
|
+
] })]
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/client/index.tsx
|
|
1954
|
+
const name = "@xrn1997/dsh-session-delete/client";
|
|
1955
|
+
/**
|
|
1956
|
+
* 只列**宿主公开面保证存在**的成员:`slots` 是浏览器侧槽位注册表
|
|
1957
|
+
* (宿主外壳的冻结平台模块表里的静态键 `@deepseek-ai/dsh-client-ui-slots`),一定在。
|
|
1958
|
+
* 取数通道不在这里——理由见文件头注(它不是 inject 能表达的事,写错了代价是进程死)。
|
|
1959
|
+
* **`locale` 也不在这里**:宿主自己的 `ui-sidebar` 是硬注入它的,但那是"宿主知道自己那一代"的前提;
|
|
1960
|
+
* 本插件要跨代活着 ⇒ 走 `ctx.get('locale')` 可选读,缺席时降级到内置 zh 文案(见 `copy.ts` 头注)。
|
|
1961
|
+
* 写进 inject 就等于"这一代没有 locale 就不让整个客户端条目激活",而那等于宿主进程死。
|
|
1962
|
+
*/
|
|
1963
|
+
const inject = ["slots"];
|
|
1964
|
+
const MENU_SLOT = "sidebar.workspaces.session.menu.item";
|
|
1965
|
+
const OVERLAY_SLOT = "shell.overlay";
|
|
1966
|
+
const PANEL_SLOT = "sidebar.panellist";
|
|
1967
|
+
const MAIN_SLOT = "main";
|
|
1968
|
+
/** 本插件在槽位里的稳定 id 前缀(宿主对重名直接抛错、对重 id 静默替换,必须带插件专属前缀)。 */
|
|
1969
|
+
const PLUGIN_ID = "@xrn1997/dsh-session-delete";
|
|
1970
|
+
/** 面板 id 与 `main` 的 key 同字:官方按 list id 去 `main` 里寻址同 id 的占用者。 */
|
|
1971
|
+
const PANEL_ID = `${PLUGIN_ID}.trash`;
|
|
1972
|
+
/** 根 hook 缺失时的兜底:永不判 live。恒等身份,不会让 hook 顺序在两次渲染之间变化。 */
|
|
1973
|
+
const NEVER_LIVE = () => false;
|
|
1974
|
+
/** 槽位 hook 缺失时的兜底:什么都不关(同样恒等身份,hook 顺序不随渲染变)。 */
|
|
1975
|
+
const NEVER_MENU_OPEN_STATE = () => [false, () => void 0];
|
|
1976
|
+
/** 菜单行的宿主:文案与远程面都从槽位载荷来(`live` 由这一行自己从 root hook 读)。 */
|
|
1977
|
+
function DeleteRow({ sessionId, displayTitle, useSessions, useMenuOpenState, t }) {
|
|
1978
|
+
const live = (typeof useSessions === "function" ? useSessions : NEVER_LIVE)((state) => state?.byId?.[sessionId]?.running === true);
|
|
1979
|
+
const [, setMenuOpen] = (typeof useMenuOpenState === "function" ? useMenuOpenState : NEVER_MENU_OPEN_STATE)();
|
|
1980
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeleteMenuItem, {
|
|
1981
|
+
sessionId,
|
|
1982
|
+
title: displayTitle,
|
|
1983
|
+
live,
|
|
1984
|
+
t,
|
|
1985
|
+
dismissMenu: () => setMenuOpen(false)
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
/** 撤销提示的宿主:组件本身只吃 `restore` 与文案。 */
|
|
1989
|
+
function UndoToastSlot({ deps, t }) {
|
|
1990
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UndoToast, {
|
|
1991
|
+
deps,
|
|
1992
|
+
t
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
/** 确认框的宿主:组件只吃 `delete`(会话 id 来自模块级现场,不从这里走)。 */
|
|
1996
|
+
function DeleteConfirmSlot({ deps, t }) {
|
|
1997
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeleteConfirmHost, {
|
|
1998
|
+
deps,
|
|
1999
|
+
t
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
/** 面板行的占用者:宿主给 `SidebarPanelIconOwnerProps { size, active }`,我们只回一个 glyph。 */
|
|
2003
|
+
function TrashPanelIconSlot({ size }) {
|
|
2004
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashPanelIcon, { size });
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
|
|
2008
|
+
*
|
|
2009
|
+
* - 探测不过(打不通 / 回的不是信封)⇒ **早返回**:一个槽位贡献都不注册。页面上的表现是"本插件
|
|
2010
|
+
* 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(设计稿 §7「未接通态」)。
|
|
2011
|
+
* - 探测过(哪怕 `list` 回的是 `{ok:false,error}`)⇒ 注册:那次往返证明路由在、依赖接上了、
|
|
2012
|
+
* 错误映射也生效了,失败该做的是把原文交给用户看。
|
|
2013
|
+
*
|
|
2014
|
+
* 面板那两条是**一对座位**:行的 `selectPanel(id)` 要求 `main` 里有同字 key,缺了面板那一头,
|
|
2015
|
+
* 用户点一下就是宿主自己的 `layout.selectPanel: main panel "…" is not registered`。
|
|
2016
|
+
* 两条分属 `sidebar.panellist` / `main` 两个槽位,只能各注册一次、**成不了原子事务**:
|
|
2017
|
+
* 谁先谁后都一样(两个 `inject` 的回调各跑各的)。所以这里不假装能防住半个座位——
|
|
2018
|
+
* 漏一半的代价是**宿主那句响亮的抛错**,不是静默降级。
|
|
2019
|
+
*/
|
|
2020
|
+
async function activate(client) {
|
|
2021
|
+
const raw = createHttpRemote();
|
|
2022
|
+
if (!await probeChannel(raw)) return;
|
|
2023
|
+
const deps = buildDeps(raw);
|
|
2024
|
+
const t = createTranslate(client);
|
|
2025
|
+
client.slots.inject(MENU_SLOT, () => client.slots.register({
|
|
2026
|
+
name: MENU_SLOT,
|
|
2027
|
+
id: `${PLUGIN_ID}.delete`,
|
|
2028
|
+
order: 900,
|
|
2029
|
+
inject: () => ({ t })
|
|
2030
|
+
}, DeleteRow));
|
|
2031
|
+
client.slots.inject(OVERLAY_SLOT, () => client.slots.register({
|
|
2032
|
+
name: OVERLAY_SLOT,
|
|
2033
|
+
id: `${PLUGIN_ID}.undo-toast`,
|
|
2034
|
+
inject: () => ({
|
|
2035
|
+
deps,
|
|
2036
|
+
t
|
|
2037
|
+
})
|
|
2038
|
+
}, UndoToastSlot));
|
|
2039
|
+
client.slots.inject(OVERLAY_SLOT, () => client.slots.register({
|
|
2040
|
+
name: OVERLAY_SLOT,
|
|
2041
|
+
id: `${PLUGIN_ID}.delete-confirm`,
|
|
2042
|
+
inject: () => ({
|
|
2043
|
+
deps,
|
|
2044
|
+
t
|
|
2045
|
+
})
|
|
2046
|
+
}, DeleteConfirmSlot));
|
|
2047
|
+
client.slots.inject(PANEL_SLOT, () => client.slots.register({
|
|
2048
|
+
name: PANEL_SLOT,
|
|
2049
|
+
id: PANEL_ID,
|
|
2050
|
+
order: 900,
|
|
2051
|
+
label: () => t("trash.title")
|
|
2052
|
+
}, TrashPanelIconSlot));
|
|
2053
|
+
client.slots.inject(MAIN_SLOT, () => client.slots.register({
|
|
2054
|
+
name: MAIN_SLOT,
|
|
2055
|
+
key: PANEL_ID,
|
|
2056
|
+
inject: () => ({
|
|
2057
|
+
deps,
|
|
2058
|
+
t
|
|
2059
|
+
})
|
|
2060
|
+
}, TrashPanel));
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* 入口:**同步返回、绝不抛**(见文件头注)。
|
|
2064
|
+
*
|
|
2065
|
+
* 注册挂在探测的续拍上,两条理由:① 前端启动自检只看 entry 声明的 `inject` 服务齐不齐(`slots`
|
|
2066
|
+
* 一定在),不看它注册了几行;② 槽位注册是注册表的一次 mutation,渲染层按微任务批量订阅
|
|
2067
|
+
* (`SlotCore` 的变更传播契约),晚一拍注册照常上屏——宿主侧栏对 `sidebar.panellist` 正是
|
|
2068
|
+
* `ctx.slots.subscribe("sidebar.panellist", syncPanels)`(asar @44795361)。
|
|
2069
|
+
*/
|
|
2070
|
+
function apply(ctx) {
|
|
2071
|
+
activate(ctx).catch((error) => {
|
|
2072
|
+
console.warn("[dsh-session-delete] 入口未注册(取数通道不可用):", error);
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
//#endregion
|
|
2076
|
+
exports.apply = apply;
|
|
2077
|
+
exports.inject = inject;
|
|
2078
|
+
exports.name = name;
|
|
2079
|
+
return module.exports;
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
//# sourceMappingURL=client.js.map
|