quick-ssh 1.0.2

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/qssh-tui.js ADDED
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * qssh-tui.js - Quick-SSH 终端用户界面 (TUI)
4
+ * 类似 yazi 的布局 + Vim 操作键位
5
+ *
6
+ * 布局:
7
+ * ┌─ 标题栏 ─────────────────────────────────┐
8
+ * │ 连接列表 (60%) │ 详情面板 (40%) │
9
+ * │ ● myserver │ 别名: myserver │
10
+ * │ ○ devbox │ 主机: 192.168.1.100│
11
+ * │ ... │ ... │
12
+ * ├─ 状态栏 ─────────────────────────────────┤
13
+ * │ NORMAL j/k ↑↓ ↵连接 d删除 /搜索 q退出│
14
+ * └──────────────────────────────────────────┘
15
+ *
16
+ * 依赖: blessed (npm install blessed)
17
+ * 数据: %USERPROFILE%\.quickssh\hosts.json
18
+ */
19
+
20
+ const blessed = require("blessed");
21
+
22
+ // 阻止 blessed 切换备用屏幕缓冲区,保留 PowerShell 透明背景
23
+ if (blessed.Program) {
24
+ blessed.Program.prototype.alternateBuffer = function (val, cb) {
25
+ if (typeof val === "function") { cb = val; }
26
+ if (typeof cb === "function") { cb(); }
27
+ return this;
28
+ };
29
+ if (blessed.Program.prototype.smcup) blessed.Program.prototype.smcup = function () { return this; };
30
+ if (blessed.Program.prototype.rmcup) blessed.Program.prototype.rmcup = function () { return this; };
31
+ }
32
+ const net = require("net");
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const { spawn } = require("child_process");
36
+
37
+ // ============================================================
38
+ // 配置
39
+ // ============================================================
40
+
41
+ const CONFIG_DIR = path.join(process.env.USERPROFILE || "~", ".quickssh");
42
+ const CONFIG_FILE = path.join(CONFIG_DIR, "hosts.json");
43
+
44
+ // ============================================================
45
+ // 数据层
46
+ // ============================================================
47
+
48
+ function ensureConfig() {
49
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
50
+ if (!fs.existsSync(CONFIG_FILE)) fs.writeFileSync(CONFIG_FILE, "[]", "utf-8");
51
+ }
52
+
53
+ function loadHosts() {
54
+ ensureConfig();
55
+ try {
56
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8").trim();
57
+ if (!raw) return [];
58
+ const data = JSON.parse(raw);
59
+ // 兼容单个对象 { ... } 和数组 [{ ... }, { ... }]
60
+ return Array.isArray(data) ? data : [data];
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ function saveHosts(hosts) {
67
+ ensureConfig();
68
+ // 确保始终保存为数组
69
+ const data = Array.isArray(hosts) ? hosts : [];
70
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 4), "utf-8");
71
+ }
72
+
73
+ // ============================================================
74
+ // SSH 连接
75
+ // ============================================================
76
+
77
+ function sshConnect(host, cbReturn) {
78
+ const sshExe = "ssh.exe";
79
+ const args = [
80
+ "-i", host.key,
81
+ "-p", String(host.port),
82
+ "-o", "HostKeyAlgorithms=+ssh-rsa",
83
+ `${host.user}@${host.host}`,
84
+ ];
85
+
86
+ screen.destroy();
87
+ process.stdin.removeAllListeners("data");
88
+
89
+ process.stdout.write(`\n\x1b[32m正在连接到 '${host.alias}' (${host.user}@${host.host}:${host.port}) ...\x1b[0m\n\n`);
90
+
91
+ const child = spawn(sshExe, args, {
92
+ stdio: "inherit",
93
+ shell: true,
94
+ });
95
+
96
+ child.on("exit", (code) => {
97
+ process.stdout.write(`\n\x1b[33mSSH 会话已结束 (退出码: ${code})\x1b[0m\n`);
98
+ process.stdout.write(`\x1b[32m按 Enter 键返回 Quick-SSH TUI...\x1b[0m`);
99
+ process.stdin.setRawMode(false);
100
+ process.stdin.once("data", () => cbReturn());
101
+ });
102
+ }
103
+
104
+ // ============================================================
105
+ // TUI 组件
106
+ // ============================================================
107
+
108
+ let screen;
109
+ let headerBar;
110
+ let listBox;
111
+ let detailBox;
112
+ let statusBar;
113
+ let inputBox;
114
+ let helpBox;
115
+ let confirmBox;
116
+
117
+ // 在线状态缓存
118
+ const hostStatus = {}; // { alias: "unknown" | "online" | "offline" }
119
+
120
+ let hosts = [];
121
+ let filteredHosts = [];
122
+ let filterText = "";
123
+
124
+ // 模式管理
125
+ const MODE = { NORMAL: 0, SEARCH: 1, ADD: 2, EXPORT: 3, IMPORT: 4, CONFIRM: 5, HELP: 6, RENAME: 7 };
126
+ let currentMode = MODE.NORMAL;
127
+
128
+ // ============================================================
129
+ // UI 更新函数
130
+ // ============================================================
131
+
132
+ const MODE_LABELS = {
133
+ [MODE.NORMAL]: "{green-fg}{bold} NORMAL {/bold}{/green-fg}",
134
+ [MODE.SEARCH]: "{cyan-fg}{bold} SEARCH {/bold}{/cyan-fg}",
135
+ [MODE.ADD]: "{yellow-fg}{bold} ADD {/bold}{/yellow-fg}",
136
+ [MODE.EXPORT]: "{yellow-fg}{bold} EXPORT {/bold}{/yellow-fg}",
137
+ [MODE.IMPORT]: "{yellow-fg}{bold} IMPORT {/bold}{/yellow-fg}",
138
+ [MODE.CONFIRM]: "{red-fg}{bold} CONFIRM {/bold}{/red-fg}",
139
+ [MODE.HELP]: "{white-fg}{bold} HELP {/bold}{/white-fg}",
140
+ [MODE.RENAME]: "{cyan-fg}{bold} RENAME {/bold}{/cyan-fg}",
141
+ };
142
+
143
+ const MODE_HINTS = {
144
+ [MODE.NORMAL]: " j/k ↑↓ g首 G尾 ↵连接 d删除 a添加 /搜索 e导出 i导入 r重命名 p检测 P全检 ?帮助 q退出",
145
+ [MODE.SEARCH]: " 输入关键词过滤 Enter确认 Esc取消",
146
+ [MODE.ADD]: " 格式: 别名 用户@主机:端口 [--key 路径] Enter确认 Esc取消",
147
+ [MODE.EXPORT]: " 输入导出文件路径 (默认: ~/.quickssh/export.json) Enter确认 Esc取消",
148
+ [MODE.IMPORT]: " 输入导入文件路径 Enter确认 Esc取消",
149
+ [MODE.CONFIRM]: " y确认 n取消",
150
+ [MODE.HELP]: " 按任意键返回",
151
+ [MODE.RENAME]: " 输入新别名 Enter确认 Esc取消",
152
+ };
153
+
154
+ function setMode(mode, inputValue) {
155
+ currentMode = mode;
156
+ statusBar.setContent(`${MODE_LABELS[mode] || ""} ${MODE_HINTS[mode] || ""}`);
157
+
158
+ if (mode === MODE.NORMAL || mode === MODE.HELP) {
159
+ inputBox.hide();
160
+ confirmBox.hide();
161
+ if (mode === MODE.NORMAL) listBox.focus();
162
+ }
163
+
164
+ if (mode === MODE.SEARCH || mode === MODE.ADD || mode === MODE.EXPORT || mode === MODE.IMPORT || mode === MODE.RENAME) {
165
+ inputBox.show();
166
+ inputBox.setValue(inputValue || "");
167
+ inputBox.focus();
168
+ inputBox.readInput();
169
+ }
170
+
171
+ if (mode === MODE.CONFIRM) {
172
+ confirmBox.show();
173
+ }
174
+
175
+ screen.render();
176
+ }
177
+
178
+ function refreshList(keepSelection) {
179
+ hosts = loadHosts();
180
+ filteredHosts = filterText
181
+ ? hosts.filter(h =>
182
+ h.alias.toLowerCase().includes(filterText.toLowerCase()) ||
183
+ h.host.toLowerCase().includes(filterText.toLowerCase()) ||
184
+ h.user.toLowerCase().includes(filterText.toLowerCase())
185
+ )
186
+ : [...hosts];
187
+
188
+ const prevIdx = listBox.selected;
189
+ const statusIndicators = {
190
+ online: "{green-fg}●{/green-fg}",
191
+ offline: "{red-fg}○{/red-fg}",
192
+ unknown: "{yellow-fg}◌{/yellow-fg}",
193
+ };
194
+ listBox.setItems(filteredHosts.map(h => {
195
+ const sta = hostStatus[h.alias] || "unknown";
196
+ const indicator = statusIndicators[sta] || statusIndicators.unknown;
197
+ return `${indicator} ${h.alias.padEnd(14)} ${h.user}@${h.host}:${h.port}`;
198
+ }));
199
+
200
+ if (keepSelection && prevIdx < filteredHosts.length) {
201
+ listBox.select(prevIdx);
202
+ listBox.scrollTo(prevIdx);
203
+ } else if (filteredHosts.length > 0) {
204
+ listBox.select(0);
205
+ listBox.scrollTo(0);
206
+ }
207
+
208
+ updateDetail();
209
+ headerBar.setContent(
210
+ `{bold} Quick-SSH {/bold}(${filteredHosts.length}/${hosts.length})`
211
+ );
212
+ screen.render();
213
+ }
214
+
215
+ function updateDetail() {
216
+ const idx = listBox.selected;
217
+ if (idx < 0 || idx >= filteredHosts.length) {
218
+ detailBox.setContent("{center}{yellow-fg}--- 无选中连接 ---{/yellow-fg}{/center}");
219
+ screen.render();
220
+ return;
221
+ }
222
+
223
+ const h = filteredHosts[idx];
224
+ const sta = hostStatus[h.alias] || "unknown";
225
+ const statusMap = {
226
+ online: "{green-fg}● 在线{/green-fg}",
227
+ offline: "{red-fg}○ 离线{/red-fg}",
228
+ unknown: "{yellow-fg}◌ 未检测{/yellow-fg}",
229
+ };
230
+ detailBox.setContent(`
231
+ {bold}{cyan-fg} 连接详情{/cyan-fg}{/bold}
232
+
233
+ {bold}别名:{/bold} ${h.alias}
234
+ {bold}主机:{/bold} ${h.host}
235
+ {bold}账号:{/bold} ${h.user}
236
+ {bold}端口:{/bold} ${h.port}
237
+ {bold}私钥:{/bold} ${h.key || "(默认)"}
238
+ {bold}状态:{/bold} ${statusMap[sta]}
239
+
240
+ {blue-fg}─────────────────────{/blue-fg}
241
+ {green-fg} Enter → 连接{/green-fg} {red-fg} d → 删除{/red-fg}
242
+ {yellow-fg} p → 检测在线{/yellow-fg}
243
+ `);
244
+ screen.render();
245
+ }
246
+
247
+ // ============================================================
248
+ // 在线检测
249
+ // ============================================================
250
+
251
+ /**
252
+ * 检查单台服务器是否在线(TCP 连接 SSH 端口)
253
+ */
254
+ function checkHost(alias) {
255
+ return new Promise((resolve) => {
256
+ const h = hosts.find(e => e.alias === alias);
257
+ if (!h) { resolve(false); return; }
258
+
259
+ hostStatus[alias] = "checking";
260
+ refreshList();
261
+
262
+ const sock = new net.Socket();
263
+ const port = h.port || 22;
264
+ const timeout = 3000; // 3s
265
+
266
+ sock.setTimeout(timeout);
267
+ sock.on("connect", () => {
268
+ sock.destroy();
269
+ hostStatus[alias] = "online";
270
+ refreshList();
271
+ resolve(true);
272
+ });
273
+ sock.on("error", () => {
274
+ sock.destroy();
275
+ hostStatus[alias] = "offline";
276
+ refreshList();
277
+ resolve(false);
278
+ });
279
+ sock.on("timeout", () => {
280
+ sock.destroy();
281
+ hostStatus[alias] = "offline";
282
+ refreshList();
283
+ resolve(false);
284
+ });
285
+ sock.connect(port, h.host);
286
+ });
287
+ }
288
+
289
+ function checkSelectedHost() {
290
+ const idx = listBox.selected;
291
+ if (idx < 0 || idx >= filteredHosts.length) return;
292
+ const alias = filteredHosts[idx].alias;
293
+ flashMessage(`正在检测 '${alias}' ...`, "yellow");
294
+ checkHost(alias).then(ok => {
295
+ flashMessage(`'${alias}' ${ok ? "● 在线" : "○ 离线"}`, ok ? "green" : "red");
296
+ });
297
+ }
298
+
299
+ function checkAllHosts() {
300
+ const list = filteredHosts.length > 0 ? filteredHosts : hosts;
301
+ if (list.length === 0) {
302
+ flashMessage("没有可检测的服务器", "red");
303
+ return;
304
+ }
305
+ flashMessage(`正在检测 ${list.length} 台服务器 ...`, "yellow");
306
+ let done = 0;
307
+ for (const h of list) {
308
+ checkHost(h.alias).then(() => {
309
+ done++;
310
+ if (done === list.length) {
311
+ const online = list.filter(e => hostStatus[e.alias] === "online").length;
312
+ const offline = list.filter(e => hostStatus[e.alias] === "offline").length;
313
+ flashMessage(`检测完成: ${online} 在线, ${offline} 离线`, online > 0 ? "green" : "red");
314
+ }
315
+ });
316
+ }
317
+ }
318
+
319
+ // ============================================================
320
+ // 操作函数
321
+ // ============================================================
322
+
323
+ function connectSelected() {
324
+ const idx = listBox.selected;
325
+ if (idx < 0 || idx >= filteredHosts.length) return;
326
+ sshConnect(filteredHosts[idx], startTUI);
327
+ }
328
+
329
+ function deleteSelected() {
330
+ const idx = listBox.selected;
331
+ if (idx < 0 || idx >= filteredHosts.length) return;
332
+ const h = filteredHosts[idx];
333
+ confirmBox.setContent(
334
+ `{center}{red-fg}确认删除连接 '{h.alias}'? (y/n){/red-fg}{/center}`
335
+ );
336
+ setMode(MODE.CONFIRM);
337
+ }
338
+
339
+ function confirmDelete(confirmed) {
340
+ confirmBox.hide();
341
+ if (confirmed) {
342
+ const idx = listBox.selected;
343
+ const h = filteredHosts[idx];
344
+ hosts = hosts.filter(item => item.alias !== h.alias);
345
+ saveHosts(hosts);
346
+ refreshList();
347
+ flashMessage(`已删除连接 '${h.alias}'`, "green");
348
+ }
349
+ setMode(MODE.NORMAL);
350
+ }
351
+
352
+ function flashMessage(msg, color) {
353
+ statusBar.setContent(`{${color}-fg} ${msg}{/${color}-fg}`);
354
+ screen.render();
355
+ setTimeout(() => {
356
+ if (currentMode === MODE.NORMAL) setMode(MODE.NORMAL);
357
+ }, 3000);
358
+ }
359
+
360
+ // ============================================================
361
+ // 输入处理
362
+ // ============================================================
363
+
364
+ function handleInputSubmit(value) {
365
+ const val = value.trim();
366
+
367
+ switch (currentMode) {
368
+ case MODE.SEARCH: {
369
+ filterText = val;
370
+ inputBox.hide();
371
+ refreshList();
372
+ setMode(MODE.NORMAL);
373
+ break;
374
+ }
375
+ case MODE.ADD: {
376
+ inputBox.hide();
377
+ const parts = val.split(/\s+/);
378
+ if (parts.length < 2) {
379
+ flashMessage("格式错误: 需要 别名 用户@主机:端口", "red");
380
+ setMode(MODE.NORMAL);
381
+ break;
382
+ }
383
+ const alias = parts[0];
384
+ const userAtHost = parts[1];
385
+ let keyPath = "";
386
+ const ki = parts.indexOf("--key");
387
+ if (ki >= 0 && ki + 1 < parts.length) keyPath = parts[ki + 1];
388
+
389
+ let user = "", hostname = "", port = 22;
390
+ const m1 = userAtHost.match(/^(.+)@(.+):(\d+)$/);
391
+ const m2 = userAtHost.match(/^(.+)@(.+)$/);
392
+ if (m1) { user = m1[1]; hostname = m1[2]; port = parseInt(m1[3]); }
393
+ else if (m2) { user = m2[1]; hostname = m2[2]; }
394
+ else {
395
+ flashMessage("格式无效: 请使用 用户@主机:端口", "red");
396
+ setMode(MODE.NORMAL);
397
+ break;
398
+ }
399
+
400
+ if (!keyPath) keyPath = path.join(process.env.USERPROFILE || "~", ".ssh", "id_rsa");
401
+
402
+ if (hosts.find(h => h.alias === alias)) {
403
+ flashMessage(`别名 '${alias}' 已存在`, "red");
404
+ } else {
405
+ hosts.push({ alias, host: hostname, user, port, key: keyPath });
406
+ saveHosts(hosts);
407
+ refreshList(true);
408
+ flashMessage(`已添加 '${alias}' → ${user}@${hostname}:${port}`, "green");
409
+ }
410
+ setMode(MODE.NORMAL);
411
+ break;
412
+ }
413
+ case MODE.EXPORT: {
414
+ inputBox.hide();
415
+ const fp = val || path.join(CONFIG_DIR, "export.json");
416
+ try {
417
+ fs.writeFileSync(fp, JSON.stringify(hosts, null, 4), "utf-8");
418
+ flashMessage(`已导出 ${hosts.length} 个连接到 '${fp}'`, "green");
419
+ } catch (e) {
420
+ flashMessage(`导出失败: ${e.message}`, "red");
421
+ }
422
+ setMode(MODE.NORMAL);
423
+ break;
424
+ }
425
+ case MODE.IMPORT: {
426
+ inputBox.hide();
427
+ if (!val || !fs.existsSync(val)) {
428
+ flashMessage("文件不存在", "red");
429
+ setMode(MODE.NORMAL);
430
+ break;
431
+ }
432
+ try {
433
+ const imported = JSON.parse(fs.readFileSync(val, "utf-8"));
434
+ let added = 0, skipped = 0;
435
+ for (const h of imported) {
436
+ if (!hosts.find(e => e.alias === h.alias)) { hosts.push(h); added++; }
437
+ else { skipped++; }
438
+ }
439
+ saveHosts(hosts);
440
+ refreshList();
441
+ flashMessage(`导入完成: 新增 ${added}, 跳过 ${skipped}`, "green");
442
+ } catch (e) {
443
+ flashMessage(`导入失败: ${e.message}`, "red");
444
+ }
445
+ setMode(MODE.NORMAL);
446
+ break;
447
+ }
448
+ case MODE.RENAME: {
449
+ inputBox.hide();
450
+ const idx = listBox.selected;
451
+ if (idx < 0 || idx >= filteredHosts.length) { setMode(MODE.NORMAL); break; }
452
+ const oldAlias = filteredHosts[idx].alias;
453
+ if (!val || val.trim() === oldAlias) { setMode(MODE.NORMAL); break; }
454
+ const newAlias = val.trim();
455
+ if (hosts.find(h => h.alias === newAlias)) {
456
+ flashMessage(`别名 '${newAlias}' 已存在`, "red");
457
+ setMode(MODE.NORMAL);
458
+ break;
459
+ }
460
+ const target = hosts.find(h => h.alias === oldAlias);
461
+ if (target) {
462
+ target.alias = newAlias;
463
+ saveHosts(hosts);
464
+ refreshList(true);
465
+ flashMessage(`已重命名 '${oldAlias}' → '${newAlias}'`, "green");
466
+ }
467
+ setMode(MODE.NORMAL);
468
+ break;
469
+ }
470
+ }
471
+ }
472
+
473
+ function handleInputCancel() {
474
+ inputBox.hide();
475
+ switch (currentMode) {
476
+ case MODE.SEARCH: setMode(MODE.NORMAL); break;
477
+ case MODE.ADD: setMode(MODE.NORMAL); break;
478
+ case MODE.EXPORT: setMode(MODE.NORMAL); break;
479
+ case MODE.IMPORT: setMode(MODE.NORMAL); break;
480
+ case MODE.RENAME: setMode(MODE.NORMAL); break;
481
+ default: setMode(MODE.NORMAL); break;
482
+ }
483
+ }
484
+
485
+ // ============================================================
486
+ // 启动 TUI
487
+ // ============================================================
488
+
489
+ function startTUI() {
490
+ screen = blessed.screen({
491
+ smartCSR: true,
492
+ title: "Quick-SSH",
493
+ cursor: { artificial: true, shape: "underline" },
494
+ dockBorders: true,
495
+ fullUnicode: true,
496
+ });
497
+
498
+ // 标题栏
499
+ headerBar = blessed.box({
500
+ top: 0, left: 0, width: "100%", height: 1,
501
+ content: "{bold} Quick-SSH {/bold}",
502
+ tags: true,
503
+ style: { fg: "white", bg: -1 },
504
+ });
505
+
506
+ // 连接列表
507
+ listBox = blessed.list({
508
+ top: 1, left: 0, width: "60%", height: "100%-2",
509
+ label: " 连接列表 ",
510
+ border: { type: "line", fg: "cyan" },
511
+ tags: true, keys: false, vi: false, mouse: true,
512
+ scrollbar: { ch: " ", bg: -1 },
513
+ style: {
514
+ fg: "white", bg: -1,
515
+ selected: { fg: "white", bg: "blue", bold: true },
516
+ item: { fg: "white", bg: -1 },
517
+ },
518
+ });
519
+
520
+ // 详情面板
521
+ detailBox = blessed.box({
522
+ top: 1, right: 0, width: "40%", height: "100%-2",
523
+ label: " 详情 ",
524
+ border: { type: "line", fg: "cyan" },
525
+ tags: true, style: { fg: "white", bg: -1 },
526
+ scrollable: true, alwaysScroll: true,
527
+ });
528
+
529
+ // 状态栏
530
+ statusBar = blessed.box({
531
+ bottom: 0, left: 0, width: "100%", height: 1,
532
+ tags: true, style: { fg: "white", bg: -1 },
533
+ });
534
+
535
+ // 输入框
536
+ inputBox = blessed.textbox({
537
+ top: "50%-3", left: "25%", width: "50%", height: 3,
538
+ label: " 输入 ",
539
+ border: { type: "line", fg: "cyan" },
540
+ hidden: true, keys: true, vi: false,
541
+ style: { fg: "white", bg: -1, border: { fg: "cyan" } },
542
+ });
543
+
544
+ // 确认框
545
+ confirmBox = blessed.box({
546
+ top: "50%-3", left: "30%", width: "40%", height: 3,
547
+ border: { type: "line", fg: "red" },
548
+ tags: true, hidden: true,
549
+ style: { fg: "white", bg: -1 },
550
+ });
551
+
552
+ // 帮助弹窗
553
+ helpBox = blessed.box({
554
+ top: "5%", left: "10%", width: "80%", height: "90%",
555
+ label: " 帮助 ",
556
+ border: { type: "line", fg: "yellow" },
557
+ tags: true, hidden: true, scrollable: true, alwaysScroll: true,
558
+ keys: true, vi: false,
559
+ style: { fg: "white", bg: -1 },
560
+ content: `
561
+ {bold}{yellow-fg}Quick-SSH TUI — 快捷键帮助{/yellow-fg}{/bold}
562
+
563
+ {cyan-fg}━━━━━━━━━ 导航 ━━━━━━━━━{/cyan-fg}
564
+ {green-fg}j{/green-fg} / {green-fg}↓{/green-fg} 向下移动
565
+ {green-fg}k{/green-fg} / {green-fg}↑{/green-fg} 向上移动
566
+ {green-ff}g{/green-fg} 跳转到第一个
567
+ {green-fg}G{/green-fg} 跳转到最后一个
568
+
569
+ {cyan-fg}━━━━━━━━━ 操作 ━━━━━━━━━{/cyan-fg}
570
+ {green-fg}Enter{/green-fg} 连接选中的服务器
571
+ {green-fg}d{/green-fg} 删除选中的连接
572
+ {green-fg}a{/green-fg} 添加新连接
573
+ {green-fg}/ {/green-fg} 搜索 / 筛选
574
+ {green-fg}Esc{/green-fg} 取消 / 返回普通模式
575
+
576
+ {cyan-fg}━━━━━━━━━ 在线检测 ━━━━━━━━━{/cyan-fg}
577
+ {green-fg}p{/green-fg} 检测选中服务器是否在线
578
+ {green-fg}P{/green-fg} / {green-fg}C-p{/green-fg} 检测全部服务器
579
+
580
+ {cyan-fg}━━━━━━━━━ 导入导出 ━━━━━━━━━{/cyan-fg}
581
+ {green-fg}e{/green-fg} 导出全部配置
582
+ {green-fg}i{/green-fg} 从 JSON 文件导入
583
+
584
+ {cyan-fg}━━━━━━━━━ 其他 ━━━━━━━━━{/cyan-fg}
585
+ {green-fg}q{/green-fg} 退出 TUI
586
+ {green-fg}?{/green-fg} 显示本帮助
587
+
588
+ {white-fg}按任意键关闭帮助{/white-fg}
589
+ `
590
+ });
591
+
592
+ screen.append(headerBar);
593
+ screen.append(listBox);
594
+ screen.append(detailBox);
595
+ screen.append(statusBar);
596
+ screen.append(inputBox);
597
+ screen.append(confirmBox);
598
+ screen.append(helpBox);
599
+
600
+ // ============================================================
601
+ // 键位绑定
602
+ // ============================================================
603
+
604
+ // ----- 全局键 -----
605
+ screen.key(["C-c"], () => {
606
+ process.stdout.write("\x1b[2J\x1b[H");
607
+ screen.destroy();
608
+ process.exit(0);
609
+ });
610
+
611
+ screen.key(["escape"], () => {
612
+ if (currentMode === MODE.HELP) {
613
+ helpBox.hide();
614
+ setMode(MODE.NORMAL);
615
+ } else if (currentMode === MODE.CONFIRM) {
616
+ confirmDelete(false);
617
+ } else if (currentMode !== MODE.NORMAL) {
618
+ handleInputCancel();
619
+ }
620
+ });
621
+
622
+ // ----- NORMAL 模式 -----
623
+ screen.key(["q"], () => {
624
+ if (currentMode === MODE.NORMAL) {
625
+ process.stdout.write("\x1b[2J\x1b[H");
626
+ screen.destroy();
627
+ process.exit(0);
628
+ }
629
+ });
630
+
631
+ screen.key(["/"], () => {
632
+ if (currentMode === MODE.NORMAL) setMode(MODE.SEARCH, filterText);
633
+ });
634
+
635
+ screen.key(["a"], () => {
636
+ if (currentMode === MODE.NORMAL) setMode(MODE.ADD);
637
+ });
638
+
639
+ screen.key(["d"], () => {
640
+ if (currentMode === MODE.NORMAL) deleteSelected();
641
+ });
642
+
643
+ screen.key(["y"], () => {
644
+ if (currentMode === MODE.CONFIRM) confirmDelete(true);
645
+ });
646
+
647
+ screen.key(["n"], () => {
648
+ if (currentMode === MODE.CONFIRM) confirmDelete(false);
649
+ });
650
+
651
+ screen.key(["enter"], () => {
652
+ if (currentMode === MODE.NORMAL) connectSelected();
653
+ });
654
+
655
+ screen.key(["e"], () => {
656
+ if (currentMode === MODE.NORMAL) setMode(MODE.EXPORT, path.join(CONFIG_DIR, "export.json"));
657
+ });
658
+
659
+ screen.key(["i"], () => {
660
+ if (currentMode === MODE.NORMAL) setMode(MODE.IMPORT);
661
+ });
662
+
663
+ screen.key(["p"], () => {
664
+ if (currentMode === MODE.NORMAL) checkSelectedHost();
665
+ });
666
+
667
+ // 全检: P(Shift) / S-p / C-p(备选,部分终端会拦截 Shift)
668
+ screen.key(["P", "S-p", "C-p"], () => {
669
+ if (currentMode === MODE.NORMAL) checkAllHosts();
670
+ });
671
+
672
+ screen.key(["r"], () => {
673
+ if (currentMode === MODE.NORMAL) {
674
+ const idx = listBox.selected;
675
+ if (idx >= 0 && idx < filteredHosts.length) {
676
+ setMode(MODE.RENAME, filteredHosts[idx].alias);
677
+ }
678
+ }
679
+ });
680
+
681
+ screen.key(["?", "h"], () => {
682
+ if (currentMode === MODE.NORMAL) { helpBox.show(); helpBox.focus(); setMode(MODE.HELP); }
683
+ });
684
+
685
+ // Vim 导航键
686
+ screen.key(["j", "down"], () => {
687
+ if (currentMode !== MODE.NORMAL) return;
688
+ if (listBox.selected < filteredHosts.length - 1) {
689
+ listBox.select(listBox.selected + 1);
690
+ listBox.scrollTo(listBox.selected);
691
+ updateDetail();
692
+ screen.render();
693
+ }
694
+ });
695
+
696
+ screen.key(["k", "up"], () => {
697
+ if (currentMode !== MODE.NORMAL) return;
698
+ if (listBox.selected > 0) {
699
+ listBox.select(listBox.selected - 1);
700
+ listBox.scrollTo(listBox.selected);
701
+ updateDetail();
702
+ screen.render();
703
+ }
704
+ });
705
+
706
+ screen.key(["g"], () => {
707
+ if (currentMode !== MODE.NORMAL) return;
708
+ if (filteredHosts.length > 0) {
709
+ listBox.select(0);
710
+ listBox.scrollTo(0);
711
+ updateDetail();
712
+ screen.render();
713
+ }
714
+ });
715
+
716
+ screen.key(["G"], () => {
717
+ if (currentMode !== MODE.NORMAL) return;
718
+ if (filteredHosts.length > 0) {
719
+ listBox.select(filteredHosts.length - 1);
720
+ listBox.scrollTo(filteredHosts.length - 1);
721
+ updateDetail();
722
+ screen.render();
723
+ }
724
+ });
725
+
726
+ // ----- 输入框事件 -----
727
+ inputBox.on("submit", handleInputSubmit);
728
+ inputBox.on("cancel", handleInputCancel);
729
+
730
+ // ----- 列表事件 -----
731
+ listBox.on("select", () => {
732
+ if (currentMode === MODE.NORMAL) connectSelected();
733
+ });
734
+
735
+ listBox.on("select item", () => { updateDetail(); });
736
+
737
+ // 鼠标滚轮
738
+ listBox.on("wheeldown", () => {
739
+ if (currentMode !== MODE.NORMAL) return;
740
+ if (listBox.selected < filteredHosts.length - 1) {
741
+ listBox.select(listBox.selected + 1);
742
+ listBox.scrollTo(listBox.selected);
743
+ updateDetail();
744
+ screen.render();
745
+ }
746
+ });
747
+
748
+ listBox.on("wheelup", () => {
749
+ if (currentMode !== MODE.NORMAL) return;
750
+ if (listBox.selected > 0) {
751
+ listBox.select(listBox.selected - 1);
752
+ listBox.scrollTo(listBox.selected);
753
+ updateDetail();
754
+ screen.render();
755
+ }
756
+ });
757
+
758
+ // ----- 帮助弹窗关闭 -----
759
+ helpBox.key(["escape", "q", "enter", "space"], () => {
760
+ helpBox.hide();
761
+ setMode(MODE.NORMAL);
762
+ });
763
+
764
+ // ============================================================
765
+ // 初始化
766
+ // ============================================================
767
+
768
+ refreshList();
769
+ listBox.focus();
770
+ setMode(MODE.NORMAL);
771
+ screen.render();
772
+ }
773
+
774
+ // ============================================================
775
+ // 入口
776
+ // ============================================================
777
+
778
+ startTUI();