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,996 @@
1
+ // dsh-remote TUI — `/dsh-remote` 全屏「远程服务器」管理页。
2
+ //
3
+ // 硬约束(dsh-tui 场景契约,见 dsh-adapter/scenes.d.ts):
4
+ // · hooks 只能用 props 注入的 React;元素只能 React.createElement(仓库没有
5
+ // JSX 编译器,也没有构建步骤)
6
+ // · 渲染期禁止同步 I/O / 网络:机器列表、状态、目录全部在 useEffect 或按键
7
+ // 回调里异步取;组件卸载后(alive ref = false)丢弃结果
8
+ // · 密码 / passphrase 永不回显:编辑时输入框恒为空,留空 = 不修改
9
+ //
10
+ // 视图状态机:list → menu / form / browse / confirm。
11
+ // Esc / q:子视图先回列表,列表根视图关闭场景。
12
+
13
+ import {
14
+ AUTH_MODES,
15
+ basenameRemote,
16
+ emptyMachineForm,
17
+ formatBytes,
18
+ formFromMachine,
19
+ statusLine,
20
+ targetFromWorkspace,
21
+ validateMachineForm,
22
+ } from './format.js'
23
+
24
+ /** 动作菜单项(顺序即渲染顺序)。 */
25
+ const MENU_ACTIONS = ['connect', 'workspace', 'workspaces', 'edit', 'delete', 'probe', 'browse']
26
+ /** 表单里永远出现的字段。 */
27
+ const BASE_FORM_FIELDS = ['name', 'host', 'useSshConfig', 'port', 'username', 'auth']
28
+
29
+ /** 取错误对象里面向用户的一句话。 */
30
+ function messageOf(error) {
31
+ if (!error) return ''
32
+ return String((error && error.message) || error)
33
+ }
34
+
35
+ /** 按**码点**截断(不是 UTF-16 码元),避免切出孤立代理对(emoji、CJK 扩展 B)。 */
36
+ function clip(text, max) {
37
+ const value = String(text === undefined || text === null ? '' : text)
38
+ const limit = Math.max(0, Number(max) || 0)
39
+ if (!limit) return value
40
+ const points = Array.from(value)
41
+ if (points.length <= limit) return value
42
+ return points.slice(0, Math.max(0, limit - 1)).join('') + '…'
43
+ }
44
+
45
+ /** 掩码显示:只体现“有值”,永远不回显内容本身。 */
46
+ function maskValue(value) {
47
+ const length = String(value === undefined || value === null ? '' : value).length
48
+ return length ? '•'.repeat(Math.min(length, 20)) : ''
49
+ }
50
+
51
+ /** 机器显示名:name → host → id。 */
52
+ function machineName(machine) {
53
+ if (!machine) return ''
54
+ const name = String(machine.name || '').trim()
55
+ return name || String(machine.host || machine.id || '')
56
+ }
57
+
58
+ /** `user@host:port` 单行身份。 */
59
+ function machineAddress(machine) {
60
+ if (!machine) return ''
61
+ const host = String(machine.host || '')
62
+ const user = String(machine.username || '')
63
+ const port = Number(machine.port) || 22
64
+ return `${user ? user + '@' : ''}${host}:${port}`
65
+ }
66
+
67
+ /** 认证方式循环切换。 */
68
+ function nextAuth(current, step) {
69
+ const index = Math.max(0, AUTH_MODES.indexOf(current))
70
+ const next = (index + (step || 1) + AUTH_MODES.length) % AUTH_MODES.length
71
+ return AUTH_MODES[next]
72
+ }
73
+
74
+ /** 表单可见字段(认证方式决定密码 / 私钥组是否出现)。 */
75
+ function fieldsFor(form) {
76
+ const fields = [...BASE_FORM_FIELDS]
77
+ if (!form || form.auth === 'password') fields.push('password')
78
+ else if (form.auth === 'key') fields.push('privateKeyPath', 'passphrase')
79
+ fields.push('group')
80
+ return fields
81
+ }
82
+
83
+ /** 以 selected 为中心切出可视窗口(只用状态派生,不存滚动偏移)。 */
84
+ function windowSlice(items, selected, height) {
85
+ const list = Array.isArray(items) ? items : []
86
+ const size = Math.max(1, Number(height) || 1)
87
+ if (list.length <= size) return { offset: 0, items: list }
88
+ let offset = Math.max(0, Math.min(Number(selected) || 0, list.length - 1) - Math.floor(size / 2))
89
+ offset = Math.min(offset, list.length - size)
90
+ return { offset, items: list.slice(offset, offset + size) }
91
+ }
92
+
93
+ /**
94
+ * 创建全屏场景组件。
95
+ *
96
+ * @param {object} deps
97
+ * @param {object} deps.api - 宿主进程内 API(apiVersion 1)。
98
+ * @param {object} [deps.config] - 行 config(tuiAutoSwitchWorkspace 等)。
99
+ * @param {object} [deps.i18n] - createI18n 的返回值。
100
+ * @param {(text: string, options?: object) => void} [deps.notify] - 无 channel 时的提示通道。
101
+ * @returns {Function} React 组件(props: { React, ui, channel, close })。
102
+ */
103
+ export function createSceneComponent({ api, config, i18n, notify } = {}) {
104
+ const t = i18n && typeof i18n.t === 'function' ? i18n.t : (key) => String(key)
105
+ const autoSwitch = !config || config.tuiAutoSwitchWorkspace !== false
106
+
107
+ /** Host kit is mandatory; the guard lives OUTSIDE the hooking component so the
108
+ * hook count never depends on props (React requires a constant hook order). */
109
+ return function DshRemoteScene(props) {
110
+ const React = props && props.React
111
+ if (!React || !props.ui) return null
112
+ return React.createElement(DshRemoteSceneBody, props)
113
+ }
114
+
115
+ /** The real scene: every hook runs here, unconditionally. */
116
+ function DshRemoteSceneBody(props) {
117
+ const React = props.React
118
+ const ui = props.ui
119
+ const channel = props && props.channel
120
+ const close = props && props.close
121
+
122
+ const { useCallback, useEffect, useRef, useState } = React
123
+ const { Box, Text, useInput, useTerminalSize } = ui
124
+ const h = React.createElement
125
+
126
+ // ── 状态 ──────────────────────────────────────────────────────────────
127
+ const [machines, setMachines] = useState([])
128
+ const [currentId, setCurrentId] = useState(null)
129
+ const [status, setStatus] = useState(null)
130
+ const [selected, setSelected] = useState(0)
131
+ const [view, setView] = useState('list')
132
+ const [menuIndex, setMenuIndex] = useState(0)
133
+ const [form, setForm] = useState(null)
134
+ const [formField, setFormField] = useState(0)
135
+ const [formErrors, setFormErrors] = useState([])
136
+ const [browse, setBrowse] = useState(null)
137
+ const [confirmMachine, setConfirmMachine] = useState(null)
138
+ const [workspaces, setWorkspaces] = useState(null)
139
+ const [wsIndex, setWsIndex] = useState(0)
140
+ const [wsMachine, setWsMachine] = useState(null)
141
+ const [wsConfirm, setWsConfirm] = useState(null)
142
+ const [busy, setBusy] = useState('')
143
+ const [error, setError] = useState('')
144
+ const alive = useRef(true)
145
+ const terminal = useTerminalSize()
146
+ const columns = Math.max(24, Number(terminal && terminal.columns) || 80)
147
+ const rows = Math.max(10, Number(terminal && terminal.rows) || 24)
148
+ /** 列表 / 表单 / 浏览共用的内容高度(头部 2 行 + 页脚 2 行)。 */
149
+ const bodyHeight = Math.max(4, rows - 6)
150
+
151
+ // ── 生命周期:挂载标记 + 首次加载 + 宿主变更订阅 ─────────────────────
152
+ useEffect(() => {
153
+ alive.current = true
154
+ return () => { alive.current = false }
155
+ }, [])
156
+
157
+ useEffect(() => {
158
+ void reload()
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, [api])
161
+
162
+ useEffect(() => {
163
+ if (!api || typeof api.subscribe !== 'function') return undefined
164
+ let off
165
+ try {
166
+ off = api.subscribe(() => { void reload() })
167
+ } catch {
168
+ off = undefined
169
+ }
170
+ return () => {
171
+ if (typeof off !== 'function') return
172
+ try { off() } catch { /* teardown is best-effort */ }
173
+ }
174
+ // eslint-disable-next-line react-hooks/exhaustive-deps
175
+ }, [api])
176
+
177
+ // ── 数据加载 ──────────────────────────────────────────────────────────
178
+ async function reload() {
179
+ if (!api || typeof api.listMachines !== 'function') {
180
+ if (alive.current) setError(t('error.noApi'))
181
+ return
182
+ }
183
+ try {
184
+ const result = await api.listMachines()
185
+ if (!alive.current) return
186
+ const list = Array.isArray(result && result.machines) ? result.machines : []
187
+ setMachines(list)
188
+ setCurrentId(result && result.currentId !== undefined ? result.currentId : null)
189
+ setSelected((previous) => Math.min(previous, Math.max(0, list.length - 1)))
190
+ let nextStatus
191
+ try {
192
+ nextStatus = await api.status()
193
+ } catch {
194
+ nextStatus = undefined
195
+ }
196
+ if (alive.current && nextStatus) setStatus(nextStatus)
197
+ } catch (failure) {
198
+ if (alive.current) setError(messageOf(failure))
199
+ }
200
+ }
201
+
202
+ /** Monotonic token: only the newest directory listing may paint. */
203
+ const dirToken = useRef(0)
204
+ /** Same guard for the workspace-cleanup list. */
205
+ const wsToken = useRef(0)
206
+ async function loadDir(machine, dirPath) {
207
+ if (!api || typeof api.listDir !== 'function' || !machine) return
208
+ const token = ++dirToken.current
209
+ setBusy(t('busy.listing'))
210
+ setError('')
211
+ try {
212
+ const listing = await api.listDir({ machineId: machine.id, path: dirPath })
213
+ if (!alive.current || token !== dirToken.current) return
214
+ setBrowse({
215
+ machine,
216
+ path: String((listing && listing.path) || dirPath || ''),
217
+ parent: listing && listing.parent !== undefined ? listing.parent : null,
218
+ items: Array.isArray(listing && listing.items) ? listing.items : [],
219
+ selected: 0,
220
+ })
221
+ } catch (failure) {
222
+ if (alive.current && token === dirToken.current) setError(messageOf(failure))
223
+ } finally {
224
+ if (alive.current && token === dirToken.current) setBusy('')
225
+ }
226
+ }
227
+
228
+ /** 统一异步动作包装:设置加载态、把失败写到页脚、卸载后丢弃。 */
229
+ const runOp = useCallback(async (what, action) => {
230
+ setBusy(what)
231
+ setError('')
232
+ try {
233
+ return await action()
234
+ } catch (failure) {
235
+ if (alive.current) setError(messageOf(failure))
236
+ return undefined
237
+ } finally {
238
+ if (alive.current) setBusy('')
239
+ }
240
+ }, [])
241
+
242
+ /** 提示:优先用活会话 channel.notify,其次注入的 notify(宿主 toast)。 */
243
+ const toast = useCallback((text, options) => {
244
+ const message = String(text)
245
+ try {
246
+ if (channel && typeof channel.notify === 'function') {
247
+ channel.notify(message, options)
248
+ return
249
+ }
250
+ } catch { /* fall through to the injected channel */ }
251
+ try {
252
+ if (typeof notify === 'function') notify(message, options)
253
+ } catch { /* notifications are best-effort */ }
254
+ }, [channel, notify])
255
+
256
+ /** 切换工作区(宿主拒绝 / 异常只提示,不改场景状态)。 */
257
+ async function switchWorkspace(target) {
258
+ if (!autoSwitch || !target) return
259
+ try {
260
+ if (!channel || typeof channel.switchWorkspace !== 'function') return
261
+ const switched = await channel.switchWorkspace(target)
262
+ if (switched === false) toast(t('notify.switchRefused', { label: target.label }), { color: 'warning' })
263
+ } catch (failure) {
264
+ toast(t('notify.switchFailed', { err: messageOf(failure) }), { color: 'warning' })
265
+ }
266
+ }
267
+
268
+ /** 把 ensure/pick 的结果转成 target,并提示 + 可选切换。 */
269
+ async function adoptWorkspace(machine, workspace, remotePath) {
270
+ const target = targetFromWorkspace({
271
+ machine,
272
+ localMirror: workspace && workspace.localMirror,
273
+ remotePath: (workspace && workspace.remotePath) || remotePath,
274
+ title: workspace && workspace.title,
275
+ })
276
+ if (!target) {
277
+ setError(t('error.badWorkspace'))
278
+ return
279
+ }
280
+ toast(t('notify.workspaceSet', { label: target.label }), { color: 'success' })
281
+ await switchWorkspace(target)
282
+ }
283
+
284
+ // ── 动作 ──────────────────────────────────────────────────────────────
285
+ async function connectMachine(machine) {
286
+ if (!machine) return
287
+ const result = await runOp(t('busy.connecting'), () => api.connect({ machineId: machine.id, persist: true }))
288
+ if (!result) return
289
+ if (result.connected === false) {
290
+ setError(t('notify.probeFailed', { err: result.activeSource || t('error.generic') }))
291
+ return
292
+ }
293
+ toast(t('notify.connected', { target: machineAddress(machine) }), { color: 'success' })
294
+ await reload()
295
+ }
296
+
297
+ async function probeMachine(machine) {
298
+ if (!machine || typeof api.probeMachine !== 'function') return
299
+ const patch = {
300
+ id: machine.id,
301
+ host: machine.host,
302
+ port: Number(machine.port) || 22,
303
+ username: machine.username,
304
+ useSshConfig: machine.useSshConfig === true,
305
+ privateKeyPath: machine.privateKeyPath || '',
306
+ useAgent: machine.useAgent === true,
307
+ backend: machine.backend || 'native',
308
+ sshBinary: machine.sshBinary || '',
309
+ keyboardInteractive: machine.keyboardInteractive === true,
310
+ }
311
+ const probe = await runOp(t('busy.probing'), () => api.probeMachine(patch))
312
+ if (!probe) return
313
+ if (probe.ok === false) {
314
+ const message = probe.error || t('error.generic')
315
+ setError(messageOf(message))
316
+ toast(t('notify.probeFailed', { err: messageOf(message) }), { color: 'error' })
317
+ return
318
+ }
319
+ const target = `${probe.user || machine.username || ''}@${probe.host || machine.host || ''}`
320
+ const latency = typeof probe.latencyMs === 'number' ? Math.round(probe.latencyMs) : '-'
321
+ toast(t('notify.probeOk', { target, latency }), { color: 'success' })
322
+ }
323
+
324
+ async function deleteMachine(machine) {
325
+ if (!machine || typeof api.removeMachine !== 'function') return
326
+ const result = await runOp(t('busy.deleting'), () => api.removeMachine(machine.id))
327
+ if (!result || !alive.current) return
328
+ toast(t('notify.deleted', { name: machineName(machine) }), { color: 'success' })
329
+ setView('list')
330
+ await reload()
331
+ }
332
+
333
+ async function ensureMachineWorkspace(machine) {
334
+ if (!machine) return
335
+ if (!machine.workspace) {
336
+ toast(t('notify.noWorkspace'), { color: 'warning' })
337
+ openBrowse(machine)
338
+ return
339
+ }
340
+ const workspace = await runOp(t('busy.settingWorkspace'), () => api.ensureRemoteWorkspace({
341
+ machineId: machine.id,
342
+ remotePath: machine.workspace,
343
+ register: true,
344
+ }))
345
+ if (!workspace) return
346
+ await adoptWorkspace(machine, workspace, machine.workspace)
347
+ }
348
+
349
+ async function pickBrowseWorkspace() {
350
+ const current = browse
351
+ if (!current) return
352
+ if (!current.path) {
353
+ toast(t('browse.noSelection'), { color: 'warning' })
354
+ return
355
+ }
356
+ setBusy(t('busy.settingWorkspace'))
357
+ setError('')
358
+ try {
359
+ const workspace = await api.pickRemoteWorkspace({
360
+ machineId: current.machine.id,
361
+ remotePath: current.path,
362
+ registerWorkspace: true,
363
+ })
364
+ if (!alive.current) return
365
+ await adoptWorkspace(current.machine, workspace, current.path)
366
+ } catch (failure) {
367
+ if (alive.current) {
368
+ setError(messageOf(failure))
369
+ toast(t('notify.pickFailed', { err: messageOf(failure) }), { color: 'warning' })
370
+ }
371
+ } finally {
372
+ if (alive.current) setBusy('')
373
+ }
374
+ }
375
+
376
+ async function saveForm() {
377
+ if (!form) return
378
+ const { errors, patch } = validateMachineForm(form, t)
379
+ if (errors.length) {
380
+ setFormErrors(errors)
381
+ return
382
+ }
383
+ setFormErrors([])
384
+ const result = await runOp(t('busy.saving'), () => api.upsertMachine(patch))
385
+ if (!result || !alive.current) return
386
+ const name = (result.machine && result.machine.name) || patch.name || patch.host
387
+ toast(t('notify.saved', { name }), { color: 'success' })
388
+ if (Array.isArray(result.warnings) && result.warnings.length) {
389
+ toast(t('notify.warnings', { text: result.warnings.join('; ') }), { color: 'warning', timeoutMs: 8000 })
390
+ }
391
+ setView('list')
392
+ await reload()
393
+ }
394
+
395
+ // ── 视图切换 ──────────────────────────────────────────────────────────
396
+ const backToList = () => {
397
+ setView('list')
398
+ setFormErrors([])
399
+ setError('')
400
+ }
401
+
402
+ function openMenu(machine) {
403
+ if (!machine) return
404
+ setMenuIndex(0)
405
+ setView('menu')
406
+ }
407
+
408
+ function openForm(machine) {
409
+ setForm(machine ? formFromMachine(machine) : emptyMachineForm())
410
+ setFormField(0)
411
+ setFormErrors([])
412
+ setError('')
413
+ setView('form')
414
+ }
415
+
416
+ function openBrowse(machine) {
417
+ if (!machine) return
418
+ setBrowse({ machine, path: '', parent: null, items: [], selected: 0 })
419
+ setError('')
420
+ setView('browse')
421
+ // Start at the machine's recorded remote workspace when there is one,
422
+ // instead of whatever the host considers its current directory.
423
+ void loadDir(machine, machine.workspace || undefined)
424
+ }
425
+
426
+ // ── 工作区清理(删除镜像 / 删除对话 / 移出最近列表) ──────────────────
427
+ function selectedWorkspace() {
428
+ const list = workspaces || []
429
+ if (!list.length) return undefined
430
+ return list[Math.max(0, Math.min(wsIndex, list.length - 1))]
431
+ }
432
+
433
+ async function loadWorkspaces(machine) {
434
+ if (!api || typeof api.listWorkspaceMirrors !== 'function') {
435
+ setError(t('ws.noApi'))
436
+ return
437
+ }
438
+ if (!machine) return
439
+ const token = ++wsToken.current
440
+ setBusy(t('busy.loadingMirrors'))
441
+ setError('')
442
+ try {
443
+ const list = await api.listWorkspaceMirrors({ machineId: machine.id })
444
+ if (!alive.current || token !== wsToken.current) return
445
+ const items = Array.isArray(list) ? list : []
446
+ setWorkspaces(items)
447
+ setWsIndex((previous) => Math.min(previous, Math.max(0, items.length - 1)))
448
+ } catch (failure) {
449
+ if (alive.current && token === wsToken.current) setError(messageOf(failure))
450
+ } finally {
451
+ if (alive.current && token === wsToken.current) setBusy('')
452
+ }
453
+ }
454
+
455
+ function openWorkspaces(machine) {
456
+ if (!machine) return
457
+ setWsMachine(machine)
458
+ setWorkspaces(null)
459
+ setWsIndex(0)
460
+ setWsConfirm(null)
461
+ setError('')
462
+ setView('workspaces')
463
+ void loadWorkspaces(machine)
464
+ }
465
+
466
+ function openWsConfirm(action) {
467
+ const item = selectedWorkspace()
468
+ if (!item) {
469
+ toast(t('ws.empty'), { color: 'warning' })
470
+ return
471
+ }
472
+ setWsConfirm({ action, item })
473
+ setError('')
474
+ setView('wsconfirm')
475
+ }
476
+
477
+ async function runWsConfirm() {
478
+ const pending = wsConfirm
479
+ if (!pending) return
480
+ const { action, item } = pending
481
+ const label = item.title || basenameRemote(item.remotePath)
482
+ const what = action === 'delete' ? t('ws.busyDelete')
483
+ : action === 'sessions' ? t('ws.busySessions') : t('ws.busyRecent')
484
+ setBusy(what)
485
+ setError('')
486
+ try {
487
+ if (action === 'delete') {
488
+ if (typeof api.deleteRemoteWorkspace !== 'function') throw new Error(t('ws.noApi'))
489
+ const result = await api.deleteRemoteWorkspace({ localMirror: item.localMirror, deleteSessions: true })
490
+ if (!alive.current) return
491
+ toast(t('ws.deleted', { name: label, sessions: String(Number(result && result.removedSessions) || 0) }), { color: 'success' })
492
+ } else if (action === 'sessions') {
493
+ if (typeof api.deleteWorkspaceSessions !== 'function') throw new Error(t('ws.noApi'))
494
+ const result = await api.deleteWorkspaceSessions({ localMirror: item.localMirror })
495
+ if (!alive.current) return
496
+ const removed = Number(result && result.removedSessions) || 0
497
+ const skipped = Array.isArray(result && result.skippedLiveSessions) ? result.skippedLiveSessions.length : 0
498
+ toast(skipped
499
+ ? t('ws.sessionsPartial', { n: String(removed), live: String(skipped) })
500
+ : t('ws.sessionsDeleted', { n: String(removed) }), { color: skipped ? 'warning' : 'success' })
501
+ } else {
502
+ if (typeof api.forgetWorkspaceRecent !== 'function') throw new Error(t('ws.noApi'))
503
+ await api.forgetWorkspaceRecent({ localMirror: item.localMirror })
504
+ if (!alive.current) return
505
+ toast(t('ws.recentForgotten', { name: label }), { color: 'success' })
506
+ }
507
+ if (!alive.current) return
508
+ setWsConfirm(null)
509
+ setView('workspaces')
510
+ await loadWorkspaces(wsMachine)
511
+ } catch (failure) {
512
+ if (!alive.current) return
513
+ // A refused deletion is not a dead end: return to the list (the footer
514
+ // explains what must stop) so the person can retry after Ctrl+X.
515
+ setWsConfirm(null)
516
+ setView('workspaces')
517
+ const live = failure && Array.isArray(failure.liveSessions) ? failure.liveSessions.length : 0
518
+ setError(live ? t('ws.busyError', { n: String(live) }) : messageOf(failure))
519
+ } finally {
520
+ if (alive.current) setBusy('')
521
+ }
522
+ }
523
+
524
+ function openConfirm(machine) {
525
+ if (!machine) return
526
+ setConfirmMachine(machine)
527
+ setView('confirm')
528
+ }
529
+
530
+ /** 执行动作菜单 / 快捷键对应的动作。 */
531
+ function executeAction(action, machine) {
532
+ if (!machine) return
533
+ switch (action) {
534
+ case 'connect': void connectMachine(machine); break
535
+ case 'workspace': void ensureMachineWorkspace(machine); break
536
+ case 'workspaces': openWorkspaces(machine); break
537
+ case 'edit': openForm(machine); break
538
+ case 'delete': openConfirm(machine); break
539
+ case 'probe': void probeMachine(machine); break
540
+ case 'browse': openBrowse(machine); break
541
+ default: break
542
+ }
543
+ }
544
+
545
+ // ── 键盘 ──────────────────────────────────────────────────────────────
546
+ const selectedMachine = machines.length
547
+ ? machines[Math.max(0, Math.min(selected, machines.length - 1))]
548
+ : undefined
549
+
550
+ function handleListKey(input, key) {
551
+ // Escape FIRST: a lone Esc carries `meta: true` in this TUI, so any
552
+ // modifier guard before it swallows every Esc press.
553
+ if (key.escape || input === 'q') {
554
+ if (typeof close === 'function') close()
555
+ return
556
+ }
557
+ if (key.ctrl || key.meta) return
558
+ if (key.leftArrow) {
559
+ if (typeof close === 'function') close()
560
+ return
561
+ }
562
+ if (key.rightArrow) {
563
+ openMenu(selectedMachine)
564
+ return
565
+ }
566
+ if (key.upArrow || input === 'k') {
567
+ setSelected((previous) => Math.max(0, previous - 1))
568
+ return
569
+ }
570
+ if (key.downArrow || input === 'j') {
571
+ setSelected((previous) => Math.min(Math.max(0, machines.length - 1), previous + 1))
572
+ return
573
+ }
574
+ if (key.return) {
575
+ openMenu(selectedMachine)
576
+ return
577
+ }
578
+ switch (input) {
579
+ case 'a': openForm(undefined); break
580
+ case 'c': void connectMachine(selectedMachine); break
581
+ case 'e': openForm(selectedMachine); break
582
+ case 'd': openConfirm(selectedMachine); break
583
+ case 't': void probeMachine(selectedMachine); break
584
+ case 'b': openBrowse(selectedMachine); break
585
+ case 'w': void ensureMachineWorkspace(selectedMachine); break
586
+ default: break
587
+ }
588
+ }
589
+
590
+ function handleMenuKey(input, key) {
591
+ if (key.escape || input === 'q' || key.leftArrow) {
592
+ backToList()
593
+ return
594
+ }
595
+ if (key.ctrl || key.meta) return
596
+ if (key.upArrow || input === 'k') {
597
+ setMenuIndex((previous) => Math.max(0, previous - 1))
598
+ return
599
+ }
600
+ if (key.downArrow || input === 'j') {
601
+ setMenuIndex((previous) => Math.min(MENU_ACTIONS.length - 1, previous + 1))
602
+ return
603
+ }
604
+ if (key.return || key.rightArrow) {
605
+ const action = MENU_ACTIONS[Math.max(0, Math.min(menuIndex, MENU_ACTIONS.length - 1))]
606
+ executeAction(action, selectedMachine)
607
+ return
608
+ }
609
+ if (input === 'c') executeAction('connect', selectedMachine)
610
+ else if (input === 'e') executeAction('edit', selectedMachine)
611
+ else if (input === 'd') executeAction('delete', selectedMachine)
612
+ else if (input === 't') executeAction('probe', selectedMachine)
613
+ else if (input === 'b') executeAction('browse', selectedMachine)
614
+ else if (input === 'w') executeAction('workspace', selectedMachine)
615
+ else if (input === 'm') executeAction('workspaces', selectedMachine)
616
+ }
617
+
618
+ function handleConfirmKey(input, key) {
619
+ if (key.escape || input === 'n' || input === 'q' || key.leftArrow) {
620
+ backToList()
621
+ return
622
+ }
623
+ if (key.ctrl || key.meta) return
624
+ if (key.return || key.rightArrow || input === 'y') {
625
+ const target = confirmMachine
626
+ setConfirmMachine(null)
627
+ void deleteMachine(target)
628
+ }
629
+ }
630
+
631
+ function handleFormKey(input, key) {
632
+ if (!form) {
633
+ backToList()
634
+ return
635
+ }
636
+ const fields = fieldsFor(form)
637
+ const bounded = Math.max(0, Math.min(formField, fields.length - 1))
638
+ if (key.escape) {
639
+ backToList()
640
+ return
641
+ }
642
+ if (key.ctrl || key.meta) return
643
+ if (key.tab) {
644
+ const next = key.shift ? bounded - 1 : bounded + 1
645
+ setFormField(Math.max(0, Math.min(fields.length - 1, next)))
646
+ return
647
+ }
648
+ if (key.upArrow) {
649
+ setFormField(Math.max(0, bounded - 1))
650
+ return
651
+ }
652
+ if (key.downArrow) {
653
+ setFormField(Math.min(fields.length - 1, bounded + 1))
654
+ return
655
+ }
656
+ if (key.return) {
657
+ void saveForm()
658
+ return
659
+ }
660
+ const field = fields[bounded]
661
+ if (!field) return
662
+ if (field === 'useSshConfig') {
663
+ if (input === ' ' || key.leftArrow || key.rightArrow) {
664
+ setForm((previous) => ({ ...previous, useSshConfig: !(previous && previous.useSshConfig) }))
665
+ }
666
+ return
667
+ }
668
+ if (field === 'auth') {
669
+ if (input === ' ' || key.leftArrow || key.rightArrow) {
670
+ const auth = nextAuth(form.auth, key.leftArrow ? -1 : 1)
671
+ setForm({ ...form, auth })
672
+ setFormField((index) => Math.min(index, fieldsFor({ auth }).length - 1))
673
+ }
674
+ return
675
+ }
676
+ if (key.backspace || key.delete) {
677
+ setForm((previous) => {
678
+ const value = String((previous && previous[field]) || '')
679
+ return { ...previous, [field]: value.slice(0, -1) }
680
+ })
681
+ return
682
+ }
683
+ if (!input || /[\u0000-\u001f\u007f]/.test(input)) return
684
+ const chunk = input.replace(/[\r\n]+/g, '')
685
+ if (!chunk) return
686
+ setForm((previous) => {
687
+ const value = String((previous && previous[field]) || '') + chunk
688
+ const next = field === 'port' ? value.replace(/[^\d]/g, '').slice(0, 5) : value
689
+ return { ...previous, [field]: next }
690
+ })
691
+ }
692
+
693
+ function handleBrowseKey(input, key) {
694
+ if (key.escape || input === 'q') {
695
+ backToList()
696
+ return
697
+ }
698
+ if (key.ctrl || key.meta) return
699
+ const current = browse
700
+ const dirs = current ? current.items.filter((item) => item && item.type === 'dir') : []
701
+ const bounded = current ? Math.max(0, Math.min(current.selected, dirs.length - 1)) : 0
702
+ if (key.upArrow || input === 'k') {
703
+ setBrowse((previous) => previous
704
+ ? { ...previous, selected: Math.max(0, Math.min(previous.selected, dirs.length - 1) - 1) }
705
+ : previous)
706
+ return
707
+ }
708
+ if (key.downArrow || input === 'j') {
709
+ setBrowse((previous) => previous
710
+ ? { ...previous, selected: Math.min(Math.max(0, dirs.length - 1), previous.selected + 1) }
711
+ : previous)
712
+ return
713
+ }
714
+ if (key.return || key.rightArrow) {
715
+ const dir = dirs[bounded]
716
+ if (dir && current) void loadDir(current.machine, dir.path)
717
+ else toast(t('browse.noSelection'), { color: 'warning' })
718
+ return
719
+ }
720
+ if (key.leftArrow || key.backspace || key.delete) {
721
+ // ← = one DIRECTORY level up (same as Backspace); at the root it leaves
722
+ // the browse view instead of doing nothing.
723
+ if (current && current.parent) void loadDir(current.machine, current.parent)
724
+ else backToList()
725
+ return
726
+ }
727
+ if (input === 'w') void pickBrowseWorkspace()
728
+ }
729
+
730
+ function handleWorkspacesKey(input, key) {
731
+ if (key.escape || input === 'q' || key.leftArrow) {
732
+ backToList()
733
+ return
734
+ }
735
+ if (key.ctrl || key.meta) return
736
+ const list = workspaces || []
737
+ if (key.upArrow || input === 'k') {
738
+ setWsIndex((previous) => Math.max(0, previous - 1))
739
+ return
740
+ }
741
+ if (key.downArrow || input === 'j') {
742
+ setWsIndex((previous) => Math.min(Math.max(0, list.length - 1), previous + 1))
743
+ return
744
+ }
745
+ if (input === 'd') { openWsConfirm('delete'); return }
746
+ if (input === 'x') { openWsConfirm('sessions'); return }
747
+ if (input === 'r') { openWsConfirm('recent'); return }
748
+ }
749
+
750
+ function handleWsConfirmKey(input, key) {
751
+ if (key.escape || input === 'n' || input === 'q' || key.leftArrow) {
752
+ setWsConfirm(null)
753
+ setView('workspaces')
754
+ return
755
+ }
756
+ if (key.ctrl || key.meta) return
757
+ if (key.return || key.rightArrow || input === 'y') void runWsConfirm()
758
+ }
759
+
760
+ useInput((input, key) => {
761
+ const pressed = key || {}
762
+ if (view === 'list') handleListKey(input, pressed)
763
+ else if (view === 'menu') handleMenuKey(input, pressed)
764
+ else if (view === 'form') handleFormKey(input, pressed)
765
+ else if (view === 'browse') handleBrowseKey(input, pressed)
766
+ else if (view === 'workspaces') handleWorkspacesKey(input, pressed)
767
+ else if (view === 'wsconfirm') handleWsConfirmKey(input, pressed)
768
+ else if (view === 'confirm') handleConfirmKey(input, pressed)
769
+ })
770
+
771
+ // ── 渲染 ──────────────────────────────────────────────────────────────
772
+ function renderHeader() {
773
+ const connected = status && status.connected === true
774
+ return h(Box, { key: 'header', flexDirection: 'row', width: '100%', paddingX: 1 },
775
+ h(Text, { bold: true, color: 'accent' }, t('title')),
776
+ h(Box, { flexGrow: 1 }),
777
+ h(Text, { color: connected ? 'success' : 'subtle' }, status ? statusLine(status, t) : t('status.loading')),
778
+ )
779
+ }
780
+
781
+ function renderList() {
782
+ const children = []
783
+ if (!machines.length) {
784
+ children.push(h(Text, { key: 'empty', color: 'subtle' }, t('list.empty')))
785
+ } else {
786
+ const slicing = windowSlice(machines, selected, bodyHeight - 1)
787
+ slicing.items.forEach((machine, index) => {
788
+ const absolute = slicing.offset + index
789
+ const focused = absolute === selected
790
+ const current = machine.id === currentId
791
+ children.push(h(Box, { key: machine.id || `machine-${absolute}`, flexDirection: 'row', width: '100%' },
792
+ h(Text, { color: focused ? 'accent' : undefined, inverse: focused }, focused ? '› ' : ' '),
793
+ h(Text, { color: current ? 'success' : 'subtle' }, current ? '● ' : '○ '),
794
+ h(Text, { bold: focused || current }, clip(machineName(machine), Math.max(8, Math.floor(columns * 0.32)))),
795
+ h(Text, { dimColor: true }, clip(` ${machineAddress(machine)}`, Math.max(10, Math.floor(columns * 0.3)))),
796
+ machine.group ? h(Text, { color: 'subtle' }, clip(` [${machine.group}]`, 16)) : null,
797
+ machine.useSshConfig ? h(Text, { color: 'subtle' }, ' ssh-config') : null,
798
+ h(Box, { flexGrow: 1 }),
799
+ h(Text, { color: 'subtle' }, machine.workspace ? clip(basenameRemote(machine.workspace), 18) : ''),
800
+ ))
801
+ })
802
+ }
803
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
804
+ }
805
+
806
+ function renderMenu() {
807
+ const machine = selectedMachine
808
+ if (!machine) return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 },
809
+ h(Text, { color: 'subtle' }, t('list.empty')))
810
+ const children = [
811
+ h(Text, { key: 'title', bold: true, color: 'accent' }, t('menu.title', { name: machineName(machine) })),
812
+ h(Text, { key: 'addr', color: 'subtle' }, `${machineAddress(machine)}${machine.workspace ? ' · ' + clip(machine.workspace, 48) : ''}`),
813
+ ]
814
+ MENU_ACTIONS.forEach((action, index) => {
815
+ const focused = index === menuIndex
816
+ children.push(h(Text, { key: action, color: focused ? 'accent' : undefined, inverse: focused },
817
+ `${focused ? '› ' : ' '}${index + 1}. ${t('menu.' + action)}`))
818
+ })
819
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
820
+ }
821
+
822
+ function formValueText(field) {
823
+ if (!form) return ''
824
+ if (field === 'useSshConfig') return form.useSshConfig ? '[x]' : '[ ]'
825
+ if (field === 'auth') return `◂ ${t('auth.' + form.auth)} ▸`
826
+ if (field === 'password') {
827
+ const masked = maskValue(form.password)
828
+ if (masked) return masked
829
+ return form.id ? t('form.passwordUnchanged') : t('form.passwordPlaceholder')
830
+ }
831
+ if (field === 'passphrase') {
832
+ const masked = maskValue(form.passphrase)
833
+ if (masked) return masked
834
+ return form.id ? t('form.passwordUnchanged') : t('form.passphrasePlaceholder')
835
+ }
836
+ if (field === 'privateKeyPath') return String(form.privateKeyPath || '') || t('form.optional')
837
+ return String(form[field] === undefined || form[field] === null ? '' : form[field])
838
+ }
839
+
840
+ function renderForm() {
841
+ if (!form) return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 })
842
+ const fields = fieldsFor(form)
843
+ const bounded = Math.max(0, Math.min(formField, fields.length - 1))
844
+ const children = [
845
+ h(Text, { key: 'title', bold: true, color: 'accent' },
846
+ form.id ? t('form.titleEdit', { name: machineName(form) || form.host }) : t('form.titleNew')),
847
+ ]
848
+ const slicing = windowSlice(fields, bounded, bodyHeight - 2)
849
+ slicing.items.forEach((field, index) => {
850
+ const absolute = slicing.offset + index
851
+ const focused = absolute === bounded
852
+ const label = t('form.' + field)
853
+ const value = formValueText(field)
854
+ children.push(h(Box, { key: field, flexDirection: 'row', width: '100%' },
855
+ h(Text, { color: focused ? 'accent' : 'subtle', bold: focused }, `${focused ? '› ' : ' '}${label}: `),
856
+ h(Text, { inverse: focused, color: focused ? 'accent' : undefined },
857
+ clip(value + (focused && field !== 'useSshConfig' && field !== 'auth' ? '▌' : ''), Math.max(8, columns - 18))),
858
+ ))
859
+ })
860
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
861
+ }
862
+
863
+ function renderBrowse() {
864
+ const current = browse
865
+ if (!current) {
866
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 },
867
+ h(Text, { color: 'subtle' }, t('browse.loading')))
868
+ }
869
+ const dirs = current.items.filter((item) => item && item.type === 'dir')
870
+ const files = current.items.filter((item) => item && item.type !== 'dir')
871
+ const combined = [...dirs, ...files]
872
+ const selection = dirs.length ? Math.max(0, Math.min(current.selected, dirs.length - 1)) : 0
873
+ const children = [
874
+ h(Text, { key: 'title', bold: true, color: 'accent' }, t('browse.title', { path: clip(current.path || '/', Math.max(8, columns - 20)) })),
875
+ h(Text, { key: 'machine', color: 'subtle' }, `${machineName(current.machine)} · ${machineAddress(current.machine)}`),
876
+ ]
877
+ if (!combined.length) {
878
+ children.push(h(Text, { key: 'empty', color: 'subtle' }, busy ? t('browse.loading') : t('browse.empty')))
879
+ } else {
880
+ const slicing = windowSlice(combined, selection, Math.max(1, bodyHeight - 3))
881
+ slicing.items.forEach((item, index) => {
882
+ const absolute = slicing.offset + index
883
+ const isDir = item.type === 'dir'
884
+ const focused = isDir && absolute === selection
885
+ const icon = isDir ? '▸' : '·'
886
+ const suffix = isDir ? '/' : ''
887
+ children.push(h(Text, {
888
+ key: `${item.path || item.name || index}`,
889
+ color: focused ? 'accent' : isDir ? undefined : 'subtle',
890
+ dimColor: !isDir,
891
+ inverse: focused,
892
+ }, `${focused ? '› ' : ' '}${icon} ${clip(String(item.name || ''), Math.max(8, columns - 8))}${suffix}`))
893
+ })
894
+ }
895
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
896
+ }
897
+
898
+ function renderConfirm() {
899
+ const machine = confirmMachine
900
+ const children = [
901
+ h(Text, { key: 'title', bold: true, color: 'warning' }, t('confirm.deleteTitle')),
902
+ h(Text, { key: 'message' }, t('confirm.delete', { name: machineName(machine) })),
903
+ h(Text, { key: 'addr', color: 'subtle' }, machineAddress(machine)),
904
+ ]
905
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
906
+ }
907
+
908
+ function renderWorkspaces() {
909
+ const machine = wsMachine
910
+ const list = workspaces || []
911
+ const children = [
912
+ h(Text, { key: 'title', bold: true, color: 'accent' }, t('ws.title', { name: machine ? machineName(machine) : '' })),
913
+ h(Text, { key: 'addr', color: 'subtle' }, machine ? machineAddress(machine) : ''),
914
+ ]
915
+ if (workspaces === null) {
916
+ children.push(h(Text, { key: 'loading', color: 'subtle' }, busy || t('busy.loading')))
917
+ } else if (!list.length) {
918
+ children.push(h(Text, { key: 'empty', color: 'subtle' }, t('ws.empty')))
919
+ } else {
920
+ const slicing = windowSlice(list, Math.max(0, Math.min(wsIndex, list.length - 1)), Math.max(2, Math.floor((bodyHeight - 2) / 2)))
921
+ slicing.items.forEach((item, index) => {
922
+ const absolute = slicing.offset + index
923
+ const focused = absolute === wsIndex
924
+ const tags = []
925
+ if (item.registered) tags.push(t('ws.registered'))
926
+ if (item.recent) tags.push(t('ws.recent'))
927
+ const live = Number(item.liveSessionCount) || 0
928
+ children.push(h(Box, { key: `ws-${item.localMirror || absolute}`, flexDirection: 'row', width: '100%' },
929
+ h(Text, { color: focused ? 'accent' : undefined, inverse: focused }, focused ? '› ' : ' '),
930
+ h(Text, { bold: focused }, clip(item.title || basenameRemote(item.remotePath), Math.max(12, Math.floor(columns * 0.34)))),
931
+ h(Text, { color: 'subtle' }, ` ${t('ws.sessions', { n: String(Number(item.sessionCount) || 0) })}`),
932
+ live ? h(Text, { color: 'warning' }, ` ${t('ws.live', { n: String(live) })}`) : null,
933
+ h(Text, { dimColor: true }, ` ${t('ws.size', { size: formatBytes(item.mirrorBytes) })}`),
934
+ tags.length ? h(Text, { color: 'subtle' }, ` [${tags.join(' · ')}]`) : null,
935
+ ))
936
+ children.push(h(Text, { key: `ws-path-${item.localMirror || absolute}`, color: 'subtle', dimColor: true },
937
+ clip(` ${item.username ? item.username + '@' : ''}${item.host}:${item.remotePath}`, Math.max(20, columns - 3))))
938
+ })
939
+ }
940
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
941
+ }
942
+
943
+ function renderWsConfirm() {
944
+ const pending = wsConfirm
945
+ const item = pending && pending.item
946
+ const action = pending && pending.action
947
+ const titleKey = action === 'delete' ? 'ws.confirmTitleDelete'
948
+ : action === 'sessions' ? 'ws.confirmTitleSessions' : 'ws.confirmTitleRecent'
949
+ const bodyKey = action === 'delete' ? 'ws.confirmDelete'
950
+ : action === 'sessions' ? 'ws.confirmSessions' : 'ws.confirmRecent'
951
+ const name = item ? (item.title || basenameRemote(item.remotePath)) : ''
952
+ const sessions = item ? Number(item.sessionCount) || 0 : 0
953
+ return h(Box, { key: 'body', flexDirection: 'column', width: '100%', paddingX: 1 },
954
+ h(Text, { key: 'title', bold: true, color: 'warning' }, t(titleKey, { name })),
955
+ h(Text, { key: 'message' }, clip(t(bodyKey, { n: String(sessions), name }), Math.max(20, columns - 4))),
956
+ h(Text, { key: 'path', color: 'subtle' }, clip(item ? `${item.username ? item.username + '@' : ''}${item.host}:${item.remotePath}` : '', Math.max(20, columns - 4))),
957
+ h(Text, { key: 'mirror', color: 'subtle' }, clip(item ? item.localMirror : '', Math.max(20, columns - 4))),
958
+ )
959
+ }
960
+
961
+ function hintForView() {
962
+ if (view === 'menu') return t('menu.hints')
963
+ if (view === 'form') return t('form.hints')
964
+ if (view === 'browse') return t('browse.hints')
965
+ if (view === 'workspaces') return t('ws.hints')
966
+ if (view === 'wsconfirm') return t('ws.confirmHints')
967
+ if (view === 'confirm') return t('confirm.hints')
968
+ return t('list.hints')
969
+ }
970
+
971
+ function renderFooter() {
972
+ const children = []
973
+ if (busy) children.push(h(Text, { key: 'busy', color: 'accent' }, t('busy.line', { what: busy })))
974
+ const failure = view === 'form' && formErrors.length ? formErrors.join(' · ') : error
975
+ if (failure) children.push(h(Text, { key: 'error', color: 'error' }, t('footer.error', { err: failure })))
976
+ children.push(h(Text, { key: 'hint', color: 'subtle' }, clip(hintForView(), Math.max(20, columns - 2))))
977
+ return h(Box, { key: 'footer', flexDirection: 'column', width: '100%', paddingX: 1 }, ...children)
978
+ }
979
+
980
+ function renderBody() {
981
+ if (view === 'menu') return renderMenu()
982
+ if (view === 'form') return renderForm()
983
+ if (view === 'browse') return renderBrowse()
984
+ if (view === 'workspaces') return renderWorkspaces()
985
+ if (view === 'wsconfirm') return renderWsConfirm()
986
+ if (view === 'confirm') return renderConfirm()
987
+ return renderList()
988
+ }
989
+
990
+ return h(Box, { flexDirection: 'column', width: '100%', flexGrow: 1, paddingY: 0 },
991
+ renderHeader(),
992
+ renderBody(),
993
+ renderFooter(),
994
+ )
995
+ }
996
+ }