dsh-remote 0.8.72 → 0.8.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +470 -389
- package/README.md +529 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4039 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/host-api.js +808 -0
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4923 -4541
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +100 -99
package/lib/tui/scene.js
ADDED
|
@@ -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
|
+
}
|