dsh-remote 0.8.73 → 0.8.75

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.
@@ -0,0 +1,344 @@
1
+ // dsh-remote TUI 文案表(中 / 英)。
2
+ //
3
+ // TUI 侧所有用户可见字符串都从这里取:场景、对话框降级流程、命令描述、页脚
4
+ // 校验错误。默认中文;只有显式传了英文 lang 或环境变量 `DSH_TUI_LANG=en`
5
+ // 时才用英文(与 dsh-tui 自身的语言开关保持同一习惯)。
6
+ //
7
+ // 约束:zh / en 两个字典必须**键集完全一致**(tui-format.test.js 会断言),
8
+ // 否则漏翻的一侧会静默回落到 key 本身。
9
+
10
+ const DICT = {
11
+ zh: {
12
+ // ── 通用 / 命令 ────────────────────────────────────────────────────────
13
+ title: 'dsh-remote · 远程服务器',
14
+ 'cmd.description': '打开 dsh-remote 远程服务器管理页(远程工作区)',
15
+ 'cmd.unavailable': '当前 profile 没有可用的 TUI 界面',
16
+ 'cmd.failed': 'dsh-remote 打开失败:{err}',
17
+ 'status.loading': '…',
18
+ 'status.none': '未连接远程机',
19
+ 'status.connected': '已连接 {target}',
20
+ 'status.disconnected': '未连接 · {target}',
21
+
22
+ // ── 机器列表 ──────────────────────────────────────────────────────────
23
+ 'list.hints': '↑↓ 选择 · →/Enter 动作菜单 · a 新增 · c 连接 · t 探测 · b 浏览 · e 编辑 · d 删除 · ←/Esc 关闭',
24
+ 'list.empty': '还没有保存的远程机 —— 按 a 添加一台',
25
+ 'list.count': '{count} 台机器',
26
+ 'list.current': '当前',
27
+ 'list.loading': '… 正在读取机器列表',
28
+
29
+ // ── 动作菜单 ──────────────────────────────────────────────────────────
30
+ 'menu.title': '动作 · {name}',
31
+ 'menu.connect': '连接',
32
+ 'menu.workspace': '设为工作区',
33
+ 'menu.workspaces': '工作区管理(删除/清理)',
34
+ 'menu.edit': '编辑',
35
+ 'menu.delete': '删除',
36
+ 'menu.probe': '探测连接',
37
+ 'menu.browse': '浏览远程目录',
38
+ 'menu.hints': '↑↓ 选择 · →/Enter 执行 · ←/Esc 返回',
39
+
40
+ // ── 表单 ──────────────────────────────────────────────────────────────
41
+ 'form.titleNew': '新增远程机',
42
+ 'form.titleEdit': '编辑远程机 · {name}',
43
+ 'form.name': '名称',
44
+ 'form.host': '主机 / 别名',
45
+ 'form.useSshConfig': '使用 ~/.ssh/config 别名',
46
+ 'form.port': '端口',
47
+ 'form.username': '用户名',
48
+ 'form.auth': '认证方式',
49
+ 'form.password': '密码',
50
+ 'form.privateKeyPath': '私钥路径',
51
+ 'form.passphrase': '私钥口令',
52
+ 'form.group': '分组',
53
+ 'form.passwordUnchanged': '(留空表示不修改)',
54
+ 'form.passwordPlaceholder': '(不保存密码)',
55
+ 'form.passphrasePlaceholder': '(无口令)',
56
+ 'form.hints': 'Tab/↓ 下一项 · Shift+Tab/↑ 上一项 · Space/←→ 切换 · Enter 保存 · Esc 取消',
57
+ 'form.required': '必填',
58
+ 'form.optional': '可选',
59
+ 'form.errHostRequired': '主机不能为空',
60
+ 'form.errHostInvalid': '主机格式不正确(不要包含空格、/ 或 @)',
61
+ 'form.errAliasInvalid': '~/.ssh/config 别名格式不正确',
62
+ 'form.errPortRange': '端口必须是 1-65535 的整数',
63
+ 'form.errAuth': '认证方式无效',
64
+ 'form.errKeyPath': '私钥认证需要填写私钥路径',
65
+
66
+ // ── 认证方式 ──────────────────────────────────────────────────────────
67
+ 'auth.password': '密码',
68
+ 'auth.key': '私钥',
69
+ 'auth.agent': 'SSH Agent',
70
+
71
+ // ── 删除确认 ──────────────────────────────────────────────────────────
72
+ 'confirm.deleteTitle': '删除远程机',
73
+ 'confirm.delete': '确定删除「{name}」?只删除本机保存的记录,不会改动远程文件。',
74
+ 'confirm.hints': 'y / → / Enter 确认 · n / ← / Esc 取消',
75
+
76
+ // ── 远程目录浏览 ──────────────────────────────────────────────────────
77
+ 'browse.title': '远程目录 · {path}',
78
+ 'browse.loading': '… 正在读取目录',
79
+ 'browse.empty': '(空目录)',
80
+ 'browse.hints': '↑↓ 选择 · →/Enter 进入 · ←/Backspace 上级 · w 设为工作区 · Esc 返回',
81
+ 'browse.noSelection': '没有可进入的子目录',
82
+
83
+ // ── 远程工作区清理 ────────────────────────────────────────────────────
84
+ 'ws.title': '工作区 · {name}',
85
+ 'ws.hints': '↑↓ 选择 · d 删除工作区(含镜像/对话)· x 仅删除对话 · r 移出最近列表 · ←/Esc 返回',
86
+ 'ws.empty': '这台机器还没有本地镜像工作区',
87
+ 'ws.sessions': '{n} 个对话',
88
+ 'ws.live': '{n} 个使用中',
89
+ 'ws.size': '镜像 {size}',
90
+ 'ws.registered': '已登记',
91
+ 'ws.recent': '最近',
92
+ 'ws.confirmTitleDelete': '删除工作区「{name}」?',
93
+ 'ws.confirmDelete': '将删除本地镜像目录、工作区登记和 {n} 个对话,并从最近列表移除该远端路径;不可恢复。',
94
+ 'ws.confirmTitleSessions': '删除「{name}」下的对话?',
95
+ 'ws.confirmSessions': '将删除 {n} 个对话,保留本地镜像目录与工作区登记;不可恢复。',
96
+ 'ws.confirmTitleRecent': '从最近列表移除「{name}」?',
97
+ 'ws.confirmRecent': '只从每台机器的「最近工作区」移除该远端路径(重启后不会再自动重建),本地镜像与台账保留。',
98
+ 'ws.confirmHints': 'y / → / Enter 确认 · n / ← / Esc 取消',
99
+ 'ws.busyDelete': '正在删除工作区',
100
+ 'ws.busySessions': '正在删除对话',
101
+ 'ws.busyRecent': '正在移出最近列表',
102
+ 'ws.deleted': '已删除工作区 {name}:{sessions} 个对话 + 本地镜像 + 登记',
103
+ 'ws.sessionsDeleted': '已删除 {n} 个对话(本地镜像保留)',
104
+ 'ws.sessionsPartial': '已删除 {n} 个对话;{live} 个正在使用,已跳过',
105
+ 'ws.recentForgotten': '已移出最近列表:{name}',
106
+ 'ws.busyError': '还有 {n} 个会话正在使用该工作区,请先停止它们',
107
+ 'ws.noApi': '当前 dshRemote 不支持工作区清理(插件版本过旧)',
108
+
109
+ // ── 加载态 / 页脚 ─────────────────────────────────────────────────────
110
+ 'busy.line': '… {what}',
111
+ 'busy.connecting': '正在连接',
112
+ 'busy.probing': '正在探测',
113
+ 'busy.listing': '正在读取目录',
114
+ 'busy.saving': '正在保存',
115
+ 'busy.deleting': '正在删除',
116
+ 'busy.settingWorkspace': '正在建立工作区',
117
+ 'busy.loading': '正在加载',
118
+ 'busy.loadingMirrors': '正在读取本地镜像工作区',
119
+ 'footer.error': '错误:{err}',
120
+ 'error.generic': '操作失败',
121
+ 'error.noApi': '宿主 API 不可用',
122
+ 'error.badWorkspace': '无法构造工作区目标(本地镜像缺失)',
123
+
124
+ // ── toast / notify ────────────────────────────────────────────────────
125
+ 'notify.connected': '已连接 {target}',
126
+ 'notify.probeOk': '连接正常 · {target} · {latency} ms',
127
+ 'notify.probeFailed': '探测失败:{err}',
128
+ 'notify.saved': '已保存 {name}',
129
+ 'notify.deleted': '已删除 {name}',
130
+ 'notify.workspaceSet': '已采用工作区 {label}',
131
+ 'notify.switchRefused': '宿主拒绝切换到 {label}',
132
+ 'notify.switchFailed': '切换工作区失败:{err}',
133
+ 'notify.pickFailed': '无法采用该目录:{err}',
134
+ 'notify.noWorkspace': '该机器还没有记录的工作区,正在打开目录浏览',
135
+ 'notify.warnings': '警告:{text}',
136
+
137
+ // ── 无场景时的对话框降级流程 ──────────────────────────────────────────
138
+ 'flow.add': '+ 新增远程机',
139
+ 'flow.addDescription': '输入主机、端口和用户名',
140
+ 'flow.selectTitle': '选择远程机',
141
+ 'flow.machineOption': '{name} · {target}',
142
+ 'flow.connectTitle': '连接到 {name}?',
143
+ 'flow.connect': '连接',
144
+ 'flow.cancel': '取消',
145
+ 'flow.machineMissing': '找不到该机器(列表已变化)',
146
+ 'flow.hostTitle': '主机(必填)',
147
+ 'flow.hostPlaceholder': 'example.com 或 ~/.ssh/config 别名',
148
+ 'flow.portTitle': '端口',
149
+ 'flow.portPlaceholder': '22',
150
+ 'flow.usernameTitle': '用户名',
151
+ 'flow.usernamePlaceholder': 'root',
152
+ 'flow.nameTitle': '显示名称',
153
+ 'flow.namePlaceholder': '留空则使用主机名',
154
+ 'flow.authTitle': '认证方式',
155
+ 'flow.keyPathTitle': '私钥路径',
156
+ 'flow.keyPathPlaceholder': '/home/me/.ssh/id_ed25519',
157
+ 'flow.groupTitle': '分组',
158
+ 'flow.groupPlaceholder': '留空表示不分组',
159
+ 'flow.saveConfirm': '保存这台远程机?',
160
+ 'flow.save': '保存',
161
+ },
162
+ en: {
163
+ title: 'dsh-remote · remote servers',
164
+ 'cmd.description': 'Open the dsh-remote server manager (remote workspaces)',
165
+ 'cmd.unavailable': 'the dsh-remote TUI is not available in this profile',
166
+ 'cmd.failed': 'dsh-remote failed to open: {err}',
167
+ 'status.loading': '…',
168
+ 'status.none': 'no remote machine',
169
+ 'status.connected': 'connected · {target}',
170
+ 'status.disconnected': 'disconnected · {target}',
171
+
172
+ 'list.hints': '↑↓ select · →/Enter actions · a add · c connect · t probe · b browse · e edit · d delete · ←/Esc close',
173
+ 'list.empty': 'no saved machines yet — press a to add one',
174
+ 'list.count': '{count} machines',
175
+ 'list.current': 'current',
176
+ 'list.loading': '… loading machines',
177
+
178
+ 'menu.title': 'Actions · {name}',
179
+ 'menu.connect': 'Connect',
180
+ 'menu.workspace': 'Use as workspace',
181
+ 'menu.workspaces': 'Manage workspaces (delete)',
182
+ 'menu.edit': 'Edit',
183
+ 'menu.delete': 'Delete',
184
+ 'menu.probe': 'Probe connection',
185
+ 'menu.browse': 'Browse remote directory',
186
+ 'menu.hints': '↑↓ select · →/Enter run · ←/Esc back',
187
+
188
+ 'form.titleNew': 'Add remote machine',
189
+ 'form.titleEdit': 'Edit remote machine · {name}',
190
+ 'form.name': 'Name',
191
+ 'form.host': 'Host / alias',
192
+ 'form.useSshConfig': 'Use ~/.ssh/config alias',
193
+ 'form.port': 'Port',
194
+ 'form.username': 'Username',
195
+ 'form.auth': 'Authentication',
196
+ 'form.password': 'Password',
197
+ 'form.privateKeyPath': 'Private key path',
198
+ 'form.passphrase': 'Key passphrase',
199
+ 'form.group': 'Group',
200
+ 'form.passwordUnchanged': '(leave empty to keep)',
201
+ 'form.passwordPlaceholder': '(not saved)',
202
+ 'form.passphrasePlaceholder': '(no passphrase)',
203
+ 'form.hints': 'Tab/↓ next · Shift+Tab/↑ previous · Space/←→ toggle · Enter save · Esc cancel',
204
+ 'form.required': 'required',
205
+ 'form.optional': 'optional',
206
+ 'form.errHostRequired': 'host is required',
207
+ 'form.errHostInvalid': 'invalid host (no spaces, / or @)',
208
+ 'form.errAliasInvalid': 'invalid ~/.ssh/config alias',
209
+ 'form.errPortRange': 'port must be an integer between 1 and 65535',
210
+ 'form.errAuth': 'invalid authentication mode',
211
+ 'form.errKeyPath': 'key authentication needs a private key path',
212
+
213
+ 'auth.password': 'Password',
214
+ 'auth.key': 'Private key',
215
+ 'auth.agent': 'SSH Agent',
216
+
217
+ 'confirm.deleteTitle': 'Delete remote machine',
218
+ 'confirm.delete': 'Delete "{name}"? Only the local record is removed; remote files are untouched.',
219
+ 'confirm.hints': 'y / → / Enter confirm · n / ← / Esc cancel',
220
+
221
+ 'browse.title': 'Remote directory · {path}',
222
+ 'browse.loading': '… loading directory',
223
+ 'browse.empty': '(empty directory)',
224
+ 'browse.hints': '↑↓ select · →/Enter open · ←/Backspace up · w use as workspace · Esc back',
225
+ 'browse.noSelection': 'no subdirectory to open',
226
+ 'ws.title': 'Workspaces · {name}',
227
+ 'ws.hints': '↑↓ select · d delete workspace (mirror + chats) · x delete chats only · r forget recent · ←/Esc back',
228
+ 'ws.empty': 'no local mirror workspaces for this machine',
229
+ 'ws.sessions': '{n} chats',
230
+ 'ws.live': '{n} in use',
231
+ 'ws.size': 'mirror {size}',
232
+ 'ws.registered': 'registered',
233
+ 'ws.recent': 'recent',
234
+ 'ws.confirmTitleDelete': 'Delete workspace "{name}"?',
235
+ 'ws.confirmDelete': 'Deletes the local mirror directory, the workspace registration and {n} chats, and forgets the remote path; this cannot be undone.',
236
+ 'ws.confirmTitleSessions': 'Delete the chats under "{name}"?',
237
+ 'ws.confirmSessions': 'Deletes {n} chats and keeps the local mirror and registration; this cannot be undone.',
238
+ 'ws.confirmTitleRecent': 'Forget "{name}" from the recent list?',
239
+ 'ws.confirmRecent': 'Only removes the remote path from every machine\'s recent list (so a restart will not recreate it); the mirror and registration stay.',
240
+ 'ws.confirmHints': 'y / → / Enter confirm · n / ← / Esc cancel',
241
+ 'ws.busyDelete': 'deleting workspace',
242
+ 'ws.busySessions': 'deleting chats',
243
+ 'ws.busyRecent': 'forgetting recent entry',
244
+ 'ws.deleted': 'workspace {name} deleted: {sessions} chats + mirror + registration',
245
+ 'ws.sessionsDeleted': 'deleted {n} chats (mirror kept)',
246
+ 'ws.sessionsPartial': 'deleted {n} chats; {live} in use were skipped',
247
+ 'ws.recentForgotten': 'forgot recent entry: {name}',
248
+ 'ws.busyError': '{n} chats still in use — stop them first',
249
+ 'ws.noApi': 'this dshRemote build has no workspace cleanup (plugin too old)',
250
+
251
+
252
+ 'busy.line': '… {what}',
253
+ 'busy.connecting': 'connecting',
254
+ 'busy.probing': 'probing',
255
+ 'busy.listing': 'loading directory',
256
+ 'busy.saving': 'saving',
257
+ 'busy.deleting': 'deleting',
258
+ 'busy.settingWorkspace': 'creating workspace',
259
+ 'busy.loading': 'loading',
260
+ 'busy.loadingMirrors': 'loading local mirror workspaces',
261
+ 'footer.error': 'error: {err}',
262
+ 'error.generic': 'operation failed',
263
+ 'error.noApi': 'the host API is unavailable',
264
+ 'error.badWorkspace': 'cannot build the workspace target (local mirror missing)',
265
+
266
+ 'notify.connected': 'connected to {target}',
267
+ 'notify.probeOk': 'connection ok · {target} · {latency} ms',
268
+ 'notify.probeFailed': 'probe failed: {err}',
269
+ 'notify.saved': 'saved {name}',
270
+ 'notify.deleted': 'deleted {name}',
271
+ 'notify.workspaceSet': 'workspace adopted: {label}',
272
+ 'notify.switchRefused': 'the host refused to switch to {label}',
273
+ 'notify.switchFailed': 'workspace switch failed: {err}',
274
+ 'notify.pickFailed': 'cannot adopt this directory: {err}',
275
+ 'notify.noWorkspace': 'this machine has no recorded workspace yet — opening the directory browser',
276
+ 'notify.warnings': 'warning: {text}',
277
+
278
+ 'flow.add': '+ Add remote machine',
279
+ 'flow.addDescription': 'Enter host, port and username',
280
+ 'flow.selectTitle': 'Pick a remote machine',
281
+ 'flow.machineOption': '{name} · {target}',
282
+ 'flow.connectTitle': 'Connect to {name}?',
283
+ 'flow.connect': 'Connect',
284
+ 'flow.cancel': 'Cancel',
285
+ 'flow.machineMissing': 'machine not found (the list changed)',
286
+ 'flow.hostTitle': 'Host (required)',
287
+ 'flow.hostPlaceholder': 'example.com or a ~/.ssh/config alias',
288
+ 'flow.portTitle': 'Port',
289
+ 'flow.portPlaceholder': '22',
290
+ 'flow.usernameTitle': 'Username',
291
+ 'flow.usernamePlaceholder': 'root',
292
+ 'flow.nameTitle': 'Display name',
293
+ 'flow.namePlaceholder': 'empty uses the host',
294
+ 'flow.authTitle': 'Authentication',
295
+ 'flow.keyPathTitle': 'Private key path',
296
+ 'flow.keyPathPlaceholder': '/home/me/.ssh/id_ed25519',
297
+ 'flow.groupTitle': 'Group',
298
+ 'flow.groupPlaceholder': 'empty means ungrouped',
299
+ 'flow.saveConfirm': 'Save this remote machine?',
300
+ 'flow.save': 'Save',
301
+ },
302
+ }
303
+
304
+ /** 规范化语言代码 → 'zh' | 'en';无法识别时返回 undefined。 */
305
+ export function normalizeLang(lang) {
306
+ const value = String(lang === undefined || lang === null ? '' : lang).trim().toLowerCase()
307
+ if (!value) return undefined
308
+ if (value === 'en' || value.startsWith('en-') || value.startsWith('en_')) return 'en'
309
+ if (value === 'zh' || value.startsWith('zh-') || value.startsWith('zh_')) return 'zh'
310
+ return undefined
311
+ }
312
+
313
+ /**
314
+ * 创建文案渲染器。
315
+ *
316
+ * @param {string} [lang] - 'zh' / 'en'(或 zh-CN / en-US)。省略时读
317
+ * `DSH_TUI_LANG` 环境变量,仍无法判定则中文。
318
+ * @returns {{ lang: 'zh'|'en', dict: Record<string, string>, t(key: string, params?: object): string }}
319
+ */
320
+ export function createI18n(lang) {
321
+ const fromArg = normalizeLang(lang)
322
+ const envLang = typeof process === 'object' && process && process.env ? process.env.DSH_TUI_LANG : ''
323
+ const resolved = fromArg || normalizeLang(envLang) || 'zh'
324
+ const dict = DICT[resolved] || DICT.zh
325
+ const fallback = DICT.zh
326
+
327
+ /** 取文案并做 {name} 插值;缺 key 时回落中文,再缺则返回 key 本身。 */
328
+ function t(key, params) {
329
+ const name = String(key)
330
+ let text = dict[name]
331
+ if (text === undefined) text = fallback[name]
332
+ if (text === undefined) return name
333
+ if (!params) return text
334
+ return text.replace(/\{(\w+)\}/g, (match, param) => {
335
+ const value = params[param]
336
+ return value === undefined || value === null ? match : String(value)
337
+ })
338
+ }
339
+
340
+ return { lang: resolved, dict, t }
341
+ }
342
+
343
+ /** 暴露字典本身(测试校验两侧键集一致;运行时请用 createI18n)。 */
344
+ export const TRANSLATIONS = DICT
@@ -0,0 +1,322 @@
1
+ // dsh-remote TUI 接入层入口:命令 / 全屏场景 / 工作区 provider / 状态行。
2
+ //
3
+ // 设计要点(见 docs/tui.md §4 与 .cache/recon/tui-seams.md):
4
+ // · 所有 TUI 服务用 `ctx.get(name, false)` **软探测**;Web profile / headless
5
+ // profile 里这些服务永远不存在,插件必须静默降级、不注册任何东西。
6
+ // · cordis 各行并发激活,`ctx.get` 在 apply 执行时可能还没就位;而 `ctx.inject`
7
+ // 会让整段 fiber 永久 pending。因此统一用 `whenService()`:服务已在则立即
8
+ // 执行,否则监听 `internal/service` 事件,到位后一次性执行。
9
+ // · `installTui` 同步返回、不 await、绝不抛错:内部 try/catch + logger.warn。
10
+ // · 每个注册返回的 disposer 都用 `ctx.effect(() => () => dispose())` 绑定生命周期。
11
+
12
+ import { createI18n } from './i18n.js'
13
+ import { createWorkspaceProvider } from './provider.js'
14
+ import { createSceneComponent } from './scene.js'
15
+ import { runDialogFlow } from './flow.js'
16
+
17
+ /** 场景 id(kebab-case,全局唯一)。 */
18
+ export const SCENE_ID = 'dsh-remote'
19
+ /** slash 命令名(dsh-commands 要求 /^[a-z][a-z0-9_-]*$/)。 */
20
+ export const COMMAND_NAME = 'dsh-remote'
21
+ /** 状态行字段 key。 */
22
+ const STATUS_KEY = 'dsh-remote'
23
+ /** 状态行字段的固定外观(docs/tui.md §4.4)。 */
24
+ const STATUS_OPTIONS = { slot: 'right-head', tone: 'accent', maxCells: 22 }
25
+
26
+ /** `ctx.get` 的软封装:ctx 缺失、服务缺失、代理抛错一律当“不存在”。 */
27
+ function getService(ctx, name) {
28
+ try {
29
+ if (!ctx || typeof ctx.get !== 'function') return undefined
30
+ const service = ctx.get(name, false)
31
+ return service === null ? undefined : service
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+
37
+ /** logger.warn 的软封装(logging 失败绝不影响注册流程)。 */
38
+ function warn(ctx, message, error) {
39
+ try {
40
+ const logger = getService(ctx, 'logger')
41
+ if (!logger || typeof logger.warn !== 'function') return
42
+ const detail = error && (error.message || error)
43
+ logger.warn(`[dsh-remote] tui: ${message}${detail ? ': ' + detail : ''}`)
44
+ } catch { /* logging is best-effort */ }
45
+ }
46
+
47
+ /** 把 disposer 绑到当前 context 的生命周期上(ctx.effect 缺失时静默跳过)。 */
48
+ function bindEffect(ctx, dispose) {
49
+ if (typeof dispose !== 'function') return
50
+ try {
51
+ if (!ctx || typeof ctx.effect !== 'function') return
52
+ ctx.effect(() => () => {
53
+ try { dispose() } catch { /* teardown is best-effort */ }
54
+ })
55
+ } catch (error) {
56
+ warn(ctx, 'effect binding failed', error)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 服务到位后再执行回调(只执行一次)。
62
+ *
63
+ * cordis 的 `internal/service` 事件在服务变化时同步发出(见
64
+ * @deepseek-ai/cordis lib/index.js 的 notify())。若服务在 apply 时已经就位,
65
+ * 直接同步执行;否则等事件。回调异常被吞掉并 warn —— 一个 TUI 接缝注册失败
66
+ * 不能让插件(以及整个宿主)崩掉。
67
+ *
68
+ * @param {object} ctx - cordis context。
69
+ * @param {string} name - 服务名(`tuiScenes` / `commands` / …)。
70
+ * @param {(service: object) => void} callback - 服务到位时执行一次。
71
+ */
72
+ export function whenService(ctx, name, callback) {
73
+ const existing = getService(ctx, name)
74
+ if (existing !== undefined) {
75
+ try { callback(existing) } catch (error) { warn(ctx, `${name} 初始化失败`, error) }
76
+ return
77
+ }
78
+ if (!ctx || typeof ctx.on !== 'function') return
79
+ let off
80
+ const listener = (serviceName, value) => {
81
+ if (serviceName !== name) return
82
+ // 事件里拿到的是**裸** service 实例;经 ctx.get 再取一次才能得到 cordis 的
83
+ // traceable 代理(把 this.ctx 指向本插件 activation)。TUI 的
84
+ // tuiWorkspaces.register / tuiScenes.register 会校验调用方身份,直接用裸
85
+ // 实例会把归属记到服务自己的 fiber 上,所以优先用代理。
86
+ const traced = getService(ctx, name)
87
+ if (traced === undefined && !value) return
88
+ try { if (typeof off === 'function') off() } catch { /* already removed */ }
89
+ off = undefined
90
+ const service = traced === undefined ? value : traced
91
+ if (!service) return
92
+ try { callback(service) } catch (error) { warn(ctx, `${name} 初始化失败`, error) }
93
+ }
94
+ try {
95
+ off = ctx.on('internal/service', listener)
96
+ } catch (error) {
97
+ warn(ctx, `无法监听 ${name} 服务`, error)
98
+ return
99
+ }
100
+ bindEffect(ctx, () => { if (typeof off === 'function') off() })
101
+ }
102
+
103
+ /**
104
+ * 安装 TUI 适配层。
105
+ *
106
+ * @param {object} ctx - 插件自己的 cordis context。
107
+ * @param {object} api - 宿主进程内 API(`ctx.dshRemote`,apiVersion 1)。
108
+ * @param {object} [config] - 行 config(tui / tuiProviderListRecent /
109
+ * tuiProviderRecentLimit / tuiAutoSwitchWorkspace / tuiLang)。
110
+ * @returns {void} 同步返回,永不 reject / throw。
111
+ */
112
+ export function installTui(ctx, api, config) {
113
+ try {
114
+ if (config && config.tui === false) return
115
+ if (!api || api.apiVersion !== 1) return
116
+ if (!ctx || typeof ctx.get !== 'function') return
117
+
118
+ const i18n = createI18n(config && (config.tuiLang || config.lang))
119
+ const sceneComponent = createSceneComponent({
120
+ api,
121
+ config: config || {},
122
+ i18n,
123
+ notify: (text, options) => {
124
+ const toast = getService(ctx, 'tuiToast')
125
+ if (toast && typeof toast.show === 'function') {
126
+ try { toast.show(text, options); return } catch { /* fall through */ }
127
+ }
128
+ warn(ctx, String(text))
129
+ },
130
+ })
131
+
132
+ // ── 内部可变状态(全是本插件生命周期内的单例) ────────────────────────
133
+ let provider = null
134
+ let workspacesSeen = false
135
+ let commandsSeen = null
136
+ let commandWanted = false
137
+ let commandRegistered = false
138
+ let tuiActive = false
139
+ let statusService = null
140
+ let statusFieldDispose = null
141
+ let statusRefreshToken = 0
142
+
143
+ const ensureProvider = () => {
144
+ if (!provider) provider = createWorkspaceProvider({ api, config: config || {}, logger: getService(ctx, 'logger') })
145
+ return provider
146
+ }
147
+
148
+ /** 注册 `/dsh-remote`(只会注册一次;失败时允许后续重试)。 */
149
+ const registerCommand = (commands) => {
150
+ if (commandRegistered || !commands || typeof commands.register !== 'function') return
151
+ commandRegistered = true
152
+ let dispose
153
+ try {
154
+ dispose = commands.register({
155
+ name: COMMAND_NAME,
156
+ description: i18n.t('cmd.description'),
157
+ handler: async (invocation) => {
158
+ try {
159
+ const scenes = getService(ctx, 'tuiScenes')
160
+ if (scenes && typeof scenes.open === 'function') {
161
+ let opened = false
162
+ try { opened = scenes.open(SCENE_ID) === true } catch (error) { warn(ctx, '场景打开失败', error) }
163
+ if (opened) return { kind: 'success' }
164
+ }
165
+ const dialogs = getService(ctx, 'tuiDialogs')
166
+ if (dialogs) {
167
+ const result = await runDialogFlow({
168
+ api,
169
+ dialogs,
170
+ i18n,
171
+ channel: invocation && invocation.channel,
172
+ })
173
+ // `runDialogFlow` returns undefined ONLY when the dialog seam
174
+ // lacks select/input/confirm (cancellation returns a success
175
+ // result) — reporting that as success would look like a no-op.
176
+ return result || { kind: 'error', text: i18n.t('cmd.unavailable') }
177
+ }
178
+ return { kind: 'error', text: i18n.t('cmd.unavailable') }
179
+ } catch (error) {
180
+ return { kind: 'error', text: i18n.t('cmd.failed', { err: String((error && error.message) || error) }) }
181
+ }
182
+ },
183
+ })
184
+ } catch (error) {
185
+ commandRegistered = false
186
+ warn(ctx, '命令注册失败', error)
187
+ return
188
+ }
189
+ bindEffect(ctx, dispose)
190
+ }
191
+
192
+ /** 有任意 TUI UI 接缝时才开始“激活”(命令注册 / api 订阅 / 状态行)。 */
193
+ const ensureCommand = () => {
194
+ commandWanted = true
195
+ if (commandsSeen) registerCommand(commandsSeen)
196
+ }
197
+
198
+ /** 状态行字段:只在服务形状符合契约时写入,否则整段跳过(不猜 API)。 */
199
+ const applyStatusField = (text) => {
200
+ if (!statusService || typeof statusService.setField !== 'function') return
201
+ try {
202
+ // 第 4 个参数是插件自己的 ctx(只用于 effect ledger 归属,可省略)。
203
+ const next = statusService.setField(STATUS_KEY, text, STATUS_OPTIONS, ctx)
204
+ if (statusFieldDispose) {
205
+ const previous = statusFieldDispose
206
+ statusFieldDispose = null
207
+ try { previous() } catch { /* already cleared */ }
208
+ }
209
+ if (typeof next === 'function') statusFieldDispose = next
210
+ } catch (error) {
211
+ warn(ctx, '状态行更新失败', error)
212
+ }
213
+ }
214
+
215
+ /** 读当前机器名并写状态行;旧结果(token 过期)直接丢弃。 */
216
+ const refreshStatusField = async () => {
217
+ if (!statusService || typeof statusService.setField !== 'function') return
218
+ if (typeof api.listMachines !== 'function') return
219
+ const token = ++statusRefreshToken
220
+ let machines = []
221
+ let currentId = null
222
+ try {
223
+ const result = await api.listMachines()
224
+ machines = Array.isArray(result && result.machines) ? result.machines : []
225
+ currentId = result && result.currentId !== undefined ? result.currentId : null
226
+ } catch (error) {
227
+ warn(ctx, '状态行读取机器列表失败', error)
228
+ return
229
+ }
230
+ if (token !== statusRefreshToken) return
231
+ const current = machines.find((machine) => machine && machine.id === currentId)
232
+ const label = current ? String(current.name || current.host || '').trim() : ''
233
+ applyStatusField(label || undefined)
234
+ }
235
+
236
+ /** 任意 TUI 接缝出现后:订阅宿主变更(刷新 provider 缓存 + 状态行)。 */
237
+ const markTuiActive = () => {
238
+ if (tuiActive) return
239
+ tuiActive = true
240
+ if (typeof api.subscribe === 'function') {
241
+ let dispose
242
+ try {
243
+ dispose = api.subscribe(() => {
244
+ if (workspacesSeen && provider) {
245
+ try { void provider.refresh() } catch (error) { warn(ctx, 'provider 刷新失败', error) }
246
+ }
247
+ void refreshStatusField()
248
+ })
249
+ } catch (error) {
250
+ warn(ctx, 'api.subscribe 失败', error)
251
+ }
252
+ bindEffect(ctx, dispose)
253
+ }
254
+ void refreshStatusField()
255
+ }
256
+
257
+ // ── 服务到位后注册(顺序无关;命令只在 TUI 接缝出现后注册) ───────────
258
+ whenService(ctx, 'commands', (commands) => {
259
+ commandsSeen = commands
260
+ if (commandWanted) registerCommand(commands)
261
+ })
262
+
263
+ whenService(ctx, 'tuiScenes', (scenes) => {
264
+ if (!scenes || typeof scenes.register !== 'function') return
265
+ ensureCommand()
266
+ markTuiActive()
267
+ let dispose
268
+ try {
269
+ dispose = scenes.register({ id: SCENE_ID, title: 'Remote', component: sceneComponent }, ctx)
270
+ } catch (error) {
271
+ warn(ctx, '场景注册失败', error)
272
+ return
273
+ }
274
+ bindEffect(ctx, dispose)
275
+ })
276
+
277
+ whenService(ctx, 'tuiDialogs', (dialogs) => {
278
+ if (!dialogs) return
279
+ ensureCommand()
280
+ markTuiActive()
281
+ })
282
+
283
+ whenService(ctx, 'tuiWorkspaces', (workspaces) => {
284
+ if (!workspaces || typeof workspaces.register !== 'function') return
285
+ workspacesSeen = true
286
+ markTuiActive()
287
+ const instance = ensureProvider()
288
+ let dispose
289
+ try {
290
+ // register 在无 live activation 时会**同步抛错**,必须包 try/catch。
291
+ dispose = workspaces.register(instance)
292
+ } catch (error) {
293
+ warn(ctx, '工作区 provider 注册失败', error)
294
+ return
295
+ }
296
+ bindEffect(ctx, dispose)
297
+ // 后台预热,绝不阻塞 apply / 首个 UI 帧。
298
+ try {
299
+ queueMicrotask(() => { void instance.refresh() })
300
+ } catch (error) {
301
+ warn(ctx, 'provider 预热失败', error)
302
+ }
303
+ })
304
+
305
+ whenService(ctx, 'tuiStatus', (status) => {
306
+ if (!status || typeof status.setField !== 'function') return
307
+ statusService = status
308
+ markTuiActive()
309
+ bindEffect(ctx, () => {
310
+ if (!statusFieldDispose) return
311
+ const dispose = statusFieldDispose
312
+ statusFieldDispose = null
313
+ dispose()
314
+ })
315
+ void refreshStatusField()
316
+ })
317
+ } catch (error) {
318
+ warn(ctx, 'installTui 初始化失败', error)
319
+ }
320
+ }
321
+
322
+ export default installTui