quick-ssh-new 1.0.0 → 1.0.1
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/Quick-SSH.psm1 +32 -5
- package/package.json +5 -2
- package/qssh-tui.js +614 -0
package/Quick-SSH.psm1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Quick-SSH.psm1 - PowerShell SSH Connection Manager
|
|
1
|
+
# Quick-SSH.psm1 - PowerShell SSH Connection Manager
|
|
2
2
|
# 仿 Docker 命令行风格的 SSH 连接管理工具
|
|
3
3
|
# 配置文件路径: %USERPROFILE%\.quickssh\hosts.json
|
|
4
4
|
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
# 内部函数 - 配置管理
|
|
7
7
|
# ============================================================
|
|
8
8
|
|
|
9
|
-
$Script:ConfigDir
|
|
10
|
-
$Script:ConfigFile
|
|
9
|
+
$Script:ConfigDir = Join-Path $env:USERPROFILE ".quickssh"
|
|
10
|
+
$Script:ConfigFile = Join-Path $Script:ConfigDir "hosts.json"
|
|
11
|
+
$Script:ModuleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
12
|
+
$Script:TUIScript = Join-Path $Script:ModuleRoot "qssh-tui.js"
|
|
11
13
|
|
|
12
14
|
# 初始化配置目录和空 JSON 文件
|
|
13
15
|
function Initialize-QuickSSHConfig {
|
|
@@ -162,7 +164,7 @@ function Invoke-QuickSSHConnect {
|
|
|
162
164
|
param([string]$Alias)
|
|
163
165
|
|
|
164
166
|
if (-not $Alias) {
|
|
165
|
-
|
|
167
|
+
Invoke-QuickSSHTUI
|
|
166
168
|
return
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -248,6 +250,29 @@ function Invoke-QuickSSHImport {
|
|
|
248
250
|
Write-QSSuccess "✔ 导入完成:新增 $added 个,跳过 $skipped 个(别名重复)。"
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
# ============================================================
|
|
254
|
+
# TUI 终端界面
|
|
255
|
+
# ============================================================
|
|
256
|
+
|
|
257
|
+
function Invoke-QuickSSHTUI {
|
|
258
|
+
# 检测 Node.js 是否可用
|
|
259
|
+
$nodePath = (Get-Command "node" -ErrorAction SilentlyContinue).Source
|
|
260
|
+
if (-not $nodePath) {
|
|
261
|
+
Write-QSError "错误:启动 TUI 需要 Node.js,请先安装 Node.js (https://nodejs.org)"
|
|
262
|
+
Write-QSError "或者使用命令行模式: qssh help"
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (-not (Test-Path $Script:TUIScript)) {
|
|
267
|
+
Write-QSError "错误:未找到 TUI 脚本: $Script:TUIScript"
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# 启动 TUI,等待退出后返回
|
|
272
|
+
Write-Host "正在启动 Quick-SSH TUI ..." -ForegroundColor Cyan
|
|
273
|
+
& "node" $Script:TUIScript
|
|
274
|
+
}
|
|
275
|
+
|
|
251
276
|
# ============================================================
|
|
252
277
|
# 帮助信息
|
|
253
278
|
# ============================================================
|
|
@@ -257,6 +282,7 @@ function Show-QuickSSHHelp {
|
|
|
257
282
|
Write-Host "Quick-SSH - PowerShell SSH 连接管理工具" -ForegroundColor Cyan
|
|
258
283
|
Write-Host ""
|
|
259
284
|
Write-Host "用法:" -ForegroundColor Yellow
|
|
285
|
+
Write-Host " qssh 启动 TUI 终端界面(推荐,类似 yazi 操作体验)"
|
|
260
286
|
Write-Host " qssh ps [关键词] 列出所有已保存的 SSH 连接(对应 docker ps)"
|
|
261
287
|
Write-Host " qssh add <别名> <用户@主机:端口> [--key <私钥路径>]" -ForegroundColor Gray
|
|
262
288
|
Write-Host " 添加新 SSH 连接(端口默认 22,私钥默认 ~/.ssh/id_rsa)"
|
|
@@ -267,6 +293,7 @@ function Show-QuickSSHHelp {
|
|
|
267
293
|
Write-Host " qssh help 显示本帮助信息"
|
|
268
294
|
Write-Host ""
|
|
269
295
|
Write-Host "示例:" -ForegroundColor Yellow
|
|
296
|
+
Write-Host " qssh # 启动 TUI 界面"
|
|
270
297
|
Write-Host " qssh ps"
|
|
271
298
|
Write-Host " qssh ps 生产"
|
|
272
299
|
Write-Host " qssh add my-server root@192.168.1.100:22 --key D:\.ssh\id_rsa"
|
|
@@ -295,7 +322,7 @@ function global:qssh {
|
|
|
295
322
|
Initialize-QuickSSHConfig
|
|
296
323
|
|
|
297
324
|
if (-not $Command) {
|
|
298
|
-
|
|
325
|
+
Invoke-QuickSSHTUI
|
|
299
326
|
return
|
|
300
327
|
}
|
|
301
328
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quick-ssh-new",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "🚀 Quick-SSH - 仿 Docker 命令行风格的 PowerShell SSH 连接管理工具",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ssh",
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"os": [
|
|
32
32
|
"win32"
|
|
33
33
|
],
|
|
34
|
-
"private": false
|
|
34
|
+
"private": false,
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"blessed": "^0.1.81"
|
|
37
|
+
}
|
|
35
38
|
}
|
package/qssh-tui.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
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
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { spawn } = require("child_process");
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// 配置
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
const CONFIG_DIR = path.join(process.env.USERPROFILE || "~", ".quickssh");
|
|
30
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "hosts.json");
|
|
31
|
+
|
|
32
|
+
// ============================================================
|
|
33
|
+
// 数据层
|
|
34
|
+
// ============================================================
|
|
35
|
+
|
|
36
|
+
function ensureConfig() {
|
|
37
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
38
|
+
if (!fs.existsSync(CONFIG_FILE)) fs.writeFileSync(CONFIG_FILE, "[]", "utf-8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadHosts() {
|
|
42
|
+
ensureConfig();
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8").trim();
|
|
45
|
+
return raw ? JSON.parse(raw) : [];
|
|
46
|
+
} catch { return []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveHosts(hosts) {
|
|
50
|
+
ensureConfig();
|
|
51
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(hosts, null, 4), "utf-8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================
|
|
55
|
+
// SSH 连接
|
|
56
|
+
// ============================================================
|
|
57
|
+
|
|
58
|
+
function sshConnect(host, cbReturn) {
|
|
59
|
+
const sshExe = "ssh.exe";
|
|
60
|
+
const args = [
|
|
61
|
+
"-i", host.key,
|
|
62
|
+
"-p", String(host.port),
|
|
63
|
+
"-o", "HostKeyAlgorithms=+ssh-rsa",
|
|
64
|
+
`${host.user}@${host.host}`,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
screen.destroy();
|
|
68
|
+
process.stdin.removeAllListeners("data");
|
|
69
|
+
|
|
70
|
+
process.stdout.write(`\n\x1b[32m正在连接到 '${host.alias}' (${host.user}@${host.host}:${host.port}) ...\x1b[0m\n\n`);
|
|
71
|
+
|
|
72
|
+
const child = spawn(sshExe, args, {
|
|
73
|
+
stdio: "inherit",
|
|
74
|
+
shell: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
child.on("exit", (code) => {
|
|
78
|
+
process.stdout.write(`\n\x1b[33mSSH 会话已结束 (退出码: ${code})\x1b[0m\n`);
|
|
79
|
+
process.stdout.write(`\x1b[32m按 Enter 键返回 Quick-SSH TUI...\x1b[0m`);
|
|
80
|
+
process.stdin.setRawMode(false);
|
|
81
|
+
process.stdin.once("data", () => cbReturn());
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// TUI 组件
|
|
87
|
+
// ============================================================
|
|
88
|
+
|
|
89
|
+
let screen;
|
|
90
|
+
let headerBar;
|
|
91
|
+
let listBox;
|
|
92
|
+
let detailBox;
|
|
93
|
+
let statusBar;
|
|
94
|
+
let inputBox;
|
|
95
|
+
let helpBox;
|
|
96
|
+
let confirmBox;
|
|
97
|
+
|
|
98
|
+
let hosts = [];
|
|
99
|
+
let filteredHosts = [];
|
|
100
|
+
let filterText = "";
|
|
101
|
+
|
|
102
|
+
// 模式管理
|
|
103
|
+
const MODE = { NORMAL: 0, SEARCH: 1, ADD: 2, EXPORT: 3, IMPORT: 4, CONFIRM: 5, HELP: 6 };
|
|
104
|
+
let currentMode = MODE.NORMAL;
|
|
105
|
+
|
|
106
|
+
// ============================================================
|
|
107
|
+
// UI 更新函数
|
|
108
|
+
// ============================================================
|
|
109
|
+
|
|
110
|
+
const MODE_LABELS = {
|
|
111
|
+
[MODE.NORMAL]: "{green-fg}{bold} NORMAL {/bold}{/green-fg}",
|
|
112
|
+
[MODE.SEARCH]: "{cyan-fg}{bold} SEARCH {/bold}{/cyan-fg}",
|
|
113
|
+
[MODE.ADD]: "{yellow-fg}{bold} ADD {/bold}{/yellow-fg}",
|
|
114
|
+
[MODE.EXPORT]: "{yellow-fg}{bold} EXPORT {/bold}{/yellow-fg}",
|
|
115
|
+
[MODE.IMPORT]: "{yellow-fg}{bold} IMPORT {/bold}{/yellow-fg}",
|
|
116
|
+
[MODE.CONFIRM]: "{red-fg}{bold} CONFIRM {/bold}{/red-fg}",
|
|
117
|
+
[MODE.HELP]: "{white-fg}{bold} HELP {/bold}{/white-fg}",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const MODE_HINTS = {
|
|
121
|
+
[MODE.NORMAL]: " j/k ↑↓ g首 G尾 ↵连接 d删除 a添加 /搜索 e导出 i导入 r重命名 ?帮助 q退出",
|
|
122
|
+
[MODE.SEARCH]: " 输入关键词过滤 Enter确认 Esc取消",
|
|
123
|
+
[MODE.ADD]: " 格式: 别名 用户@主机:端口 [--key 路径] Enter确认 Esc取消",
|
|
124
|
+
[MODE.EXPORT]: " 输入导出文件路径 (默认: ~/.quickssh/export.json) Enter确认 Esc取消",
|
|
125
|
+
[MODE.IMPORT]: " 输入导入文件路径 Enter确认 Esc取消",
|
|
126
|
+
[MODE.CONFIRM]: " y确认 n取消",
|
|
127
|
+
[MODE.HELP]: " 按任意键返回",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function setMode(mode, inputValue) {
|
|
131
|
+
currentMode = mode;
|
|
132
|
+
statusBar.setContent(`${MODE_LABELS[mode] || ""} ${MODE_HINTS[mode] || ""}`);
|
|
133
|
+
|
|
134
|
+
if (mode === MODE.NORMAL || mode === MODE.HELP) {
|
|
135
|
+
inputBox.hide();
|
|
136
|
+
confirmBox.hide();
|
|
137
|
+
if (mode === MODE.NORMAL) listBox.focus();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (mode === MODE.SEARCH || mode === MODE.ADD || mode === MODE.EXPORT || mode === MODE.IMPORT) {
|
|
141
|
+
inputBox.show();
|
|
142
|
+
inputBox.setValue(inputValue || "");
|
|
143
|
+
inputBox.focus();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (mode === MODE.CONFIRM) {
|
|
147
|
+
confirmBox.show();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
screen.render();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function refreshList(keepSelection) {
|
|
154
|
+
hosts = loadHosts();
|
|
155
|
+
filteredHosts = filterText
|
|
156
|
+
? hosts.filter(h =>
|
|
157
|
+
h.alias.toLowerCase().includes(filterText.toLowerCase()) ||
|
|
158
|
+
h.host.toLowerCase().includes(filterText.toLowerCase()) ||
|
|
159
|
+
h.user.toLowerCase().includes(filterText.toLowerCase())
|
|
160
|
+
)
|
|
161
|
+
: [...hosts];
|
|
162
|
+
|
|
163
|
+
const prevIdx = listBox.selected;
|
|
164
|
+
listBox.setItems(filteredHosts.map(h =>
|
|
165
|
+
`${h.alias.padEnd(16)} ${h.user}@${h.host}:${h.port}`
|
|
166
|
+
));
|
|
167
|
+
|
|
168
|
+
if (keepSelection && prevIdx < filteredHosts.length) {
|
|
169
|
+
listBox.select(prevIdx);
|
|
170
|
+
listBox.scrollTo(prevIdx);
|
|
171
|
+
} else if (filteredHosts.length > 0) {
|
|
172
|
+
listBox.select(0);
|
|
173
|
+
listBox.scrollTo(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
updateDetail();
|
|
177
|
+
headerBar.setContent(
|
|
178
|
+
`{bold} Quick-SSH {/bold}(${filteredHosts.length}/${hosts.length})`
|
|
179
|
+
);
|
|
180
|
+
screen.render();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function updateDetail() {
|
|
184
|
+
const idx = listBox.selected;
|
|
185
|
+
if (idx < 0 || idx >= filteredHosts.length) {
|
|
186
|
+
detailBox.setContent("{center}{yellow-fg}--- 无选中连接 ---{/yellow-fg}{/center}");
|
|
187
|
+
screen.render();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const h = filteredHosts[idx];
|
|
192
|
+
detailBox.setContent(`
|
|
193
|
+
{bold}{cyan-fg} 连接详情{/cyan-fg}{/bold}
|
|
194
|
+
|
|
195
|
+
{bold}别名:{/bold} ${h.alias}
|
|
196
|
+
{bold}主机:{/bold} ${h.host}
|
|
197
|
+
{bold}账号:{/bold} ${h.user}
|
|
198
|
+
{bold}端口:{/bold} ${h.port}
|
|
199
|
+
{bold}私钥:{/bold} ${h.key || "(默认)"}
|
|
200
|
+
|
|
201
|
+
{blue-fg}─────────────────────{/blue-fg}
|
|
202
|
+
{green-fg} Enter → 连接{/green-fg}
|
|
203
|
+
{red-fg} d → 删除{/red-fg}
|
|
204
|
+
`);
|
|
205
|
+
screen.render();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ============================================================
|
|
209
|
+
// 操作函数
|
|
210
|
+
// ============================================================
|
|
211
|
+
|
|
212
|
+
function connectSelected() {
|
|
213
|
+
const idx = listBox.selected;
|
|
214
|
+
if (idx < 0 || idx >= filteredHosts.length) return;
|
|
215
|
+
sshConnect(filteredHosts[idx], startTUI);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function deleteSelected() {
|
|
219
|
+
const idx = listBox.selected;
|
|
220
|
+
if (idx < 0 || idx >= filteredHosts.length) return;
|
|
221
|
+
const h = filteredHosts[idx];
|
|
222
|
+
confirmBox.setContent(
|
|
223
|
+
`{center}{red-fg}确认删除连接 '{h.alias}'? (y/n){/red-fg}{/center}`
|
|
224
|
+
);
|
|
225
|
+
setMode(MODE.CONFIRM);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function confirmDelete(confirmed) {
|
|
229
|
+
confirmBox.hide();
|
|
230
|
+
if (confirmed) {
|
|
231
|
+
const idx = listBox.selected;
|
|
232
|
+
const h = filteredHosts[idx];
|
|
233
|
+
hosts = hosts.filter(item => item.alias !== h.alias);
|
|
234
|
+
saveHosts(hosts);
|
|
235
|
+
refreshList();
|
|
236
|
+
flashMessage(`已删除连接 '${h.alias}'`, "green");
|
|
237
|
+
}
|
|
238
|
+
setMode(MODE.NORMAL);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function flashMessage(msg, color) {
|
|
242
|
+
statusBar.setContent(`{${color}-fg} ${msg}{/${color}-fg}`);
|
|
243
|
+
screen.render();
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
if (currentMode === MODE.NORMAL) setMode(MODE.NORMAL);
|
|
246
|
+
}, 3000);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============================================================
|
|
250
|
+
// 输入处理
|
|
251
|
+
// ============================================================
|
|
252
|
+
|
|
253
|
+
function handleInputSubmit(value) {
|
|
254
|
+
const val = value.trim();
|
|
255
|
+
|
|
256
|
+
switch (currentMode) {
|
|
257
|
+
case MODE.SEARCH: {
|
|
258
|
+
filterText = val;
|
|
259
|
+
inputBox.hide();
|
|
260
|
+
refreshList();
|
|
261
|
+
setMode(MODE.NORMAL);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
case MODE.ADD: {
|
|
265
|
+
inputBox.hide();
|
|
266
|
+
const parts = val.split(/\s+/);
|
|
267
|
+
if (parts.length < 2) {
|
|
268
|
+
flashMessage("格式错误: 需要 别名 用户@主机:端口", "red");
|
|
269
|
+
setMode(MODE.NORMAL);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
const alias = parts[0];
|
|
273
|
+
const userAtHost = parts[1];
|
|
274
|
+
let keyPath = "";
|
|
275
|
+
const ki = parts.indexOf("--key");
|
|
276
|
+
if (ki >= 0 && ki + 1 < parts.length) keyPath = parts[ki + 1];
|
|
277
|
+
|
|
278
|
+
let user = "", hostname = "", port = 22;
|
|
279
|
+
const m1 = userAtHost.match(/^(.+)@(.+):(\d+)$/);
|
|
280
|
+
const m2 = userAtHost.match(/^(.+)@(.+)$/);
|
|
281
|
+
if (m1) { user = m1[1]; hostname = m1[2]; port = parseInt(m1[3]); }
|
|
282
|
+
else if (m2) { user = m2[1]; hostname = m2[2]; }
|
|
283
|
+
else {
|
|
284
|
+
flashMessage("格式无效: 请使用 用户@主机:端口", "red");
|
|
285
|
+
setMode(MODE.NORMAL);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!keyPath) keyPath = path.join(process.env.USERPROFILE || "~", ".ssh", "id_rsa");
|
|
290
|
+
|
|
291
|
+
if (hosts.find(h => h.alias === alias)) {
|
|
292
|
+
flashMessage(`别名 '${alias}' 已存在`, "red");
|
|
293
|
+
} else {
|
|
294
|
+
hosts.push({ alias, host: hostname, user, port, key: keyPath });
|
|
295
|
+
saveHosts(hosts);
|
|
296
|
+
refreshList(true);
|
|
297
|
+
flashMessage(`已添加 '${alias}' → ${user}@${hostname}:${port}`, "green");
|
|
298
|
+
}
|
|
299
|
+
setMode(MODE.NORMAL);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case MODE.EXPORT: {
|
|
303
|
+
inputBox.hide();
|
|
304
|
+
const fp = val || path.join(CONFIG_DIR, "export.json");
|
|
305
|
+
try {
|
|
306
|
+
fs.writeFileSync(fp, JSON.stringify(hosts, null, 4), "utf-8");
|
|
307
|
+
flashMessage(`已导出 ${hosts.length} 个连接到 '${fp}'`, "green");
|
|
308
|
+
} catch (e) {
|
|
309
|
+
flashMessage(`导出失败: ${e.message}`, "red");
|
|
310
|
+
}
|
|
311
|
+
setMode(MODE.NORMAL);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case MODE.IMPORT: {
|
|
315
|
+
inputBox.hide();
|
|
316
|
+
if (!val || !fs.existsSync(val)) {
|
|
317
|
+
flashMessage("文件不存在", "red");
|
|
318
|
+
setMode(MODE.NORMAL);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const imported = JSON.parse(fs.readFileSync(val, "utf-8"));
|
|
323
|
+
let added = 0, skipped = 0;
|
|
324
|
+
for (const h of imported) {
|
|
325
|
+
if (!hosts.find(e => e.alias === h.alias)) { hosts.push(h); added++; }
|
|
326
|
+
else { skipped++; }
|
|
327
|
+
}
|
|
328
|
+
saveHosts(hosts);
|
|
329
|
+
refreshList();
|
|
330
|
+
flashMessage(`导入完成: 新增 ${added}, 跳过 ${skipped}`, "green");
|
|
331
|
+
} catch (e) {
|
|
332
|
+
flashMessage(`导入失败: ${e.message}`, "red");
|
|
333
|
+
}
|
|
334
|
+
setMode(MODE.NORMAL);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function handleInputCancel() {
|
|
341
|
+
inputBox.hide();
|
|
342
|
+
switch (currentMode) {
|
|
343
|
+
case MODE.SEARCH: setMode(MODE.NORMAL); break;
|
|
344
|
+
case MODE.ADD: setMode(MODE.NORMAL); break;
|
|
345
|
+
case MODE.EXPORT: setMode(MODE.NORMAL); break;
|
|
346
|
+
case MODE.IMPORT: setMode(MODE.NORMAL); break;
|
|
347
|
+
default: setMode(MODE.NORMAL); break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============================================================
|
|
352
|
+
// 启动 TUI
|
|
353
|
+
// ============================================================
|
|
354
|
+
|
|
355
|
+
function startTUI() {
|
|
356
|
+
screen = blessed.screen({
|
|
357
|
+
smartCSR: true,
|
|
358
|
+
title: "Quick-SSH",
|
|
359
|
+
cursor: { artificial: true, shape: "underline" },
|
|
360
|
+
dockBorders: true,
|
|
361
|
+
fullUnicode: true,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// 标题栏
|
|
365
|
+
headerBar = blessed.box({
|
|
366
|
+
top: 0, left: 0, width: "100%", height: 1,
|
|
367
|
+
content: "{bold} Quick-SSH {/bold}",
|
|
368
|
+
tags: true,
|
|
369
|
+
style: { fg: "white", bg: "blue" },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// 连接列表
|
|
373
|
+
listBox = blessed.list({
|
|
374
|
+
top: 1, left: 0, width: "60%", height: "100%-2",
|
|
375
|
+
label: " 连接列表 ",
|
|
376
|
+
border: { type: "line", fg: "cyan" },
|
|
377
|
+
tags: true, keys: false, vi: false, mouse: true,
|
|
378
|
+
scrollbar: { ch: " ", bg: "cyan" },
|
|
379
|
+
style: {
|
|
380
|
+
fg: "white", bg: "black",
|
|
381
|
+
selected: { fg: "white", bg: "blue", bold: true },
|
|
382
|
+
item: { fg: "white", bg: "black" },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 详情面板
|
|
387
|
+
detailBox = blessed.box({
|
|
388
|
+
top: 1, right: 0, width: "40%", height: "100%-2",
|
|
389
|
+
label: " 详情 ",
|
|
390
|
+
border: { type: "line", fg: "cyan" },
|
|
391
|
+
tags: true, style: { fg: "white", bg: "black" },
|
|
392
|
+
scrollable: true, alwaysScroll: true,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// 状态栏
|
|
396
|
+
statusBar = blessed.box({
|
|
397
|
+
bottom: 0, left: 0, width: "100%", height: 1,
|
|
398
|
+
tags: true, style: { fg: "white", bg: "black" },
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// 输入框
|
|
402
|
+
inputBox = blessed.textbox({
|
|
403
|
+
top: "50%-3", left: "25%", width: "50%", height: 3,
|
|
404
|
+
label: " 输入 ",
|
|
405
|
+
border: { type: "line", fg: "cyan" },
|
|
406
|
+
hidden: true, keys: true, vi: false,
|
|
407
|
+
style: { fg: "white", bg: "black", border: { fg: "cyan" } },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// 确认框
|
|
411
|
+
confirmBox = blessed.box({
|
|
412
|
+
top: "50%-3", left: "30%", width: "40%", height: 3,
|
|
413
|
+
border: { type: "line", fg: "red" },
|
|
414
|
+
tags: true, hidden: true,
|
|
415
|
+
style: { fg: "white", bg: "black" },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// 帮助弹窗
|
|
419
|
+
helpBox = blessed.box({
|
|
420
|
+
top: "5%", left: "10%", width: "80%", height: "90%",
|
|
421
|
+
label: " 帮助 ",
|
|
422
|
+
border: { type: "line", fg: "yellow" },
|
|
423
|
+
tags: true, hidden: true, scrollable: true, alwaysScroll: true,
|
|
424
|
+
keys: true, vi: false,
|
|
425
|
+
style: { fg: "white", bg: "black" },
|
|
426
|
+
content: `
|
|
427
|
+
{bold}{yellow-fg}Quick-SSH TUI — 快捷键帮助{/yellow-fg}{/bold}
|
|
428
|
+
|
|
429
|
+
{cyan-fg}━━━━━━━━━ 导航 ━━━━━━━━━{/cyan-fg}
|
|
430
|
+
{green-fg}j{/green-fg} / {green-fg}↓{/green-fg} 向下移动
|
|
431
|
+
{green-fg}k{/green-fg} / {green-fg}↑{/green-fg} 向上移动
|
|
432
|
+
{green-fg}g{/green-fg} 跳转到第一个
|
|
433
|
+
{green-fg}G{/green-fg} 跳转到最后一个
|
|
434
|
+
|
|
435
|
+
{cyan-fg}━━━━━━━━━ 操作 ━━━━━━━━━{/cyan-fg}
|
|
436
|
+
{green-fg}Enter{/green-fg} 连接选中的服务器
|
|
437
|
+
{green-fg}d{/green-fg} 删除选中的连接
|
|
438
|
+
{green-fg}a{/green-fg} 添加新连接
|
|
439
|
+
{green-fg}/ {/green-fg} 搜索 / 筛选
|
|
440
|
+
{green-fg}Esc{/green-fg} 取消 / 返回普通模式
|
|
441
|
+
|
|
442
|
+
{cyan-fg}━━━━━━━━━ 导入导出 ━━━━━━━━━{/cyan-fg}
|
|
443
|
+
{green-fg}e{/green-fg} 导出全部配置
|
|
444
|
+
{green-fg}i{/green-fg} 从 JSON 文件导入
|
|
445
|
+
|
|
446
|
+
{cyan-fg}━━━━━━━━━ 其他 ━━━━━━━━━{/cyan-fg}
|
|
447
|
+
{green-fg}q{/green-fg} 退出 TUI
|
|
448
|
+
{green-fg}?{/green-fg} 显示本帮助
|
|
449
|
+
|
|
450
|
+
{white-fg}按任意键关闭帮助{/white-fg}
|
|
451
|
+
`
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
screen.append(headerBar);
|
|
455
|
+
screen.append(listBox);
|
|
456
|
+
screen.append(detailBox);
|
|
457
|
+
screen.append(statusBar);
|
|
458
|
+
screen.append(inputBox);
|
|
459
|
+
screen.append(confirmBox);
|
|
460
|
+
screen.append(helpBox);
|
|
461
|
+
|
|
462
|
+
// ============================================================
|
|
463
|
+
// 键位绑定
|
|
464
|
+
// ============================================================
|
|
465
|
+
|
|
466
|
+
// ----- 全局键 -----
|
|
467
|
+
screen.key(["C-c"], () => { screen.destroy(); process.exit(0); });
|
|
468
|
+
|
|
469
|
+
screen.key(["escape"], () => {
|
|
470
|
+
if (currentMode === MODE.HELP) {
|
|
471
|
+
helpBox.hide();
|
|
472
|
+
setMode(MODE.NORMAL);
|
|
473
|
+
} else if (currentMode === MODE.CONFIRM) {
|
|
474
|
+
confirmDelete(false);
|
|
475
|
+
} else if (currentMode !== MODE.NORMAL) {
|
|
476
|
+
handleInputCancel();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ----- NORMAL 模式 -----
|
|
481
|
+
screen.key(["q"], () => {
|
|
482
|
+
if (currentMode === MODE.NORMAL) { screen.destroy(); process.exit(0); }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
screen.key(["/"], () => {
|
|
486
|
+
if (currentMode === MODE.NORMAL) setMode(MODE.SEARCH, filterText);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
screen.key(["a"], () => {
|
|
490
|
+
if (currentMode === MODE.NORMAL) setMode(MODE.ADD);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
screen.key(["d"], () => {
|
|
494
|
+
if (currentMode === MODE.NORMAL) deleteSelected();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
screen.key(["y"], () => {
|
|
498
|
+
if (currentMode === MODE.CONFIRM) confirmDelete(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
screen.key(["n"], () => {
|
|
502
|
+
if (currentMode === MODE.CONFIRM) confirmDelete(false);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
screen.key(["enter"], () => {
|
|
506
|
+
if (currentMode === MODE.NORMAL) connectSelected();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
screen.key(["e"], () => {
|
|
510
|
+
if (currentMode === MODE.NORMAL) setMode(MODE.EXPORT, path.join(CONFIG_DIR, "export.json"));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
screen.key(["i"], () => {
|
|
514
|
+
if (currentMode === MODE.NORMAL) setMode(MODE.IMPORT);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
screen.key(["?", "h"], () => {
|
|
518
|
+
if (currentMode === MODE.NORMAL) { helpBox.show(); helpBox.focus(); setMode(MODE.HELP); }
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Vim 导航键
|
|
522
|
+
screen.key(["j", "down"], () => {
|
|
523
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
524
|
+
if (listBox.selected < filteredHosts.length - 1) {
|
|
525
|
+
listBox.select(listBox.selected + 1);
|
|
526
|
+
listBox.scrollTo(listBox.selected);
|
|
527
|
+
updateDetail();
|
|
528
|
+
screen.render();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
screen.key(["k", "up"], () => {
|
|
533
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
534
|
+
if (listBox.selected > 0) {
|
|
535
|
+
listBox.select(listBox.selected - 1);
|
|
536
|
+
listBox.scrollTo(listBox.selected);
|
|
537
|
+
updateDetail();
|
|
538
|
+
screen.render();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
screen.key(["g"], () => {
|
|
543
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
544
|
+
if (filteredHosts.length > 0) {
|
|
545
|
+
listBox.select(0);
|
|
546
|
+
listBox.scrollTo(0);
|
|
547
|
+
updateDetail();
|
|
548
|
+
screen.render();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
screen.key(["G"], () => {
|
|
553
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
554
|
+
if (filteredHosts.length > 0) {
|
|
555
|
+
listBox.select(filteredHosts.length - 1);
|
|
556
|
+
listBox.scrollTo(filteredHosts.length - 1);
|
|
557
|
+
updateDetail();
|
|
558
|
+
screen.render();
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ----- 输入框事件 -----
|
|
563
|
+
inputBox.on("submit", handleInputSubmit);
|
|
564
|
+
inputBox.on("cancel", handleInputCancel);
|
|
565
|
+
|
|
566
|
+
// ----- 列表事件 -----
|
|
567
|
+
listBox.on("select", () => {
|
|
568
|
+
if (currentMode === MODE.NORMAL) connectSelected();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
listBox.on("select item", () => { updateDetail(); });
|
|
572
|
+
|
|
573
|
+
// 鼠标滚轮
|
|
574
|
+
listBox.on("wheeldown", () => {
|
|
575
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
576
|
+
if (listBox.selected < filteredHosts.length - 1) {
|
|
577
|
+
listBox.select(listBox.selected + 1);
|
|
578
|
+
listBox.scrollTo(listBox.selected);
|
|
579
|
+
updateDetail();
|
|
580
|
+
screen.render();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
listBox.on("wheelup", () => {
|
|
585
|
+
if (currentMode !== MODE.NORMAL) return;
|
|
586
|
+
if (listBox.selected > 0) {
|
|
587
|
+
listBox.select(listBox.selected - 1);
|
|
588
|
+
listBox.scrollTo(listBox.selected);
|
|
589
|
+
updateDetail();
|
|
590
|
+
screen.render();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ----- 帮助弹窗关闭 -----
|
|
595
|
+
helpBox.key(["escape", "q", "enter", "space"], () => {
|
|
596
|
+
helpBox.hide();
|
|
597
|
+
setMode(MODE.NORMAL);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ============================================================
|
|
601
|
+
// 初始化
|
|
602
|
+
// ============================================================
|
|
603
|
+
|
|
604
|
+
refreshList();
|
|
605
|
+
listBox.focus();
|
|
606
|
+
setMode(MODE.NORMAL);
|
|
607
|
+
screen.render();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ============================================================
|
|
611
|
+
// 入口
|
|
612
|
+
// ============================================================
|
|
613
|
+
|
|
614
|
+
startTUI();
|