dsh-ssh-vault 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1134 @@
1
+ /**
2
+ * SSH 保险库的浏览器半边:设置界面的连接管理页。
3
+ *
4
+ * 与 Host 侧一样保持零依赖、零构建——这里手写 React 元素,只 require 宿主提供的 'react'。
5
+ * 数据经同源、document-relative 的 POST 走 `/ssh-vault/*`(见 lib/vault-api.js)。
6
+ * 文案交给宿主的 locale 服务:注册 `ssh-vault` 命名空间的中英字典,slot 组件用宿主注入的 t,
7
+ * 所以 DSH 里切换语言后设置页与对话内卡片会立刻跟着变;没有 locale 服务的老宿主退回按 <html lang> 判断。
8
+ *
9
+ * 安全性上值得注意的一点:口令通过 HTTP body 提交,**不进入会话日志**,
10
+ * 这比让模型调用 `ssh_hosts add password=...` 更干净。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-ssh-vault',
14
+ factory(require) {
15
+ const React = require('react')
16
+ const h = React.createElement
17
+ const { useCallback, useContext, useEffect, useRef, useState } = React
18
+
19
+ // —— 文案:全部经宿主的 locale 服务提供,这里只维护中英两份字典 ——
20
+ const DICT = {
21
+ zh: {
22
+ sectionTitle: 'SSH 连接',
23
+ heading: 'SSH 连接',
24
+ intro: '集中管理远程主机。地址、端口、账号与口令都保存在加密保险库中,对话里只用别名。',
25
+ keyMode: '密钥保护',
26
+ vaultDir: '保险库位置',
27
+ add: '添加连接',
28
+ empty: '还没有配置任何主机。点「添加连接」开始。',
29
+ loading: '加载中…',
30
+ alias: '别名',
31
+ host: '主机地址',
32
+ port: '端口',
33
+ username: '账号',
34
+ authType: '认证方式',
35
+ authPassword: '口令认证',
36
+ authKey: '密钥认证',
37
+ password: '密码',
38
+ passwordKeep: '留空表示保持已保存的密码',
39
+ keyUpload: '上传私钥文件',
40
+ keyUploadHint: '私钥会保存到保险库目录并自动收紧权限,无需自己处理文件 ACL',
41
+ keyUploaded: '已选择',
42
+ keyPath: '或填写私钥路径(运行 DSH 的机器上的绝对路径)',
43
+ shell: '远程 shell',
44
+ description: '备注',
45
+ tags: '标签(逗号分隔)',
46
+ save: '保存',
47
+ cancel: '取消',
48
+ edit: '编辑',
49
+ remove: '删除',
50
+ test: '测试连接',
51
+ testing: '测试中…',
52
+ testOk: '连接正常',
53
+ testFail: '连接失败',
54
+ confirmRemove: '确定要删除 {alias} 吗?保险库中的凭据会一并移除。',
55
+ noHostsForTest: '请先添加主机。',
56
+ saved: '已保存',
57
+ errorRequired: '请填写必填项。',
58
+ transportError: '无法连接到宿主:路由不可用。',
59
+ routeMissing: '宿主没有注册设置接口:插件文件已更新,但宿主进程仍在运行旧代码。请重启 DSH Desktop。',
60
+ notAuthorized: '请求被宿主拒绝(未认证)。请刷新页面后重试。',
61
+ chooseFile: '选择文件',
62
+ noFileChosen: '未选择任何文件',
63
+ usernameKeyHint: '密钥认证同样需要账号——SSH 连接的是 user@host。',
64
+ openVault: '用资源管理器打开',
65
+ openWorking: '正在打开…',
66
+ openDone: '已打开',
67
+ healthIdle: '尚未检测',
68
+ keyAcceptHint: '支持 OpenSSH(id_rsa / id_ed25519,通常没有扩展名)与 PEM(.pem / .key)。'
69
+ + '若文件选择器里看不到你的私钥,把类型切到「所有文件」即可。',
70
+ keyIsPublic: '这是公钥,不能用于登录。请选择不带 .pub 后缀的私钥文件。',
71
+ keyIsPpk: '这是 PuTTY 的 .ppk 私钥,OpenSSH 无法直接使用;请先用 puttygen 转换。',
72
+ keyNotPrivate: '这不是 OpenSSH/PEM 私钥(缺少 BEGIN … PRIVATE KEY 头)。',
73
+ keyTooLarge: '文件过大,不像是私钥。',
74
+ keyReadError: '无法读取该文件。',
75
+ passphrase: '私钥口令(可选)',
76
+ passphraseHint: '私钥本身带密码时填写;留空表示私钥未加密。',
77
+ passwordBadge: '已存密码',
78
+ noPasswordBadge: '无密码',
79
+ sudoBadge: 'sudo 口令',
80
+ keyBadge: '密钥',
81
+ agentBadge: 'agent',
82
+ bindAddress: '出口源地址(可选)',
83
+ updatedAt: '更新于',
84
+ running: '执行中…',
85
+ noOutput: '(无输出)',
86
+ anyFailed: '有失败',
87
+ shellLabel: 'shell',
88
+ dockHint: '命令输出:发送第一条消息后并入对话记录',
89
+ },
90
+ en: {
91
+ sectionTitle: 'SSH Connections',
92
+ heading: 'SSH Connections',
93
+ intro: 'Manage remote hosts in one place. Address, port, account and password live in an encrypted vault; the conversation only uses aliases.',
94
+ keyMode: 'Key protection',
95
+ vaultDir: 'Vault location',
96
+ add: 'Add connection',
97
+ empty: 'No hosts configured yet. Start with “Add connection”.',
98
+ loading: 'Loading…',
99
+ alias: 'Alias',
100
+ host: 'Host address',
101
+ port: 'Port',
102
+ username: 'Account',
103
+ authType: 'Authentication',
104
+ authPassword: 'Password',
105
+ authKey: 'Private key',
106
+ password: 'Password',
107
+ passwordKeep: 'Leave blank to keep the stored password',
108
+ keyUpload: 'Upload private key file',
109
+ keyUploadHint: 'The key is stored in the vault directory with its permissions tightened for you',
110
+ keyUploaded: 'selected',
111
+ keyPath: 'Or give a private key path (absolute, on the machine running DSH)',
112
+ shell: 'Remote shell',
113
+ description: 'Note',
114
+ tags: 'Tags (comma separated)',
115
+ save: 'Save',
116
+ cancel: 'Cancel',
117
+ edit: 'Edit',
118
+ remove: 'Delete',
119
+ test: 'Test connection',
120
+ testing: 'Testing…',
121
+ testOk: 'Connection OK',
122
+ testFail: 'Connection failed',
123
+ confirmRemove: 'Delete {alias}? Its credentials are removed from the vault too.',
124
+ noHostsForTest: 'Add a host first.',
125
+ saved: 'Saved',
126
+ errorRequired: 'Fill in the required fields.',
127
+ transportError: 'Cannot reach the host: route unavailable.',
128
+ routeMissing: 'The host has no settings route: the plugin files are updated but the host process is still running the old code. Restart DSH Desktop.',
129
+ notAuthorized: 'The host rejected the request (not authenticated). Refresh the page and try again.',
130
+ chooseFile: 'Choose file',
131
+ noFileChosen: 'No file chosen',
132
+ usernameKeyHint: 'Key authentication still needs the account — SSH connects as user@host.',
133
+ openVault: 'Open in file manager',
134
+ openWorking: 'Opening…',
135
+ openDone: 'Opened',
136
+ healthIdle: 'Not checked yet',
137
+ keyAcceptHint: 'OpenSSH keys (id_rsa / id_ed25519, usually without an extension) and PEM keys (.pem / .key) '
138
+ + 'are accepted. If your key is not listed, switch the file type filter to “All files”.',
139
+ keyIsPublic: 'This is a public key; it cannot be used to log in. Choose the private key without the .pub suffix.',
140
+ keyIsPpk: 'This is a PuTTY .ppk key, which OpenSSH cannot use directly. Convert it with puttygen first.',
141
+ keyNotPrivate: 'This is not an OpenSSH/PEM private key (no BEGIN … PRIVATE KEY header).',
142
+ keyTooLarge: 'The file is too large to be a private key.',
143
+ keyReadError: 'Could not read that file.',
144
+ passphrase: 'Key passphrase (optional)',
145
+ passphraseHint: 'Fill in when the key itself is encrypted; leave blank for an unencrypted key.',
146
+ passwordBadge: 'password stored',
147
+ noPasswordBadge: 'no password',
148
+ sudoBadge: 'sudo password',
149
+ keyBadge: 'key',
150
+ agentBadge: 'agent',
151
+ bindAddress: 'Source address (optional)',
152
+ updatedAt: 'updated',
153
+ running: 'Running…',
154
+ noOutput: 'No output',
155
+ anyFailed: 'Failed',
156
+ shellLabel: 'shell',
157
+ dockHint: 'Command output: it moves into the transcript after your first message',
158
+ },
159
+ }
160
+ // —— 界面语言:交给宿主的 locale 服务 ——
161
+ //
162
+ // DSH 的客户端 locale 服务(`ctx.get('locale')`)提供 register(ns, { zh, en }) 注册字典、
163
+ // bind(ns) 取 t(key, vars);slot 注册时带 `locale: ns`,宿主就会把 t 作为 prop 传给组件,
164
+ // 并在语言切换时换掉 t 的引用、按 revision 重新渲染该组件。
165
+ // 老宿主没有这个服务,所以下面保留了 fallbackT:只在这种情况下按 <html lang> 猜中英。
166
+ const NS = 'ssh-vault'
167
+ const LocaleContext = React.createContext(null)
168
+
169
+ /**
170
+ * 兜底取词:没有 locale 服务(或组件不在 withLocale 内)时使用。
171
+ * @param key - 字典键。
172
+ * @returns 文案;两种语言都缺该键时返回键名本身。
173
+ */
174
+ const fallbackT = (key) => {
175
+ const lang = String(globalThis.document?.documentElement?.lang ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'
176
+ return DICT[lang]?.[key] ?? DICT.zh[key] ?? key
177
+ }
178
+
179
+ /** 组件内取 t:优先用 withLocale 注入的那个;语言切换后它的引用会变,从而触发重渲染。 */
180
+ const useT = () => useContext(LocaleContext) ?? fallbackT
181
+
182
+ /**
183
+ * 把要注册的 slot 组件包一层:宿主传进来的 t(注册选项带 `locale` 时才有)放进 context,
184
+ * 老宿主没传就退回 fallbackT。
185
+ * @param Component - 被注册的组件。
186
+ * @returns 包了 LocaleContext.Provider 的组件。
187
+ */
188
+ function withLocale(Component) {
189
+ return function LocaleRoot(props) {
190
+ const t = props.t ?? fallbackT
191
+ return h(LocaleContext.Provider, { value: t }, h(Component, props))
192
+ }
193
+ }
194
+
195
+ // —— 配色:照搬宿主 primitives 的真实定义 ——
196
+ //
197
+ // 下面每个值都取自 @deepseek-ai/dsh-client-ui-primitives 的 Button.module.css 与
198
+ // Input.module.css,不再凭 token 名字猜语义。之前的两处错误正是猜出来的:
199
+ // · 副按钮拿 `bg-layer-1` 当底色——宿主按钮其实是「透明底 + border-l3 描边」;
200
+ // · 主按钮用 `brand-primary` 配写死的白字——宿主用的是
201
+ // `button-primary-fill` 配 `label-primary-foreground`。
202
+ // 表面色保留中性半透明回退,token 缺失时也不会落成看不清的底色。
203
+ const C = {
204
+ text: 'var(--dsw-alias-label-primary, currentColor)',
205
+ textDim: 'var(--dsw-alias-label-secondary, currentColor)',
206
+ textDimmed: 'var(--dsw-alias-label-dimmed, currentColor)',
207
+ border: 'var(--dsw-alias-border-l3, rgba(127,127,127,0.45))',
208
+ borderSubtle: 'var(--dsw-alias-border-l4, rgba(127,127,127,0.28))',
209
+ inputBg: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.10))',
210
+ primaryFill: 'var(--dsw-alias-button-primary-fill, #4f7cff)',
211
+ primaryText: 'var(--dsw-alias-label-primary-foreground, #ffffff)',
212
+ radiusSm: 'var(--dsw-radius-sm, 6px)',
213
+ radiusMd: 'var(--dsw-radius-md, 8px)',
214
+ surface: 'rgba(127,127,127,0.08)',
215
+ surfaceStrong: 'rgba(127,127,127,0.14)',
216
+ ok: 'var(--dsw-alias-state-success-primary, #2e9e5b)',
217
+ bad: 'var(--dsw-alias-state-error-primary, #d64545)',
218
+ }
219
+
220
+ const S = {
221
+ page: { display: 'flex', flexDirection: 'column', gap: '14px', color: C.text, fontSize: '13px', lineHeight: 1.6 },
222
+ head: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' },
223
+ h: { margin: 0, fontSize: '15px', fontWeight: 600, color: C.text },
224
+ titleRow: { display: 'flex', alignItems: 'center', gap: '8px' },
225
+ titleIcon: { flex: 'none', color: C.text },
226
+ intro: { margin: '4px 0 0', color: C.textDim, maxWidth: '62ch' },
227
+ meta: {
228
+ marginTop: '6px', color: C.textDim, fontSize: '12px',
229
+ // 第一行(版本 + 密钥保护)保持单行;全角空格也是可换行点,必须显式禁掉。
230
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
231
+ },
232
+ metaBlock: { display: 'flex', flexDirection: 'column', minWidth: 0 },
233
+ metaRow: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '2px', color: C.textDim, fontSize: '12px', minWidth: 0 },
234
+ metaPath: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 },
235
+ miniButton: {
236
+ appearance: 'none', WebkitAppearance: 'none', boxSizing: 'border-box', flex: 'none',
237
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
238
+ height: '22px', padding: '0 8px',
239
+ border: `0.5px solid ${C.border}`, borderRadius: C.radiusSm,
240
+ background: 'transparent', color: C.text,
241
+ font: 'inherit', fontSize: '11px', lineHeight: '16px', cursor: 'pointer', whiteSpace: 'nowrap',
242
+ },
243
+ button: {
244
+ // 与宿主 .button / .outline 对齐:透明底、0.5px 描边、继承的文字色。
245
+ appearance: 'none', WebkitAppearance: 'none', boxSizing: 'border-box',
246
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '4px',
247
+ height: '28px', padding: '0 10px',
248
+ border: `0.5px solid ${C.border}`, borderRadius: C.radiusSm,
249
+ background: 'transparent', color: C.text,
250
+ font: 'inherit', fontSize: '12px', lineHeight: '18px', cursor: 'pointer', whiteSpace: 'nowrap',
251
+ },
252
+ primary: {
253
+ // 与宿主 .primary 对齐:专用填充色与专用前景色,都不写死。
254
+ border: 'none', background: C.primaryFill, color: C.primaryText,
255
+ },
256
+ danger: { borderColor: C.bad, color: C.bad },
257
+ card: { border: `0.5px solid ${C.borderSubtle}`, borderRadius: C.radiusMd, background: C.surface, padding: '12px', display: 'flex', flexDirection: 'column', gap: '8px' },
258
+ hostBlock: { display: 'flex', flexDirection: 'column', gap: '8px' },
259
+ cardHead: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
260
+ alias: { fontWeight: 600, fontSize: '13px' },
261
+ badge: { border: `0.5px solid ${C.border}`, borderRadius: '999px', padding: '1px 8px', fontSize: '11px', color: C.textDim },
262
+ kv: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', color: C.textDim, fontSize: '12px' },
263
+ row: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' },
264
+ form: { border: `0.5px solid ${C.border}`, borderRadius: C.radiusMd, background: C.surfaceStrong, padding: '12px', display: 'flex', flexDirection: 'column', gap: '10px' },
265
+ fields: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: '10px' },
266
+ field: { display: 'flex', flexDirection: 'column', gap: '3px' },
267
+ label: { fontSize: '12px', color: C.textDim },
268
+ input: {
269
+ // 与宿主 .wrap / .input 对齐:bg-layer-1 底、border-l4 描边、label-primary 字。
270
+ boxSizing: 'border-box', height: '28px', width: '100%', padding: '0 8px',
271
+ border: `0.5px solid ${C.borderSubtle}`, borderRadius: C.radiusSm,
272
+ background: C.inputBg, color: C.text, font: 'inherit', fontSize: '12px',
273
+ },
274
+ hint: { fontSize: '11px', color: C.textDim },
275
+ error: { color: C.bad, fontSize: '12px' },
276
+ okText: { color: C.ok, fontSize: '12px', fontWeight: 600 },
277
+ badText: { color: C.bad, fontSize: '12px', fontWeight: 600 },
278
+ pre: {
279
+ margin: 0, padding: '8px', borderRadius: C.radiusSm, background: C.inputBg, border: `0.5px solid ${C.borderSubtle}`,
280
+ fontSize: '11.5px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '200px', overflow: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
281
+ },
282
+ footer: { color: C.textDimmed, fontSize: '11px' },
283
+ dot: { width: '8px', height: '8px', borderRadius: '50%', flex: 'none' },
284
+ }
285
+
286
+ /** 连通状态点:灰=未测、黄=检测中、绿=通、红=不通。 */
287
+ const HEALTH_DOT = {
288
+ idle: { background: C.textDim },
289
+ checking: { background: 'var(--dsw-alias-state-warn-primary, #c98a12)' },
290
+ ok: { background: C.ok },
291
+ fail: { background: C.bad },
292
+ }
293
+
294
+ /**
295
+ * 连通状态点的悬停说明。
296
+ * @param entry - 该主机的体检结果,未检测时为 undefined。
297
+ * @param t - 当前语言的取词函数。
298
+ * @returns 说明文本。
299
+ */
300
+ function healthTitle(entry, t) {
301
+ if (entry === undefined) return t('healthIdle')
302
+ if (entry.status === 'checking') return t('testing')
303
+ const head = entry.status === 'ok' ? t('testOk') : t('testFail')
304
+ return entry.detail ? `${head}:${entry.detail}` : head
305
+ }
306
+
307
+ // inline style 表达不了的状态。选择器与宿主的 hover / active / disabled 行为对齐:
308
+ // 主按钮用 data-variant 排除,改走宿主自己的 button-primary-hover。
309
+ const SCOPE = 'dsh-ssh-vault'
310
+ const CSS = [
311
+ `.${SCOPE} button { transition: background-color .12s ease, border-color .12s ease; }`,
312
+ `.${SCOPE} button:not([data-variant="primary"]):hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12)); }`,
313
+ `.${SCOPE} button:not([data-variant="primary"]):active:not(:disabled) { background: var(--dsw-alias-interactive-bg-active, rgba(127,127,127,0.18)); }`,
314
+ `.${SCOPE} button[data-variant="primary"]:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-button-primary-fill, #4f7cff)); }`,
315
+ `.${SCOPE} button:disabled { opacity: .4; cursor: not-allowed; }`,
316
+ `.${SCOPE} button:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, currentColor); outline-offset: 1px; }`,
317
+ `.${SCOPE} input::placeholder { color: var(--dsw-alias-label-dimmed, currentColor); }`,
318
+ ].join('\n')
319
+
320
+ /**
321
+ * 调一次宿主路由。
322
+ * @param action - 路由末段。
323
+ * @param payload - JSON 请求体。
324
+ * @param t - 当前语言的取词函数,用于本地生成的错误文案。
325
+ * @returns `{ ok: true, value }` 或 `{ ok: false, error }`。
326
+ */
327
+ async function call(action, payload, t = fallbackT) {
328
+ let response
329
+ try {
330
+ // document-relative:页面用 <base href="./">,绝对路径会逃出部署前缀。
331
+ response = await fetch(`ssh-vault/${action}`, {
332
+ method: 'POST',
333
+ headers: { 'content-type': 'application/json' },
334
+ body: JSON.stringify(payload ?? {}),
335
+ })
336
+ } catch {
337
+ return { ok: false, error: t('transportError') }
338
+ }
339
+ // 404 与 401/403 的含义完全不同:前者是宿主根本没注册路由(进程跑的还是旧代码),
340
+ // 后者只是这次请求没被信任。混成一句话会把排查引向错误方向。
341
+ if (response.status === 404) return { ok: false, error: t('routeMissing') }
342
+ if (response.status === 401 || response.status === 403) return { ok: false, error: t('notAuthorized') }
343
+ if (!response.ok) return { ok: false, error: `${t('transportError')} (HTTP ${response.status})` }
344
+ try {
345
+ const envelope = await response.json()
346
+ if (envelope && envelope.ok === true) return { ok: true, value: envelope.value }
347
+ const message = envelope && envelope.error && envelope.error.message ? envelope.error.message : t('transportError')
348
+ return { ok: false, error: message }
349
+ } catch {
350
+ return { ok: false, error: t('transportError') }
351
+ }
352
+ }
353
+
354
+ /** 把一个文件读成 base64(去掉 data URL 前缀)。 */
355
+ function readAsBase64(file) {
356
+ return new Promise((resolve, reject) => {
357
+ const reader = new FileReader()
358
+ reader.onload = () => {
359
+ const text = String(reader.result ?? '')
360
+ const comma = text.indexOf(',')
361
+ resolve(comma >= 0 ? text.slice(comma + 1) : text)
362
+ }
363
+ reader.onerror = () => reject(reader.error ?? new Error('read failed'))
364
+ reader.readAsDataURL(file)
365
+ })
366
+ }
367
+
368
+ /**
369
+ * 先在本机看一眼选中的文件到底是不是私钥。
370
+ *
371
+ * 这才是真正管用的限制:`accept` 只能按扩展名过滤,而最常见的私钥
372
+ * (`id_rsa`、`id_ed25519`)恰恰没有扩展名——只靠扩展名会把主用途挡在门外。
373
+ * 内容检查既拦得住无关文件,也不会误伤它们。
374
+ *
375
+ * @param file - 用户选中的文件。
376
+ * @param t - 当前语言的取词函数。
377
+ * @returns 问题描述;看起来是私钥时返回 `null`。
378
+ */
379
+ async function inspectKeyFile(file, t = fallbackT) {
380
+ if (file.size > 64 * 1024) return t('keyTooLarge')
381
+ let text
382
+ try {
383
+ text = (await file.text()).slice(0, 8192)
384
+ } catch {
385
+ return t('keyReadError')
386
+ }
387
+ const first = (text.split(/\r?\n/, 1)[0] ?? '').trim()
388
+ if (/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-|sk-ssh-|sk-ecdsa-)/.test(first)) return t('keyIsPublic')
389
+ if (/^PuTTY-User-Key-File-/.test(first)) return t('keyIsPpk')
390
+ if (!/^-----BEGIN (?:OPENSSH |RSA |DSA |EC |ENCRYPTED )?PRIVATE KEY-----/m.test(text)) return t('keyNotPrivate')
391
+ return null
392
+ }
393
+
394
+ /** 一行表单字段。 */
395
+ function Field(props) {
396
+ return h('label', { style: S.field },
397
+ h('span', { style: S.label }, props.label),
398
+ props.children,
399
+ props.hint ? h('span', { style: S.hint }, props.hint) : null)
400
+ }
401
+
402
+ /**
403
+ * 认证方式徽章。
404
+ * @param host - 列表里的一条主机记录。
405
+ * @param t - 当前语言的取词函数。
406
+ * @returns 徽章文字数组。
407
+ */
408
+ function authBadges(host, t) {
409
+ const badges = []
410
+ if (host.authType === 'key') badges.push(t('keyBadge'))
411
+ else if (host.hasPassword) badges.push(t('passwordBadge'))
412
+ else badges.push(t('noPasswordBadge'))
413
+ if (host.hasSudoPassword) badges.push(t('sudoBadge'))
414
+ if (host.credentialRef) badges.push(`credentialRef: ${host.credentialRef}`)
415
+ return badges
416
+ }
417
+
418
+ /** 新增/编辑表单。 */
419
+ function HostForm(props) {
420
+ const t = useT()
421
+ const existing = props.record
422
+ const [alias, setAlias] = useState(existing ? existing.alias : '')
423
+ const [host, setHost] = useState('')
424
+ const [port, setPort] = useState('22')
425
+ const [username, setUsername] = useState('')
426
+ const [authType, setAuthType] = useState(existing ? existing.authType : 'password')
427
+ const [password, setPassword] = useState('')
428
+ const [keyPath, setKeyPath] = useState(existing && existing.privateKeyPath ? existing.privateKeyPath : '')
429
+ const [keyFile, setKeyFile] = useState(null)
430
+ const [keyFileError, setKeyFileError] = useState('')
431
+ const [passphrase, setPassphrase] = useState('')
432
+ const [shell, setShell] = useState(existing ? existing.shell : 'bash')
433
+ const [description, setDescription] = useState(existing ? existing.description : '')
434
+ const [tags, setTags] = useState(existing && existing.tags.length > 0 ? existing.tags.join(', ') : '')
435
+ const [bindAddress, setBindAddress] = useState(existing ? existing.bindAddress : '')
436
+ const [error, setError] = useState('')
437
+ const [busy, setBusy] = useState(false)
438
+
439
+ // 编辑一台已存在的主机时,地址与账号需要先向宿主索取(列表里是脱敏值)。
440
+ useEffect(() => {
441
+ if (!existing) return
442
+ let alive = true
443
+ // reveal 的失败结果本来就不展示,取词函数用默认值即可,免得语言切换触发重复请求。
444
+ call('reveal', { alias: existing.alias }).then((result) => {
445
+ if (!alive || !result.ok) return
446
+ setHost(result.value.host ?? '')
447
+ setPort(String(result.value.port ?? 22))
448
+ setUsername(result.value.username ?? '')
449
+ })
450
+ return () => { alive = false }
451
+ }, [existing])
452
+
453
+ const submit = async () => {
454
+ setError('')
455
+ if (alias.trim().length === 0 || host.trim().length === 0 || username.trim().length === 0) {
456
+ setError(t('errorRequired'))
457
+ return
458
+ }
459
+ if (authType === 'password' && password.length === 0 && !(existing && existing.hasPassword)) {
460
+ setError(t('errorRequired'))
461
+ return
462
+ }
463
+ setBusy(true)
464
+ const payload = {
465
+ alias: alias.trim(),
466
+ // 编辑时带上旧别名:改名要靠它定位原记录,凭据、私钥路径、备注才会跟着搬过去。
467
+ ...(existing !== null ? { renameFrom: existing.alias } : {}),
468
+ host: host.trim(),
469
+ port: Number(port),
470
+ username: username.trim(),
471
+ authType,
472
+ shell,
473
+ description,
474
+ tags: tags.split(',').map((tag) => tag.trim()).filter((tag) => tag.length > 0),
475
+ bindAddress,
476
+ }
477
+ if (authType === 'password') payload.password = password
478
+ else {
479
+ payload.privateKeyPath = keyPath.trim()
480
+ if (passphrase.length > 0) payload.passphrase = passphrase
481
+ if (keyFile) {
482
+ try {
483
+ payload.privateKeyFile = { name: keyFile.name, base64: await readAsBase64(keyFile) }
484
+ } catch {
485
+ setBusy(false)
486
+ setError(t('transportError'))
487
+ return
488
+ }
489
+ }
490
+ }
491
+ const result = await call('save', payload, t)
492
+ setBusy(false)
493
+ if (!result.ok) {
494
+ setError(result.error)
495
+ return
496
+ }
497
+ props.onDone(alias.trim())
498
+ }
499
+
500
+ return h('div', { style: S.form },
501
+ h('div', { style: S.fields },
502
+ h(Field, { label: t('alias') },
503
+ h('input', { style: S.input, value: alias, onChange: (e) => setAlias(e.target.value) })),
504
+ h(Field, { label: t('host') },
505
+ h('input', { style: S.input, value: host, placeholder: '192.0.2.10', onChange: (e) => setHost(e.target.value) })),
506
+ h(Field, { label: t('port') },
507
+ h('input', { style: S.input, value: port, inputMode: 'numeric', onChange: (e) => setPort(e.target.value) })),
508
+ h(Field, { label: t('username'), hint: authType === 'key' ? t('usernameKeyHint') : undefined },
509
+ h('input', { style: S.input, value: username, onChange: (e) => setUsername(e.target.value) }))),
510
+
511
+ h('div', { style: S.row },
512
+ h('span', { style: S.label }, t('authType')),
513
+ h('label', { style: S.row },
514
+ h('input', { type: 'radio', checked: authType === 'password', onChange: () => setAuthType('password') }),
515
+ t('authPassword')),
516
+ h('label', { style: S.row },
517
+ h('input', { type: 'radio', checked: authType === 'key', onChange: () => setAuthType('key') }),
518
+ t('authKey'))),
519
+
520
+ authType === 'password'
521
+ ? h(Field, { label: t('password'), hint: existing && existing.hasPassword ? t('passwordKeep') : undefined },
522
+ h('input', { style: S.input, type: 'password', value: password, autoComplete: 'new-password', onChange: (e) => setPassword(e.target.value) }))
523
+ : h('div', { style: S.fields },
524
+ h(Field, { label: t('keyUpload'), hint: `${t('keyUploadHint')} ${t('keyAcceptHint')}` },
525
+ // 原生 file input 自带边框与内边距,塞进 28px 的输入框里就会像截图那样错位。
526
+ // 隐藏它、用自己的按钮当触发器,高度与圆角便与其它控件一致。
527
+ h('label', { style: { ...S.button, alignSelf: 'flex-start' } },
528
+ t('chooseFile'),
529
+ h('input', {
530
+ type: 'file',
531
+ // accept 只是给文件选择器的建议:无扩展名的 id_rsa 仍需切到「所有文件」,
532
+ // 真正的把关在 inspectKeyFile(本机预检)与 Host 侧的内容校验。
533
+ accept: '.pem,.key,.ppk,.openssh,application/x-pem-file',
534
+ style: { display: 'none' },
535
+ onChange: async (e) => {
536
+ const picked = e.target.files && e.target.files[0] ? e.target.files[0] : null
537
+ setKeyFileError('')
538
+ if (picked === null) {
539
+ setKeyFile(null)
540
+ return
541
+ }
542
+ const problem = await inspectKeyFile(picked, t)
543
+ if (problem !== null) {
544
+ // 选错就地拒绝并清空,免得把无关文件传上去再被 Host 退回来。
545
+ setKeyFile(null)
546
+ setKeyFileError(problem)
547
+ e.target.value = ''
548
+ return
549
+ }
550
+ setKeyFile(picked)
551
+ },
552
+ })),
553
+ h('span', { style: keyFileError.length > 0 ? S.badText : S.hint },
554
+ keyFileError.length > 0
555
+ ? keyFileError
556
+ : keyFile ? `${t('keyUploaded')}: ${keyFile.name}` : t('noFileChosen'))),
557
+ h(Field, { label: t('keyPath') },
558
+ h('input', { style: S.input, value: keyPath, placeholder: 'C:\\keys\\id_rsa', onChange: (e) => setKeyPath(e.target.value) })),
559
+ h(Field, { label: t('passphrase'), hint: t('passphraseHint') },
560
+ h('input', { style: S.input, type: 'password', autoComplete: 'new-password', value: passphrase, onChange: (e) => setPassphrase(e.target.value) }))),
561
+
562
+ h('div', { style: S.fields },
563
+ h(Field, { label: t('shell') },
564
+ h('input', { style: S.input, value: shell, placeholder: 'bash', onChange: (e) => setShell(e.target.value) })),
565
+ h(Field, { label: t('bindAddress') },
566
+ h('input', { style: S.input, value: bindAddress, onChange: (e) => setBindAddress(e.target.value) })),
567
+ h(Field, { label: t('description') },
568
+ h('input', { style: S.input, value: description, onChange: (e) => setDescription(e.target.value) })),
569
+ h(Field, { label: t('tags') },
570
+ h('input', { style: S.input, value: tags, onChange: (e) => setTags(e.target.value) }))),
571
+
572
+ error.length > 0 ? h('div', { style: S.error }, error) : null,
573
+ h('div', { style: S.row },
574
+ h('button', { 'data-variant': 'primary', style: { ...S.button, ...S.primary }, disabled: busy, onClick: submit }, t('save')),
575
+ h('button', { style: S.button, disabled: busy, onClick: props.onCancel }, t('cancel'))))
576
+ }
577
+
578
+ /** 设置页主体。 */
579
+ function SshVaultSettings() {
580
+ const t = useT()
581
+ // t 会随语言切换换引用;两个 useCallback 用 ref 取最新 t,保持依赖稳定——
582
+ // 否则切一次语言就会重新拉列表、重测所有主机(串行 SSH,代价很高)。
583
+ const tRef = useRef(t)
584
+ tRef.current = t
585
+ const [state, setState] = useState({ status: 'loading', hosts: [], error: '', keyMode: '', vaultDir: '', version: '' })
586
+ const [form, setForm] = useState(null)
587
+ const [tests, setTests] = useState({})
588
+ // 打开按钮的反馈必须留在按钮旁边:失败信息若只出现在页顶,点按钮的人看不到。
589
+ const [openState, setOpenState] = useState({ status: 'idle', detail: '' })
590
+ /** 每台主机的连通体检结果:alias -> { status, detail }。 */
591
+ const [health, setHealth] = useState({})
592
+
593
+ const load = useCallback(async () => {
594
+ const result = await call('list', undefined, tRef.current)
595
+ if (result.ok) {
596
+ setState({
597
+ status: 'ready',
598
+ hosts: result.value.hosts,
599
+ error: '',
600
+ keyMode: result.value.keyMode,
601
+ vaultDir: result.value.vaultDir,
602
+ version: result.value.version ?? '',
603
+ })
604
+ } else {
605
+ setState({ status: 'ready', hosts: [], error: result.error, keyMode: '', vaultDir: '', version: '' })
606
+ }
607
+ }, [])
608
+
609
+ useEffect(() => { load() }, [load])
610
+
611
+ /**
612
+ * 体检一台主机,同时更新卡片上的状态点。
613
+ * @param alias - 主机别名。
614
+ * @returns 归一化结果,供下方的详细结果区复用。
615
+ */
616
+ const measureHost = useCallback(async (alias) => {
617
+ setHealth((map) => ({ ...map, [alias]: { status: 'checking' } }))
618
+ const result = await call('test', { alias }, tRef.current)
619
+ const ok = result.ok === true && result.value.ok === true
620
+ const detail = result.ok === true ? result.value.detail : result.error
621
+ const notes = result.ok === true ? (result.value.notes ?? []) : []
622
+ setHealth((map) => ({ ...map, [alias]: { status: ok ? 'ok' : 'fail', detail } }))
623
+ return { ok, status: ok ? 'ok' : 'fail', detail, notes }
624
+ }, [])
625
+
626
+ // 卡片一出现就各自体检一次。串行执行:同时开一批 SSH 连接对远端不友好,而且
627
+ // 一台不通的主机(Host 侧给了 15 秒超时)会让并发的其余连接一起挤在超时上。
628
+ //
629
+ // 依赖里带 updatedAt:编辑连接改的是地址/端口/凭据,别名并不变。只看别名的话
630
+ // 保存后不会重测,旧的红点就一直留在那里。
631
+ const aliasKey = state.hosts.map((host) => `${host.alias}|${host.updatedAt}`).join(',')
632
+ useEffect(() => {
633
+ if (state.status !== 'ready' || state.hosts.length === 0) return undefined
634
+ let cancelled = false
635
+ const run = async () => {
636
+ for (const host of state.hosts) {
637
+ if (cancelled) return
638
+ await measureHost(host.alias)
639
+ if (cancelled) return
640
+ }
641
+ }
642
+ run()
643
+ return () => { cancelled = true }
644
+ // 用 aliasKey 而不是 state.hosts:数组引用每次渲染都变,会变成无限重测。
645
+ }, [aliasKey, state.status, measureHost])
646
+
647
+ const runTest = async (alias) => {
648
+ setTests((map) => ({ ...map, [alias]: { status: 'running' } }))
649
+ // 手动测试也必须走 measureHost:否则测试成功了,卡片上的点还停在红色。
650
+ const entry = await measureHost(alias)
651
+ setTests((map) => ({ ...map, [alias]: { status: entry.status, detail: entry.detail, notes: entry.notes } }))
652
+ }
653
+
654
+ const removeHost = async (alias) => {
655
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function'
656
+ && !window.confirm(t('confirmRemove').replace('{alias}', alias))) return
657
+ const result = await call('remove', { alias }, t)
658
+ if (!result.ok) {
659
+ setState((s) => ({ ...s, error: result.error }))
660
+ return
661
+ }
662
+ await load()
663
+ }
664
+
665
+ // 终端图标:设置导航项的图标由 shell 按 section id 绘制,插件无法覆盖
666
+ // (settings.section 的注册选项只有 id / order / label),所以在页面标题旁补一个。
667
+ const terminalIcon = h('svg', {
668
+ viewBox: '0 0 24 24', width: 18, height: 18, style: S.titleIcon, 'aria-hidden': true,
669
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
670
+ },
671
+ h('rect', { x: 2.5, y: 4.5, width: 19, height: 15, rx: 2.5 }),
672
+ h('path', { d: 'M6.5 9.5l3 2.5-3 2.5' }),
673
+ h('path', { d: 'M12.5 14.5h5' }))
674
+
675
+ const openVault = async () => {
676
+ setOpenState({ status: 'working', detail: '' })
677
+ const result = await call('openVault', undefined, t)
678
+ setOpenState(result.ok ? { status: 'ok', detail: '' } : { status: 'fail', detail: result.error })
679
+ }
680
+
681
+ const metaText = `dsh-ssh-vault v${state.version} · ${t('keyMode')}: ${state.keyMode}`
682
+
683
+ const header = h('div', { style: S.head },
684
+ h('div', { style: { minWidth: 0 } },
685
+ h('div', { style: S.titleRow }, terminalIcon, h('h3', { style: S.h }, t('heading'))),
686
+ h('p', { style: S.intro }, t('intro')),
687
+ state.status === 'ready' && state.error.length === 0
688
+ ? h('div', { style: S.metaBlock },
689
+ h('div', { style: S.meta, title: metaText }, metaText),
690
+ h('div', { style: S.metaRow },
691
+ h('span', { style: S.metaPath, title: state.vaultDir }, `${t('vaultDir')}: ${state.vaultDir}`),
692
+ h('button', {
693
+ style: S.miniButton,
694
+ disabled: openState.status === 'working',
695
+ onClick: openVault,
696
+ }, openState.status === 'working' ? t('openWorking') : t('openVault')),
697
+ openState.status === 'ok' ? h('span', { key: 'open-ok', style: S.okText }, t('openDone')) : null,
698
+ openState.status === 'fail'
699
+ ? h('span', {
700
+ key: 'open-fail',
701
+ title: openState.detail,
702
+ style: { ...S.badText, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
703
+ }, openState.detail)
704
+ : null))
705
+ : null),
706
+ h('button', {
707
+ 'data-variant': 'primary',
708
+ style: { ...S.button, ...S.primary },
709
+ onClick: () => setForm({ mode: 'new', record: null }),
710
+ }, t('add')))
711
+
712
+ // 「添加」的表单出现在顶部;「编辑」的表单在**对应卡片下方**就地展开——
713
+ // 表单原先渲染在列表之前,点编辑时会把视线和滚动位置一起拉到页面顶端。
714
+ const formHandlers = {
715
+ onDone: async (savedAlias) => {
716
+ setForm(null)
717
+ await load()
718
+ // 编辑动的是地址/凭据,保存后必须重测这一台,否则旧的红点会一直挂着。
719
+ if (typeof savedAlias === 'string' && savedAlias.length > 0) await measureHost(savedAlias)
720
+ },
721
+ onCancel: () => setForm(null),
722
+ }
723
+ const isEditing = (host) => form !== null && form.mode === 'edit' && form.record !== null && form.record.alias === host.alias
724
+
725
+ return h('div', { className: SCOPE, style: S.page },
726
+ h('style', { key: 'scope-style' }, CSS),
727
+ header,
728
+ state.error.length > 0 ? h('div', { style: S.error }, state.error) : null,
729
+ form !== null && form.mode === 'new'
730
+ ? h(HostForm, { key: 'new-form', record: null, ...formHandlers })
731
+ : null,
732
+ state.status === 'loading'
733
+ ? h('div', { style: { color: C.textDim } }, t('loading'))
734
+ : state.hosts.length === 0 && form === null
735
+ ? h('div', { style: { color: C.textDim } }, t('empty'))
736
+ : state.hosts.map((host) => {
737
+ const test = tests[host.alias]
738
+ return h('div', { key: host.alias, style: S.hostBlock },
739
+ h('div', { style: S.card },
740
+ h('div', { style: S.cardHead },
741
+ h('span', {
742
+ style: {
743
+ ...S.dot,
744
+ ...HEALTH_DOT[health[host.alias] === undefined ? 'idle' : health[host.alias].status],
745
+ },
746
+ title: healthTitle(health[host.alias], t),
747
+ }),
748
+ h('span', { style: S.alias }, host.alias),
749
+ ...authBadges(host, t).map((badge, index) => h('span', { key: index, style: S.badge }, badge)),
750
+ host.updatedAt ? h('span', { style: { ...S.badge, borderColor: 'transparent' } }, `${t('updatedAt')} ${String(host.updatedAt).slice(0, 19).replace('T', ' ')}`) : null),
751
+ h('div', { style: S.kv },
752
+ h('span', null, `${host.host}:${host.port}`),
753
+ h('span', null, host.username),
754
+ h('span', null, t('shellLabel')),
755
+ h('span', null, host.shell),
756
+ ...(host.description ? [h('span', null, t('description')), h('span', null, host.description)] : []),
757
+ ...(host.tags.length > 0 ? [h('span', null, t('tags')), h('span', null, host.tags.join(', '))] : [])),
758
+ h('div', { style: S.row },
759
+ h('button', { style: S.button, disabled: test && test.status === 'running', onClick: () => runTest(host.alias) },
760
+ test && test.status === 'running' ? t('testing') : t('test')),
761
+ h('button', {
762
+ style: S.button,
763
+ onClick: () => setForm(isEditing(host) ? null : { mode: 'edit', record: host }),
764
+ }, isEditing(host) ? t('cancel') : t('edit')),
765
+ h('button', { style: { ...S.button, ...S.danger }, onClick: () => removeHost(host.alias) }, t('remove'))),
766
+ test && test.status !== 'running'
767
+ ? h('div', null,
768
+ h('div', { style: test.status === 'ok' ? S.okText : S.badText }, test.status === 'ok' ? t('testOk') : t('testFail')),
769
+ test.detail ? h('pre', { style: S.pre }, test.detail) : null,
770
+ test.notes && test.notes.length > 0 ? h('pre', { style: S.pre }, test.notes.join('\n')) : null)
771
+ : null),
772
+ isEditing(host) ? h(HostForm, { record: host, ...formHandlers }) : null)
773
+ }))
774
+ }
775
+
776
+ // —— SSH 工具的对话内终端视图 ——
777
+ //
778
+ // 注册点是 `tool.call.toolview`:按**工具名**分发,所以我们只影响自己注册的三个工具,
779
+ // 不覆盖任何内置 UI(ssh_run / ssh_probe / ssh_transfer 都不在已占用列表里)。
780
+ // 内置 bash 工具在同一槽位用的是宿主的 TerminalBlock;纯 JS 客户端拿不到宿主包
781
+ // (builtins 只有 ctx / React / host / styles / console),所以这里自绘一个可滚动终端。
782
+
783
+ /**
784
+ * 从工具节点里取出这次调用的形状。
785
+ *
786
+ * 运行中时节点本身就是 call;已结算时挂在 `block.call` 上,结果在 `block.content`。
787
+ *
788
+ * @param block - 工具节点。
789
+ * @returns 归一化的调用视图,或 null(交给通用行)。
790
+ */
791
+ function sshCallOf(block) {
792
+ if (block === null || typeof block !== 'object') return null
793
+ const settled = Object.prototype.hasOwnProperty.call(block, 'kind')
794
+ const call = settled ? block.call : block
795
+ if (call === null || typeof call !== 'object') return null
796
+ let args = {}
797
+ try {
798
+ const parsed = JSON.parse(call.argsRaw)
799
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed
800
+ } catch {
801
+ args = {}
802
+ }
803
+ return {
804
+ settled,
805
+ name: typeof call.name === 'string' ? call.name : '',
806
+ args,
807
+ text: settled ? resultTextOf(block) : '',
808
+ isError: block.isError === true,
809
+ }
810
+ }
811
+
812
+ /**
813
+ * 取结果里的唯一文本块,并剥掉 Host 侧包上的 console 围栏(围栏只是给通用行兜底用的)。
814
+ * @param block - 已结算的工具节点。
815
+ * @returns 输出文本。
816
+ */
817
+ function resultTextOf(block) {
818
+ const content = block.content
819
+ if (!Array.isArray(content) || content.length !== 1) return ''
820
+ const only = content[0]
821
+ if (only === null || typeof only !== 'object' || only.type !== 'text' || typeof only.text !== 'string') return ''
822
+ const fenced = /^```[a-z]*\n([\s\S]*?)\n```$/.exec(only.text)
823
+ return fenced === null ? only.text : fenced[1]
824
+ }
825
+
826
+ /**
827
+ * 终端标题行:优先显示实际命令,其余工具给出一行等价描述。
828
+ * @param call - 归一化的调用视图。
829
+ * @returns 标题文本。
830
+ */
831
+ function sshHeadlineOf(call) {
832
+ if (typeof call.args.command === 'string' && call.args.command.trim().length > 0) return call.args.command
833
+ if (call.name === 'ssh_probe') return `ssh_probe ${String(call.args.host ?? '')}`
834
+ if (call.name === 'ssh_transfer') {
835
+ const arrow = call.args.direction === 'upload' ? '→' : '←'
836
+ return `scp ${String(call.args.local ?? '')} ${arrow} ${String(call.args.remote ?? '')}`
837
+ }
838
+ return call.name
839
+ }
840
+
841
+ /**
842
+ * 把输出拆成正文与退出状态。
843
+ *
844
+ * 标记约定与 Host 侧渲染一致:`[exit code: N]` / `[killed by signal: X]`。退出码要从
845
+ * 正文里摘掉,否则同一次失败会既出现在文本里又出现在状态标签上。标记后面还可能跟着
846
+ * 诊断建议,所以匹配不要求它位于整段文本的最末尾。
847
+ *
848
+ * @param text - 工具结果文本。
849
+ * @returns 去掉标记的正文,以及退出码或终止信号。
850
+ */
851
+ function splitTerminalStatus(text) {
852
+ const signal = /\n\[killed by signal: ([^\]\n]+)\](?:\n|$)/.exec(text)
853
+ if (signal !== null) {
854
+ return { body: text.replace(signal[0], '\n').replace(/\n+$/, ''), signal: signal[1], exitCode: undefined }
855
+ }
856
+ const exit = /\n\[exit code: (\d+)\](?:\n|$)/.exec(text)
857
+ if (exit !== null) {
858
+ return { body: text.replace(exit[0], '\n').replace(/\n+$/, ''), signal: undefined, exitCode: Number(exit[1]) }
859
+ }
860
+ return { body: text.replace(/\n+$/, ''), signal: undefined, exitCode: 0 }
861
+ }
862
+
863
+ const MONO = 'ui-monospace, SFMono-Regular, Consolas, "Courier New", monospace'
864
+ const T = {
865
+ card: {
866
+ border: `0.5px solid ${C.border}`, borderRadius: C.radiusMd, overflow: 'hidden',
867
+ // 中性半透明底:深色主题下变深、浅色主题下是淡灰,文字始终走 label-primary 保证对比。
868
+ background: 'rgba(0,0,0,0.14)',
869
+ },
870
+ head: {
871
+ display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 10px',
872
+ borderBottom: `0.5px solid ${C.borderSubtle}`, color: C.textDim,
873
+ fontFamily: MONO, fontSize: '11.5px', lineHeight: 1.5,
874
+ },
875
+ dot: { width: '7px', height: '7px', borderRadius: '50%', flex: 'none', background: C.textDim },
876
+ dotOk: { width: '7px', height: '7px', borderRadius: '50%', flex: 'none', background: C.ok },
877
+ dotRunning: { width: '7px', height: '7px', borderRadius: '50%', flex: 'none', background: 'var(--dsw-alias-state-business-primary, #4f7cff)' },
878
+ dotError: { width: '7px', height: '7px', borderRadius: '50%', flex: 'none', background: C.bad },
879
+ headText: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: C.text },
880
+ pre: {
881
+ margin: 0, padding: '8px 10px', maxHeight: '320px', overflow: 'auto',
882
+ fontFamily: MONO, fontSize: '12px', lineHeight: 1.55,
883
+ color: C.text, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
884
+ },
885
+ empty: { padding: '8px 10px', color: C.textDim, fontSize: '11.5px', fontFamily: MONO },
886
+ statusBadge: {
887
+ marginLeft: 'auto', flex: 'none', padding: '0 6px', borderRadius: '999px',
888
+ border: `0.5px solid ${C.border}`, fontSize: '10.5px', color: C.textDim,
889
+ },
890
+ /** 居中的段名徽章:绝对定位到标题栏正中,不受左右两项的宽度影响。 */
891
+ badgeCentered: {
892
+ position: 'absolute', left: '50%', transform: 'translateX(-50%)',
893
+ flex: 'none', padding: '0 6px', borderRadius: '999px',
894
+ border: `0.5px solid ${C.border}`, fontSize: '10.5px', color: C.textDim,
895
+ },
896
+ }
897
+
898
+ /**
899
+ * 对话里的 SSH 终端行:标题显示命令与主机,下面是一个可滚动的输出区。
900
+ * @param props - toolview 运行时注入的阶段数据。
901
+ * @returns 终端视图。
902
+ */
903
+ function SshTerminalRow(props) {
904
+ const t = useT()
905
+ const call = sshCallOf(props?.block)
906
+ if (call === null) return null
907
+ const running = !call.settled
908
+ const host = typeof call.args.host === 'string' ? call.args.host : ''
909
+ const headline = sshHeadlineOf(call)
910
+ const label = host === '' ? headline : `${host} · ${headline}`
911
+ const status = splitTerminalStatus(call.text)
912
+ // 非零退出码不算错误返回(工具按约定不抛错),但状态点必须反映它,否则失败看起来是成功的。
913
+ const failed = call.isError || status.signal !== undefined
914
+ || (status.exitCode !== undefined && status.exitCode !== 0)
915
+ const badge = status.signal !== undefined
916
+ ? status.signal
917
+ : status.exitCode !== undefined && status.exitCode !== 0 ? `exit ${status.exitCode}` : null
918
+ const body = status.body
919
+ return h('div', { style: T.card },
920
+ h('div', { style: T.head },
921
+ h('span', { style: failed ? T.dotError : running ? T.dotRunning : T.dot, 'aria-hidden': true }),
922
+ h('span', { style: T.headText, title: label }, label),
923
+ badge !== null ? h('span', { style: T.statusBadge }, badge) : null),
924
+ body.length > 0
925
+ ? h('pre', { style: T.pre }, body)
926
+ : h('div', { style: T.empty }, running ? t('running') : t('noOutput')))
927
+ }
928
+
929
+ /** 与 Host 侧约定的分节标记:`─── 连接状态:ok ───` / `─── 命令执行:fail ───`。 */
930
+ const SECTION_RE = /^─── (.+?):(ok|fail) ───$/
931
+
932
+ /**
933
+ * 把 `/hosts` 的输出按分节标记拆开。
934
+ * @param text - 命令结果文本。
935
+ * @returns 各段:标题、该段是否失败、正文。
936
+ */
937
+ function splitSections(text) {
938
+ const sections = []
939
+ let current = null
940
+ for (const line of text.split('\n')) {
941
+ const match = SECTION_RE.exec(line)
942
+ if (match !== null) {
943
+ current = { title: match[1], failed: match[2] === 'fail', lines: [] }
944
+ sections.push(current)
945
+ continue
946
+ }
947
+ if (current !== null) current.lines.push(line)
948
+ }
949
+ return sections.map((section) => ({ ...section, body: section.lines.join('\n').trim() }))
950
+ }
951
+
952
+ /**
953
+ * 对话里 `/hosts` 命令行的终端视图。
954
+ *
955
+ * 通用命令行卡片会把结果塞进一个无样式的 `<pre>` 并默认折叠,和 `ssh_run` 的终端卡片
956
+ * 完全不像两种东西。命令名的渲染槽位目前是空的,所以直接接管,复用同一套卡片样式。
957
+ *
958
+ * 每个分段渲染成一张独立卡片、**各自**亮灯:连接状态只看探测结果,命令执行只看命令的
959
+ * 退出状态。早先两张卡共用整条命令的成败标志,命令一失败连通的卡片也跟着变红。
960
+ *
961
+ * @param props - 命令行注入的节点;`node` 就是这次命令的生命周期数据。
962
+ * @returns 终端视图。
963
+ */
964
+ function HostsCommandRow(props) {
965
+ const t = useT()
966
+ const node = props?.node
967
+ const outcome = node?.outcome
968
+ const running = outcome === null || outcome === undefined
969
+ const text = typeof outcome?.text === 'string' ? outcome.text : ''
970
+ const name = typeof node?.name === 'string' && node.name.length > 0 ? node.name : 'hosts'
971
+ const sections = splitSections(text)
972
+ if (sections.length === 0) {
973
+ // 运行中或整条命令报错时没有分节标记,退回单卡片。
974
+ return h('div', { style: T.card },
975
+ h('div', { style: T.head },
976
+ h('span', { style: running ? T.dotRunning : T.dotError, 'aria-hidden': true }),
977
+ h('span', { style: T.headText }, `/${name}`)),
978
+ text.length > 0
979
+ ? h('pre', { style: T.pre }, text)
980
+ : h('div', { style: T.empty }, running ? t('running') : t('noOutput')))
981
+ }
982
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
983
+ ...sections.map((section, index) => h('div', { key: index, style: T.card },
984
+ // 段名居中:它是这张卡的身份,不该和右侧的「有失败」挤在同一条对齐线上。
985
+ h('div', { style: { ...T.head, position: 'relative' } },
986
+ h('span', {
987
+ style: section.failed ? T.dotError : running ? T.dotRunning : T.dotOk,
988
+ 'aria-hidden': true,
989
+ }),
990
+ h('span', { style: T.headText }, index === 0 ? `/${name}` : section.title),
991
+ index === 0 ? h('span', { style: T.badgeCentered }, section.title) : null,
992
+ section.failed ? h('span', { style: T.statusBadge }, t('anyFailed')) : null),
993
+ section.body.length > 0
994
+ ? h('pre', { style: T.pre }, section.body)
995
+ : h('div', { style: T.empty }, running ? t('running') : t('noOutput')))))
996
+ }
997
+
998
+ /**
999
+ * 从宿主 Chat 目标快照里取出 `/hosts` 的命令节点。
1000
+ *
1001
+ * DSH 的 Chat 视图有一个约定:**整段记录只有命令节点时它不算「活跃视图」**
1002
+ * (`chatViewDefinition.isActive` 要求至少有一个非 command 节点)。于是新建会话里
1003
+ * 先跑 `/hosts` 的结果既不在对话区显示,也不会自动展开——用户看起来就是「回车没反应」,
1004
+ * 直到发出第一条消息,命令卡片才作为历史出现在顶部。
1005
+ *
1006
+ * 这个函数把那些命令节点找出来,交给下面的 dock 面板在「首条消息之前」代为显示。
1007
+ *
1008
+ * @param snapshot - Chat 目标快照 `{ order, nodes }`;尚未激活时为 undefined。
1009
+ * @returns `/hosts` 的命令数据数组(按时间顺序);快照不可读时为 null。
1010
+ */
1011
+ function hostsCommandNodesOf(snapshot) {
1012
+ if (snapshot === undefined || snapshot === null || !Array.isArray(snapshot.order)) return null
1013
+ const nodes = snapshot.nodes
1014
+ if (nodes === undefined || typeof nodes.get !== 'function') return null
1015
+ const commands = []
1016
+ for (const key of snapshot.order) {
1017
+ const node = nodes.get(key)
1018
+ if (node?.kind === 'command' && node.data?.name === 'hosts') commands.push(node.data)
1019
+ }
1020
+ return commands
1021
+ }
1022
+
1023
+ /**
1024
+ * Chat 视图是否已经有非命令内容。
1025
+ * @param snapshot - Chat 目标快照。
1026
+ * @returns 有普通消息(对话区会自己渲染)时为 true。
1027
+ */
1028
+ function chatHasTranscript(snapshot) {
1029
+ if (snapshot === undefined || snapshot === null || !Array.isArray(snapshot.order)) return false
1030
+ const nodes = snapshot.nodes
1031
+ if (nodes === undefined || typeof nodes.get !== 'function') return false
1032
+ return snapshot.order.some((key) => nodes.get(key)?.kind !== 'command')
1033
+ }
1034
+
1035
+ /**
1036
+ * 首条消息之前的 `/hosts` 结果面板。
1037
+ *
1038
+ * 宿主只会在「有非命令节点」时才把 Chat 视图当作活跃视图,所以新会话里单独跑 `/hosts`
1039
+ * 的结果默认不可见。这里把这些命令节点渲染在输入框上方的 dock 里:运行中立刻能看到
1040
+ * 「执行中…」,结果出来就地显示;一旦发出第一条消息,对话区接管,本面板返回 null,
1041
+ * 不会重复显示。
1042
+ *
1043
+ * @param props - 注入的两个 hook 与 locale 的 t。
1044
+ * @returns dock 卡片,或 null(不该显示时)。
1045
+ */
1046
+ function HostsResultDock(props) {
1047
+ const t = useT()
1048
+ const session = props.useSshHostsSession((snapshot) => snapshot)
1049
+ const chat = props.useSshHostsChat((snapshot) => snapshot)
1050
+ const commands = hostsCommandNodesOf(chat)
1051
+ if (session?.blank !== true || chatHasTranscript(chat) || commands === null || commands.length === 0) return null
1052
+ return h('div', {
1053
+ style: { display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '38vh', overflowY: 'auto' },
1054
+ 'data-ssh-vault-hosts-dock': '',
1055
+ },
1056
+ h('div', { style: { color: C.textDim, fontSize: '11px' } }, t('dockHint')),
1057
+ ...commands.map((command, index) => h(HostsCommandRow, {
1058
+ key: `hosts-${command.commandId ?? command.seq ?? index}`,
1059
+ node: command,
1060
+ })))
1061
+ }
1062
+
1063
+ return {
1064
+ // locale 是可选的:这里只声明 slots,locale 用 ctx.get('locale') 做可选查找,
1065
+ // 没有它的老宿主照样能加载(文案退回 fallbackT)。
1066
+ inject: ['slots'],
1067
+ apply(ctx) {
1068
+ const locale = ctx.get('locale')
1069
+ if (locale !== undefined) {
1070
+ // 字典随插件卸载一并撤销;语言切换时宿主按 revision 重渲染带 locale 的 slot。
1071
+ ctx.effect(() => locale.register(NS, { zh: DICT.zh, en: DICT.en }), 'ssh-vault: dictionaries')
1072
+ }
1073
+ const loc = locale === undefined ? {} : { locale: NS }
1074
+ // 设置导航项的文字在宿主列表快照里求值,locale revision 变化时会被重新调用。
1075
+ const label = () => (locale === undefined ? fallbackT('sectionTitle') : locale.bind(NS)('sectionTitle'))
1076
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1077
+ name: 'settings.section',
1078
+ id: 'ssh-vault',
1079
+ order: 20,
1080
+ label,
1081
+ ...loc,
1082
+ }, withLocale(SshVaultSettings)))
1083
+ // SSH 工具的对话内终端:按工具名分发,只占我们自己的三个名字。
1084
+ ctx.slots.inject('tool.call.toolview', function* () {
1085
+ for (const key of ['ssh_run', 'ssh_probe', 'ssh_transfer']) {
1086
+ yield ctx.slots.register({ name: 'tool.call.toolview', key, ...loc }, withLocale(SshTerminalRow))
1087
+ }
1088
+ })
1089
+ // `/hosts` 命令自己的行:让它的输出也呈终端样式,而不是通用的折叠 <pre>。
1090
+ ctx.slots.inject('conversation.chat.commandview', () => ctx.slots.register({
1091
+ name: 'conversation.chat.commandview',
1092
+ key: 'hosts',
1093
+ ...loc,
1094
+ }, withLocale(HostsCommandRow)))
1095
+
1096
+ // 新会话里「只有命令、还没有任何消息」时,宿主不把 Chat 视图当活跃视图,
1097
+ // `/hosts` 结果因此不可见。把它渲染到输入框上方的 dock 里补上这个空档。
1098
+ // sessions / uiConversation 用 ctx.inject 等它们就绪(它们可能比本插件晚加载);
1099
+ // 老宿主没有这两个服务时回调永不触发,插件其余部分照常工作。
1100
+ const EMPTY_SOURCE = { getSnapshot: () => undefined, subscribe: () => () => {} }
1101
+ ctx.inject(['sessions', 'uiConversation'], (dockCtx) => {
1102
+ const sessions = dockCtx.sessions
1103
+ const uiConversation = dockCtx.uiConversation
1104
+ dockCtx.slots.inject('conversation.input.dock', () => dockCtx.slots.register({
1105
+ name: 'conversation.input.dock',
1106
+ id: 'ssh-vault-hosts-result',
1107
+ order: 5,
1108
+ ...loc,
1109
+ inject: (sessionId) => {
1110
+ let sessionSource = EMPTY_SOURCE
1111
+ let chatSource = EMPTY_SOURCE
1112
+ try {
1113
+ const binding = sessions.binding(sessionId)
1114
+ if (binding !== undefined) {
1115
+ sessionSource = binding.session
1116
+ // Chat 目标快照在第一次订阅时才会激活;绑定失败就退回空源,面板不显示。
1117
+ chatSource = uiConversation.binding(binding).target('chat') ?? EMPTY_SOURCE
1118
+ }
1119
+ } catch {
1120
+ // 会话正在销毁等边界情况:让面板安静地不显示,不把异常抛给渲染器。
1121
+ }
1122
+ return {
1123
+ hooks: {
1124
+ sshHostsSession: sessionSource,
1125
+ sshHostsChat: chatSource,
1126
+ },
1127
+ }
1128
+ },
1129
+ }, withLocale(HostsResultDock)))
1130
+ })
1131
+ },
1132
+ }
1133
+ },
1134
+ })